Public-release prep: ELv2 license, README/CLA/NOTICE, SSRF safeFetch, identifier genericization, export tooling

This commit is contained in:
Jannis Braun
2026-06-22 16:04:03 +02:00
parent c0a6477059
commit 8dd76f3435
44 changed files with 1272 additions and 267 deletions
@@ -18,8 +18,8 @@ vi.mock('../../api/client', () => ({
const actor: User = {
id: 'U1',
username: 'jannis',
displayName: 'Jannis',
username: 'heidi',
displayName: 'Heidi',
avatar: null,
banner: null,
accentColor: null,
@@ -66,18 +66,18 @@ function renderSM(message: MessageWithUser, dmArg: Pick<DmChannel, 'members'> |
}
describe('SystemMessage — name_changed', () => {
it('newName="Cool Group" with resolvable actor → "✎ Jannis renamed the group to \\"Cool Group\\""', () => {
it('newName="Cool Group" with resolvable actor → "✎ Heidi renamed the group to \\"Cool Group\\""', () => {
const msg = buildMessage({ event: 'name_changed', oldName: null, newName: 'Cool Group' });
renderSM(msg, dm);
expect(screen.getByText('✎')).toBeDefined();
expect(screen.getByText(/Jannis renamed the group to "Cool Group"/)).toBeDefined();
expect(screen.getByText(/Heidi renamed the group to "Cool Group"/)).toBeDefined();
});
it('newName=null (cleared) with resolvable actor → "✎ Jannis cleared the group name"', () => {
it('newName=null (cleared) with resolvable actor → "✎ Heidi cleared the group name"', () => {
const msg = buildMessage({ event: 'name_changed', oldName: 'Old', newName: null });
renderSM(msg, dm);
expect(screen.getByText('✎')).toBeDefined();
expect(screen.getByText(/Jannis cleared the group name/)).toBeDefined();
expect(screen.getByText(/Heidi cleared the group name/)).toBeDefined();
});
it('unresolvable actor (member missing from roster) → "✎ Unknown renamed …"', () => {
@@ -88,11 +88,11 @@ describe('SystemMessage — name_changed', () => {
});
describe('SystemMessage — icon_changed', () => {
it('resolvable actor → "🖼 Jannis updated the group icon"', () => {
it('resolvable actor → "🖼 Heidi updated the group icon"', () => {
const msg = buildMessage({ event: 'icon_changed' });
renderSM(msg, dm);
// The 🖼 character is U+1F5BC (FRAME WITH PICTURE), not 🖼️ (with VS-16).
expect(screen.getByText('\u{1F5BC}')).toBeDefined();
expect(screen.getByText(/Jannis updated the group icon/)).toBeDefined();
expect(screen.getByText(/Heidi updated the group icon/)).toBeDefined();
});
});
@@ -33,11 +33,11 @@ vi.mock('../audio/AudioManager', () => ({
const mockUser = {
id: 'user-1',
username: 'youruser',
username: 'erin',
homeInstance: null,
homeUserId: 'user-1',
replicatedInstances: [
{ origin: 'https://orbit.example', username: 'youruser@nova.example' },
{ origin: 'https://orbit.example', username: 'erin@nova.example' },
],
};
@@ -90,7 +90,7 @@ describe('instanceStore registry sync gating', () => {
registry: [{
origin: 'https://orbit.example',
label: 'Orbit',
username: 'youruser@nova.example',
username: 'erin@nova.example',
remoteUserId: 'remote-1',
status: 'auth_expired',
addedAt: 1,
+1 -1
View File
@@ -242,7 +242,7 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
}
// Step 2: Compute the user's true home identity
// If we're a federated user (e.g. youruser@nova browsing orbit),
// If we're a federated user (e.g. erin@nova browsing orbit),
// homeInstance points to the real home, not window.location.host.
const trueHomeHost = currentUser.homeInstance ?? window.location.host;
const bareUsername = currentUser.username.includes('@')
@@ -75,28 +75,28 @@ describe('spaceStore.upsertUserView preference rule', () => {
});
it('home view (delivered by user home) wins over an existing stub', () => {
// orbit delivers Axel as a federated stub (axel's home is nova).
// orbit delivers Frank as a federated stub (frank's home is nova).
const stubAxel = makeUser({
id: 'orbit-local-id',
username: 'axel@nova.ddns.net',
homeUserId: 'nova-axel-id',
username: 'frank@nova.ddns.net',
homeUserId: 'nova-frank-id',
homeInstance: 'nova.ddns.net',
avatarColor: 'lavender',
avatar: 'https://nova.ddns.net/api/uploads/old.png',
});
useSpaceStore.getState().upsertUserView(stubAxel, 'https://orbit.ddns.net');
// Then nova delivers axel natively (no homeInstance, our home origin '').
// Then nova delivers frank natively (no homeInstance, our home origin '').
// canonicalUserKey for the home view: needs to match the stub's key.
// Stub key = "nova.ddns.net:nova-axel-id".
// Home view (nova native): homeInstance=null, homeUserId=null, id="nova-axel-id"
// → key = ":nova-axel-id"
// Stub key = "nova.ddns.net:nova-frank-id".
// Home view (nova native): homeInstance=null, homeUserId=null, id="nova-frank-id"
// → key = ":nova-frank-id"
// These keys are different on purpose: the home record on its home instance
// has no homeInstance/homeUserId. The cross-instance match relies on the
// stub being the federated form. Verify behavior accordingly.
const homeAxel = makeUser({
id: 'nova-axel-id',
username: 'axel',
id: 'nova-frank-id',
username: 'frank',
avatar: '',
avatarColor: 'teal',
});
@@ -111,19 +111,19 @@ describe('spaceStore.upsertUserView preference rule', () => {
});
it('two same-canonical-key federated views: home delivery upgrades over sibling stub', () => {
// Same person, same canonical key (homeInstance=nova, homeUserId=nova-axel-id),
// Same person, same canonical key (homeInstance=nova, homeUserId=nova-frank-id),
// but delivered from two different origins.
const fromOrbit = makeUser({
id: 'orbit-local',
username: 'axel@nova.ddns.net',
homeUserId: 'nova-axel-id',
username: 'frank@nova.ddns.net',
homeUserId: 'nova-frank-id',
homeInstance: 'nova.ddns.net',
avatarColor: 'lavender',
});
const fromNova = makeUser({
id: 'nova-local',
username: 'axel@nova.ddns.net',
homeUserId: 'nova-axel-id',
username: 'frank@nova.ddns.net',
homeUserId: 'nova-frank-id',
homeInstance: 'nova.ddns.net',
avatarColor: 'teal',
});
@@ -140,15 +140,15 @@ describe('spaceStore.upsertUserView preference rule', () => {
it('stub view does NOT overwrite an existing home view', () => {
const fromNova = makeUser({
id: 'nova-local',
username: 'axel@nova.ddns.net',
homeUserId: 'nova-axel-id',
username: 'frank@nova.ddns.net',
homeUserId: 'nova-frank-id',
homeInstance: 'nova.ddns.net',
avatarColor: 'teal',
});
const fromOrbit = makeUser({
id: 'orbit-local',
username: 'axel@nova.ddns.net',
homeUserId: 'nova-axel-id',
username: 'frank@nova.ddns.net',
homeUserId: 'nova-frank-id',
homeInstance: 'nova.ddns.net',
avatarColor: 'lavender',
});
@@ -165,15 +165,15 @@ describe('spaceStore.upsertUserView preference rule', () => {
it('same-tier writes update freshness (later write wins)', () => {
const a = makeUser({
id: 'orbit-1',
username: 'axel@nova.ddns.net',
homeUserId: 'nova-axel-id',
username: 'frank@nova.ddns.net',
homeUserId: 'nova-frank-id',
homeInstance: 'nova.ddns.net',
avatarColor: 'lavender',
});
const b = makeUser({
id: 'orbit-1',
username: 'axel@nova.ddns.net',
homeUserId: 'nova-axel-id',
username: 'frank@nova.ddns.net',
homeUserId: 'nova-frank-id',
homeInstance: 'nova.ddns.net',
avatarColor: 'sky', // simulating a later profile-update event
});
@@ -195,14 +195,14 @@ describe('spaceStore.upsertUserView preference rule', () => {
it('removeInstanceSpaces prunes entries delivered by the removed origin only', () => {
const homeView = makeUser({
id: 'nova-axel-id',
username: 'axel',
id: 'nova-frank-id',
username: 'frank',
avatarColor: 'teal',
});
const stubView = makeUser({
id: 'orbit-axel-stub',
username: 'axel@nova.ddns.net',
homeUserId: 'nova-axel-id',
id: 'orbit-frank-stub',
username: 'frank@nova.ddns.net',
homeUserId: 'nova-frank-id',
homeInstance: 'nova.ddns.net',
avatarColor: 'lavender',
});
@@ -221,8 +221,8 @@ describe('spaceStore.upsertUserView preference rule', () => {
it('removeInstanceSpaces of the home origin evicts entries it delivered', () => {
const homeView = makeUser({
id: 'nova-axel-id',
username: 'axel',
id: 'nova-frank-id',
username: 'frank',
avatarColor: 'teal',
});
useSpaceStore.getState().upsertUserView(homeView, '');
@@ -231,20 +231,20 @@ describe('spaceStore.upsertUserView preference rule', () => {
});
it('treats native users delivered by a remote as that remote\'s home view', () => {
// jannis is native to orbit (homeInstance=null on orbit). When orbit
// heidi is native to orbit (homeInstance=null on orbit). When orbit
// delivers him, that's the home view. canonicalKey uses orbit-host.
const jannis = makeUser({
id: 'orbit-jannis-id',
username: 'jannis',
const heidi = makeUser({
id: 'orbit-heidi-id',
username: 'heidi',
avatarColor: 'sky',
});
useSpaceStore.getState().upsertUserView(jannis, 'https://orbit.ddns.net');
// Key is built from user.homeInstance — but jannis has none. So the key is
// ':orbit-jannis-id'. That's correct: when delivered later from a sibling,
// jannis would arrive WITH homeInstance set (synthesized by normalizeUserAssets),
useSpaceStore.getState().upsertUserView(heidi, 'https://orbit.ddns.net');
// Key is built from user.homeInstance — but heidi has none. So the key is
// ':orbit-heidi-id'. That's correct: when delivered later from a sibling,
// heidi would arrive WITH homeInstance set (synthesized by normalizeUserAssets),
// producing a different (federated) key. The cache holds both, with the
// home view winning on a cross-key collision-free basis.
const entry = useSpaceStore.getState().userViews.get(`:${jannis.id}`);
const entry = useSpaceStore.getState().userViews.get(`:${heidi.id}`);
expect(entry?.isHome).toBe(true);
});
});
+17
View File
@@ -35,6 +35,23 @@ for (const name of ['localStorage', 'sessionStorage'] as const) {
});
}
// jsdom does not implement navigator.mediaDevices. Provide a default no-op stub
// so components that enumerate devices or subscribe to `devicechange` (e.g.
// MobileVoiceFullScreen) don't crash during render. Tests that need real device
// behavior override it per-test (configurable: true).
if (!navigator.mediaDevices) {
Object.defineProperty(navigator, 'mediaDevices', {
value: {
enumerateDevices: () => Promise.resolve([]),
getUserMedia: () => Promise.reject(new Error('mediaDevices.getUserMedia not available in tests')),
addEventListener: () => {},
removeEventListener: () => {},
},
configurable: true,
writable: true,
});
}
// Polyfill ClipboardItem for jsdom (not included in jsdom)
if (typeof ClipboardItem === 'undefined') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
+5 -5
View File
@@ -185,8 +185,8 @@ describe('formatDmPreview', () => {
const actor: User = {
id: 'U1',
username: 'jannis',
displayName: 'Jannis',
username: 'heidi',
displayName: 'Heidi',
avatarColor: 'mint',
avatar: null,
bio: null,
@@ -217,7 +217,7 @@ describe('formatDmSidebarPreview — name_changed system message', () => {
content: JSON.stringify({ event: 'name_changed', oldName: null, newName: 'Cool Group' }),
createdAt: 1,
});
expect(formatDmSidebarPreview(dm, { id: 'OTHER', username: 'other' })).toBe('Jannis renamed the group');
expect(formatDmSidebarPreview(dm, { id: 'OTHER', username: 'other' })).toBe('Heidi renamed the group');
});
it('newName=null (cleared) → "<actor> cleared the group name"', () => {
@@ -227,7 +227,7 @@ describe('formatDmSidebarPreview — name_changed system message', () => {
content: JSON.stringify({ event: 'name_changed', oldName: 'Old', newName: null }),
createdAt: 1,
});
expect(formatDmSidebarPreview(dm, { id: 'OTHER', username: 'other' })).toBe('Jannis cleared the group name');
expect(formatDmSidebarPreview(dm, { id: 'OTHER', username: 'other' })).toBe('Heidi cleared the group name');
});
it('unresolvable actor → "Unknown renamed the group"', () => {
@@ -391,7 +391,7 @@ describe('formatDmSidebarPreview — icon_changed system message', () => {
content: JSON.stringify({ event: 'icon_changed' }),
createdAt: 1,
});
expect(formatDmSidebarPreview(dm, { id: 'OTHER', username: 'other' })).toBe('Jannis updated the group icon');
expect(formatDmSidebarPreview(dm, { id: 'OTHER', username: 'other' })).toBe('Heidi updated the group icon');
});
it('unresolvable actor → "Unknown updated the group icon"', () => {
+1 -1
View File
@@ -299,7 +299,7 @@ export function formatDmHeaderName(dm: DmChannel, currentUser: AuthLike): string
*
* The unnamed-group case intentionally collapses to a generic noun: the
* joined-names form is unreadable as a one-line placeholder once a group
* has 4+ members ("Message #Test, Nova, youruser, Nova" runs off-screen
* has 4+ members ("Message #Test, Nova, erin, Nova" runs off-screen
* and obscures the actual call-to-action).
*/
export function formatDmInputLabel(dm: DmChannel, currentUser: AuthLike): string {
+9 -9
View File
@@ -16,7 +16,7 @@ describe('normalizeOriginToHost', () => {
it('extracts host from full URLs', () => {
expect(normalizeOriginToHost('https://nova.ddns.net')).toBe('nova.ddns.net');
expect(normalizeOriginToHost('http://localhost:3000')).toBe('localhost:3000');
expect(normalizeOriginToHost('https://orbit.example.com:8443/path')).toBe('orbit.example.com:8443');
expect(normalizeOriginToHost('https://orbit.ddns.net:8443/path')).toBe('orbit.ddns.net:8443');
});
it('returns bare-domain inputs unchanged', () => {
@@ -47,12 +47,12 @@ describe('canonicalUserKey', () => {
it('produces the same key for stubs of the same person across instances', () => {
const fromOrbit = canonicalUserKey({
id: 'orbitLocalId',
homeUserId: 'nova-axel',
homeUserId: 'nova-frank',
homeInstance: 'nova.ddns.net',
});
const fromAnotherPeer = canonicalUserKey({
id: 'otherPeerLocalId',
homeUserId: 'nova-axel',
homeUserId: 'nova-frank',
homeInstance: 'nova.ddns.net',
});
expect(fromOrbit).toBe(fromAnotherPeer);
@@ -109,7 +109,7 @@ describe('isDeliveryFromHome', () => {
)).toBe(true);
});
it('rejects sibling-stub deliveries (orbit delivering Axel whose home is nova)', () => {
it('rejects sibling-stub deliveries (orbit delivering Frank whose home is nova)', () => {
expect(isDeliveryFromHome(
{ homeInstance: 'nova.ddns.net' },
'https://orbit.ddns.net',
@@ -141,16 +141,16 @@ describe('isFederationGlobeApplicable', () => {
});
it('returns false for purely-local users (no @domain in username)', () => {
expect(isFederationGlobeApplicable({ username: 'axel' })).toBe(false);
expect(isFederationGlobeApplicable({ username: 'youruser' })).toBe(false);
expect(isFederationGlobeApplicable({ username: 'frank' })).toBe(false);
expect(isFederationGlobeApplicable({ username: 'erin' })).toBe(false);
});
it('returns false when the username domain matches our own host (the load-bearing case)', () => {
// Logged in to nova; viewing orbit-stub of Axel whose username is "axel@nova.ddns.net".
expect(isFederationGlobeApplicable({ username: 'axel@nova.ddns.net' })).toBe(false);
// Logged in to nova; viewing orbit-stub of Frank whose username is "frank@nova.ddns.net".
expect(isFederationGlobeApplicable({ username: 'frank@nova.ddns.net' })).toBe(false);
});
it('returns true for genuinely remote users', () => {
expect(isFederationGlobeApplicable({ username: 'jannis@orbit.ddns.net' })).toBe(true);
expect(isFederationGlobeApplicable({ username: 'heidi@orbit.ddns.net' })).toBe(true);
});
});
+4 -4
View File
@@ -2,8 +2,8 @@ import type { User } from '@backspace/shared';
/**
* Splits a potentially federated username into base name and domain.
* "youruser@nova.ddns.net" → { baseName: "youruser", domain: "nova.ddns.net" }
* "youruser" → { baseName: "youruser", domain: null }
* "erin@nova.ddns.net" → { baseName: "erin", domain: "nova.ddns.net" }
* "erin" → { baseName: "erin", domain: null }
*/
export function parseFederatedUsername(username: string): { baseName: string; domain: string | null } {
const atIndex = username.indexOf('@');
@@ -42,7 +42,7 @@ export function isSelf(
// Replicated user: homeInstance matches our origin
if (!user.homeInstance) return false;
if (user.homeInstance !== window.location.host) return false;
// Username: "youruser" or "youruser@nova.ddns.net" → base must match
// Username: "erin" or "erin@nova.ddns.net" → base must match
const { baseName } = parseFederatedUsername(user.username);
const { baseName: homeBase } = parseFederatedUsername(homeUser.username);
return baseName === homeBase;
@@ -150,7 +150,7 @@ export function isDeliveryFromHome(
*
* True iff the user is genuinely remote: their username carries an `@domain`
* suffix AND that domain is NOT our own host. Catches the bug where a stub
* delivered by a sibling instance (e.g. orbit-side `axel@nova.ddns.net`
* delivered by a sibling instance (e.g. orbit-side `frank@nova.ddns.net`
* viewed from a session logged in to nova) would otherwise show the globe.
*
* Compose with {@link useCanonicalUserView} at render sites: resolve the
+11 -11
View File
@@ -66,9 +66,9 @@ beforeEach(() => {
describe('getCanonicalUserView', () => {
it('returns the input unchanged on cache miss', () => {
const stub = makeUser({
id: 'orbit-axel-stub',
username: 'axel@nova.ddns.net',
homeUserId: 'nova-axel-id',
id: 'orbit-frank-stub',
username: 'frank@nova.ddns.net',
homeUserId: 'nova-frank-id',
homeInstance: 'nova.ddns.net',
avatarColor: 'lavender',
});
@@ -77,16 +77,16 @@ describe('getCanonicalUserView', () => {
it('returns the cached entry when one exists for the same canonical key', () => {
const stub = makeUser({
id: 'orbit-axel-stub',
username: 'axel@nova.ddns.net',
homeUserId: 'nova-axel-id',
id: 'orbit-frank-stub',
username: 'frank@nova.ddns.net',
homeUserId: 'nova-frank-id',
homeInstance: 'nova.ddns.net',
avatarColor: 'lavender',
});
const homeFromNova = makeUser({
id: 'nova-local-id',
username: 'axel@nova.ddns.net',
homeUserId: 'nova-axel-id',
username: 'frank@nova.ddns.net',
homeUserId: 'nova-frank-id',
homeInstance: 'nova.ddns.net',
avatarColor: 'teal',
});
@@ -106,9 +106,9 @@ describe('getCanonicalUserView', () => {
useSpaceStore.getState().upsertUserView(someOther, '');
const stub = makeUser({
id: 'orbit-axel-stub',
username: 'axel@nova.ddns.net',
homeUserId: 'nova-axel-id',
id: 'orbit-frank-stub',
username: 'frank@nova.ddns.net',
homeUserId: 'nova-frank-id',
homeInstance: 'nova.ddns.net',
});
expect(getCanonicalUserView(stub)).toBe(stub);
+1 -1
View File
@@ -24,7 +24,7 @@ export function getCanonicalUserView(user: User): User {
/**
* Reactive lookup into the userViews cache. Subscribes to the specific cache
* entry so the calling component re-renders when an upsert lands a better
* view (e.g. nova's home view of Axel arriving after orbit's stub
* view (e.g. nova's home view of Frank arriving after orbit's stub
* populated the cache first). Returns the input unchanged on cache miss; the
* site falls back to the current best information until the cache fills.
*