From 9e043859303b6d1cfe4c9bcfd9a13ef3b9a4e285 Mon Sep 17 00:00:00 2001 From: Till JS Date: Sat, 25 Apr 2026 15:38:09 +0200 Subject: [PATCH] feat(augur): unlisted-snapshot publish pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit augur.setVisibility now coordinates with the server-side unlisted- snapshots table — same pattern as library/calendar/places. The local token-allocation placeholder from M6 is replaced with real publish/ revoke calls; deletion revokes any active link before tombstoning. - resolvers.ts: buildAugurEntryBlob with strict whitelist (source, claim, kind, vibe, encounteredAt, outcome, outcomeNote when resolved). NEVER inlines feltMeaning, expectedOutcome, probability, tags, livingOracleSnapshot, sourceCategory or related FK references — divinatory captures stay sensitive even when shared. - SharedAugurEntryView: SSR card with vibe-colored border, kind + date meta, outcome badge, "Wie es kam" section only when the sign was actually resolved. - Dispatcher in /share/[token]/+page.svelte gains the augurEntries branch. - mana-api ALLOWED_COLLECTIONS extended to four items so the publish endpoint accepts augurEntries. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/api/src/modules/unlisted/routes.ts | 2 +- .../web/src/lib/data/unlisted/resolvers.ts | 45 ++++ .../modules/augur/SharedAugurEntryView.svelte | 197 ++++++++++++++++++ .../modules/augur/stores/entries.svelte.ts | 78 +++++-- .../web/src/routes/share/[token]/+page.svelte | 3 + 5 files changed, 309 insertions(+), 16 deletions(-) create mode 100644 apps/mana/apps/web/src/lib/modules/augur/SharedAugurEntryView.svelte diff --git a/apps/api/src/modules/unlisted/routes.ts b/apps/api/src/modules/unlisted/routes.ts index 36544430b..eff0954a4 100644 --- a/apps/api/src/modules/unlisted/routes.ts +++ b/apps/api/src/modules/unlisted/routes.ts @@ -32,7 +32,7 @@ const routes = new Hono<{ Variables: AuthVariables }>(); * honest about what it accepts (a confused client trying to publish * an arbitrary collection gets 400). */ -const ALLOWED_COLLECTIONS = new Set(['events', 'libraryEntries', 'places']); +const ALLOWED_COLLECTIONS = new Set(['events', 'libraryEntries', 'places', 'augurEntries']); const PublishBodySchema = z.object({ spaceId: z.string().min(1).max(64), diff --git a/apps/mana/apps/web/src/lib/data/unlisted/resolvers.ts b/apps/mana/apps/web/src/lib/data/unlisted/resolvers.ts index edf69b1e0..17d10a272 100644 --- a/apps/mana/apps/web/src/lib/data/unlisted/resolvers.ts +++ b/apps/mana/apps/web/src/lib/data/unlisted/resolvers.ts @@ -21,6 +21,7 @@ import type { LocalEvent } from '$lib/modules/calendar/types'; import type { LocalLibraryEntry } from '$lib/modules/library/types'; import type { LocalPlace } from '$lib/modules/places/types'; import type { LocalTimeBlock } from '$lib/data/time-blocks/types'; +import type { LocalAugurEntry } from '$lib/modules/augur/types'; export class UnsupportedCollectionError extends Error { constructor(collection: string) { @@ -51,6 +52,8 @@ export async function buildUnlistedBlob( return buildLibraryEntryBlob(recordId); case 'places': return buildPlaceBlob(recordId); + case 'augurEntries': + return buildAugurEntryBlob(recordId); default: throw new UnsupportedCollectionError(collection); } @@ -177,3 +180,45 @@ async function buildPlaceBlob(recordId: string): Promise category: decrypted.category ?? 'other', }; } + +/** + * Augur entry → snapshot blob. + * + * Whitelist: source, claim, kind, vibe, encounteredAt, outcome, + * outcomeNote (only when resolved). Defensive about what counts as + * "shareable" for a divinatory record — this is sensitive territory. + * + * EXPLICITLY NOT inlined: + * - feltMeaning (the user's private interpretation — + * "soll den Job nicht annehmen" — never share) + * - expectedOutcome (private prediction) + * - probability (the user's forecaster number) + * - livingOracleSnapshot (data-derived hint, not narrative) + * - tags (organisational, can leak topology) + * - relatedDreamId / Decision (FK references would dox other modules) + * - sourceCategory (small-cardinality leak of method) + */ +async function buildAugurEntryBlob(recordId: string): Promise> { + const raw = await db.table('augurEntries').get(recordId); + if (!raw || raw.deletedAt) { + throw new RecordNotFoundError('augurEntries', recordId); + } + + const decrypted = (await decryptRecord('augurEntries', { ...raw })) as LocalAugurEntry; + + const isResolved = decrypted.outcome && decrypted.outcome !== 'open'; + + return { + source: decrypted.source, + claim: decrypted.claim, + kind: decrypted.kind, + vibe: decrypted.vibe, + encounteredAt: decrypted.encounteredAt, + outcome: decrypted.outcome ?? 'open', + // Only inline the post-mortem note when the user actually resolved + // the sign — open entries' outcomeNote is always null anyway, but + // being explicit keeps the contract clear. + outcomeNote: isResolved ? (decrypted.outcomeNote ?? null) : null, + resolvedAt: isResolved ? (decrypted.resolvedAt ?? null) : null, + }; +} diff --git a/apps/mana/apps/web/src/lib/modules/augur/SharedAugurEntryView.svelte b/apps/mana/apps/web/src/lib/modules/augur/SharedAugurEntryView.svelte new file mode 100644 index 000000000..a3a6d6814 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/augur/SharedAugurEntryView.svelte @@ -0,0 +1,197 @@ + + + +
+
+
+ {KIND_LABELS[entry.kind]} + · + {entry.encounteredAt} +
+

{entry.source}

+

{entry.claim}

+
+ {VIBE_LABELS[entry.vibe]} + + {OUTCOME_LABELS[entry.outcome]} + +
+
+ + {#if isResolved && entry.outcomeNote} +
+

Wie es kam

+

{entry.outcomeNote}

+ {#if entry.resolvedAt} +

{entry.resolvedAt.slice(0, 10)}

+ {/if} +
+ {/if} + +
+ via Mana Augur +
+
+ + diff --git a/apps/mana/apps/web/src/lib/modules/augur/stores/entries.svelte.ts b/apps/mana/apps/web/src/lib/modules/augur/stores/entries.svelte.ts index 6a983290d..16f02b840 100644 --- a/apps/mana/apps/web/src/lib/modules/augur/stores/entries.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/augur/stores/entries.svelte.ts @@ -1,7 +1,15 @@ import { augurEntriesTable } from '../collections'; import { toAugurEntry } from '../queries'; import { encryptRecord } from '$lib/data/crypto'; -import { generateUnlistedToken, type VisibilityLevel } from '@mana/shared-privacy'; +import { + publishUnlistedSnapshot, + revokeUnlistedSnapshot, + type VisibilityLevel, +} from '@mana/shared-privacy'; +import { buildUnlistedBlob } from '$lib/data/unlisted/resolvers'; +import { authStore } from '$lib/stores/auth.svelte'; +import { getManaApiUrl } from '$lib/api/config'; +import { getActiveSpace } from '$lib/data/scope'; import { getEffectiveUserId } from '$lib/data/current-user'; import type { AugurEntry, @@ -112,6 +120,25 @@ export const augurStore = { }, async deleteEntry(id: string) { + const existing = await augurEntriesTable.get(id); + // Revoke any active share-link before tombstoning so a recipient + // reloading the link gets 410 Gone instead of stale data. + if (existing?.visibility === 'unlisted' && existing.unlistedToken) { + const jwt = await authStore.getValidToken(); + if (jwt) { + try { + await revokeUnlistedSnapshot({ + apiUrl: getManaApiUrl(), + jwt, + collection: 'augurEntries', + recordId: id, + }); + } catch (e) { + console.error('[augur] revoke on delete failed', e); + } + } + } + await augurEntriesTable.update(id, { deletedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), @@ -119,33 +146,54 @@ export const augurStore = { }, /** - * Flip the visibility level. M6 wires the local field + token-allocation; - * the unlisted-snapshot publish/revoke pipeline (server-side blob store) - * is a follow-up — until then, 'unlisted' just allocates a local token so - * the share URL is stable when we wire the backend. + * Flip the visibility level. Coordinates with the server-side + * unlisted-snapshots table — same pattern as library/calendar. + * Server is authoritative for the token. */ async setVisibility(id: string, next: VisibilityLevel) { const existing = await augurEntriesTable.get(id); - if (!existing) return; + if (!existing) throw new Error(`Augur entry ${id} not found`); + const before: VisibilityLevel = existing.visibility ?? 'private'; + if (before === next) return; - const userId = getEffectiveUserId(); const now = new Date().toISOString(); - const diff: Partial = { + const patch: Partial = { visibility: next, visibilityChangedAt: now, - visibilityChangedBy: userId ?? undefined, + visibilityChangedBy: getEffectiveUserId() ?? undefined, updatedAt: now, }; if (next === 'unlisted') { - if (!existing.unlistedToken) diff.unlistedToken = generateUnlistedToken(); - } else { - if (existing.unlistedToken) { - diff.unlistedToken = undefined; - diff.unlistedExpiresAt = null; + const blob = await buildUnlistedBlob('augurEntries', id); + const jwt = await authStore.getValidToken(); + if (!jwt) throw new Error('Nicht eingeloggt'); + const spaceId = + (existing as unknown as { spaceId?: string }).spaceId ?? getActiveSpace()?.id ?? ''; + const { token } = await publishUnlistedSnapshot({ + apiUrl: getManaApiUrl(), + jwt, + collection: 'augurEntries', + recordId: id, + spaceId, + blob, + }); + patch.unlistedToken = token; + patch.unlistedExpiresAt = null; + } else if (before === 'unlisted') { + const jwt = await authStore.getValidToken(); + if (jwt) { + await revokeUnlistedSnapshot({ + apiUrl: getManaApiUrl(), + jwt, + collection: 'augurEntries', + recordId: id, + }); } + patch.unlistedToken = undefined; + patch.unlistedExpiresAt = null; } - await augurEntriesTable.update(id, diff); + await augurEntriesTable.update(id, patch); }, }; diff --git a/apps/mana/apps/web/src/routes/share/[token]/+page.svelte b/apps/mana/apps/web/src/routes/share/[token]/+page.svelte index d29c9eac0..fbfecc7bf 100644 --- a/apps/mana/apps/web/src/routes/share/[token]/+page.svelte +++ b/apps/mana/apps/web/src/routes/share/[token]/+page.svelte @@ -7,6 +7,7 @@ import SharedEventView from '$lib/modules/calendar/SharedEventView.svelte'; import SharedLibraryEntryView from '$lib/modules/library/SharedLibraryEntryView.svelte'; import SharedPlaceView from '$lib/modules/places/SharedPlaceView.svelte'; + import SharedAugurEntryView from '$lib/modules/augur/SharedAugurEntryView.svelte'; import type { PageData } from './$types'; let { data }: { data: PageData } = $props(); @@ -18,6 +19,8 @@ {:else if data.collection === 'places'} +{:else if data.collection === 'augurEntries'} + {:else}

Unbekannter Link-Typ