From 5501f472aec59d22fed8a94bae0173acac4efeb0 Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 24 Apr 2026 17:18:56 +0200 Subject: [PATCH] =?UTF-8?q?feat(shared-privacy):=20M8.2=20=E2=80=94=20unli?= =?UTF-8?q?sted-client=20+=20SharedLinkControls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second milestone of the unlisted-share rollout. Backend endpoints from M8.1 are now callable from the client, and a reusable SharedLinkControls component is available for the detail views that wire up in M8.3/M8.4. Scope: shared primitives only. No module store integrates them yet — that's the next step per module. Changes: - @mana/shared-privacy/unlisted-client.ts: publishUnlistedSnapshot(opts) → { token, url } Idempotent per (collection, recordId) — server reuses token on re-publish, so store code can call on every edit without caring whether it's first publish or refresh. revokeUnlistedSnapshot(opts) Idempotent — resolves silently even on { revoked: 0 }. buildShareUrl(origin, token) Convenience for UIs that already know the token. UnlistedApiError Thrown on non-2xx. Carries { status, code } so callers can distinguish 400 COLLECTION_NOT_ALLOWED vs 410 REVOKED vs 500 UNKNOWN. - @mana/shared-privacy/SharedLinkControls.svelte: Dumb presentational component. Props: token, url, expiresAt, onRegenerate, onRevoke, onExpiryChange (optional), disabled. Renders URL + copy, regenerate with confirm dialog, revoke, optional datetime-local expiry picker, debug token fingerprint. Clipboard-API fallback to prompt() for unsecure origins. QR-code button deferred to M8.5 polish. - Exports added to index.ts: functions, error class, both types, SharedLinkControls component. - 10 new unit tests (25 total): publish URL shape, headers, body, expiresAt serialisation, 4xx/5xx handling, trailing-slash trimming on apiUrl, revoke idempotence, buildShareUrl join. Verified: - pnpm --filter @mana/shared-privacy test: 25/25 green - pnpm --filter @mana/shared-privacy check: 0 errors - pnpm --filter @mana/web check: 7531 files, 0 errors Next: M8.3 — wire Calendar through the new client. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/SharedLinkControls.svelte | 335 ++++++++++++++++++ packages/shared-privacy/src/index.ts | 11 + .../src/unlisted-client.test.ts | 193 ++++++++++ .../shared-privacy/src/unlisted-client.ts | 137 +++++++ 4 files changed, 676 insertions(+) create mode 100644 packages/shared-privacy/src/SharedLinkControls.svelte create mode 100644 packages/shared-privacy/src/unlisted-client.test.ts create mode 100644 packages/shared-privacy/src/unlisted-client.ts diff --git a/packages/shared-privacy/src/SharedLinkControls.svelte b/packages/shared-privacy/src/SharedLinkControls.svelte new file mode 100644 index 000000000..0cac6fdfb --- /dev/null +++ b/packages/shared-privacy/src/SharedLinkControls.svelte @@ -0,0 +1,335 @@ + + + +
+
Geteilter Link
+ +
+ (e.currentTarget as HTMLInputElement).select()} + /> + +
+ +
+ + +
+ + {#if onExpiryChange} +
+ {#if editingExpiry || expiresAt} + + {#if expiresAt} + + {/if} + {:else} + + {/if} +
+ {/if} + + +

Token: {token.slice(0, 8)}…

+
+ +{#if showConfirmRegenerate} + + +
(showConfirmRegenerate = false)} + role="presentation" + >
+ +{/if} + + diff --git a/packages/shared-privacy/src/index.ts b/packages/shared-privacy/src/index.ts index a0492de42..fe773bc36 100644 --- a/packages/shared-privacy/src/index.ts +++ b/packages/shared-privacy/src/index.ts @@ -32,4 +32,15 @@ export { } from './predicates'; export { generateUnlistedToken } from './tokens'; +export { + publishUnlistedSnapshot, + revokeUnlistedSnapshot, + buildShareUrl, + UnlistedApiError, + type PublishUnlistedOptions, + type PublishUnlistedResult, + type RevokeUnlistedOptions, +} from './unlisted-client'; + export { default as VisibilityPicker } from './VisibilityPicker.svelte'; +export { default as SharedLinkControls } from './SharedLinkControls.svelte'; diff --git a/packages/shared-privacy/src/unlisted-client.test.ts b/packages/shared-privacy/src/unlisted-client.test.ts new file mode 100644 index 000000000..1ec8909bf --- /dev/null +++ b/packages/shared-privacy/src/unlisted-client.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { + publishUnlistedSnapshot, + revokeUnlistedSnapshot, + buildShareUrl, + UnlistedApiError, +} from './unlisted-client'; + +const API = 'http://localhost:3060'; +const JWT = 'test-jwt'; + +function mockFetch(impl: (url: string, init: RequestInit) => Promise) { + const fetchMock = vi.fn(impl); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +describe('publishUnlistedSnapshot', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('POSTs to /api/v1/unlisted/:collection/:recordId with jwt + body', async () => { + const fetchMock = mockFetch(async () => { + return new Response(JSON.stringify({ token: 'AbC123', url: 'http://w/share/AbC123' }), { + status: 201, + headers: { 'content-type': 'application/json' }, + }); + }); + + const res = await publishUnlistedSnapshot({ + apiUrl: API, + jwt: JWT, + collection: 'events', + recordId: '12345678-1234-1234-1234-123456789012', + spaceId: '_personal:u1', + blob: { title: 'Concert' }, + }); + + expect(res).toEqual({ token: 'AbC123', url: 'http://w/share/AbC123' }); + expect(fetchMock).toHaveBeenCalledWith( + `${API}/api/v1/unlisted/events/12345678-1234-1234-1234-123456789012`, + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + authorization: `Bearer ${JWT}`, + 'content-type': 'application/json', + }), + }) + ); + + const body = JSON.parse((fetchMock.mock.calls[0]?.[1]?.body as string) ?? '{}'); + expect(body).toEqual({ + spaceId: '_personal:u1', + blob: { title: 'Concert' }, + }); + }); + + it('includes expiresAt as ISO string when provided', async () => { + const fetchMock = mockFetch(async () => { + return new Response(JSON.stringify({ token: 'X', url: 'u' }), { status: 200 }); + }); + const exp = new Date('2030-01-15T12:00:00Z'); + + await publishUnlistedSnapshot({ + apiUrl: API, + jwt: JWT, + collection: 'events', + recordId: 'abc', + spaceId: 's', + blob: {}, + expiresAt: exp, + }); + + const body = JSON.parse((fetchMock.mock.calls[0]?.[1]?.body as string) ?? '{}'); + expect(body.expiresAt).toBe('2030-01-15T12:00:00.000Z'); + }); + + it('throws UnlistedApiError with code + status on non-2xx', async () => { + mockFetch(async () => { + return new Response( + JSON.stringify({ error: 'Collection not allowed', code: 'COLLECTION_NOT_ALLOWED' }), + { status: 400, headers: { 'content-type': 'application/json' } } + ); + }); + + await expect( + publishUnlistedSnapshot({ + apiUrl: API, + jwt: JWT, + collection: 'forbidden', + recordId: 'abc', + spaceId: 's', + blob: {}, + }) + ).rejects.toMatchObject({ + name: 'UnlistedApiError', + status: 400, + code: 'COLLECTION_NOT_ALLOWED', + message: 'Collection not allowed', + }); + }); + + it('falls back to generic error when server returns no JSON body', async () => { + mockFetch(async () => new Response('Internal Server Error', { status: 500 })); + + const promise = publishUnlistedSnapshot({ + apiUrl: API, + jwt: JWT, + collection: 'events', + recordId: 'abc', + spaceId: 's', + blob: {}, + }); + await expect(promise).rejects.toBeInstanceOf(UnlistedApiError); + await expect(promise).rejects.toMatchObject({ status: 500, code: 'UNKNOWN' }); + }); + + it('trims trailing slash from apiUrl', async () => { + const fetchMock = mockFetch(async () => { + return new Response(JSON.stringify({ token: 'x', url: 'u' }), { status: 200 }); + }); + + await publishUnlistedSnapshot({ + apiUrl: 'http://localhost:3060/', + jwt: JWT, + collection: 'events', + recordId: 'abc', + spaceId: 's', + blob: {}, + }); + + expect(fetchMock.mock.calls[0]?.[0]).toBe('http://localhost:3060/api/v1/unlisted/events/abc'); + }); +}); + +describe('revokeUnlistedSnapshot', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('DELETEs /api/v1/unlisted/:collection/:recordId with jwt', async () => { + const fetchMock = mockFetch(async () => { + return new Response(JSON.stringify({ revoked: 1 }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }); + + await revokeUnlistedSnapshot({ + apiUrl: API, + jwt: JWT, + collection: 'events', + recordId: 'abc', + }); + + expect(fetchMock).toHaveBeenCalledWith( + `${API}/api/v1/unlisted/events/abc`, + expect.objectContaining({ + method: 'DELETE', + headers: { authorization: `Bearer ${JWT}` }, + }) + ); + }); + + it('resolves silently even when server returns { revoked: 0 }', async () => { + mockFetch(async () => new Response(JSON.stringify({ revoked: 0 }), { status: 200 })); + + await expect( + revokeUnlistedSnapshot({ apiUrl: API, jwt: JWT, collection: 'events', recordId: 'abc' }) + ).resolves.toBeUndefined(); + }); + + it('throws UnlistedApiError on non-2xx', async () => { + mockFetch( + async () => + new Response(JSON.stringify({ error: 'Nope', code: 'NOPE' }), { + status: 403, + headers: { 'content-type': 'application/json' }, + }) + ); + + await expect( + revokeUnlistedSnapshot({ apiUrl: API, jwt: JWT, collection: 'events', recordId: 'abc' }) + ).rejects.toMatchObject({ status: 403, code: 'NOPE' }); + }); +}); + +describe('buildShareUrl', () => { + it('joins origin and token with /share/', () => { + expect(buildShareUrl('https://mana.how', 'AbC123')).toBe('https://mana.how/share/AbC123'); + }); + + it('trims trailing slash from origin', () => { + expect(buildShareUrl('https://mana.how/', 'AbC123')).toBe('https://mana.how/share/AbC123'); + }); +}); diff --git a/packages/shared-privacy/src/unlisted-client.ts b/packages/shared-privacy/src/unlisted-client.ts new file mode 100644 index 000000000..ad5debdef --- /dev/null +++ b/packages/shared-privacy/src/unlisted-client.ts @@ -0,0 +1,137 @@ +/** + * Thin client for the mana-api unlisted-snapshot endpoints. + * + * Wraps the three HTTP calls the module stores make when a record's + * visibility changes to/from 'unlisted' or its whitelisted fields get + * edited. + * + * Contract: + * - Publish is idempotent per (user+collection+recordId). Re-calling + * returns the same token; the blob is updated in place. + * - Revoke is idempotent. Calling on a never-published record returns + * { revoked: 0 } cleanly. + * - The server's 400/410/404 responses surface as `UnlistedApiError` + * so callers can disambiguate "link already dead" vs. "server + * refused the payload". + * + * See docs/plans/unlisted-sharing.md §3. + */ + +export interface PublishUnlistedOptions { + /** Base URL of mana-api (e.g. http://localhost:3060). */ + apiUrl: string; + /** Bearer token for the authenticated owner. */ + jwt: string; + /** Dexie collection name. Must match the server's ALLOWED_COLLECTIONS. */ + collection: string; + /** Original record id (UUID from Dexie). */ + recordId: string; + /** Active space id — server stores this for future admin-revoke-per-space. */ + spaceId: string; + /** Plaintext, whitelist-filtered payload. Built by the module's resolver. */ + blob: Record; + /** Optional expiry. `null` or `undefined` = never expires. */ + expiresAt?: Date | null; +} + +export interface PublishUnlistedResult { + token: string; + /** Full share URL the server computed from Origin/Referer/env. */ + url: string; +} + +export interface RevokeUnlistedOptions { + apiUrl: string; + jwt: string; + collection: string; + recordId: string; +} + +export class UnlistedApiError extends Error { + readonly status: number; + readonly code: string; + + constructor(message: string, status: number, code: string) { + super(message); + this.name = 'UnlistedApiError'; + this.status = status; + this.code = code; + } +} + +/** + * Publish a snapshot. Re-calling with the same (collection, recordId) + * updates the existing row and returns the same token — so store + * code can unconditionally call this on every edit of an unlisted + * record without caring whether it's the first publish or a refresh. + */ +export async function publishUnlistedSnapshot( + opts: PublishUnlistedOptions +): Promise { + const url = `${trimSlash(opts.apiUrl)}/api/v1/unlisted/${encodeURIComponent( + opts.collection + )}/${encodeURIComponent(opts.recordId)}`; + + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${opts.jwt}`, + }, + body: JSON.stringify({ + spaceId: opts.spaceId, + blob: opts.blob, + expiresAt: opts.expiresAt ? opts.expiresAt.toISOString() : undefined, + }), + }); + + if (!res.ok) throw await toApiError(res, 'publish'); + const data = (await res.json()) as PublishUnlistedResult; + return data; +} + +/** + * Revoke a snapshot. Idempotent — returns silently even if there was + * no active snapshot to revoke. The server returns { revoked: 0|1 }; + * we don't surface that to callers (they don't need to care). + */ +export async function revokeUnlistedSnapshot(opts: RevokeUnlistedOptions): Promise { + const url = `${trimSlash(opts.apiUrl)}/api/v1/unlisted/${encodeURIComponent( + opts.collection + )}/${encodeURIComponent(opts.recordId)}`; + + const res = await fetch(url, { + method: 'DELETE', + headers: { authorization: `Bearer ${opts.jwt}` }, + }); + + if (!res.ok) throw await toApiError(res, 'revoke'); +} + +/** + * Build the public share URL for a token given the webapp's origin. + * The server also returns this in the publish response — this helper + * exists so UIs that already know the token can render the URL + * without a round-trip. + */ +export function buildShareUrl(origin: string, token: string): string { + return `${trimSlash(origin)}/share/${token}`; +} + +// ─── Internal helpers ─────────────────────────────────── + +function trimSlash(s: string): string { + return s.endsWith('/') ? s.slice(0, -1) : s; +} + +async function toApiError(res: Response, op: 'publish' | 'revoke'): Promise { + let payload: { error?: string; code?: string } = {}; + try { + payload = (await res.json()) as { error?: string; code?: string }; + } catch { + // Body not JSON — fall through with defaults. + } + const message = payload.error ?? `Unlisted ${op} failed (${res.status})`; + const code = payload.code ?? 'UNKNOWN'; + return new UnlistedApiError(message, res.status, code); +}