license: relicense to AGPL-3.0-only with commercial dual-license
- LICENSE -> verbatim GNU AGPL-3.0; add LICENSE-COMMERCIAL.md + SECURITY.md - CLA -> exclusive-license grant (contributors keep copyright); add README anti-rugpull covenant + relicense record - NOTICE / README / CONTRIBUTING / CLAUDE.md / package.json x5 updated; contact routed through GitHub (no email placeholders) - AGPL section 13 source offer: operator-configurable BACKSPACE_SOURCE_URL + build-injected commit; sourceCodeUrl+commit on /api/instance/info; SourceCodeLink on login/register/settings/desktop; docs + .env.example updated
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
"name": "@backspace/desktop",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"license": "Elastic-2.0",
|
||||
"license": "AGPL-3.0-only",
|
||||
"description": "Backspace",
|
||||
"author": {
|
||||
"name": "Jannis Braun",
|
||||
|
||||
@@ -69,6 +69,45 @@ let pendingDeepLink: string | null = null;
|
||||
|
||||
const knownInstanceOrigins = new Set<string>();
|
||||
|
||||
// ─── AGPL-3.0 § 13 source offer ─────────────────────────────────────────────
|
||||
// Upstream fallback for the "Source code" menu items and the About panel.
|
||||
// Used when the connected instance can't be reached or advertises no source URL.
|
||||
const UPSTREAM_SOURCE_URL = 'https://github.com/TheZwiss/backspace';
|
||||
|
||||
/**
|
||||
* Resolve the Corresponding Source URL for the instance the desktop app is
|
||||
* pointed at, honouring an operator's modified fork via GET /api/instance/info.
|
||||
* Falls back to the upstream repo when no instance is loaded or the probe fails.
|
||||
*/
|
||||
async function resolveSourceUrl(): Promise<string> {
|
||||
let base: string | null = process.env.BACKSPACE_URL ?? loadInstanceUrl();
|
||||
if (!base && mainWindow && !mainWindow.isDestroyed()) {
|
||||
const current = mainWindow.webContents.getURL();
|
||||
if (current.startsWith('http://') || current.startsWith('https://')) base = current;
|
||||
}
|
||||
if (!base) return UPSTREAM_SOURCE_URL;
|
||||
|
||||
try {
|
||||
const origin = new URL(base).origin;
|
||||
const res = await fetch(`${origin}/api/instance/info`, { signal: AbortSignal.timeout(5000) });
|
||||
if (!res.ok) return UPSTREAM_SOURCE_URL;
|
||||
const info = (await res.json()) as { sourceCodeUrl?: unknown };
|
||||
if (typeof info.sourceCodeUrl === 'string' && /^https?:\/\//i.test(info.sourceCodeUrl)) {
|
||||
return info.sourceCodeUrl;
|
||||
}
|
||||
} catch {
|
||||
// Unreachable / malformed — fall back to upstream.
|
||||
}
|
||||
return UPSTREAM_SOURCE_URL;
|
||||
}
|
||||
|
||||
/** Open the resolved source URL externally; upstream fallback on any failure. */
|
||||
function openSourceCode(): void {
|
||||
resolveSourceUrl()
|
||||
.then((url) => shell.openExternal(url))
|
||||
.catch(() => { void shell.openExternal(UPSTREAM_SOURCE_URL); });
|
||||
}
|
||||
|
||||
// ─── Window State Persistence ───────────────────────────────────────────────
|
||||
|
||||
interface WindowState {
|
||||
@@ -930,6 +969,14 @@ if (!gotTheLock) {
|
||||
createWindow();
|
||||
createTray();
|
||||
|
||||
// AGPL-3.0 § 13: native About panel advertises the version + source repo.
|
||||
app.setAboutPanelOptions({
|
||||
applicationName: 'Backspace',
|
||||
applicationVersion: app.getVersion(),
|
||||
copyright: `AGPL-3.0-only · Source: ${UPSTREAM_SOURCE_URL}`,
|
||||
website: UPSTREAM_SOURCE_URL,
|
||||
});
|
||||
|
||||
// Tray + macOS app-menu actions. Defined once so the subscriber and the
|
||||
// initial-fire share one implementation (no drift on future menu changes).
|
||||
const trayActions = {
|
||||
@@ -941,6 +988,7 @@ if (!gotTheLock) {
|
||||
onChangeInstance: () => handleRecoveryAction('change-instance'),
|
||||
onCheckForUpdates: () => handleRecoveryAction('check-update'),
|
||||
onRestartToInstall: () => handleRecoveryAction('install-update'),
|
||||
onOpenSource: () => openSourceCode(),
|
||||
onQuit: () => requestQuit(),
|
||||
};
|
||||
|
||||
|
||||
@@ -96,6 +96,8 @@ interface MenuActions {
|
||||
onChangeInstance: () => void;
|
||||
onCheckForUpdates: () => void;
|
||||
onRestartToInstall: () => void;
|
||||
// AGPL-3.0 § 13: open the Corresponding Source of the running instance.
|
||||
onOpenSource: () => void;
|
||||
onQuit: () => void;
|
||||
}
|
||||
|
||||
@@ -138,6 +140,7 @@ export function buildTrayMenuTemplate(
|
||||
items.push(
|
||||
{ type: 'separator' },
|
||||
{ label: 'Change Instance', click: actions?.onChangeInstance },
|
||||
{ label: 'Source code (AGPL)', click: actions?.onOpenSource },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Quit', click: actions?.onQuit },
|
||||
);
|
||||
@@ -152,6 +155,7 @@ export function buildAppMenuTemplate(
|
||||
): MenuItemConstructorOptions[] {
|
||||
const appSubmenu: MenuItemConstructorOptions[] = [
|
||||
{ role: 'about' },
|
||||
{ label: 'Source code (AGPL)', click: () => actions?.onOpenSource?.() },
|
||||
{ type: 'separator' },
|
||||
checkForUpdatesItem(state, () => actions?.onCheckForUpdates?.()),
|
||||
];
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@backspace/server",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"license": "Elastic-2.0",
|
||||
"license": "AGPL-3.0-only",
|
||||
"author": "Jannis Braun",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -48,6 +48,24 @@ if (publicOrigin !== undefined) {
|
||||
}
|
||||
}
|
||||
|
||||
// AGPL-3.0 § 13 "network-use source offer": users interacting over the network
|
||||
// must be able to obtain the Corresponding Source of the *running* version.
|
||||
// Operators who modify Backspace and self-host MUST point this at their own
|
||||
// fork's source so the offer stays accurate. Defaults to the upstream repo for
|
||||
// unmodified deployments.
|
||||
const UPSTREAM_SOURCE_URL = 'https://github.com/TheZwiss/backspace';
|
||||
const sourceCodeUrl = envOptional('BACKSPACE_SOURCE_URL') ?? UPSTREAM_SOURCE_URL;
|
||||
if (!/^https?:\/\//i.test(sourceCodeUrl)) {
|
||||
throw new Error(
|
||||
`BACKSPACE_SOURCE_URL must start with http:// or https:// — got: ${sourceCodeUrl}`
|
||||
);
|
||||
}
|
||||
|
||||
// Short git SHA/tag of the running build, injected at Docker build time via the
|
||||
// BACKSPACE_COMMIT build arg (see Dockerfile / deploy.sh). Null in local dev
|
||||
// (no build step) — the § 13 offer still works via version + sourceCodeUrl.
|
||||
const commit = envOptional('BACKSPACE_COMMIT') ?? null;
|
||||
|
||||
export const config = {
|
||||
port: envInt('PORT', 3000),
|
||||
host: env('HOST', '0.0.0.0'),
|
||||
@@ -55,6 +73,8 @@ export const config = {
|
||||
jwtExpiresIn: env('JWT_EXPIRES_IN', '30d'),
|
||||
domain: envOptional('DOMAIN'),
|
||||
publicOrigin,
|
||||
sourceCodeUrl,
|
||||
commit,
|
||||
|
||||
livekit: {
|
||||
url: envOptional('LIVEKIT_URL'),
|
||||
|
||||
@@ -82,7 +82,7 @@ describe('GET /api/instance/info', () => {
|
||||
expect(body.federatedRegistrationOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('returns the full contract: name, version, registrationOpen, federatedRegistrationOpen', async () => {
|
||||
it('returns the full contract: name, version, registrationOpen, federatedRegistrationOpen, sourceCodeUrl, commit', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/api/instance/info' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
@@ -90,5 +90,9 @@ describe('GET /api/instance/info', () => {
|
||||
expect(typeof body.version).toBe('string');
|
||||
expect(typeof body.registrationOpen).toBe('boolean');
|
||||
expect(typeof body.federatedRegistrationOpen).toBe('boolean');
|
||||
// AGPL § 13 source offer — always a URL; commit is a string or null.
|
||||
expect(typeof body.sourceCodeUrl).toBe('string');
|
||||
expect(body.sourceCodeUrl).toMatch(/^https?:\/\//);
|
||||
expect(body.commit === null || typeof body.commit === 'string').toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,10 @@ export async function instanceRoutes(app: FastifyInstance): Promise<void> {
|
||||
version: BACKSPACE_VERSION,
|
||||
registrationOpen,
|
||||
federatedRegistrationOpen: settings?.federatedRegistrationOpen === 1,
|
||||
// AGPL-3.0 § 13: advertise the source of the running version to every
|
||||
// network user (and federated peer) — public/unauthenticated by design.
|
||||
sourceCodeUrl: config.sourceCodeUrl,
|
||||
commit: config.commit,
|
||||
};
|
||||
|
||||
return reply.code(200).send(response);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@backspace/shared",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"license": "Elastic-2.0",
|
||||
"license": "AGPL-3.0-only",
|
||||
"author": "Jannis Braun",
|
||||
"type": "module",
|
||||
"main": "./src/types.ts",
|
||||
|
||||
@@ -798,6 +798,12 @@ export interface InstanceInfoResponse {
|
||||
version: string;
|
||||
registrationOpen: boolean;
|
||||
federatedRegistrationOpen: boolean;
|
||||
// AGPL-3.0 § 13 network-use source offer: URL to the Corresponding Source of
|
||||
// the version this instance is running (operator-configurable via
|
||||
// BACKSPACE_SOURCE_URL so forks point at their own source).
|
||||
sourceCodeUrl: string;
|
||||
// Short git SHA/tag of the running build; null in dev builds with no commit injected.
|
||||
commit: string | null;
|
||||
}
|
||||
|
||||
export interface VerifyPasswordRequest {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||
<meta name="description" content="The self-hosted Discord and TeamSpeak alternative. HD voice, video, and screen share — open-source and free." />
|
||||
<meta name="description" content="The self-hosted Discord and TeamSpeak alternative. HD voice, video, and screen share — open source and free (AGPL-3.0)." />
|
||||
<meta name="theme-color" content="#0b0b10" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@backspace/web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"license": "Elastic-2.0",
|
||||
"license": "AGPL-3.0-only",
|
||||
"author": "Jannis Braun",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { RateLimitError } from '../../api/client';
|
||||
import { api, RateLimitError } from '../../api/client';
|
||||
import type { InstanceInfoResponse } from '@backspace/shared';
|
||||
import { SourceCodeLink } from '../ui/SourceCodeLink';
|
||||
|
||||
export function LoginPage() {
|
||||
const [username, setUsername] = useState('');
|
||||
@@ -14,6 +16,17 @@ export function LoginPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const redirect = searchParams.get('redirect');
|
||||
|
||||
// AGPL § 13: anonymous users must be able to reach the source of the running
|
||||
// version. Fetched from the unauthenticated public info endpoint.
|
||||
const [instanceInfo, setInstanceInfo] = useState<InstanceInfoResponse | null>(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.instance.info()
|
||||
.then((info) => { if (!cancelled) setInstanceInfo(info); })
|
||||
.catch(() => { /* Non-critical — link is simply omitted if unreachable. */ });
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (retryAfter <= 0) return;
|
||||
const timer = setInterval(() => {
|
||||
@@ -129,6 +142,12 @@ export function LoginPage() {
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
{instanceInfo && (
|
||||
<div className="mt-6 pt-4 border-t border-white/[0.04] flex justify-center">
|
||||
<SourceCodeLink sourceCodeUrl={instanceInfo.sourceCodeUrl} version={instanceInfo.version} commit={instanceInfo.commit} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { AvatarColor, CheckInviteResponse, InstanceInfoResponse } from '@ba
|
||||
import { api, RateLimitError } from '../../api/client';
|
||||
import { useTransferStore } from '../../stores/transferStore';
|
||||
import { waitForTransferAttachment } from '../../utils/waitForTransfer';
|
||||
import { SourceCodeLink } from '../ui/SourceCodeLink';
|
||||
|
||||
// Single-source regex for extracting a bare invite token from a pasted full URL.
|
||||
// Token format: 22 chars base64url ([A-Za-z0-9_-]).
|
||||
@@ -727,6 +728,13 @@ export function RegisterPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AGPL § 13: source offer for anonymous visitors, shown on both steps. */}
|
||||
{instanceInfo && (
|
||||
<div className="mt-6 pt-4 border-t border-white/[0.04] flex justify-center">
|
||||
<SourceCodeLink sourceCodeUrl={instanceInfo.sourceCodeUrl} version={instanceInfo.version} commit={instanceInfo.commit} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { SourceCodeLink } from '../ui/SourceCodeLink';
|
||||
import { api } from '../../api/client';
|
||||
import type { InstanceInfoResponse } from '@backspace/shared';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { AccountPanel } from './settingsPanels/AccountPanel';
|
||||
@@ -59,9 +62,21 @@ export function UserSettingsModal() {
|
||||
|
||||
const [tab, setTab] = useState<SettingsTab>('account');
|
||||
const [mobileView, setMobileView] = useState<'tabs' | 'content'>('tabs');
|
||||
// AGPL § 13: home-instance source offer. Fetched from the public info endpoint
|
||||
// so the source link reflects the version this instance is actually running.
|
||||
const [instanceInfo, setInstanceInfo] = useState<InstanceInfoResponse | null>(null);
|
||||
|
||||
const isOpen = activeModal === 'userSettings';
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
let cancelled = false;
|
||||
api.instance.info()
|
||||
.then((info) => { if (!cancelled) setInstanceInfo(info); })
|
||||
.catch(() => { /* Non-critical — link falls back to hidden if unreachable. */ });
|
||||
return () => { cancelled = true; };
|
||||
}, [isOpen]);
|
||||
|
||||
// Deep-linking: read modalData.tab when opening
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
@@ -148,6 +163,12 @@ export function UserSettingsModal() {
|
||||
>
|
||||
Log Out
|
||||
</button>
|
||||
|
||||
{instanceInfo && (
|
||||
<div className="px-3 pt-2">
|
||||
<SourceCodeLink sourceCodeUrl={instanceInfo.sourceCodeUrl} version={instanceInfo.version} commit={instanceInfo.commit} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -196,6 +217,12 @@ export function UserSettingsModal() {
|
||||
>
|
||||
Log Out
|
||||
</button>
|
||||
|
||||
{instanceInfo && (
|
||||
<div className="px-3 pt-2">
|
||||
<SourceCodeLink sourceCodeUrl={instanceInfo.sourceCodeUrl} version={instanceInfo.version} commit={instanceInfo.commit} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
interface SourceCodeLinkProps {
|
||||
/** URL to the Corresponding Source of the running version (AGPL § 13). */
|
||||
sourceCodeUrl: string;
|
||||
/** Running version string, e.g. "1.0.0". Omitted from the label when unknown. */
|
||||
version?: string;
|
||||
/** Short git commit/tag of the running build; appended to pin the exact version. */
|
||||
commit?: string | null;
|
||||
/** Extra classes for layout composition (alignment, spacing) at the call site. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* AGPL-3.0 § 13 "network-use source offer".
|
||||
*
|
||||
* Renders an accessible external link to the Corresponding Source of the version
|
||||
* the instance is actually running. The URL is operator-configurable server-side
|
||||
* (BACKSPACE_SOURCE_URL) and surfaced via GET /api/instance/info, so a modified
|
||||
* self-hosted fork points humans at its own source.
|
||||
*
|
||||
* Rendered on every network-facing surface (settings sidebars, pre-auth pages)
|
||||
* so any network user — authenticated or anonymous — can reach the source.
|
||||
*/
|
||||
export function SourceCodeLink({ sourceCodeUrl, version, commit, className }: SourceCodeLinkProps) {
|
||||
const build = version ? `v${version}${commit ? ` (${commit})` : ''}` : '';
|
||||
const label = build ? `Source code (AGPL) · ${build}` : 'Source code (AGPL)';
|
||||
|
||||
return (
|
||||
<a
|
||||
href={sourceCodeUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className={`inline-flex items-center gap-1.5 text-xs text-txt-tertiary hover:text-txt-secondary transition-colors${
|
||||
className ? ` ${className}` : ''
|
||||
}`}
|
||||
title="View the source code of the version this instance is running (AGPL-3.0)"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<polyline points="16 18 22 12 16 6" />
|
||||
<polyline points="8 6 2 12 8 18" />
|
||||
</svg>
|
||||
<span>{label}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -536,9 +536,30 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
|
||||
try {
|
||||
const user = await inst.api.users.me();
|
||||
|
||||
// Refresh the instance label (non-critical — a failure here must not
|
||||
// block a successful token reconnect).
|
||||
let info: (InstanceInfoResponse) | null = null;
|
||||
try {
|
||||
info = await inst.api.instance.info();
|
||||
} catch {
|
||||
// Keep whatever label the instance already had.
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
instances: state.instances.map(i =>
|
||||
i.origin === origin ? { ...i, status: 'connected' as const, user, error: undefined } : i
|
||||
i.origin === origin
|
||||
? {
|
||||
...i,
|
||||
status: 'connected' as const,
|
||||
user,
|
||||
error: undefined,
|
||||
...(info
|
||||
? {
|
||||
label: info.name,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: i
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -996,10 +1017,11 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
|
||||
// Verify the token is still valid
|
||||
const user = await client.users.me();
|
||||
|
||||
// Fetch instance info for fresh label
|
||||
// Fetch instance info for a fresh label
|
||||
let label = cachedEntry.label || new URL(origin).host;
|
||||
let info: InstanceInfoResponse | null = null;
|
||||
try {
|
||||
const info = await client.instance.info();
|
||||
info = await client.instance.info();
|
||||
label = info.name;
|
||||
} catch {
|
||||
// Non-critical — keep cached label
|
||||
|
||||
@@ -13,7 +13,7 @@ export default defineConfig({
|
||||
manifest: {
|
||||
name: 'Backspace',
|
||||
short_name: 'Backspace',
|
||||
description: 'The self-hosted Discord and TeamSpeak alternative. HD voice, video, and screen share — open-source and free.',
|
||||
description: 'The self-hosted Discord and TeamSpeak alternative. HD voice, video, and screen share — open source and free (AGPL-3.0).',
|
||||
display: 'standalone',
|
||||
start_url: '/',
|
||||
theme_color: '#0b0b10',
|
||||
|
||||
Reference in New Issue
Block a user