feat(augur): unlisted-snapshot publish pipeline

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-25 15:38:09 +02:00
parent a1f2dccb68
commit 9e04385930
5 changed files with 309 additions and 16 deletions

View file

@ -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<string>(['events', 'libraryEntries', 'places']);
const ALLOWED_COLLECTIONS = new Set<string>(['events', 'libraryEntries', 'places', 'augurEntries']);
const PublishBodySchema = z.object({
spaceId: z.string().min(1).max(64),

View file

@ -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<Record<string, unknown>
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<Record<string, unknown>> {
const raw = await db.table<LocalAugurEntry>('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,
};
}

View file

@ -0,0 +1,197 @@
<!--
Shared-Augur-Entry view — public render of a single sign behind an
unlisted share link.
Whitelist (set by buildAugurEntryBlob): source, claim, kind, vibe,
encounteredAt, outcome, outcomeNote (when resolved), resolvedAt.
feltMeaning / expectedOutcome / probability / tags / livingOracleSnapshot
/ sourceCategory all stay PRIVATE.
Tone: respectful — divinatory captures are personal even when the
user chose to share. No analytics, no oracle stats here.
-->
<script lang="ts">
interface AugurEntryBlob {
source: string;
claim: string;
kind: 'omen' | 'fortune' | 'hunch';
vibe: 'good' | 'bad' | 'mysterious';
encounteredAt: string;
outcome: 'open' | 'fulfilled' | 'partly' | 'not-fulfilled';
outcomeNote: string | null;
resolvedAt: string | null;
}
let {
blob,
}: {
blob: Record<string, unknown>;
token: string;
expiresAt: string | null;
} = $props();
const entry = $derived(blob as unknown as AugurEntryBlob);
const KIND_LABELS: Record<AugurEntryBlob['kind'], string> = {
omen: 'Omen',
fortune: 'Wahrsagung',
hunch: 'Bauchgefühl',
};
const VIBE_COLORS: Record<AugurEntryBlob['vibe'], string> = {
good: '#22c55e',
bad: '#ef4444',
mysterious: '#8b5cf6',
};
const VIBE_LABELS: Record<AugurEntryBlob['vibe'], string> = {
good: 'Gutes Zeichen',
bad: 'Warnung',
mysterious: 'Rätselhaft',
};
const OUTCOME_LABELS: Record<AugurEntryBlob['outcome'], string> = {
open: 'Noch offen',
fulfilled: 'Eingetreten',
partly: 'Teilweise eingetreten',
'not-fulfilled': 'Nicht eingetreten',
};
const OUTCOME_COLORS: Record<AugurEntryBlob['outcome'], string> = {
open: '#94a3b8',
fulfilled: '#10b981',
partly: '#f59e0b',
'not-fulfilled': '#ef4444',
};
const isResolved = $derived(entry.outcome !== 'open');
</script>
<article class="card" style:--vibe={VIBE_COLORS[entry.vibe]}>
<header>
<div class="meta">
<span class="kind">{KIND_LABELS[entry.kind]}</span>
<span class="dot">·</span>
<span class="date">{entry.encounteredAt}</span>
</div>
<h1 class="source">{entry.source}</h1>
<p class="claim">{entry.claim}</p>
<div class="badges">
<span class="vibe">{VIBE_LABELS[entry.vibe]}</span>
<span class="outcome" style:--outcome-color={OUTCOME_COLORS[entry.outcome]}>
{OUTCOME_LABELS[entry.outcome]}
</span>
</div>
</header>
{#if isResolved && entry.outcomeNote}
<section class="resolved">
<h2>Wie es kam</h2>
<p>{entry.outcomeNote}</p>
{#if entry.resolvedAt}
<p class="resolved-meta">{entry.resolvedAt.slice(0, 10)}</p>
{/if}
</section>
{/if}
<footer>
<small>via Mana Augur</small>
</footer>
</article>
<style>
.card {
max-width: 36rem;
margin: 4rem auto;
padding: 2rem 2.25rem;
background: white;
border-radius: 1rem;
border: 1px solid #e5e7eb;
border-left: 5px solid var(--vibe);
box-shadow: 0 4px 24px rgba(15, 23, 42, 0.06);
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
color: #0f172a;
}
.meta {
display: flex;
gap: 0.45rem;
font-size: 0.78rem;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
.kind {
color: var(--vibe);
font-weight: 600;
}
.dot {
opacity: 0.5;
}
.source {
font-size: 1.65rem;
font-weight: 600;
margin: 0 0 0.4rem;
line-height: 1.25;
}
.claim {
margin: 0 0 1rem;
font-size: 1.05rem;
color: #334155;
line-height: 1.55;
}
.badges {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.vibe {
font-size: 0.78rem;
padding: 0.2rem 0.65rem;
border-radius: 999px;
color: var(--vibe);
background: color-mix(in srgb, var(--vibe) 12%, white);
font-weight: 500;
}
.outcome {
font-size: 0.78rem;
padding: 0.2rem 0.65rem;
border-radius: 999px;
color: var(--outcome-color);
background: color-mix(in srgb, var(--outcome-color) 12%, white);
font-weight: 500;
}
.resolved {
margin-top: 1.5rem;
padding-top: 1.25rem;
border-top: 1px solid #f1f5f9;
}
.resolved h2 {
margin: 0 0 0.4rem;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
font-weight: 500;
}
.resolved p {
margin: 0;
font-size: 1rem;
line-height: 1.55;
color: #1f2937;
}
.resolved-meta {
margin-top: 0.4rem !important;
font-size: 0.82rem !important;
color: #94a3b8 !important;
}
footer {
margin-top: 1.75rem;
text-align: center;
font-size: 0.75rem;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.08em;
}
</style>

View file

@ -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<LocalAugurEntry> = {
const patch: Partial<LocalAugurEntry> = {
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);
},
};

View file

@ -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 @@
<SharedLibraryEntryView blob={data.blob} token={data.token} expiresAt={data.expiresAt} />
{:else if data.collection === 'places'}
<SharedPlaceView blob={data.blob} token={data.token} expiresAt={data.expiresAt} />
{:else if data.collection === 'augurEntries'}
<SharedAugurEntryView blob={data.blob} token={data.token} expiresAt={data.expiresAt} />
{:else}
<div class="unknown">
<h1>Unbekannter Link-Typ</h1>