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:
Till JS 2026-04-24 01:59:11 +02:00
parent 91fd88e77d
commit 49935c9628
16 changed files with 1100 additions and 148 deletions

View 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/`

View file

@ -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 + `<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-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). |

View file

@ -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

View 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"
}
}

View 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>

View 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');
});
});

View 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';
}

View 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';

View 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);
}
});
});

View 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;
}

View 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');

View 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();
});
});

View 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(/=+$/, '');
}

View 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;
}

View 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
View file

@ -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)