From 49935c9628ede4c35470260f5bc4575849a09d23 Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 24 Apr 2026 01:59:11 +0200 Subject: [PATCH] =?UTF-8?q?feat(shared-privacy):=20M1=20=E2=80=94=20visibi?= =?UTF-8?q?lity=20foundation=20package?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scaffold the unified visibility/privacy layer introduced by docs/plans/ visibility-system.md. No module adopts it yet — this is the foundation PR (M1). Module rollout lands in follow-ups starting with Library (M2). What ships: - @mana/shared-privacy package - VisibilityLevel enum ('private' | 'space' | 'unlisted' | 'public') - VisibilityLevelSchema + UnlistedTokenSchema (zod) - defaultVisibilityFor(spaceType): personal → private, else → space - predicates: canEmbedOnWebsite, isReachableByLink, isVisibleToSpaceMember, canAiAccessCrossUser (always false in P1) - generateUnlistedToken() — 32-char base64url, CSPRNG, ~192 bits - VISIBILITY_METADATA: German labels + descriptions + phosphor icon names so non-UI surfaces (audit logs, CLI) label levels consistently - svelte component: compact lock/globe trigger with 4-option menu, full descriptions, optional compact + disabledLevels - VisibilityChangedPayload type for the domain-event catalog (consumer registers it when the first module adopts the system) - .claude/guidelines/visibility.md — step-by-step for module authors (schema migrations + store wiring + picker placement + embed resolver + legacy isPublic migration), with a pre-PR checklist - Plan-doc "Offene Fragen" section rewritten as "Designentscheidungen" with the seven resolutions the user approved - CLAUDE.md: shared-privacy listed in the packages table; visibility.md listed in the guidelines table - 15 unit tests covering predicates (one-and-only-one 'public' for embed; phase-1 AI always-deny), defaults (personal vs multi-member, null fallback), token uniqueness + schema round-trip Key constraints honored: - `visibility` stays plaintext (NOT added to the encryption registry) so RLS predicates and publish resolvers can read it without the user's master key - Publish flow remains "decrypt client-side, inline plaintext into snapshot" — the pattern picture.board already uses in embeds.ts - Deny-by-default everywhere (personal default = private; unknown space type defaults to private; cross-user AI always false) Not in this PR (per plan): - No schema migrations in any module (M2–M6) - No RLS predicate updates (arrives with M2) - No /settings/privacy overview (M7) - No unlisted share routes (M8) Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/guidelines/visibility.md | 242 ++++++++++++ CLAUDE.md | 2 + docs/plans/visibility-system.md | 30 +- packages/shared-privacy/package.json | 40 ++ .../src/VisibilityPicker.svelte | 218 ++++++++++ packages/shared-privacy/src/defaults.test.ts | 24 ++ packages/shared-privacy/src/defaults.ts | 20 + packages/shared-privacy/src/index.ts | 35 ++ .../shared-privacy/src/predicates.test.ts | 45 +++ packages/shared-privacy/src/predicates.ts | 43 ++ packages/shared-privacy/src/schema.ts | 23 ++ packages/shared-privacy/src/tokens.test.ts | 39 ++ packages/shared-privacy/src/tokens.ts | 25 ++ packages/shared-privacy/src/types.ts | 71 ++++ packages/shared-privacy/tsconfig.json | 20 + pnpm-lock.yaml | 371 +++++++++++------- 16 files changed, 1100 insertions(+), 148 deletions(-) create mode 100644 .claude/guidelines/visibility.md create mode 100644 packages/shared-privacy/package.json create mode 100644 packages/shared-privacy/src/VisibilityPicker.svelte create mode 100644 packages/shared-privacy/src/defaults.test.ts create mode 100644 packages/shared-privacy/src/defaults.ts create mode 100644 packages/shared-privacy/src/index.ts create mode 100644 packages/shared-privacy/src/predicates.test.ts create mode 100644 packages/shared-privacy/src/predicates.ts create mode 100644 packages/shared-privacy/src/schema.ts create mode 100644 packages/shared-privacy/src/tokens.test.ts create mode 100644 packages/shared-privacy/src/tokens.ts create mode 100644 packages/shared-privacy/src/types.ts create mode 100644 packages/shared-privacy/tsconfig.json diff --git a/.claude/guidelines/visibility.md b/.claude/guidelines/visibility.md new file mode 100644 index 000000000..7d300a6f1 --- /dev/null +++ b/.claude/guidelines/visibility.md @@ -0,0 +1,242 @@ +# Visibility — Adding Visibility Control to a Module + +How to adopt the unified visibility/privacy system for a module. Applies to every public-capable module (see `docs/plans/visibility-system.md` for the full list). + +## TL;DR + +Adding visibility control needs edits in **five places**: + +1. **Dexie + mana-sync schema** — add `visibility`, `unlistedToken`, `visibilityChangedAt`, `visibilityChangedBy` columns +2. **`types.ts`** — add `visibility: VisibilityLevel` to the local record type + converter +3. **`stores/*.svelte.ts`** — add `setVisibility()` method + stamp default on create +4. **Detail-View `.svelte`** — drop `` into the header +5. **Embed resolver (if applicable)** — gate on `canEmbedOnWebsite` + +Legacy `isPublic` flags are migrated in the same PR: `isPublic=true → visibility='public'`, else `'private'`. + +## Architecture + +``` +┌──────────────────────────────────────────────────────────┐ +│ @mana/shared-privacy │ +│ VisibilityLevel = 'private'|'space'|'unlisted'|'public' │ +│ VisibilityLevelSchema (zod) │ +│ defaultVisibilityFor(spaceType) │ +│ canEmbedOnWebsite / isReachableByLink / … (predicates) │ +│ generateUnlistedToken() │ +│ │ +└───────────┬─────────────────────────────────┬────────────┘ + │ │ + ┌────────▼────────┐ ┌─────────▼──────────────┐ + │ Module stores │ │ website/embeds.ts │ + │ - setVisibility │ │ - filter by predicate │ + │ - default on │ │ - decrypt + inline │ + │ create │ │ matching records │ + └──────────────────┘ └─────────────────────────┘ + │ + ┌────────▼────────────┐ + │ Detail-View │ + │ │ + └──────────────────────┘ +``` + +`visibility` stays **plaintext** (not in the encryption registry) so RLS predicates and publish resolvers can read it without the user's master key. + +## Step-by-step + +### 1. Schema + +**Dexie** — bump the module's table in `apps/mana/apps/web/src/lib/data/database.ts` (soft-migration): + +```ts +// v{N+1}: add visibility +{ + version: N + 1, + stores: { + myTable: 'id, spaceId, visibility, createdAt, …', // add visibility to indexes if you'll query on it + }, + upgrade: async (tx) => { + await tx.table('myTable').toCollection().modify((r) => { + r.visibility = 'private'; + }); + }, +} +``` + +**Postgres** — add columns to the mana-sync schema for this module (find the drizzle schema under `services/mana-sync/` or the per-module schema in `apps/api/`): + +```sql +alter table . + add column visibility text not null default 'private', + add column unlisted_token text, + add column visibility_changed_at timestamptz, + add column visibility_changed_by text; +``` + +Partial index for the common embed query: + +```sql +create index
_public_idx on .
(space_id) + where visibility = 'public'; +``` + +### 2. Local types + +```ts +// types.ts +import type { VisibilityLevel } from '@mana/shared-privacy'; + +export interface LocalMyRecord { + id: string; + // ... existing fields ... + visibility: VisibilityLevel; + unlistedToken?: string; + visibilityChangedAt?: string; + visibilityChangedBy?: string; +} +``` + +Update `toMyRecord()` converter in `queries.ts` to forward the fields. + +### 3. Store + +```ts +// stores/my.svelte.ts +import { + VisibilityLevelSchema, + defaultVisibilityFor, + generateUnlistedToken, + type VisibilityLevel, +} from '@mana/shared-privacy'; +import { emitDomainEvent } from '$lib/data/events'; +import { activeSpace } from '$lib/stores/space.svelte'; + +export const myStore = { + async createRecord(input: CreateInput) { + const now = new Date().toISOString(); + const record: LocalMyRecord = { + id: crypto.randomUUID(), + // ... + visibility: defaultVisibilityFor(activeSpace.current?.type), + createdAt: now, + updatedAt: now, + }; + await myTable.add(record); + return record; + }, + + async setVisibility(id: string, next: VisibilityLevel) { + const existing = await myTable.get(id); + if (!existing) throw new Error(`Record ${id} not found`); + const before = existing.visibility; + if (before === next) return; + + const now = new Date().toISOString(); + const patch: Partial = { + visibility: next, + visibilityChangedAt: now, + visibilityChangedBy: currentUserId(), + updatedAt: now, + }; + // Mint a fresh token on first transition to unlisted; wipe on leaving. + if (next === 'unlisted' && !existing.unlistedToken) { + patch.unlistedToken = generateUnlistedToken(); + } else if (next !== 'unlisted' && existing.unlistedToken) { + patch.unlistedToken = undefined; + } + await myTable.update(id, patch); + + emitDomainEvent('VisibilityChanged', '', '', id, { + recordId: id, + collection: '', + before, + after: next, + }); + }, +}; +``` + +Register the `VisibilityChanged` event type in `apps/mana/apps/web/src/lib/data/events/catalog.ts` once (not per-module); the payload comes from `@mana/shared-privacy`. + +### 4. Detail-view UI + +```svelte + + +
+

{record.title}

+ +
+``` + +For list views, show a small lock/globe icon next to items whose visibility differs from the space default. `VISIBILITY_METADATA[level].icon` gives the Phosphor icon name. + +### 5. Embed resolver (only for public-embeddable modules) + +```ts +// apps/mana/apps/web/src/lib/modules/website/embeds.ts +import { canEmbedOnWebsite } from '@mana/shared-privacy'; + +async function resolveMyModule(props): Promise { + let records = await db.table('myTable').toArray(); + records = records.filter((r) => !r.deletedAt && canEmbedOnWebsite(r.visibility ?? 'private')); + // User filters (tags, status, date window) stack ON TOP — never replace. + // ... + const decrypted = await decryptRecords('myTable', records); + return decrypted.map(toEmbedItem); +} +``` + +Register the new source in `packages/website-blocks/src/moduleEmbed/schema.ts` under `EmbedSourceSchema`. + +### 6. Legacy `isPublic` migration (if your module had one) + +In the same PR that adds visibility: + +```ts +// Dexie upgrade step +await tx + .table('myTable') + .toCollection() + .modify((r) => { + r.visibility = r.isPublic === true ? 'public' : 'private'; + delete r.isPublic; + }); +``` + +And the corresponding Postgres migration: + +```sql +update .
set visibility = 'public' where is_public = true; +alter table .
drop column is_public; +``` + +## Checklist before PR + +- [ ] Dexie schema bumped, upgrade step migrates existing rows to `'private'` (or maps `isPublic`) +- [ ] Postgres migration adds `visibility`, `unlisted_token`, `visibility_changed_at`, `visibility_changed_by` +- [ ] Partial index on `(space_id) where visibility = 'public'` (if module is embeddable) +- [ ] `visibility` **not** added to the encryption registry +- [ ] Default on create via `defaultVisibilityFor(space.type)` +- [ ] Store has `setVisibility()` + emits `VisibilityChanged` +- [ ] `` in the detail view +- [ ] Embed resolver (if applicable) gates on `canEmbedOnWebsite` +- [ ] Tests: store `setVisibility` flips correctly; resolver filters out non-public records +- [ ] `validate:all` passes + +## Reference + +- Plan + rationale: [`docs/plans/visibility-system.md`](../../docs/plans/visibility-system.md) +- Package: `packages/shared-privacy/` diff --git a/CLAUDE.md b/CLAUDE.md index 846741ddb..c8d678df1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,6 +53,7 @@ Always consult before changing code: | [`.claude/guidelines/testing.md`](.claude/guidelines/testing.md) | Vitest, mock factories | | [`.claude/guidelines/design-ux.md`](.claude/guidelines/design-ux.md) | UI patterns, a11y | | [`.claude/guidelines/ai-tools.md`](.claude/guidelines/ai-tools.md) | Adding AI tools to a module | +| [`.claude/guidelines/visibility.md`](.claude/guidelines/visibility.md) | Adopting the visibility/privacy system per module | ## Development Quick Start @@ -154,6 +155,7 @@ Enforced by `pnpm run validate:turbo` (`scripts/validate-no-recursive-turbo.mjs` | `@mana/shared-ui` | React Native UI components | | `@mana/shared-theme` | Theme config | | `@mana/shared-i18n` | i18n | +| `@mana/shared-privacy` | Unified visibility/privacy system: `VisibilityLevel` enum + zod schema + `` + predicates (`canEmbedOnWebsite`, …). Plan: [`docs/plans/visibility-system.md`](docs/plans/visibility-system.md). Rollout per-module, not yet adopted anywhere. | | `@mana/local-store` | Local-first store primitives — used by unified Mana, manavoxel, arcade, and shared-uload/-stores/-links | | `@mana/local-llm` | Browser-local LLM inference (transformers.js + Gemma 4 E2B, WebGPU). Powers `/llm-test` and the playground module. See [`packages/local-llm/CLAUDE.md`](packages/local-llm/CLAUDE.md) for the CSP requirements and the transformers.js v4 gotchas. | | `@mana/local-stt` | Browser-local speech-to-text (transformers.js + Whisper, WebGPU). Powers the QuickInputBar mic button. Same architecture as local-llm. See [`packages/local-stt/CLAUDE.md`](packages/local-stt/CLAUDE.md). | diff --git a/docs/plans/visibility-system.md b/docs/plans/visibility-system.md index 41f7a6ae1..040d9c95b 100644 --- a/docs/plans/visibility-system.md +++ b/docs/plans/visibility-system.md @@ -254,15 +254,29 @@ Breite Welle — alle Module, die noch public-relevant sind. Jedes ist ein klein - Per Modul: Share-Dialog "Link erstellen" - Neu erzeugte Tokens rotieren nicht automatisch — wer den Link weitergibt, akzeptiert permanente Exposure bis Revoke -## Offene Designfragen +## Designentscheidungen (2026-04-23 festgeschrieben) -1. **Subressourcen-Redaction.** Calendar-Event mit Gästen: werden die Gästenamen beim Publish redacted? Vermutung: **ja**, vom Publish-Resolver. Pro Modul entscheiden. -2. **Public-Items + Owner-Identität.** Ist der Name des Owners auf einem public Item sichtbar? Vermutung: **ja für Events** (Veranstalter), **nein für Todos** (Creator ist irrelevant). Pro Resolver. -3. **AI-Agent-Zugriff.** Darf ein User-AI-Agent auf public Items anderer User zugreifen (Future-Feature)? Per `canEmbedOnWebsite`-ähnliches Predicate, aber ein neues: `canAiAccessCrossUser`. Nicht Phase 1. -4. **Encryption-Registry-Update.** Für Module, die das Feld bekommen: Record-Body wie gehabt encrypted (weil auch bei public-Items will man die Dexie-Backup-Exports verschlüsselt haben). `visibility` ist das einzige plaintext-Feld. Publish-Flow bleibt "clientseitig entschlüsseln → inline". -5. **Mehrere Websites pro Space.** Plan-Doc `website-builder.md` geht von 1 Website pro Space aus. Bei 2+ Websites per Space müsste Visibility pro-Website differenziert werden (`visibleOnSite: siteId[]` statt bool). **Nicht Phase 1.** -6. **Preview-Mode im Editor.** Der Editor rendert selbst ohne Filter (Owner sieht alles). Der Publish-Preview muss die Filter anwenden, damit der User weiß, was wirklich public geht. Kleines Feature, auf M4 hängen. -7. **Default-Migration.** Beim Erstmigration der 7 ad-hoc-Flags: alle `isPublic=false` werden `visibility='private'`. Das ist strikt — ein existierendes Private-Item bleibt privat. Aber: ein User, der bisher alles implizit behandelt hat ohne den Flag anzufassen, hat nichts public. OK-Verhalten. +Die folgenden Fragen waren offen beim ersten Entwurf und wurden vor der Umsetzung entschieden. + +1. **Subressourcen-Redaction.** Entschieden: **Whitelist, nicht Blacklist.** Publish-Resolver ziehen nur explizit freigegebene Felder in den Snapshot. Beim Calendar-Event: `{ title, startsAt, endsAt, location.publicAddress }`; das Gäste-Array wird auf `guestCount: number` degradiert. Pro-Feld-Freigabe (`publicFields: string[]` am Record) ist Phase 2. + +2. **Owner-Identität auf public Items.** Entschieden: **Space-Setting, nicht per-Record.** Der Space bekommt ein Feld `publicDisplayName` (z. B. "Tills Gigs" oder "Anonym"). Publish-Snapshots zeigen nur diesen Namen — nie User-Real-Name, Avatar oder Email. Space ist ohnehin der Publish-Container; dort gehört die Identitätsentscheidung hin. + +3. **AI-Agent-Zugriff cross-user.** Entschieden: **Phase 1 nein, Predicate vorbereitet.** `canAiAccessCrossUser(level)` im `@mana/shared-privacy`-Package returnt immer `false`. Wenn später ein Feature cross-user-Reads will, wird's pro Modul explizit freigeschaltet. Fließt nicht ins MVP. + +4. **Encryption-Registry-Update.** Entschieden: **Record bleibt encrypted, nur `visibility` ist plaintext.** Publish-Flow entschlüsselt clientseitig und inlined plaintext in den Snapshot (heutiges Picture-Board-Muster). Vorteile: Dexie-Backups bleiben durchgehend encrypted, Zero-Knowledge-Mode funktioniert weiter, keine Re-Encryption-Migration beim Visibility-Toggle. + +5. **Mehrere Websites pro Space.** Entschieden: **Phase 1 ignoriert das.** `visibility='public'` heißt "für das aktive Space-Publish-Target". Bei späterer Multi-Site kommt eine `siteIds`-Filter am Embed-Block, **nicht** am Record. Record-Modell bleibt simpel; Komplexität lebt im Publish-Layer. + +6. **Preview-Mode im Editor.** Entschieden: **Zwei explizite Modi, Toggle im Editor.** "Bearbeiten" (Owner sieht alles, default) + "Als Besucher ansehen" (Embed-Filter werden angewandt). `previewAsPublic: boolean` im EditorView-State, den Embed-Renderer respektieren. Implementierung hängt an M4. + +7. **Default-Migration der 7 ad-hoc `isPublic`-Flags.** Entschieden: **Strict Mapping, kein User-Prompt.** `isPublic === true` → `visibility='public'`. Alles andere → `visibility='private'` (nicht `space`, weil die existierenden Flags keine Space-Dimension hatten). Hard-Drop des alten Booleans in M6. + +**Zusätzlich für M1 festgeschrieben:** + +- **Unlisted-Token-Format:** 32-char base64url, generiert via `crypto.randomUUID()` + Base-Normalisierung. Rotiert NICHT automatisch. Revoke = Token auf NULL setzen und Visibility auf `private` zurückdrehen. +- **Domain-Event:** `VisibilityChanged` mit Payload `{ recordId, collection, before, after, actor }`. Landet im `_events`-Log → integriert sich in Workbench-Timeline und AI-Revert-System. +- **Rate-Limit-Warnung (optional, M7):** "mehr als 100 Records public in einer Minute" zeigt einen Toast "X Items wurden public gemacht — rückgängig machen?". Kein Hard-Block, nur Fat-Finger-Schutz. ## Anti-Patterns — was wir nicht bauen diff --git a/packages/shared-privacy/package.json b/packages/shared-privacy/package.json new file mode 100644 index 000000000..f91b61584 --- /dev/null +++ b/packages/shared-privacy/package.json @@ -0,0 +1,40 @@ +{ + "name": "@mana/shared-privacy", + "version": "0.1.0", + "private": true, + "type": "module", + "sideEffects": [ + "**/*.svelte", + "**/*.css" + ], + "svelte": "./src/index.ts", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "svelte": "./src/index.ts", + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "scripts": { + "check": "svelte-check --tsconfig ./tsconfig.json", + "type-check": "svelte-check --tsconfig ./tsconfig.json", + "test": "vitest run", + "test:watch": "vitest", + "lint": "eslint ." + }, + "dependencies": { + "@mana/shared-icons": "workspace:*", + "zod": "^3.25.76" + }, + "devDependencies": { + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "typescript": "^5.7.3", + "vitest": "^4.1.2" + }, + "peerDependencies": { + "svelte": "^5.0.0" + } +} diff --git a/packages/shared-privacy/src/VisibilityPicker.svelte b/packages/shared-privacy/src/VisibilityPicker.svelte new file mode 100644 index 000000000..844d92e5b --- /dev/null +++ b/packages/shared-privacy/src/VisibilityPicker.svelte @@ -0,0 +1,218 @@ + + + + + + diff --git a/packages/shared-privacy/src/defaults.test.ts b/packages/shared-privacy/src/defaults.test.ts new file mode 100644 index 000000000..43e6e5864 --- /dev/null +++ b/packages/shared-privacy/src/defaults.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import { defaultVisibilityFor } from './defaults'; + +describe('defaultVisibilityFor', () => { + it('returns private for personal space', () => { + expect(defaultVisibilityFor('personal')).toBe('private'); + }); + + it('returns space for multi-member types', () => { + expect(defaultVisibilityFor('team')).toBe('space'); + expect(defaultVisibilityFor('club')).toBe('space'); + expect(defaultVisibilityFor('firma')).toBe('space'); + }); + + it('returns space for unknown multi-member types (safe assumption)', () => { + expect(defaultVisibilityFor('band')).toBe('space'); + }); + + it('falls back to private when space type is missing', () => { + expect(defaultVisibilityFor(null)).toBe('private'); + expect(defaultVisibilityFor(undefined)).toBe('private'); + expect(defaultVisibilityFor('')).toBe('private'); + }); +}); diff --git a/packages/shared-privacy/src/defaults.ts b/packages/shared-privacy/src/defaults.ts new file mode 100644 index 000000000..690547810 --- /dev/null +++ b/packages/shared-privacy/src/defaults.ts @@ -0,0 +1,20 @@ +import type { VisibilityLevel } from './types'; + +/** + * Default visibility for newly-created records, derived from the space + * type. Personal spaces stay `private` so a fresh note or task doesn't + * accidentally leak to cohabitants of a team space; multi-member spaces + * (team, club, firma, …) default to `space` so collaboration works + * without requiring a manual toggle on every write. + * + * Accepts `null`/`undefined`/unknown strings and treats them as personal + * — the safer direction. Callers that know the space type pass it + * directly; callers that don't (e.g. during sync-apply) fall back to + * 'private'. + */ +export function defaultVisibilityFor(spaceType: string | null | undefined): VisibilityLevel { + if (!spaceType) return 'private'; + if (spaceType === 'personal') return 'private'; + // team, club, firma, or any future multi-member type. + return 'space'; +} diff --git a/packages/shared-privacy/src/index.ts b/packages/shared-privacy/src/index.ts new file mode 100644 index 000000000..a0492de42 --- /dev/null +++ b/packages/shared-privacy/src/index.ts @@ -0,0 +1,35 @@ +/** + * @mana/shared-privacy + * + * Unified visibility/privacy primitives for every Mana module. Provides: + * + * - VisibilityLevel enum: 'private' | 'space' | 'unlisted' | 'public' + * - Zod schema for validation at the record/schema layer + * - Default helper (derives private vs space from the active space type) + * - Predicates for publish-time gating (canEmbedOnWebsite, …) + * - Unlisted-token generator (32-char base64url, CSPRNG) + * - Svelte component for the consistent UI control + * + * Design + rollout: docs/plans/visibility-system.md. + * + * Import path stays flat: + * import { + * VisibilityLevelSchema, + * canEmbedOnWebsite, + * VisibilityPicker, + * } from '@mana/shared-privacy'; + */ + +export type { VisibilityLevel, VisibilityMeta, VisibilityChangedPayload } from './types'; +export { VISIBILITY_LEVELS, VISIBILITY_METADATA } from './types'; +export { VisibilityLevelSchema, UnlistedTokenSchema } from './schema'; +export { defaultVisibilityFor } from './defaults'; +export { + canEmbedOnWebsite, + isReachableByLink, + isVisibleToSpaceMember, + canAiAccessCrossUser, +} from './predicates'; +export { generateUnlistedToken } from './tokens'; + +export { default as VisibilityPicker } from './VisibilityPicker.svelte'; diff --git a/packages/shared-privacy/src/predicates.test.ts b/packages/shared-privacy/src/predicates.test.ts new file mode 100644 index 000000000..eda543896 --- /dev/null +++ b/packages/shared-privacy/src/predicates.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest'; +import { + canEmbedOnWebsite, + isReachableByLink, + isVisibleToSpaceMember, + canAiAccessCrossUser, +} from './predicates'; +import { VISIBILITY_LEVELS } from './types'; + +describe('canEmbedOnWebsite', () => { + it('allows only public', () => { + expect(canEmbedOnWebsite('public')).toBe(true); + expect(canEmbedOnWebsite('unlisted')).toBe(false); + expect(canEmbedOnWebsite('space')).toBe(false); + expect(canEmbedOnWebsite('private')).toBe(false); + }); +}); + +describe('isReachableByLink', () => { + it('allows public and unlisted', () => { + expect(isReachableByLink('public')).toBe(true); + expect(isReachableByLink('unlisted')).toBe(true); + }); + it('rejects space and private', () => { + expect(isReachableByLink('space')).toBe(false); + expect(isReachableByLink('private')).toBe(false); + }); +}); + +describe('isVisibleToSpaceMember', () => { + it('allows everything except private', () => { + expect(isVisibleToSpaceMember('space')).toBe(true); + expect(isVisibleToSpaceMember('unlisted')).toBe(true); + expect(isVisibleToSpaceMember('public')).toBe(true); + expect(isVisibleToSpaceMember('private')).toBe(false); + }); +}); + +describe('canAiAccessCrossUser', () => { + it('denies for every level in Phase 1', () => { + for (const lvl of VISIBILITY_LEVELS) { + expect(canAiAccessCrossUser(lvl)).toBe(false); + } + }); +}); diff --git a/packages/shared-privacy/src/predicates.ts b/packages/shared-privacy/src/predicates.ts new file mode 100644 index 000000000..46828949b --- /dev/null +++ b/packages/shared-privacy/src/predicates.ts @@ -0,0 +1,43 @@ +import type { VisibilityLevel } from './types'; + +/** + * Can this record be embedded on a published website? This is the + * strictest exposure — a public website snapshot is readable by any + * anonymous visitor, so gate hard on `public` only. Unlisted is + * link-sharing, not website-embedding. + * + * Every embed resolver in `apps/mana/apps/web/src/lib/modules/website/ + * embeds.ts` must call this before inlining records into the snapshot. + */ +export function canEmbedOnWebsite(level: VisibilityLevel): boolean { + return level === 'public'; +} + +/** + * Can this record be fetched via a direct unlisted-token link? Includes + * `public` because a public record is reachable by link too (it just + * also appears in embeds). + */ +export function isReachableByLink(level: VisibilityLevel): boolean { + return level === 'public' || level === 'unlisted'; +} + +/** + * Is this record visible to other members of the owner's space, under + * the normal `spaceModulePermissions` matrix? All non-private levels + * are. Private records are owner-only, even inside multi-member spaces. + */ +export function isVisibleToSpaceMember(level: VisibilityLevel): boolean { + return level !== 'private'; +} + +/** + * Placeholder for a future cross-user AI-agent feature (see + * docs/plans/visibility-system.md §3). Always returns false in Phase 1 + * so no current AI code path accidentally leaks data. When we're ready + * to let agents read public cross-user records, flip this per-module + * with an explicit opt-in. + */ +export function canAiAccessCrossUser(_level: VisibilityLevel): boolean { + return false; +} diff --git a/packages/shared-privacy/src/schema.ts b/packages/shared-privacy/src/schema.ts new file mode 100644 index 000000000..453cfb251 --- /dev/null +++ b/packages/shared-privacy/src/schema.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +/** + * Zod schema for the visibility enum. Use this in module record schemas + * so every table validates the same way on writes. + * + * export const TaskSchema = z.object({ + * id: z.string().uuid(), + * title: z.string(), + * visibility: VisibilityLevelSchema, + * ... + * }); + */ +export const VisibilityLevelSchema = z.enum(['private', 'space', 'unlisted', 'public']); + +/** + * Unlisted-token shape — 32 base64url chars (see tokens.ts). Zod check + * enforces format so a corrupt/shortened token from a manual edit gets + * rejected at the schema layer. + */ +export const UnlistedTokenSchema = z + .string() + .regex(/^[A-Za-z0-9_-]{32}$/, 'must be a 32-char base64url token'); diff --git a/packages/shared-privacy/src/tokens.test.ts b/packages/shared-privacy/src/tokens.test.ts new file mode 100644 index 000000000..50bcd2169 --- /dev/null +++ b/packages/shared-privacy/src/tokens.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import { generateUnlistedToken } from './tokens'; +import { UnlistedTokenSchema } from './schema'; + +describe('generateUnlistedToken', () => { + it('returns a 32-char base64url string', () => { + const token = generateUnlistedToken(); + expect(token).toHaveLength(32); + expect(token).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it('passes the UnlistedTokenSchema', () => { + for (let i = 0; i < 10; i++) { + const token = generateUnlistedToken(); + expect(() => UnlistedTokenSchema.parse(token)).not.toThrow(); + } + }); + + it('is unique across many calls (entropy check)', () => { + const tokens = new Set(); + for (let i = 0; i < 1000; i++) tokens.add(generateUnlistedToken()); + expect(tokens.size).toBe(1000); + }); +}); + +describe('UnlistedTokenSchema', () => { + it('rejects tokens that are too short', () => { + expect(() => UnlistedTokenSchema.parse('short')).toThrow(); + }); + + it('rejects tokens with invalid chars', () => { + expect(() => UnlistedTokenSchema.parse('a/b+c=d'.padEnd(32, 'x'))).toThrow(); + }); + + it('accepts 32-char base64url', () => { + expect(() => UnlistedTokenSchema.parse('A'.repeat(32))).not.toThrow(); + expect(() => UnlistedTokenSchema.parse('-_'.repeat(16))).not.toThrow(); + }); +}); diff --git a/packages/shared-privacy/src/tokens.ts b/packages/shared-privacy/src/tokens.ts new file mode 100644 index 000000000..c54eb957c --- /dev/null +++ b/packages/shared-privacy/src/tokens.ts @@ -0,0 +1,25 @@ +/** + * Generate a URL-safe 32-character share token for unlisted-mode + * records. Randomness comes from `crypto.getRandomValues` (CSPRNG on + * every target Mana runs on: modern browsers, Bun, Node ≥18). + * + * 24 random bytes encode to exactly 32 base64url characters, so we get + * ~192 bits of entropy — far more than enough to resist enumeration + * attacks on an unlisted-link endpoint. + * + * Token rotation is NOT automatic. To revoke a share: unset the token + * column and flip visibility back to 'private'. Regenerating is a + * deliberate user action (e.g. "neu generieren" button in the share + * dialog). + */ +export function generateUnlistedToken(): string { + const bytes = new Uint8Array(24); + crypto.getRandomValues(bytes); + return base64urlEncode(bytes); +} + +function base64urlEncode(bytes: Uint8Array): string { + let bin = ''; + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} diff --git a/packages/shared-privacy/src/types.ts b/packages/shared-privacy/src/types.ts new file mode 100644 index 000000000..ce4182ff6 --- /dev/null +++ b/packages/shared-privacy/src/types.ts @@ -0,0 +1,71 @@ +/** + * Canonical visibility levels for any user-owned record in Mana. + * + * See docs/plans/visibility-system.md for the full design. Short form: + * + * private — only the owner (personal space) sees it + * space — all space members per spaceModulePermissions + * unlisted — reachable via direct link + token; not listed, noindex + * public — embeddable on websites, discoverable to anonymous visitors + */ +export type VisibilityLevel = 'private' | 'space' | 'unlisted' | 'public'; + +/** Iteration-safe ordering. Used by the picker to render radio-list choices. */ +export const VISIBILITY_LEVELS: readonly VisibilityLevel[] = [ + 'private', + 'space', + 'unlisted', + 'public', +] as const; + +/** + * UI-agnostic descriptors so non-Svelte surfaces (CLI, audit logs, AI + * agent explanations) can label a level consistently without reaching + * into the Svelte component. + * + * German copy lives in the metadata because the whole Mana UI is German + * today — i18n for privacy copy is a concrete follow-up when we add a + * locale switch, not something to solve upfront. + */ +export interface VisibilityMeta { + label: string; + description: string; + /** Phosphor icon name — resolved at render time via @mana/shared-icons. */ + icon: 'Lock' | 'UsersThree' | 'LinkSimple' | 'Globe'; +} + +export const VISIBILITY_METADATA: Record = { + private: { + label: 'Privat', + description: 'Nur du siehst es.', + icon: 'Lock', + }, + space: { + label: 'Bereich', + description: 'Alle Mitglieder dieses Bereichs sehen es.', + icon: 'UsersThree', + }, + unlisted: { + label: 'Per Link', + description: 'Wer den Link hat, kann es sehen. Nicht gelistet.', + icon: 'LinkSimple', + }, + public: { + label: 'Öffentlich', + description: 'Auf deiner Website und für alle sichtbar.', + icon: 'Globe', + }, +}; + +/** + * Payload for the `VisibilityChanged` domain event — emitted by module + * stores whenever a record's visibility flips. The event catalog in the + * web app registers this type when the first module adopts the system + * (see docs/plans/visibility-system.md §M2). + */ +export interface VisibilityChangedPayload { + recordId: string; + collection: string; + before: VisibilityLevel; + after: VisibilityLevel; +} diff --git a/packages/shared-privacy/tsconfig.json b/packages/shared-privacy/tsconfig.json new file mode 100644 index 000000000..8b1b48626 --- /dev/null +++ b/packages/shared-privacy/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "verbatimModuleSyntax": true, + "types": ["svelte"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39a36fa6c..a7648828c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -141,14 +141,14 @@ importers: version: link:../../../../packages/shared-landing-ui astro: specifier: ^5.16.0 - version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) typescript: specifier: ^5.9.2 version: 5.9.3 devDependencies: '@astrojs/tailwind': specifier: ^6.0.2 - version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3)) + version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3)) '@tailwindcss/typography': specifier: ^0.5.18 version: 0.5.19(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3)) @@ -157,13 +157,13 @@ importers: version: 20.19.39 eslint: specifier: ^9.0.0 - version: 9.39.4(jiti@2.6.1) + version: 9.39.4(jiti@1.21.7) eslint-config-prettier: specifier: ^9.1.0 - version: 9.1.2(eslint@9.39.4(jiti@2.6.1)) + version: 9.1.2(eslint@9.39.4(jiti@1.21.7)) eslint-plugin-astro: specifier: ^1.0.0 - version: 1.6.0(eslint@9.39.4(jiti@2.6.1)) + version: 1.6.0(eslint@9.39.4(jiti@1.21.7)) prettier: specifier: ^3.6.2 version: 3.8.1 @@ -256,10 +256,10 @@ importers: version: 3.7.2 '@astrojs/tailwind': specifier: ^6.0.0 - version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3)) + version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3)) astro: specifier: ^5.16.11 - version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) tailwindcss: specifier: ^3.4.17 version: 3.4.19(tsx@4.21.0)(yaml@2.8.3) @@ -2230,6 +2230,28 @@ importers: specifier: ^5.7.2 version: 5.9.3 + packages/shared-privacy: + dependencies: + '@mana/shared-icons': + specifier: workspace:* + version: link:../shared-icons + zod: + specifier: ^3.25.76 + version: 3.25.76 + devDependencies: + svelte: + specifier: ^5.0.0 + version: 5.55.1 + svelte-check: + specifier: ^4.0.0 + version: 4.4.6(picomatch@4.0.4)(svelte@5.55.1)(typescript@5.9.3) + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vitest: + specifier: ^4.1.2 + version: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.3)(@vitest/ui@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + packages/shared-pwa: devDependencies: '@vite-pwa/sveltekit': @@ -17328,6 +17350,16 @@ snapshots: transitivePeerDependencies: - ts-node + '@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + autoprefixer: 10.4.27(postcss@8.5.8) + postcss: 8.5.8 + postcss-load-config: 4.0.2(postcss@8.5.8) + tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.3) + transitivePeerDependencies: + - ts-node + '@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))': dependencies: astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) @@ -17348,16 +17380,6 @@ snapshots: transitivePeerDependencies: - ts-node - '@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))': - dependencies: - astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) - autoprefixer: 10.4.27(postcss@8.5.8) - postcss: 8.5.8 - postcss-load-config: 4.0.2(postcss@8.5.8) - tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.3) - transitivePeerDependencies: - - ts-node - '@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))': dependencies: astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) @@ -19529,6 +19551,11 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@1.21.7))': + dependencies: + eslint: 9.39.4(jiti@1.21.7) + eslint-visitor-keys: 3.4.3 + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': dependencies: eslint: 9.39.4(jiti@2.6.1) @@ -24608,6 +24635,108 @@ snapshots: transitivePeerDependencies: - supports-color + astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): + dependencies: + '@astrojs/compiler': 2.13.1 + '@astrojs/internal-helpers': 0.7.6 + '@astrojs/markdown-remark': 6.3.11 + '@astrojs/telemetry': 3.3.0 + '@capsizecss/unpack': 4.0.0 + '@oslojs/encoding': 1.1.0 + '@rollup/pluginutils': 5.3.0(rollup@4.60.1) + acorn: 8.16.0 + aria-query: 5.3.2 + axobject-query: 4.1.0 + boxen: 8.0.1 + ci-info: 4.4.0 + clsx: 2.1.1 + common-ancestor-path: 1.0.1 + cookie: 1.1.1 + cssesc: 3.0.0 + debug: 4.4.3 + deterministic-object-hash: 2.0.2 + devalue: 5.7.0 + diff: 8.0.4 + dlv: 1.1.3 + dset: 3.1.4 + es-module-lexer: 1.7.0 + esbuild: 0.27.7 + estree-walker: 3.0.3 + flattie: 1.1.1 + fontace: 0.4.1 + github-slugger: 2.0.0 + html-escaper: 3.0.3 + http-cache-semantics: 4.2.0 + import-meta-resolve: 4.2.0 + js-yaml: 4.1.1 + magic-string: 0.30.21 + magicast: 0.5.2 + mrmime: 2.0.1 + neotraverse: 0.6.18 + p-limit: 6.2.0 + p-queue: 8.1.1 + package-manager-detector: 1.6.0 + piccolore: 0.1.3 + picomatch: 4.0.4 + prompts: 2.4.2 + rehype: 13.0.2 + semver: 7.7.4 + shiki: 3.23.0 + smol-toml: 1.6.1 + svgo: 4.0.1 + tinyexec: 1.0.4 + tinyglobby: 0.2.15 + tsconfck: 3.1.6(typescript@5.9.3) + ultrahtml: 1.6.0 + unifont: 0.7.4 + unist-util-visit: 5.1.0 + unstorage: 1.17.5(@azure/storage-blob@12.31.0)(ioredis@5.10.1) + vfile: 6.0.3 + vite: 6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vitefu: 1.1.3(vite@6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + xxhash-wasm: 1.1.0 + yargs-parser: 21.1.1 + yocto-spinner: 0.2.3 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + zod-to-ts: 1.2.0(typescript@5.9.3)(zod@3.25.76) + optionalDependencies: + sharp: 0.34.5 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@types/node' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - db0 + - idb-keyval + - ioredis + - jiti + - less + - lightningcss + - rollup + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - typescript + - uploadthing + - yaml + astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): dependencies: '@astrojs/compiler': 2.13.1 @@ -24812,108 +24941,6 @@ snapshots: - uploadthing - yaml - astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): - dependencies: - '@astrojs/compiler': 2.13.1 - '@astrojs/internal-helpers': 0.7.6 - '@astrojs/markdown-remark': 6.3.11 - '@astrojs/telemetry': 3.3.0 - '@capsizecss/unpack': 4.0.0 - '@oslojs/encoding': 1.1.0 - '@rollup/pluginutils': 5.3.0(rollup@4.60.1) - acorn: 8.16.0 - aria-query: 5.3.2 - axobject-query: 4.1.0 - boxen: 8.0.1 - ci-info: 4.4.0 - clsx: 2.1.1 - common-ancestor-path: 1.0.1 - cookie: 1.1.1 - cssesc: 3.0.0 - debug: 4.4.3 - deterministic-object-hash: 2.0.2 - devalue: 5.7.0 - diff: 8.0.4 - dlv: 1.1.3 - dset: 3.1.4 - es-module-lexer: 1.7.0 - esbuild: 0.27.7 - estree-walker: 3.0.3 - flattie: 1.1.1 - fontace: 0.4.1 - github-slugger: 2.0.0 - html-escaper: 3.0.3 - http-cache-semantics: 4.2.0 - import-meta-resolve: 4.2.0 - js-yaml: 4.1.1 - magic-string: 0.30.21 - magicast: 0.5.2 - mrmime: 2.0.1 - neotraverse: 0.6.18 - p-limit: 6.2.0 - p-queue: 8.1.1 - package-manager-detector: 1.6.0 - piccolore: 0.1.3 - picomatch: 4.0.4 - prompts: 2.4.2 - rehype: 13.0.2 - semver: 7.7.4 - shiki: 3.23.0 - smol-toml: 1.6.1 - svgo: 4.0.1 - tinyexec: 1.0.4 - tinyglobby: 0.2.15 - tsconfck: 3.1.6(typescript@5.9.3) - ultrahtml: 1.6.0 - unifont: 0.7.4 - unist-util-visit: 5.1.0 - unstorage: 1.17.5(@azure/storage-blob@12.31.0)(ioredis@5.10.1) - vfile: 6.0.3 - vite: 6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - vitefu: 1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) - xxhash-wasm: 1.1.0 - yargs-parser: 21.1.1 - yocto-spinner: 0.2.3 - zod: 3.25.76 - zod-to-json-schema: 3.25.2(zod@3.25.76) - zod-to-ts: 1.2.0(typescript@5.9.3)(zod@3.25.76) - optionalDependencies: - sharp: 0.34.5 - transitivePeerDependencies: - - '@azure/app-configuration' - - '@azure/cosmos' - - '@azure/data-tables' - - '@azure/identity' - - '@azure/keyvault-secrets' - - '@azure/storage-blob' - - '@capacitor/preferences' - - '@deno/kv' - - '@netlify/blobs' - - '@planetscale/database' - - '@types/node' - - '@upstash/redis' - - '@vercel/blob' - - '@vercel/functions' - - '@vercel/kv' - - aws4fetch - - db0 - - idb-keyval - - ioredis - - jiti - - less - - lightningcss - - rollup - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - typescript - - uploadthing - - yaml - astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): dependencies: '@astrojs/compiler': 2.13.1 @@ -26745,6 +26772,11 @@ snapshots: eslint: 9.39.4(jiti@2.6.1) semver: 7.7.4 + eslint-compat-utils@0.6.5(eslint@9.39.4(jiti@1.21.7)): + dependencies: + eslint: 9.39.4(jiti@1.21.7) + semver: 7.7.4 + eslint-compat-utils@0.6.5(eslint@9.39.4(jiti@2.6.1)): dependencies: eslint: 9.39.4(jiti@2.6.1) @@ -26754,6 +26786,10 @@ snapshots: dependencies: eslint: 9.39.4(jiti@2.6.1) + eslint-config-prettier@9.1.2(eslint@9.39.4(jiti@1.21.7)): + dependencies: + eslint: 9.39.4(jiti@1.21.7) + eslint-config-prettier@9.1.2(eslint@9.39.4(jiti@2.6.1)): dependencies: eslint: 9.39.4(jiti@2.6.1) @@ -26798,6 +26834,20 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-plugin-astro@1.6.0(eslint@9.39.4(jiti@1.21.7)): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7)) + '@jridgewell/sourcemap-codec': 1.5.5 + '@typescript-eslint/types': 8.58.0 + astro-eslint-parser: 1.4.0 + eslint: 9.39.4(jiti@1.21.7) + eslint-compat-utils: 0.6.5(eslint@9.39.4(jiti@1.21.7)) + globals: 16.5.0 + postcss: 8.5.8 + postcss-selector-parser: 7.1.1 + transitivePeerDependencies: + - supports-color + eslint-plugin-astro@1.6.0(eslint@9.39.4(jiti@2.6.1)): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) @@ -26971,6 +27021,47 @@ snapshots: eslint-visitor-keys@5.0.1: {} + eslint@9.39.4(jiti@1.21.7): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.14.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 1.21.7 + transitivePeerDependencies: + - supports-color + eslint@9.39.4(jiti@2.6.1): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) @@ -34088,6 +34179,23 @@ snapshots: lightningcss: 1.32.0 terser: 5.46.1 + vite@6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.8 + rollup: 4.60.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 20.19.39 + fsevents: 2.3.3 + jiti: 1.21.7 + lightningcss: 1.32.0 + terser: 5.46.1 + tsx: 4.21.0 + yaml: 2.8.3 + vite@6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: esbuild: 0.25.12 @@ -34122,23 +34230,6 @@ snapshots: tsx: 4.21.0 yaml: 2.8.3 - vite@6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): - dependencies: - esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - postcss: 8.5.8 - rollup: 4.60.1 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 24.12.2 - fsevents: 2.3.3 - jiti: 1.21.7 - lightningcss: 1.32.0 - terser: 5.46.1 - tsx: 4.21.0 - yaml: 2.8.3 - vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: esbuild: 0.25.12 @@ -34156,6 +34247,10 @@ snapshots: tsx: 4.21.0 yaml: 2.8.3 + vitefu@1.1.3(vite@6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + optionalDependencies: + vite: 6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vitefu@1.1.3(vite@6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): optionalDependencies: vite: 6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) @@ -34164,10 +34259,6 @@ snapshots: optionalDependencies: vite: 6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - vitefu@1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): - optionalDependencies: - vite: 6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - vitefu@1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): optionalDependencies: vite: 6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)