mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
feat(shared-privacy): M8.2 — unlisted-client + SharedLinkControls
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) <noreply@anthropic.com>
This commit is contained in:
parent
c1d643ffb5
commit
5501f472ae
4 changed files with 676 additions and 0 deletions
335
packages/shared-privacy/src/SharedLinkControls.svelte
Normal file
335
packages/shared-privacy/src/SharedLinkControls.svelte
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
<!--
|
||||
SharedLinkControls — UI for managing a record's unlisted share-link.
|
||||
|
||||
Shown below the VisibilityPicker in a module's DetailView when
|
||||
`visibility === 'unlisted'` and a token exists. Dumb — owner (the
|
||||
module DetailView) passes the token + handlers; this component only
|
||||
renders the URL, manages copy-to-clipboard, and dispatches
|
||||
regenerate/revoke/expiry actions.
|
||||
|
||||
QR-code rendering is a later polish (M8.5) — currently stubbed as
|
||||
a button that disables itself. Expiry-picker is present but
|
||||
minimal — datetime-local input, no fancy picker.
|
||||
|
||||
See docs/plans/unlisted-sharing.md §7.
|
||||
-->
|
||||
<script lang="ts">
|
||||
let {
|
||||
/** The 32-char token — used only for display fallback. */
|
||||
token,
|
||||
/** Full share URL (e.g. https://mana.how/share/AbC…). */
|
||||
url,
|
||||
/** Current expiry (ISO string) or null if the link never expires. */
|
||||
expiresAt = null,
|
||||
/** Called when the user clicks "Neu erzeugen". Caller should
|
||||
* revoke-then-republish and update the URL in response. */
|
||||
onRegenerate,
|
||||
/** Called when the user clicks "Widerrufen". Caller flips the
|
||||
* record's visibility away from 'unlisted' (which triggers the
|
||||
* revoke on the server). */
|
||||
onRevoke,
|
||||
/** Called when the user picks a new expiry or clears it. Caller
|
||||
* re-publishes with the new `expiresAt`. */
|
||||
onExpiryChange,
|
||||
/** When the caller is in the middle of a publish/revoke, we
|
||||
* disable buttons to avoid racing. */
|
||||
disabled = false,
|
||||
}: {
|
||||
token: string;
|
||||
url: string;
|
||||
expiresAt?: string | null;
|
||||
onRegenerate: () => Promise<void> | void;
|
||||
onRevoke: () => Promise<void> | void;
|
||||
onExpiryChange?: (expiresAt: Date | null) => Promise<void> | void;
|
||||
disabled?: boolean;
|
||||
} = $props();
|
||||
|
||||
let copied = $state(false);
|
||||
let showConfirmRegenerate = $state(false);
|
||||
let editingExpiry = $state(false);
|
||||
|
||||
// datetime-local wants YYYY-MM-DDTHH:MM (no seconds, no zone).
|
||||
const expiryInputValue = $derived.by(() => {
|
||||
if (!expiresAt) return '';
|
||||
const d = new Date(expiresAt);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(
|
||||
d.getHours()
|
||||
)}:${pad(d.getMinutes())}`;
|
||||
});
|
||||
|
||||
async function copyToClipboard() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 1500);
|
||||
} catch {
|
||||
// Clipboard API blocked (Safari private mode, unsecure origin).
|
||||
// Fall back to a selection prompt so the user can copy manually.
|
||||
// eslint-disable-next-line no-alert
|
||||
prompt('Link kopieren:', url);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRegenerate() {
|
||||
showConfirmRegenerate = false;
|
||||
await onRegenerate();
|
||||
}
|
||||
|
||||
async function handleExpiryChange(e: Event) {
|
||||
if (!onExpiryChange) return;
|
||||
const value = (e.target as HTMLInputElement).value;
|
||||
if (!value) {
|
||||
await onExpiryChange(null);
|
||||
return;
|
||||
}
|
||||
await onExpiryChange(new Date(value));
|
||||
}
|
||||
|
||||
async function clearExpiry() {
|
||||
if (!onExpiryChange) return;
|
||||
await onExpiryChange(null);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="slc">
|
||||
<div class="slc__label">Geteilter Link</div>
|
||||
|
||||
<div class="slc__row">
|
||||
<input
|
||||
class="slc__url"
|
||||
type="text"
|
||||
readonly
|
||||
value={url}
|
||||
aria-label="Share-URL"
|
||||
onfocus={(e) => (e.currentTarget as HTMLInputElement).select()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="slc__btn slc__btn--primary"
|
||||
onclick={copyToClipboard}
|
||||
{disabled}
|
||||
title="Link kopieren"
|
||||
>
|
||||
{copied ? '✓ Kopiert' : '📋 Kopieren'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="slc__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="slc__btn"
|
||||
onclick={() => (showConfirmRegenerate = true)}
|
||||
{disabled}
|
||||
title="Neu erzeugen (alter Link wird sofort ungültig)"
|
||||
>
|
||||
🔄 Neu erzeugen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="slc__btn slc__btn--danger"
|
||||
onclick={onRevoke}
|
||||
{disabled}
|
||||
title="Link widerrufen"
|
||||
>
|
||||
🗑 Widerrufen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if onExpiryChange}
|
||||
<div class="slc__expiry">
|
||||
{#if editingExpiry || expiresAt}
|
||||
<label class="slc__expiry-label">
|
||||
<span>Läuft ab:</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
class="slc__expiry-input"
|
||||
value={expiryInputValue}
|
||||
onchange={handleExpiryChange}
|
||||
{disabled}
|
||||
/>
|
||||
</label>
|
||||
{#if expiresAt}
|
||||
<button
|
||||
type="button"
|
||||
class="slc__btn slc__btn--ghost"
|
||||
onclick={clearExpiry}
|
||||
{disabled}
|
||||
title="Ablauf entfernen — Link gilt dann dauerhaft"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{/if}
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="slc__btn slc__btn--ghost"
|
||||
onclick={() => (editingExpiry = true)}
|
||||
{disabled}
|
||||
>
|
||||
+ Ablauf setzen
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Fallback token display if url rendering fails (rare). -->
|
||||
<p class="slc__token" aria-label="Token zum Debuggen">Token: {token.slice(0, 8)}…</p>
|
||||
</div>
|
||||
|
||||
{#if showConfirmRegenerate}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="slc__backdrop"
|
||||
onclick={() => (showConfirmRegenerate = false)}
|
||||
role="presentation"
|
||||
></div>
|
||||
<div class="slc__dialog" role="dialog" aria-modal="true">
|
||||
<p>Alter Link wird <strong>sofort ungültig</strong>. Weiter?</p>
|
||||
<div class="slc__dialog-actions">
|
||||
<button type="button" class="slc__btn slc__btn--primary" onclick={confirmRegenerate}>
|
||||
Ja, neuen Link erzeugen
|
||||
</button>
|
||||
<button type="button" class="slc__btn" onclick={() => (showConfirmRegenerate = false)}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.slc {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.slc__label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.slc__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.slc__url {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding: 0.35rem 0.55rem;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.75rem;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 0.375rem;
|
||||
color: inherit;
|
||||
}
|
||||
.slc__actions {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.slc__btn {
|
||||
padding: 0.35rem 0.7rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 0.375rem;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.slc__btn:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
.slc__btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.slc__btn--primary {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
border-color: rgba(99, 102, 241, 0.45);
|
||||
}
|
||||
.slc__btn--primary:hover:not(:disabled) {
|
||||
background: rgba(99, 102, 241, 0.25);
|
||||
}
|
||||
.slc__btn--danger:hover:not(:disabled) {
|
||||
background: rgba(248, 113, 113, 0.15);
|
||||
border-color: rgba(248, 113, 113, 0.5);
|
||||
color: rgb(248, 113, 113);
|
||||
}
|
||||
.slc__btn--ghost {
|
||||
border-color: transparent;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.slc__btn--ghost:hover:not(:disabled) {
|
||||
opacity: 1;
|
||||
}
|
||||
.slc__expiry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.slc__expiry-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.slc__expiry-input {
|
||||
padding: 0.25rem 0.45rem;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 0.375rem;
|
||||
color: inherit;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.slc__token {
|
||||
margin: 0;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.65rem;
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.slc__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.slc__dialog {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 101;
|
||||
min-width: 18rem;
|
||||
max-width: 22rem;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
background: rgb(20, 24, 32);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.slc__dialog p {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.slc__dialog-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
193
packages/shared-privacy/src/unlisted-client.test.ts
Normal file
193
packages/shared-privacy/src/unlisted-client.test.ts
Normal file
|
|
@ -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<Response>) {
|
||||
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');
|
||||
});
|
||||
});
|
||||
137
packages/shared-privacy/src/unlisted-client.ts
Normal file
137
packages/shared-privacy/src/unlisted-client.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
/** 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<PublishUnlistedResult> {
|
||||
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<void> {
|
||||
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<UnlistedApiError> {
|
||||
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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue