mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +02:00
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:
parent
a1f2dccb68
commit
9e04385930
5 changed files with 309 additions and 16 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue