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"
+ >
+
+
Alter Link wird sofort ungültig. Weiter?
+
+
+
+
+
+{/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);
+}