mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:21:10 +02:00
feat(shared-privacy): M1 — visibility foundation package
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
- <VisibilityPicker> 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) <noreply@anthropic.com>
This commit is contained in:
parent
91fd88e77d
commit
49935c9628
16 changed files with 1100 additions and 148 deletions
242
.claude/guidelines/visibility.md
Normal file
242
.claude/guidelines/visibility.md
Normal file
|
|
@ -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 `<VisibilityPicker>` 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() │
|
||||||
|
│ <VisibilityPicker> │
|
||||||
|
└───────────┬─────────────────────────────────┬────────────┘
|
||||||
|
│ │
|
||||||
|
┌────────▼────────┐ ┌─────────▼──────────────┐
|
||||||
|
│ Module stores │ │ website/embeds.ts │
|
||||||
|
│ - setVisibility │ │ - filter by predicate │
|
||||||
|
│ - default on │ │ - decrypt + inline │
|
||||||
|
│ create │ │ matching records │
|
||||||
|
└──────────────────┘ └─────────────────────────┘
|
||||||
|
│
|
||||||
|
┌────────▼────────────┐
|
||||||
|
│ Detail-View │
|
||||||
|
│ <VisibilityPicker │
|
||||||
|
│ level={...} │
|
||||||
|
│ onChange={...} /> │
|
||||||
|
└──────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
`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 <schema>.<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 <table>_public_idx on <schema>.<table> (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<LocalMyRecord> = {
|
||||||
|
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', '<appId>', '<collection>', id, {
|
||||||
|
recordId: id,
|
||||||
|
collection: '<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
|
||||||
|
<script lang="ts">
|
||||||
|
import { VisibilityPicker, type VisibilityLevel } from '@mana/shared-privacy';
|
||||||
|
import { myStore } from '../stores/my.svelte';
|
||||||
|
import type { MyRecord } from '../types';
|
||||||
|
|
||||||
|
let { record }: { record: MyRecord } = $props();
|
||||||
|
|
||||||
|
async function onVisibilityChange(next: VisibilityLevel) {
|
||||||
|
await myStore.setVisibility(record.id, next);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<h1>{record.title}</h1>
|
||||||
|
<VisibilityPicker level={record.visibility} onChange={onVisibilityChange} />
|
||||||
|
</header>
|
||||||
|
```
|
||||||
|
|
||||||
|
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<EmbedItem[]> {
|
||||||
|
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 <schema>.<table> set visibility = 'public' where is_public = true;
|
||||||
|
alter table <schema>.<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`
|
||||||
|
- [ ] `<VisibilityPicker>` 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/`
|
||||||
|
|
@ -53,6 +53,7 @@ Always consult before changing code:
|
||||||
| [`.claude/guidelines/testing.md`](.claude/guidelines/testing.md) | Vitest, mock factories |
|
| [`.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/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/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
|
## 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-ui` | React Native UI components |
|
||||||
| `@mana/shared-theme` | Theme config |
|
| `@mana/shared-theme` | Theme config |
|
||||||
| `@mana/shared-i18n` | i18n |
|
| `@mana/shared-i18n` | i18n |
|
||||||
|
| `@mana/shared-privacy` | Unified visibility/privacy system: `VisibilityLevel` enum + zod schema + `<VisibilityPicker>` + 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-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-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). |
|
| `@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). |
|
||||||
|
|
|
||||||
|
|
@ -254,15 +254,29 @@ Breite Welle — alle Module, die noch public-relevant sind. Jedes ist ein klein
|
||||||
- Per Modul: Share-Dialog "Link erstellen"
|
- Per Modul: Share-Dialog "Link erstellen"
|
||||||
- Neu erzeugte Tokens rotieren nicht automatisch — wer den Link weitergibt, akzeptiert permanente Exposure bis Revoke
|
- 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.
|
Die folgenden Fragen waren offen beim ersten Entwurf und wurden vor der Umsetzung entschieden.
|
||||||
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.
|
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.
|
||||||
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.**
|
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.
|
||||||
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.
|
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
|
## Anti-Patterns — was wir nicht bauen
|
||||||
|
|
||||||
|
|
|
||||||
40
packages/shared-privacy/package.json
Normal file
40
packages/shared-privacy/package.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
218
packages/shared-privacy/src/VisibilityPicker.svelte
Normal file
218
packages/shared-privacy/src/VisibilityPicker.svelte
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
<!--
|
||||||
|
VisibilityPicker — one compact dropdown used in every module's detail
|
||||||
|
view to toggle a record's visibility. The trigger is a lock/globe icon
|
||||||
|
with the short label; opening it reveals all four levels with their
|
||||||
|
descriptions.
|
||||||
|
|
||||||
|
Stateless — the consumer owns the current level and the onChange
|
||||||
|
callback. Keeps the component reusable across stores and encryption
|
||||||
|
boundaries.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { Lock, UsersThree, LinkSimple, Globe, CaretDown } from '@mana/shared-icons';
|
||||||
|
import type { Component } from 'svelte';
|
||||||
|
import { VISIBILITY_LEVELS, VISIBILITY_METADATA, type VisibilityLevel } from './types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
level: VisibilityLevel;
|
||||||
|
onChange: (next: VisibilityLevel) => void;
|
||||||
|
/** Hide specific levels — e.g. a single-user space has no `space` option. */
|
||||||
|
disabledLevels?: VisibilityLevel[];
|
||||||
|
/** Show only the icon, not the label. For tight layouts. */
|
||||||
|
compact?: boolean;
|
||||||
|
/** Disable the whole control. */
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { level, onChange, disabledLevels = [], compact = false, disabled = false }: Props = $props();
|
||||||
|
|
||||||
|
const ICON_MAP: Record<VisibilityLevel, Component> = {
|
||||||
|
private: Lock,
|
||||||
|
space: UsersThree,
|
||||||
|
unlisted: LinkSimple,
|
||||||
|
public: Globe,
|
||||||
|
};
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
let triggerEl = $state<HTMLButtonElement | null>(null);
|
||||||
|
|
||||||
|
const current = $derived(VISIBILITY_METADATA[level]);
|
||||||
|
const CurrentIcon = $derived(ICON_MAP[level]);
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
if (disabled) return;
|
||||||
|
open = !open;
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(next: VisibilityLevel) {
|
||||||
|
open = false;
|
||||||
|
if (next === level) return;
|
||||||
|
onChange(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape' && open) {
|
||||||
|
e.preventDefault();
|
||||||
|
open = false;
|
||||||
|
triggerEl?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="vp" onkeydown={onKeyDown} role="presentation">
|
||||||
|
<button
|
||||||
|
bind:this={triggerEl}
|
||||||
|
class="vp__trigger"
|
||||||
|
class:vp__trigger--compact={compact}
|
||||||
|
class:vp__trigger--open={open}
|
||||||
|
onclick={toggle}
|
||||||
|
{disabled}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
title={current.description}
|
||||||
|
>
|
||||||
|
<CurrentIcon size={14} weight="bold" />
|
||||||
|
{#if !compact}
|
||||||
|
<span class="vp__label">{current.label}</span>
|
||||||
|
<span class="vp__caret"><CaretDown size={10} weight="bold" /></span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="vp__backdrop" onclick={() => (open = false)}></div>
|
||||||
|
<div class="vp__menu" role="menu">
|
||||||
|
{#each VISIBILITY_LEVELS as lvl (lvl)}
|
||||||
|
{@const meta = VISIBILITY_METADATA[lvl]}
|
||||||
|
{@const Icon = ICON_MAP[lvl]}
|
||||||
|
{@const isDisabled = disabledLevels.includes(lvl)}
|
||||||
|
<button
|
||||||
|
class="vp__opt"
|
||||||
|
class:vp__opt--active={lvl === level}
|
||||||
|
disabled={isDisabled}
|
||||||
|
role="menuitemradio"
|
||||||
|
aria-checked={lvl === level}
|
||||||
|
onclick={() => select(lvl)}
|
||||||
|
>
|
||||||
|
<span class="vp__opt-icon"><Icon size={16} weight="bold" /></span>
|
||||||
|
<span class="vp__opt-text">
|
||||||
|
<span class="vp__opt-label">{meta.label}</span>
|
||||||
|
<span class="vp__opt-desc">{meta.description}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.vp {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
.vp__trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.25rem 0.55rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
border-color 0.15s,
|
||||||
|
opacity 0.15s;
|
||||||
|
}
|
||||||
|
.vp__trigger:hover:not(:disabled) {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border-color: rgba(255, 255, 255, 0.22);
|
||||||
|
}
|
||||||
|
.vp__trigger--open {
|
||||||
|
background: rgba(99, 102, 241, 0.12);
|
||||||
|
border-color: rgba(99, 102, 241, 0.4);
|
||||||
|
}
|
||||||
|
.vp__trigger:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.vp__trigger--compact {
|
||||||
|
padding: 0.25rem 0.35rem;
|
||||||
|
}
|
||||||
|
.vp__label {
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.vp__caret {
|
||||||
|
display: inline-flex;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp__backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 50;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.vp__menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 0.35rem);
|
||||||
|
right: 0;
|
||||||
|
z-index: 60;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 16rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
.vp__opt {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding: 0.5rem 0.625rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.vp__opt:hover:not(:disabled) {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
.vp__opt--active {
|
||||||
|
background: rgba(99, 102, 241, 0.12);
|
||||||
|
}
|
||||||
|
.vp__opt:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.vp__opt-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.vp__opt-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.vp__opt-label {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.vp__opt-desc {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
24
packages/shared-privacy/src/defaults.test.ts
Normal file
24
packages/shared-privacy/src/defaults.test.ts
Normal file
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
20
packages/shared-privacy/src/defaults.ts
Normal file
20
packages/shared-privacy/src/defaults.ts
Normal file
|
|
@ -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';
|
||||||
|
}
|
||||||
35
packages/shared-privacy/src/index.ts
Normal file
35
packages/shared-privacy/src/index.ts
Normal file
|
|
@ -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)
|
||||||
|
* - <VisibilityPicker> 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';
|
||||||
45
packages/shared-privacy/src/predicates.test.ts
Normal file
45
packages/shared-privacy/src/predicates.test.ts
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
43
packages/shared-privacy/src/predicates.ts
Normal file
43
packages/shared-privacy/src/predicates.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
23
packages/shared-privacy/src/schema.ts
Normal file
23
packages/shared-privacy/src/schema.ts
Normal file
|
|
@ -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');
|
||||||
39
packages/shared-privacy/src/tokens.test.ts
Normal file
39
packages/shared-privacy/src/tokens.test.ts
Normal file
|
|
@ -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<string>();
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
25
packages/shared-privacy/src/tokens.ts
Normal file
25
packages/shared-privacy/src/tokens.ts
Normal file
|
|
@ -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(/=+$/, '');
|
||||||
|
}
|
||||||
71
packages/shared-privacy/src/types.ts
Normal file
71
packages/shared-privacy/src/types.ts
Normal file
|
|
@ -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<VisibilityLevel, VisibilityMeta> = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
20
packages/shared-privacy/tsconfig.json
Normal file
20
packages/shared-privacy/tsconfig.json
Normal file
|
|
@ -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"]
|
||||||
|
}
|
||||||
371
pnpm-lock.yaml
generated
371
pnpm-lock.yaml
generated
|
|
@ -141,14 +141,14 @@ importers:
|
||||||
version: link:../../../../packages/shared-landing-ui
|
version: link:../../../../packages/shared-landing-ui
|
||||||
astro:
|
astro:
|
||||||
specifier: ^5.16.0
|
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:
|
typescript:
|
||||||
specifier: ^5.9.2
|
specifier: ^5.9.2
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@astrojs/tailwind':
|
'@astrojs/tailwind':
|
||||||
specifier: ^6.0.2
|
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':
|
'@tailwindcss/typography':
|
||||||
specifier: ^0.5.18
|
specifier: ^0.5.18
|
||||||
version: 0.5.19(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))
|
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
|
version: 20.19.39
|
||||||
eslint:
|
eslint:
|
||||||
specifier: ^9.0.0
|
specifier: ^9.0.0
|
||||||
version: 9.39.4(jiti@2.6.1)
|
version: 9.39.4(jiti@1.21.7)
|
||||||
eslint-config-prettier:
|
eslint-config-prettier:
|
||||||
specifier: ^9.1.0
|
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:
|
eslint-plugin-astro:
|
||||||
specifier: ^1.0.0
|
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:
|
prettier:
|
||||||
specifier: ^3.6.2
|
specifier: ^3.6.2
|
||||||
version: 3.8.1
|
version: 3.8.1
|
||||||
|
|
@ -256,10 +256,10 @@ importers:
|
||||||
version: 3.7.2
|
version: 3.7.2
|
||||||
'@astrojs/tailwind':
|
'@astrojs/tailwind':
|
||||||
specifier: ^6.0.0
|
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:
|
astro:
|
||||||
specifier: ^5.16.11
|
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:
|
tailwindcss:
|
||||||
specifier: ^3.4.17
|
specifier: ^3.4.17
|
||||||
version: 3.4.19(tsx@4.21.0)(yaml@2.8.3)
|
version: 3.4.19(tsx@4.21.0)(yaml@2.8.3)
|
||||||
|
|
@ -2230,6 +2230,28 @@ importers:
|
||||||
specifier: ^5.7.2
|
specifier: ^5.7.2
|
||||||
version: 5.9.3
|
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:
|
packages/shared-pwa:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@vite-pwa/sveltekit':
|
'@vite-pwa/sveltekit':
|
||||||
|
|
@ -17328,6 +17350,16 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- ts-node
|
- 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))':
|
'@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:
|
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)
|
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:
|
transitivePeerDependencies:
|
||||||
- ts-node
|
- 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))':
|
'@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:
|
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)
|
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':
|
'@esbuild/win32-x64@0.27.7':
|
||||||
optional: true
|
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))':
|
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
eslint: 9.39.4(jiti@2.6.1)
|
eslint: 9.39.4(jiti@2.6.1)
|
||||||
|
|
@ -24608,6 +24635,108 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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):
|
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:
|
dependencies:
|
||||||
'@astrojs/compiler': 2.13.1
|
'@astrojs/compiler': 2.13.1
|
||||||
|
|
@ -24812,108 +24941,6 @@ snapshots:
|
||||||
- uploadthing
|
- uploadthing
|
||||||
- yaml
|
- 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):
|
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:
|
dependencies:
|
||||||
'@astrojs/compiler': 2.13.1
|
'@astrojs/compiler': 2.13.1
|
||||||
|
|
@ -26745,6 +26772,11 @@ snapshots:
|
||||||
eslint: 9.39.4(jiti@2.6.1)
|
eslint: 9.39.4(jiti@2.6.1)
|
||||||
semver: 7.7.4
|
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)):
|
eslint-compat-utils@0.6.5(eslint@9.39.4(jiti@2.6.1)):
|
||||||
dependencies:
|
dependencies:
|
||||||
eslint: 9.39.4(jiti@2.6.1)
|
eslint: 9.39.4(jiti@2.6.1)
|
||||||
|
|
@ -26754,6 +26786,10 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
eslint: 9.39.4(jiti@2.6.1)
|
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)):
|
eslint-config-prettier@9.1.2(eslint@9.39.4(jiti@2.6.1)):
|
||||||
dependencies:
|
dependencies:
|
||||||
eslint: 9.39.4(jiti@2.6.1)
|
eslint: 9.39.4(jiti@2.6.1)
|
||||||
|
|
@ -26798,6 +26834,20 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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)):
|
eslint-plugin-astro@1.6.0(eslint@9.39.4(jiti@2.6.1)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
|
'@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-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):
|
eslint@9.39.4(jiti@2.6.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
|
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
|
||||||
|
|
@ -34088,6 +34179,23 @@ snapshots:
|
||||||
lightningcss: 1.32.0
|
lightningcss: 1.32.0
|
||||||
terser: 5.46.1
|
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):
|
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:
|
dependencies:
|
||||||
esbuild: 0.25.12
|
esbuild: 0.25.12
|
||||||
|
|
@ -34122,23 +34230,6 @@ snapshots:
|
||||||
tsx: 4.21.0
|
tsx: 4.21.0
|
||||||
yaml: 2.8.3
|
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):
|
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:
|
dependencies:
|
||||||
esbuild: 0.25.12
|
esbuild: 0.25.12
|
||||||
|
|
@ -34156,6 +34247,10 @@ snapshots:
|
||||||
tsx: 4.21.0
|
tsx: 4.21.0
|
||||||
yaml: 2.8.3
|
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)):
|
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:
|
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)
|
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:
|
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)
|
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)):
|
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:
|
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)
|
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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue