diff --git a/packages/contrail/src/core/persistent.ts b/packages/contrail/src/core/persistent.ts index 5eb48a2..4dade65 100644 --- a/packages/contrail/src/core/persistent.ts +++ b/packages/contrail/src/core/persistent.ts @@ -120,21 +120,15 @@ async function streamAndFlush( }); const buffer: IngestEvent[] = []; - let flushDue = false; + // Guards against overlap between the periodic timer flush and a main-loop + // batchSize-driven flush. The main loop only ever awaits flush() sequentially, + // but the setInterval callback is a second entry point on another tick. let flushing = false; - let flushTimer: ReturnType | null = null; - - const resetFlushTimer = () => { - if (flushTimer) clearTimeout(flushTimer); - flushTimer = setTimeout(() => { flushDue = true; }, flushIntervalMs); - }; const flush = async () => { if (buffer.length === 0 || flushing) return; flushing = true; const batch = buffer.splice(0); - flushDue = false; - resetFlushTimer(); try { await applyEvents(db, batch, config, { pubsub: opts.pubsub }); @@ -142,7 +136,6 @@ async function streamAndFlush( const lastTimeUs = Math.max(...batch.map((e) => e.time_us)); await saveCursor(db, lastTimeUs); - // Identity refresh const uniqueDids = [...new Set(batch.map((e) => e.did))]; if (uniqueDids.length > 0) { try { @@ -152,7 +145,6 @@ async function streamAndFlush( } } - // Feed pruning if (config.feeds && Date.now() - state.lastFeedPruneMs > FEED_PRUNE_INTERVAL_MS) { const maxItems = Math.max( ...Object.values(config.feeds).map((f) => f.maxItems ?? DEFAULT_FEED_MAX_ITEMS) @@ -168,38 +160,37 @@ async function streamAndFlush( } }; - // Handle abort + // Periodic flush decoupled from the main loop. Runs even when Jetstream is + // idle, which is the whole point — without it, buffered events strand until + // the next event or abort. Errors log and retry next interval rather than + // propagate, so transient DB hiccups don't force a reconnect. + const flushTimer = setInterval(() => { + flush().catch((err) => log.error(`Timer flush failed: ${err}`)); + }, flushIntervalMs); + const onAbort = () => { - if (flushTimer) clearTimeout(flushTimer); + clearInterval(flushTimer); }; signal?.addEventListener("abort", onAbort, { once: true }); - resetFlushTimer(); - - // Use manual iterator so we can race next() against abort signal const iterator = subscription[Symbol.asyncIterator](); try { - while (true) { - if (signal?.aborted) break; + while (!signal?.aborted) { + // Per-iteration abort race so the handler can be removed synchronously + // after the race settles — otherwise addEventListener calls accumulate on + // the signal across the streamAndFlush lifetime. + let abortHandler!: () => void; + const abortPromise = new Promise>((resolve) => { + abortHandler = () => resolve({ value: undefined, done: true }); + signal?.addEventListener("abort", abortHandler, { once: true }); + }); - // Race the next event against abort signal let result: IteratorResult; - if (signal) { - const nextPromise = iterator.next(); - if (signal.aborted) { - result = { value: undefined, done: true }; - } else { - let abortHandler: () => void; - const abortPromise = new Promise>((resolve) => { - abortHandler = () => resolve({ value: undefined, done: true }); - signal.addEventListener("abort", abortHandler, { once: true }); - }); - result = await Promise.race([nextPromise, abortPromise]); - signal.removeEventListener("abort", abortHandler!); - } - } else { - result = await iterator.next(); + try { + result = await Promise.race([iterator.next(), abortPromise]); + } finally { + signal?.removeEventListener("abort", abortHandler); } if (result.done) break; @@ -232,15 +223,14 @@ async function streamAndFlush( } } - if (buffer.length >= batchSize || flushDue) { + if (buffer.length >= batchSize) { await flush(); } } } finally { - // Clean up iterator + clearInterval(flushTimer); + signal?.removeEventListener("abort", onAbort); await iterator.return?.({ value: undefined, done: true }); - // Final flush on exit await flush(); - signal?.removeEventListener("abort", onAbort); } } diff --git a/packages/contrail/tests/persistent.test.ts b/packages/contrail/tests/persistent.test.ts index 531304e..5fba923 100644 --- a/packages/contrail/tests/persistent.test.ts +++ b/packages/contrail/tests/persistent.test.ts @@ -154,6 +154,53 @@ describe("runPersistent", () => { expect(cursor).toBe(3004); }); + it("flushes buffered events on timer while subscription is still live", async () => { + // Regression test for the idle-stream flush bug. Forces exactly one code + // path — timer-driven flush — by: + // - batchSize=100 with only 3 events: batchSize flush can never fire + // - assert BEFORE controller.abort(): the finally-block's final flush + // can't contribute, so records in the DB prove the periodic timer ran + // The mock subscription yields 3 events then hangs forever, mimicking a + // Jetstream connection that goes quiet. The previous implementation only + // checked the flush condition when a new event arrived, so those 3 events + // would sit in memory until the next event or shutdown — which in prod + // surfaces as "events published but never indexed." + const events = Array.from({ length: 3 }, (_, i) => ({ + kind: "commit" as const, + did: `did:plc:idle${i}`, + time_us: 5000 + i, + commit: { + collection: "community.lexicon.calendar.event", + operation: "create", + rkey: `idle${i}`, + cid: `cid${i}`, + record: { name: `Idle ${i}`, startsAt: "2026-04-01T10:00:00Z", mode: "online" }, + }, + })); + + const controller = new AbortController(); + const promise = runPersistent(db, TEST_CONFIG, { + batchSize: 100, + flushIntervalMs: 100, + signal: controller.signal, + createSubscription: () => mockSubscription(events) as any, + }); + + // Wait well past the flush interval — no further events will arrive. + await new Promise((r) => setTimeout(r, 500)); + + // Assert BEFORE abort: the timer must have driven the flush on its own. + const mid = await queryRecords(db, TEST_CONFIG, { + collection: "community.lexicon.calendar.event", + limit: 100, + }); + const idleUris = mid.records.map((r) => r.uri).filter((u) => u.includes("/idle")); + expect(idleUris.length, "timer flush did not run while subscription was idle").toBe(3); + + controller.abort(); + await promise; + }); + it("skips non-commit events", async () => { const events = [ { kind: "identity" as const, did: "did:plc:someone", time_us: 4000 }, -- 2.51.2 From 2cd5ea3b7b9085d12ec409d2e25d62646c9ce386 Mon Sep 17 00:00:00 2001 From: Tom Scanlan Date: Fri, 24 Apr 2026 17:44:23 -0400 Subject: [PATCH 2/5] add contrail-e2e app with end-to-end tests against a local ATProto stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New app under apps/contrail-e2e that brings up a full sealed local ATProto network (PDS, PLC, Jetstream, TAP, Postgres, maildev) via docker-compose and runs three end-to-end test files against it: - health.test.ts — service health checks - ingest-roundtrip.test.ts — publish → index roundtrip, verifying a calendar event and an RSVP land in the DB and that the grouped rsvpsGoingCount is populated via an in-process XRPC handler - cursor-resume.test.ts — regression for runPersistent's durable cursor. Publishes A, stops the ingester, publishes B + updates B + deletes A, restarts, and asserts the saved cursor replays the gap. The basic roundtrip can't catch a broken cursor because events always arrive while the ingester is live. Each test stands up its own runPersistent and XRPC handler in-process against an isolated pg schema (search_path pinned per test), so the suite works with just pnpm stack:up and doesn't fight a dogfooding ingester writing to public. --- apps/contrail-e2e/.env.example | 29 +++ apps/contrail-e2e/.gitignore | 2 + apps/contrail-e2e/README.md | 90 ++++++++ apps/contrail-e2e/config.ts | 51 +++++ apps/contrail-e2e/docker-compose.yml | 55 +++++ apps/contrail-e2e/init-db/01-databases.sql | 5 + apps/contrail-e2e/package.json | 23 ++ apps/contrail-e2e/scripts/down.sh | 9 + apps/contrail-e2e/scripts/up.sh | 39 ++++ apps/contrail-e2e/tests/cursor-resume.test.ts | 192 +++++++++++++++++ apps/contrail-e2e/tests/health.test.ts | 58 +++++ apps/contrail-e2e/tests/helpers.ts | 107 ++++++++++ .../tests/ingest-roundtrip.test.ts | 150 +++++++++++++ apps/contrail-e2e/tsconfig.json | 13 ++ apps/contrail-e2e/vitest.config.ts | 11 + pnpm-lock.yaml | 198 ++++++++++++------ 16 files changed, 966 insertions(+), 66 deletions(-) create mode 100644 apps/contrail-e2e/.env.example create mode 100644 apps/contrail-e2e/.gitignore create mode 100644 apps/contrail-e2e/README.md create mode 100644 apps/contrail-e2e/config.ts create mode 100644 apps/contrail-e2e/docker-compose.yml create mode 100644 apps/contrail-e2e/init-db/01-databases.sql create mode 100644 apps/contrail-e2e/package.json create mode 100755 apps/contrail-e2e/scripts/down.sh create mode 100755 apps/contrail-e2e/scripts/up.sh create mode 100644 apps/contrail-e2e/tests/cursor-resume.test.ts create mode 100644 apps/contrail-e2e/tests/health.test.ts create mode 100644 apps/contrail-e2e/tests/helpers.ts create mode 100644 apps/contrail-e2e/tests/ingest-roundtrip.test.ts create mode 100644 apps/contrail-e2e/tsconfig.json create mode 100644 apps/contrail-e2e/vitest.config.ts diff --git a/apps/contrail-e2e/.env.example b/apps/contrail-e2e/.env.example new file mode 100644 index 0000000..03bf68a --- /dev/null +++ b/apps/contrail-e2e/.env.example @@ -0,0 +1,29 @@ +# Ports (override to avoid conflicts with other local services) +DEVNET_PDS_PORT=4000 +DEVNET_PLC_PORT=2582 +DEVNET_JETSTREAM_PORT=6008 +DEVNET_JETSTREAM_METRICS_PORT=6009 +DEVNET_TAP_PORT=2480 +PG_PORT=5433 +MAILDEV_WEB_PORT=1080 +MAILDEV_SMTP_PORT=1025 + +# Devnet database (PLC server) — uses the shared postgres service +DEVNET_DB_HOST=postgres +DEVNET_DB_PORT=5432 +DEVNET_DB_USER=postgres +DEVNET_DB_PASSWORD=postgres +DEVNET_DB_NAME=plc + +# Devnet SMTP (PDS email verification) — uses the shared maildev service +DEVNET_SMTP_URL=smtp://maildev:1025 + +# Devnet handle config +DEVNET_PDS_HOSTNAME=devnet.test +DEVNET_PDS_ADMIN_PASSWORD=devnet-admin-password +DEVNET_HANDLE_DOMAIN=.devnet.test +DEVNET_SEED_ACCOUNTS=true + +# Contrail runtime (consumed by the e2e suite) +DATABASE_URL=postgresql://postgres:postgres@localhost:5433/contrail +JETSTREAM_URL=ws://localhost:6008/subscribe diff --git a/apps/contrail-e2e/.gitignore b/apps/contrail-e2e/.gitignore new file mode 100644 index 0000000..6ed48a9 --- /dev/null +++ b/apps/contrail-e2e/.gitignore @@ -0,0 +1,2 @@ +.env +node_modules diff --git a/apps/contrail-e2e/README.md b/apps/contrail-e2e/README.md new file mode 100644 index 0000000..ac0a789 --- /dev/null +++ b/apps/contrail-e2e/README.md @@ -0,0 +1,90 @@ +# contrail-e2e — end-to-end tests against a local ATProto stack + +End-to-end test suite for Contrail against a sealed local ATProto stack. +Nothing touches the public ATProto — writes go to a local PDS, the local +Jetstream reads that PDS's firehose, and each test stands up its own +in-process Contrail ingester + XRPC handler to consume it. + +## What's inside + +| Service | Port | Purpose | +|---------|------|---------| +| PDS | 4000 | Personal Data Server (user repos) | +| PLC | 2582 | DID registry | +| Jetstream | 6008 | Firehose relay in JSON | +| TAP | 2480 | Sync/backfill relay | +| PostgreSQL | 5433 | Shared by PLC + Contrail | +| Maildev | 1080 | PDS email verification catcher | + +## Prerequisites + +Clone `atproto-devnet` as a sibling of this contrail repo: + +``` +some-workspace/ +├── contrail/ (this repo) +└── atproto-devnet/ +``` + +```bash +git clone https://github.com/OpenMeet-Team/atproto-devnet.git /path/to/atproto-devnet +``` + +## Running the tests + +```bash +# From the monorepo root (one-time build of @atmo-dev/contrail) +pnpm install && pnpm build + +# Bring the devnet stack up (creates .env from .env.example on first run) +cd apps/contrail-e2e +pnpm stack:up + +# Run the suite +pnpm test:e2e # once +pnpm test:e2e:watch # re-run on change +``` + +> **Note:** `up` and `down` are reserved by pnpm (aliases for `update`), so +> the stack scripts are prefixed `stack:` to disambiguate. The test scripts +> are named `test:e2e` so the monorepo-wide `pnpm test` skips them in CI +> (they need the live docker stack). + +## Tests + +- `tests/health.test.ts` — service health checks (PLC, PDS, TAP, Jetstream) +- `tests/ingest-roundtrip.test.ts` — publish → index roundtrip. Creates a + fresh PDS account, publishes a calendar event and an RSVP, and verifies + both the record and its `rsvpsGoingCount` via an in-process XRPC handler. +- `tests/cursor-resume.test.ts` — regression for `runPersistent`'s durable + cursor. Starts the ingester, publishes A, stops the ingester, publishes + B + updates B + deletes A, restarts, and verifies the saved cursor + replays the gap. + +Each test spins up its own `runPersistent` in-process against an isolated +postgres schema, so tests don't interfere with each other or with any +dogfooding ingester running in another terminal. + +## Teardown + +```bash +pnpm stack:down # stops containers AND wipes volumes for a clean slate +``` + +## Namespace + +These tests use `rsvp.atmo` as the Contrail namespace — same as +[atmo-events](https://github.com/flo-bit/atmo-events), so the fixtures are +compatible with that frontend. If you're running your own namespace, edit +`config.ts`. + +## Why not use `apps/postgres/`? + +`apps/postgres/` connects Contrail to the public Bluesky Jetstream — useful +for indexing real events, unusable for writing test fixtures. This suite +gives a sealed environment where the tests control every record. + +## Overrides + +Copy `.env.example` to `.env` and edit. All ports, database credentials, and +devnet hostnames are overridable. diff --git a/apps/contrail-e2e/config.ts b/apps/contrail-e2e/config.ts new file mode 100644 index 0000000..0c94c29 --- /dev/null +++ b/apps/contrail-e2e/config.ts @@ -0,0 +1,51 @@ +import type { ContrailConfig } from "@atmo-dev/contrail"; + +// Namespace matches atmo.rsvp so the same atmo-events frontend can point at +// this devnet-backed Contrail without patching its hardcoded namespace. +// See https://github.com/flo-bit/atmo-events/issues/26 for making this +// configurable upstream. +export const config: ContrailConfig = { + namespace: "rsvp.atmo", + jetstreams: [ + process.env.JETSTREAM_URL ?? "ws://localhost:6008/subscribe", + ], + collections: { + event: { + collection: "community.lexicon.calendar.event", + queryable: { + mode: {}, + name: {}, + status: {}, + startsAt: { type: "range" }, + endsAt: { type: "range" }, + createdAt: { type: "range" }, + }, + searchable: ["name", "description"], + relations: { + rsvps: { + collection: "rsvp", + groupBy: "status", + count: true, + groups: { + interested: "community.lexicon.calendar.rsvp#interested", + going: "community.lexicon.calendar.rsvp#going", + notgoing: "community.lexicon.calendar.rsvp#notgoing", + }, + }, + }, + }, + rsvp: { + collection: "community.lexicon.calendar.rsvp", + queryable: { + status: {}, + "subject.uri": {}, + }, + references: { + event: { + collection: "event", + field: "subject.uri", + }, + }, + }, + }, +}; diff --git a/apps/contrail-e2e/docker-compose.yml b/apps/contrail-e2e/docker-compose.yml new file mode 100644 index 0000000..78d1668 --- /dev/null +++ b/apps/contrail-e2e/docker-compose.yml @@ -0,0 +1,55 @@ +# contrail-e2e: Contrail + atproto-devnet for local testing. +# +# Brings up a full local ATProto network (PDS, PLC, Jetstream, TAP) alongside +# the PostgreSQL instance Contrail indexes into. No traffic leaves your laptop. +# +# Prerequisites: +# - atproto-devnet cloned as a sibling of this contrail repo: +# some-workspace/ +# ├── contrail/ (this repo) +# └── atproto-devnet/ +# - Copy .env.example to .env first (scripts/up.sh does this automatically) +# +# Usage: +# pnpm up # bring everything up, wait for health +# pnpm down # stop + wipe volumes (clean slate) +# pnpm smoke # verify services are healthy + +include: + - path: ../../../atproto-devnet/docker-compose.yml + +services: + # PostgreSQL — shared by PLC (devnet) and Contrail (this example). + # NOTE: included services like PLC can't be modified via `include:` merge, + # so scripts/up.sh brings postgres up first, then the rest. + postgres: + image: postgres:17 + restart: unless-stopped + environment: + POSTGRES_USER: ${DEVNET_DB_USER:-postgres} + POSTGRES_PASSWORD: ${DEVNET_DB_PASSWORD:-postgres} + ports: + - "${PG_PORT:-5433}:5432" + volumes: + - pgdata:/var/lib/postgresql/data + - ./init-db:/docker-entrypoint-initdb.d:ro + networks: + - atproto-devnet + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DEVNET_DB_USER:-postgres}"] + interval: 3s + timeout: 3s + retries: 20 + + # Maildev — catches PDS email verification messages + maildev: + image: maildev/maildev:latest + restart: unless-stopped + ports: + - "${MAILDEV_WEB_PORT:-1080}:1080" + - "${MAILDEV_SMTP_PORT:-1025}:1025" + networks: + - atproto-devnet + +volumes: + pgdata: diff --git a/apps/contrail-e2e/init-db/01-databases.sql b/apps/contrail-e2e/init-db/01-databases.sql new file mode 100644 index 0000000..ae5a095 --- /dev/null +++ b/apps/contrail-e2e/init-db/01-databases.sql @@ -0,0 +1,5 @@ +-- Create both databases used by this example: +-- plc — DID registry state for the devnet PLC server +-- contrail — Contrail's own indexed records +CREATE DATABASE plc; +CREATE DATABASE contrail; diff --git a/apps/contrail-e2e/package.json b/apps/contrail-e2e/package.json new file mode 100644 index 0000000..2e42c0b --- /dev/null +++ b/apps/contrail-e2e/package.json @@ -0,0 +1,23 @@ +{ + "name": "contrail-e2e", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "stack:up": "./scripts/up.sh", + "stack:down": "./scripts/down.sh", + "test:e2e": "vitest run", + "test:e2e:watch": "vitest" + }, + "dependencies": { + "@atmo-dev/contrail": "workspace:*", + "pg": "^8.20.0" + }, + "devDependencies": { + "@atcute/atproto": "^3.1.10", + "@atcute/client": "^4.2.1", + "@types/pg": "^8.20.0", + "typescript": "^5.9.3", + "vitest": "^4.1.0" + } +} diff --git a/apps/contrail-e2e/scripts/down.sh b/apps/contrail-e2e/scripts/down.sh new file mode 100755 index 0000000..ae74736 --- /dev/null +++ b/apps/contrail-e2e/scripts/down.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." + +echo "→ Tearing down (removes volumes — clean slate)..." +docker compose down -v + +echo "✓ Down. All state wiped." diff --git a/apps/contrail-e2e/scripts/up.sh b/apps/contrail-e2e/scripts/up.sh new file mode 100755 index 0000000..cf5341a --- /dev/null +++ b/apps/contrail-e2e/scripts/up.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." + +if [ ! -f .env ]; then + echo "→ First run: copying .env.example to .env" + cp .env.example .env +fi + +DEVNET_COMPOSE="../../../atproto-devnet/docker-compose.yml" +if [ ! -f "$DEVNET_COMPOSE" ]; then + echo "Error: atproto-devnet not found at $DEVNET_COMPOSE" + echo "" + echo "Clone it as a sibling of the contrail repo:" + echo " git clone https://github.com/OpenMeet-Team/atproto-devnet.git ../../../../atproto-devnet" + exit 1 +fi + +echo "→ Bringing up postgres (PLC depends on it)..." +docker compose up -d --wait postgres maildev + +echo "→ Bringing up devnet (PLC, PDS, Jetstream, TAP, init)..." +docker compose up -d --wait + +# shellcheck disable=SC1091 +source .env + +echo "" +echo "Services:" +echo " PDS: http://localhost:${DEVNET_PDS_PORT:-4000}" +echo " PLC: http://localhost:${DEVNET_PLC_PORT:-2582}" +echo " Jetstream: ws://localhost:${DEVNET_JETSTREAM_PORT:-6008}" +echo " TAP: http://localhost:${DEVNET_TAP_PORT:-2480}" +echo " Postgres: localhost:${PG_PORT:-5433}" +echo " MailDev UI: http://localhost:${MAILDEV_WEB_PORT:-1080}" +echo "" +echo "Next:" +echo " pnpm test # run the e2e suite (ingester + XRPC handler run in-process)" diff --git a/apps/contrail-e2e/tests/cursor-resume.test.ts b/apps/contrail-e2e/tests/cursor-resume.test.ts new file mode 100644 index 0000000..1b01066 --- /dev/null +++ b/apps/contrail-e2e/tests/cursor-resume.test.ts @@ -0,0 +1,192 @@ +/** + * End-to-end: the ingester must resume from its persisted cursor after a + * restart, capturing events that arrived on the firehose while it was down. + * + * Flow: + * 1. Start runPersistent in-process against an isolated pg schema. + * 2. Publish event A → wait for it to be indexed → abort the ingester and + * await the promise. The finally-block's final flush must persist the + * cursor or this test fails at step 5. + * 3. While the ingester is DOWN: publish event B, update B, delete A. + * 4. Restart runPersistent against the SAME schema (same cursor row). + * 5. Assert: A is gone, B is indexed with its updated name. Those three + * commits can only be seen by replaying from the saved cursor — Jetstream + * is "live tail" without it, and the events already happened. + * + * Why this matters: runPersistent's whole reason to exist is durable state. + * A silently broken cursor would pass the basic roundtrip test because there's + * nothing to replay — every event arrives while the ingester is live. This + * test is the only one that exercises the replay path. + * + * Requires `pnpm stack:up` only. Ingester runs in-process. + */ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import pg from "pg"; +import { CredentialManager, Client } from "@atcute/client"; +import "@atcute/atproto"; +import { Contrail } from "@atmo-dev/contrail"; +import { createPostgresDatabase } from "@atmo-dev/contrail/postgres"; +import { config as baseConfig } from "../config"; +import { + createTestAccount, + createIsolatedSchema, + waitFor, + PDS_URL, + type TestAccount, +} from "./helpers"; + +const EVENT_NSID = "community.lexicon.calendar.event"; + +describe("cursor resume (ingester stops, events pile up, ingester restarts)", () => { + let account: TestAccount; + let client: Client; + let pool: pg.Pool; + let cleanupSchema: () => Promise; + + beforeAll(async () => { + account = await createTestAccount(); + const creds = new CredentialManager({ service: PDS_URL }); + await creds.login({ identifier: account.handle, password: account.password }); + client = new Client({ handler: creds }); + + const iso = await createIsolatedSchema("test_cursor"); + pool = iso.pool; + cleanupSchema = iso.cleanup; + }); + + afterAll(async () => { + await cleanupSchema?.(); + }); + + async function queryRecord(uri: string): Promise<{ record: any } | undefined> { + const res = await pool.query(`SELECT uri, record FROM records_event WHERE uri = $1`, [uri]); + if (res.rows.length === 0) return undefined; + const row = res.rows[0]; + return { record: typeof row.record === "string" ? JSON.parse(row.record) : row.record }; + } + + async function getCursor(): Promise { + const res = await pool.query(`SELECT time_us FROM cursor WHERE id = 1`); + if (res.rows.length === 0) return null; + return Number(res.rows[0].time_us); + } + + it("resumes from saved cursor and picks up commits issued while down", async () => { + const db = createPostgresDatabase(pool); + // Go through the Contrail wrapper so the config is resolved; raw + // runPersistent expects a resolved config. + const contrail = new Contrail({ ...baseConfig, db }); + await contrail.init(); + const runOpts = { batchSize: 50, flushIntervalMs: 500 }; + + // ---- Phase 1: start ingester, publish A, verify indexed ---- + const c1 = new AbortController(); + const ingest1 = contrail.runPersistent({ ...runOpts, signal: c1.signal }); + + const nameA = `A-${Date.now()}`; + const aRes = await client.post("com.atproto.repo.createRecord", { + input: { + repo: account.did, + collection: EVENT_NSID, + record: { + $type: EVENT_NSID, + name: nameA, + createdAt: new Date().toISOString(), + startsAt: new Date(Date.now() + 60 * 60_000).toISOString(), + mode: `${EVENT_NSID}#inperson`, + status: `${EVENT_NSID}#scheduled`, + }, + }, + }); + expect(aRes.ok).toBe(true); + if (!aRes.ok) throw new Error("unreachable"); + const uriA = aRes.data.uri; + + await waitFor(() => queryRecord(uriA), { label: `A indexed (${uriA})` }); + const cursorAfterA = await getCursor(); + expect(cursorAfterA, "cursor must be persisted after first flush").not.toBeNull(); + + // ---- Phase 2: stop ingester ---- + c1.abort(); + await ingest1; + + const cursorAfterStop = await getCursor(); + expect(cursorAfterStop).toBe(cursorAfterA); + + // ---- Phase 3: mutate while ingester is DOWN ---- + const nameB = `B-${Date.now()}`; + const bRes = await client.post("com.atproto.repo.createRecord", { + input: { + repo: account.did, + collection: EVENT_NSID, + record: { + $type: EVENT_NSID, + name: nameB, + createdAt: new Date().toISOString(), + startsAt: new Date(Date.now() + 2 * 60 * 60_000).toISOString(), + mode: `${EVENT_NSID}#inperson`, + status: `${EVENT_NSID}#scheduled`, + }, + }, + }); + expect(bRes.ok).toBe(true); + if (!bRes.ok) throw new Error("unreachable"); + const uriB = bRes.data.uri; + const rkeyB = uriB.split("/").pop()!; + const rkeyA = uriA.split("/").pop()!; + + const nameBUpdated = `${nameB}-renamed`; + const putRes = await client.post("com.atproto.repo.putRecord", { + input: { + repo: account.did, + collection: EVENT_NSID, + rkey: rkeyB, + record: { + $type: EVENT_NSID, + name: nameBUpdated, + createdAt: new Date().toISOString(), + startsAt: new Date(Date.now() + 2 * 60 * 60_000).toISOString(), + mode: `${EVENT_NSID}#inperson`, + status: `${EVENT_NSID}#scheduled`, + }, + }, + }); + expect(putRes.ok).toBe(true); + + const delRes = await client.post("com.atproto.repo.deleteRecord", { + input: { repo: account.did, collection: EVENT_NSID, rkey: rkeyA }, + }); + expect(delRes.ok).toBe(true); + + // Confirm the DB didn't move while the ingester was down. + expect(await queryRecord(uriA), "A must still be indexed — nobody is consuming").toBeDefined(); + expect(await queryRecord(uriB), "B should not be indexed yet — ingester is down").toBeUndefined(); + + // ---- Phase 4: restart ingester, assert replay ---- + const c2 = new AbortController(); + const ingest2 = contrail.runPersistent({ ...runOpts, signal: c2.signal }); + + try { + const indexedB = await waitFor( + async () => { + const r = await queryRecord(uriB); + return r && r.record.name === nameBUpdated ? r : undefined; + }, + { label: `B replayed with updated name` }, + ); + expect(indexedB.record.name).toBe(nameBUpdated); + + await waitFor( + async () => ((await queryRecord(uriA)) === undefined ? true : undefined), + { label: `A deletion replayed` }, + ); + + const cursorAfterReplay = await getCursor(); + expect(cursorAfterReplay).not.toBeNull(); + expect(cursorAfterReplay!).toBeGreaterThan(cursorAfterStop!); + } finally { + c2.abort(); + await ingest2; + } + }, 60_000); +}); diff --git a/apps/contrail-e2e/tests/health.test.ts b/apps/contrail-e2e/tests/health.test.ts new file mode 100644 index 0000000..c980e27 --- /dev/null +++ b/apps/contrail-e2e/tests/health.test.ts @@ -0,0 +1,58 @@ +/** + * Health checks for the running devnet stack. + * + * Assumes `pnpm stack:up` has completed. Reads ports from the environment + * with fallbacks matching `.env.example` so tests work out of the box. + * + * The Contrail handler is covered by ingest-roundtrip.test.ts in-process — + * this file only verifies the external devnet services are reachable. + */ +import { describe, it, expect } from "vitest"; +import net from "node:net"; + +const PDS_PORT = Number(process.env.DEVNET_PDS_PORT ?? 4000); +const PLC_PORT = Number(process.env.DEVNET_PLC_PORT ?? 2582); +const TAP_PORT = Number(process.env.DEVNET_TAP_PORT ?? 2480); +const JS_PORT = Number(process.env.DEVNET_JETSTREAM_PORT ?? 6008); + +async function get(url: string): Promise { + return fetch(url); +} + +function tcpConnect(port: number, host = "127.0.0.1"): Promise { + return new Promise((resolve, reject) => { + const socket = new net.Socket(); + socket.setTimeout(3000); + socket.once("connect", () => { + socket.destroy(); + resolve(); + }); + socket.once("timeout", () => { + socket.destroy(); + reject(new Error(`TCP connect to ${host}:${port} timed out`)); + }); + socket.once("error", reject); + socket.connect(port, host); + }); +} + +describe("devnet services", () => { + it("PLC responds to /_health", async () => { + const res = await get(`http://localhost:${PLC_PORT}/_health`); + expect(res.ok).toBe(true); + }); + + it("PDS responds to /xrpc/_health", async () => { + const res = await get(`http://localhost:${PDS_PORT}/xrpc/_health`); + expect(res.ok).toBe(true); + }); + + it("TAP responds to /health", async () => { + const res = await get(`http://localhost:${TAP_PORT}/health`); + expect(res.ok).toBe(true); + }); + + it("Jetstream accepts TCP connections", async () => { + await expect(tcpConnect(JS_PORT)).resolves.toBeUndefined(); + }); +}); diff --git a/apps/contrail-e2e/tests/helpers.ts b/apps/contrail-e2e/tests/helpers.ts new file mode 100644 index 0000000..f3965ef --- /dev/null +++ b/apps/contrail-e2e/tests/helpers.ts @@ -0,0 +1,107 @@ +/** + * Shared test infrastructure for the contrail-e2e suite. + * + * The ingester and XRPC handler run in-process against an isolated pg schema + * per test — no external processes required, and no collision with a + * dogfooding ingester the developer might have running in another terminal. + */ +import pg from "pg"; + +export type Did = `did:${string}:${string}`; + +export const PDS_PORT = Number(process.env.DEVNET_PDS_PORT ?? 4000); +export const PDS_URL = `http://localhost:${PDS_PORT}`; +export const HANDLE_DOMAIN = process.env.DEVNET_HANDLE_DOMAIN ?? ".devnet.test"; +export const PDS_ADMIN_PASSWORD = process.env.DEVNET_PDS_ADMIN_PASSWORD ?? "devnet-admin-password"; +export const DATABASE_URL = + process.env.DATABASE_URL ?? "postgresql://postgres:postgres@localhost:5433/contrail"; + +export type TestAccount = { handle: string; password: string; did: Did }; + +export async function createTestAccount(): Promise { + const inviteRes = await fetch(`${PDS_URL}/xrpc/com.atproto.server.createInviteCode`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Basic ${Buffer.from(`admin:${PDS_ADMIN_PASSWORD}`).toString("base64")}`, + }, + body: JSON.stringify({ useCount: 1 }), + }); + if (!inviteRes.ok) { + throw new Error(`createInviteCode → ${inviteRes.status}: ${await inviteRes.text()}`); + } + const { code: inviteCode } = (await inviteRes.json()) as { code: string }; + + const suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; + const handle = `test-${suffix}${HANDLE_DOMAIN}`; + const password = `pw-${suffix}`; + + const accountRes = await fetch(`${PDS_URL}/xrpc/com.atproto.server.createAccount`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ handle, email: `${suffix}@devnet.test`, password, inviteCode }), + }); + if (!accountRes.ok) { + throw new Error(`createAccount → ${accountRes.status}: ${await accountRes.text()}`); + } + const { did } = (await accountRes.json()) as { did: string }; + return { handle, password, did: did as Did }; +} + +/** + * Create a fresh pg schema and return a Pool pinned to it via search_path. + * Call the returned `cleanup` in afterAll to drop the schema. + */ +export async function createIsolatedSchema( + prefix = "test", +): Promise<{ pool: pg.Pool; schemaName: string; cleanup: () => Promise }> { + const schemaName = `${prefix}_${Date.now()}${Math.floor(Math.random() * 1000)}`; + const bootstrap = new pg.Pool({ connectionString: DATABASE_URL }); + await bootstrap.query(`CREATE SCHEMA "${schemaName}"`); + await bootstrap.end(); + + const pool = new pg.Pool({ + connectionString: DATABASE_URL, + options: `-c search_path="${schemaName}"`, + }); + + const cleanup = async () => { + await pool.end(); + const c = new pg.Pool({ connectionString: DATABASE_URL }); + await c.query(`DROP SCHEMA IF EXISTS "${schemaName}" CASCADE`); + await c.end(); + }; + + return { pool, schemaName, cleanup }; +} + +/** + * Poll `fn` until it returns a defined value, or time out. + * + * Thrown errors count as "not yet" (last is included in the timeout message) + * so transient 404s during startup don't abort. Use `label` to say what was + * missing. Defaults (15s / 250ms) cover Contrail's 500ms test-mode flush plus + * firehose propagation with plenty of headroom. + */ +export async function waitFor( + fn: () => Promise, + { timeoutMs = 15_000, intervalMs = 250, label }: { timeoutMs?: number; intervalMs?: number; label: string }, +): Promise { + const deadline = Date.now() + timeoutMs; + let lastErr: unknown; + let attempts = 0; + while (Date.now() < deadline) { + attempts++; + try { + const v = await fn(); + if (v !== undefined) return v; + } catch (err) { + lastErr = err; + } + await new Promise((r) => setTimeout(r, intervalMs)); + } + throw new Error( + `waitFor(${label}) timed out after ${timeoutMs}ms (${attempts} attempts)` + + (lastErr ? `: ${(lastErr as Error).message}` : ""), + ); +} diff --git a/apps/contrail-e2e/tests/ingest-roundtrip.test.ts b/apps/contrail-e2e/tests/ingest-roundtrip.test.ts new file mode 100644 index 0000000..b597245 --- /dev/null +++ b/apps/contrail-e2e/tests/ingest-roundtrip.test.ts @@ -0,0 +1,150 @@ +/** + * End-to-end: publish a record via the devnet PDS, verify Contrail indexes it + * via Jetstream. + * + * Flow: + * 1. Stand up an in-process Contrail against an isolated pg schema — no + * collision with a dogfooding `pnpm ingest` and no external processes + * required. + * 2. Create a fresh PDS account + auth (unique handle per run). + * 3. Publish records through `com.atproto.repo.createRecord` on the PDS. + * 4. Poll the in-process XRPC handler's `rsvp.atmo.event.getRecord` until + * the record appears. Flush cadence is 500ms in tests, so publish → + * indexed is bounded by firehose propagation plus one flush. + * 5. Assert indexed fields match what we published. + * + * Requires `pnpm stack:up` only. The ingester and XRPC handler run in-process. + */ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import pg from "pg"; +import { CredentialManager, Client } from "@atcute/client"; +import "@atcute/atproto"; +import { Contrail } from "@atmo-dev/contrail"; +import { createHandler } from "@atmo-dev/contrail/server"; +import { createPostgresDatabase } from "@atmo-dev/contrail/postgres"; +import { config as baseConfig } from "../config"; +import { + createTestAccount, + createIsolatedSchema, + waitFor, + PDS_URL, + type TestAccount, +} from "./helpers"; + +const EVENT_NSID = "community.lexicon.calendar.event"; +const RSVP_NSID = "community.lexicon.calendar.rsvp"; + +describe("ingest roundtrip (devnet PDS → Jetstream → Contrail)", () => { + let account: TestAccount; + let client: Client; + let pool: pg.Pool; + let cleanupSchema: () => Promise; + let handle: (req: Request) => Promise; + let ingestController: AbortController; + let ingestPromise: Promise; + + let eventUri: string; + let eventCid: string; + const eventName = `devnet roundtrip ${Date.now()}`; + const startsAt = new Date(Date.now() + 60 * 60_000).toISOString(); + + beforeAll(async () => { + // PDS auth + account = await createTestAccount(); + const creds = new CredentialManager({ service: PDS_URL }); + await creds.login({ identifier: account.handle, password: account.password }); + client = new Client({ handler: creds }); + + // Isolated schema + in-process Contrail + const iso = await createIsolatedSchema("test_roundtrip"); + pool = iso.pool; + cleanupSchema = iso.cleanup; + const db = createPostgresDatabase(pool); + const contrail = new Contrail({ ...baseConfig, db }); + await contrail.init(); + handle = createHandler(contrail); + + // In-process ingester via Contrail so the config is resolved (raw + // runPersistent expects a resolved config — grouped counts silently + // don't update otherwise). + ingestController = new AbortController(); + ingestPromise = contrail.runPersistent({ + batchSize: 50, + flushIntervalMs: 500, + signal: ingestController.signal, + }); + }); + + afterAll(async () => { + ingestController?.abort(); + await ingestPromise?.catch(() => {}); + await cleanupSchema?.(); + }); + + async function getIndexedRecord(uri: string): Promise { + const url = `http://test/xrpc/rsvp.atmo.event.getRecord?uri=${encodeURIComponent(uri)}`; + const res = await handle(new Request(url)); + if (res.status === 404) return undefined; + if (!res.ok) throw new Error(`getRecord ${uri} → ${res.status}: ${await res.text()}`); + // Contrail's getRecord returns the formatted row — uri/did/cid/time_us/record + // plus flattened relation counts (e.g. rsvpsGoingCount). + return await res.json(); + } + + it("indexes a community.lexicon.calendar.event published to devnet PDS", async () => { + const res = await client.post("com.atproto.repo.createRecord", { + input: { + repo: account.did, + collection: EVENT_NSID, + record: { + $type: EVENT_NSID, + name: eventName, + createdAt: new Date().toISOString(), + startsAt, + mode: `${EVENT_NSID}#inperson`, + status: `${EVENT_NSID}#scheduled`, + }, + }, + }); + expect(res.ok, `createRecord failed: ${JSON.stringify(res.data)}`).toBe(true); + if (!res.ok) throw new Error("unreachable"); + eventUri = res.data.uri; + eventCid = res.data.cid; + expect(eventUri).toMatch(new RegExp(`^at://${account.did}/${EVENT_NSID}/`)); + + const indexed = await waitFor(() => getIndexedRecord(eventUri), { label: `event ${eventUri}` }); + + expect(indexed.uri).toBe(eventUri); + expect(indexed.did).toBe(account.did); + expect(indexed.record.name).toBe(eventName); + expect(indexed.record.startsAt).toBe(startsAt); + }); + + it("increments rsvpsGoingCount when an RSVP is published", async () => { + expect(eventUri, "event must be published by the previous test").toBeTruthy(); + + const res = await client.post("com.atproto.repo.createRecord", { + input: { + repo: account.did, + collection: RSVP_NSID, + record: { + $type: RSVP_NSID, + subject: { uri: eventUri, cid: eventCid }, + status: `${RSVP_NSID}#going`, + createdAt: new Date().toISOString(), + }, + }, + }); + expect(res.ok, `createRecord failed: ${JSON.stringify(res.data)}`).toBe(true); + + const indexed = await waitFor( + async () => { + const r = await getIndexedRecord(eventUri); + return r && r.rsvpsGoingCount >= 1 ? r : undefined; + }, + { label: `rsvpsGoingCount for ${eventUri}` }, + ); + + expect(indexed.rsvpsGoingCount).toBe(1); + }); +}); diff --git a/apps/contrail-e2e/tsconfig.json b/apps/contrail-e2e/tsconfig.json new file mode 100644 index 0000000..9b7d9df --- /dev/null +++ b/apps/contrail-e2e/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "isolatedModules": true + }, + "include": ["."] +} diff --git a/apps/contrail-e2e/vitest.config.ts b/apps/contrail-e2e/vitest.config.ts new file mode 100644 index 0000000..164b0c7 --- /dev/null +++ b/apps/contrail-e2e/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"], + // Live-stack tests mutate a shared devnet — can't run in parallel. + fileParallelism: false, + // Network round-trips can be slow on a cold stack. + testTimeout: 30_000, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 89a1941..7a51e70 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,6 +46,31 @@ importers: specifier: ^4.63.0 version: 4.84.1(@cloudflare/workers-types@4.20260424.1) + apps/contrail-e2e: + dependencies: + '@atmo-dev/contrail': + specifier: workspace:* + version: link:../../packages/contrail + pg: + specifier: ^8.20.0 + version: 8.20.0 + devDependencies: + '@atcute/atproto': + specifier: ^3.1.10 + version: 3.1.11 + '@atcute/client': + specifier: ^4.2.1 + version: 4.2.1 + '@types/pg': + specifier: ^8.20.0 + version: 8.20.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.1.0 + version: 4.1.5(@types/node@25.6.0)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) + apps/group-chat: dependencies: '@atcute/jetstream': @@ -62,13 +87,13 @@ importers: version: link:../../packages/sync '@foxui/core': specifier: ^0.9.1 - version: 0.9.1(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) + version: 0.9.1(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) '@foxui/social': specifier: ^0.8.10 - version: 0.8.10(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(@tiptap/extension-code-block@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(highlight.js@11.11.1)(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) + version: 0.8.10(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(@tiptap/extension-code-block@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(highlight.js@11.11.1)(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) '@foxui/time': specifier: ^0.8.5 - version: 0.8.5(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) + version: 0.8.5(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) valibot: specifier: ^1.3.1 version: 1.3.1(typescript@6.0.3) @@ -111,22 +136,22 @@ importers: version: 10.0.1(eslint@10.2.1(jiti@2.6.1)) '@sveltejs/adapter-cloudflare': specifier: ^7.2.8 - version: 7.2.8(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(wrangler@4.84.1(@cloudflare/workers-types@4.20260424.1)) + version: 7.2.8(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(wrangler@4.84.1(@cloudflare/workers-types@4.20260424.1)) '@sveltejs/kit': specifier: ^2.55.0 - version: 2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)) + version: 2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) '@sveltejs/vite-plugin-svelte': specifier: ^7.0.0 - version: 7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)) + version: 7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) '@tailwindcss/forms': specifier: ^0.5.11 version: 0.5.11(tailwindcss@4.2.4) '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.4(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)) + version: 4.2.4(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) bits-ui: specifier: ^2.16.4 - version: 2.18.0(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)) + version: 2.18.0(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)) eslint: specifier: ^10.1.0 version: 10.2.1(jiti@2.6.1) @@ -168,7 +193,7 @@ importers: version: 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) vite: specifier: ^8.0.3 - version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3) + version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0) wrangler: specifier: ^4.78.0 version: 4.84.1(@cloudflare/workers-types@4.20260424.1) @@ -245,13 +270,13 @@ importers: version: link:../../packages/lexicons '@foxui/core': specifier: ^0.9.1 - version: 0.9.1(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) + version: 0.9.1(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) '@foxui/social': specifier: ^0.8.10 - version: 0.8.10(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(@tiptap/extension-code-block@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(highlight.js@11.11.1)(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) + version: 0.8.10(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(@tiptap/extension-code-block@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(highlight.js@11.11.1)(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) '@foxui/time': specifier: ^0.8.5 - version: 0.8.5(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) + version: 0.8.5(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) valibot: specifier: ^1.3.1 version: 1.3.1(typescript@6.0.3) @@ -294,22 +319,22 @@ importers: version: 10.0.1(eslint@10.2.1(jiti@2.6.1)) '@sveltejs/adapter-cloudflare': specifier: ^7.2.8 - version: 7.2.8(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(wrangler@4.84.1(@cloudflare/workers-types@4.20260424.1)) + version: 7.2.8(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(wrangler@4.84.1(@cloudflare/workers-types@4.20260424.1)) '@sveltejs/kit': specifier: ^2.55.0 - version: 2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)) + version: 2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) '@sveltejs/vite-plugin-svelte': specifier: ^7.0.0 - version: 7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)) + version: 7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) '@tailwindcss/forms': specifier: ^0.5.11 version: 0.5.11(tailwindcss@4.2.4) '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.4(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)) + version: 4.2.4(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) bits-ui: specifier: ^2.16.4 - version: 2.18.0(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)) + version: 2.18.0(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)) eslint: specifier: ^10.1.0 version: 10.2.1(jiti@2.6.1) @@ -351,7 +376,7 @@ importers: version: 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) vite: specifier: ^8.0.3 - version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3) + version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0) wrangler: specifier: ^4.78.0 version: 4.84.1(@cloudflare/workers-types@4.20260424.1) @@ -400,13 +425,13 @@ importers: version: 8.20.0 tsup: specifier: ^8.5.0 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3)(yaml@1.10.3) + version: 8.5.1(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3) typescript: specifier: ^5.7.3 version: 5.9.3 vitest: specifier: ^4.1.0 - version: 4.1.5(@types/node@25.6.0)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)) + version: 4.1.5(@types/node@25.6.0)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) packages/lexicons: dependencies: @@ -422,19 +447,19 @@ importers: version: 25.6.0 tsup: specifier: ^8.5.0 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3)(yaml@1.10.3) + version: 8.5.1(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3) typescript: specifier: ^5.7.3 version: 5.9.3 vitest: specifier: ^4.1.0 - version: 4.1.5(@types/node@25.6.0)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)) + version: 4.1.5(@types/node@25.6.0)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) packages/sync: devDependencies: tsup: specifier: ^8.5.0 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3)(yaml@1.10.3) + version: 8.5.1(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3) typescript: specifier: ^5.7.3 version: 5.9.3 @@ -1105,89 +1130,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -1349,36 +1390,42 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} @@ -1440,66 +1487,79 @@ packages: resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.2': resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.2': resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.2': resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.2': resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.2': resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.2': resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.2': resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.2': resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.2': resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.2': resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.2': resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.2': resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.60.2': resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==} @@ -1621,24 +1681,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.4': resolution: {integrity: sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.4': resolution: {integrity: sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.4': resolution: {integrity: sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.4': resolution: {integrity: sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==} @@ -2551,24 +2615,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -4152,14 +4220,14 @@ snapshots: '@fontsource-variable/geist@5.2.8': {} - '@foxui/core@0.9.1(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4)': + '@foxui/core@0.9.1(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4)': dependencies: '@fontsource-variable/geist': 5.2.8 '@fontsource-variable/geist-mono': 5.2.7 '@jis3r/icons': 2.7.0(svelte@5.55.5(@typescript-eslint/types@8.59.0)) '@lucide/svelte': 1.9.0(svelte@5.55.5(@typescript-eslint/types@8.59.0)) '@number-flow/svelte': 0.4.0(svelte@5.55.5(@typescript-eslint/types@8.59.0)) - bits-ui: 2.18.0(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)) + bits-ui: 2.18.0(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)) clsx: 2.1.1 dompurify: 3.4.1 mode-watcher: 1.1.0(svelte@5.55.5(@typescript-eslint/types@8.59.0)) @@ -4176,15 +4244,15 @@ snapshots: - react-dom - vue - '@foxui/social@0.8.10(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(@tiptap/extension-code-block@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(highlight.js@11.11.1)(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4)': + '@foxui/social@0.8.10(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(@tiptap/extension-code-block@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(highlight.js@11.11.1)(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4)': dependencies: '@atcute/bluesky': 3.3.3 '@atcute/bluesky-richtext-segmenter': 3.0.0 - '@foxui/core': 0.9.1(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) - '@foxui/text': 0.8.5(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(@tiptap/extension-code-block@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(highlight.js@11.11.1)(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) - '@foxui/time': 0.8.5(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) + '@foxui/core': 0.9.1(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) + '@foxui/text': 0.8.5(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(@tiptap/extension-code-block@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(highlight.js@11.11.1)(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) + '@foxui/time': 0.8.5(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) '@use-gesture/vanilla': 10.3.1 - bits-ui: 2.18.0(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)) + bits-ui: 2.18.0(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)) emoji-picker-element: 1.29.1 hls.js: 1.6.16 is-emoji-supported: 0.0.5 @@ -4201,10 +4269,10 @@ snapshots: - react-dom - vue - '@foxui/text@0.8.5(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(@tiptap/extension-code-block@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(highlight.js@11.11.1)(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4)': + '@foxui/text@0.8.5(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(@tiptap/extension-code-block@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(highlight.js@11.11.1)(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4)': dependencies: '@floating-ui/dom': 1.7.6 - '@foxui/core': 0.9.1(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) + '@foxui/core': 0.9.1(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) '@tiptap/extension-bubble-menu': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) '@tiptap/extension-code-block-lowlight': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/extension-code-block@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)(highlight.js@11.11.1)(lowlight@3.3.0) @@ -4224,7 +4292,7 @@ snapshots: '@tiptap/pm': 3.22.4 '@tiptap/starter-kit': 3.22.4 '@tiptap/suggestion': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) - bits-ui: 2.18.0(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)) + bits-ui: 2.18.0(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)) lowlight: 3.3.0 state: link:@tiptap/pm/state svelte: 5.55.5(@typescript-eslint/types@8.59.0) @@ -4240,11 +4308,11 @@ snapshots: - react-dom - vue - '@foxui/time@0.8.5(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4)': + '@foxui/time@0.8.5(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4)': dependencies: - '@foxui/core': 0.9.1(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) + '@foxui/core': 0.9.1(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) '@number-flow/svelte': 0.4.0(svelte@5.55.5(@typescript-eslint/types@8.59.0)) - bits-ui: 2.18.0(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)) + bits-ui: 2.18.0(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)) svelte: 5.55.5(@typescript-eslint/types@8.59.0) tailwindcss: 4.2.4 transitivePeerDependencies: @@ -4616,18 +4684,18 @@ snapshots: dependencies: acorn: 8.16.0 - '@sveltejs/adapter-cloudflare@7.2.8(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(wrangler@4.84.1(@cloudflare/workers-types@4.20260424.1))': + '@sveltejs/adapter-cloudflare@7.2.8(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(wrangler@4.84.1(@cloudflare/workers-types@4.20260424.1))': dependencies: '@cloudflare/workers-types': 4.20260424.1 - '@sveltejs/kit': 2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)) + '@sveltejs/kit': 2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) worktop: 0.8.0-next.18 wrangler: 4.84.1(@cloudflare/workers-types@4.20260424.1) - '@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3))': + '@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0))': dependencies: '@standard-schema/spec': 1.1.0 '@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0) - '@sveltejs/vite-plugin-svelte': 7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)) + '@sveltejs/vite-plugin-svelte': 7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) '@types/cookie': 0.6.0 acorn: 8.16.0 cookie: 0.6.0 @@ -4639,18 +4707,18 @@ snapshots: set-cookie-parser: 3.1.0 sirv: 3.0.2 svelte: 5.55.5(@typescript-eslint/types@8.59.0) - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3) + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0) optionalDependencies: typescript: 6.0.3 - '@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3))': + '@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0))': dependencies: deepmerge: 4.3.1 magic-string: 0.30.21 obug: 2.1.1 svelte: 5.55.5(@typescript-eslint/types@8.59.0) - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3) - vitefu: 1.1.3(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)) + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0) + vitefu: 1.1.3(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) '@swc/helpers@0.5.21': dependencies: @@ -4722,12 +4790,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.2.4 '@tailwindcss/oxide-win32-x64-msvc': 4.2.4 - '@tailwindcss/vite@4.2.4(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3))': + '@tailwindcss/vite@4.2.4(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0))': dependencies: '@tailwindcss/node': 4.2.4 '@tailwindcss/oxide': 4.2.4 tailwindcss: 4.2.4 - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3) + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0) '@tiptap/core@3.22.4(@tiptap/pm@3.22.4)': dependencies: @@ -5080,13 +5148,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3))': + '@vitest/mocker@4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0))': dependencies: '@vitest/spy': 4.1.5 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3) + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0) '@vitest/pretty-format@4.1.5': dependencies: @@ -5151,15 +5219,15 @@ snapshots: dependencies: is-windows: 1.0.2 - bits-ui@2.18.0(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)): + bits-ui@2.18.0(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)): dependencies: '@floating-ui/core': 1.7.5 '@floating-ui/dom': 1.7.6 '@internationalized/date': 3.12.1 esm-env: 1.2.2 - runed: 0.35.1(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)) + runed: 0.35.1(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)) svelte: 5.55.5(@typescript-eslint/types@8.59.0) - svelte-toolbelt: 0.10.6(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)) + svelte-toolbelt: 0.10.6(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)) tabbable: 6.4.0 transitivePeerDependencies: - '@sveltejs/kit' @@ -5885,14 +5953,13 @@ snapshots: optionalDependencies: postcss: 8.5.10 - postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0)(yaml@1.10.3): + postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 2.6.1 postcss: 8.5.10 tsx: 4.21.0 - yaml: 1.10.3 postcss-safe-parser@7.0.1(postcss@8.5.10): dependencies: @@ -6107,14 +6174,14 @@ snapshots: esm-env: 1.2.2 svelte: 5.55.5(@typescript-eslint/types@8.59.0) - runed@0.35.1(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)): + runed@0.35.1(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)): dependencies: dequal: 2.0.3 esm-env: 1.2.2 lz-string: 1.5.0 svelte: 5.55.5(@typescript-eslint/types@8.59.0) optionalDependencies: - '@sveltejs/kit': 2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)) + '@sveltejs/kit': 2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) sade@1.8.1: dependencies: @@ -6252,10 +6319,10 @@ snapshots: '@tiptap/pm': 3.22.4 svelte: 5.55.5(@typescript-eslint/types@8.59.0) - svelte-toolbelt@0.10.6(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)): + svelte-toolbelt@0.10.6(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)): dependencies: clsx: 2.1.1 - runed: 0.35.1(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)) + runed: 0.35.1(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)) style-to-object: 1.0.14 svelte: 5.55.5(@typescript-eslint/types@8.59.0) transitivePeerDependencies: @@ -6342,7 +6409,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.1(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3)(yaml@1.10.3): + tsup@8.5.1(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3): dependencies: bundle-require: 5.1.0(esbuild@0.27.7) cac: 6.7.14 @@ -6353,7 +6420,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0)(yaml@1.10.3) + postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0) resolve-from: 5.0.0 rollup: 4.60.2 source-map: 0.7.6 @@ -6433,7 +6500,7 @@ snapshots: optionalDependencies: typescript: 6.0.3 - vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3): + vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -6446,16 +6513,15 @@ snapshots: fsevents: 2.3.3 jiti: 2.6.1 tsx: 4.21.0 - yaml: 1.10.3 - vitefu@1.1.3(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)): + vitefu@1.1.3(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)): optionalDependencies: - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3) + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0) - vitest@4.1.5(@types/node@25.6.0)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)): + vitest@4.1.5(@types/node@25.6.0)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)): dependencies: '@vitest/expect': 4.1.5 - '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)) + '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) '@vitest/pretty-format': 4.1.5 '@vitest/runner': 4.1.5 '@vitest/snapshot': 4.1.5 @@ -6472,7 +6538,7 @@ snapshots: tinyexec: 1.1.1 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3) + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.6.0 -- 2.51.2 From 4ef9cf70f0935ac1a79b46ac3fc3524e153c0f52 Mon Sep 17 00:00:00 2001 From: Tom Scanlan Date: Fri, 24 Apr 2026 15:24:33 -0400 Subject: [PATCH 3/5] fix: resolve config defensively in raw runPersistent export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exported runPersistent(db, config, options) from @atmo-dev/contrail silently skipped grouped relation count updates when passed an unresolved ContrailConfig. Internals (applyEvents' buildBatchCountStatements, query planning, etc.) read `config._resolved?.relations[...]?.groups` — if `_resolved` was missing, the grouped-counts loop never ran, so columns like count_rsvp_going stayed at 0 while total count_rsvp updated normally. The Contrail class constructor resolves the config, so callers going through `new Contrail(...).runPersistent()` were fine. Only the raw export was affected — a real footgun for library consumers. Fix: mirror what Contrail's constructor does and defensively call resolveConfig when `_resolved` is absent. resolveConfig is idempotent, so this is a no-op for already-resolved configs. Regression test in packages/contrail/tests/persistent.test.ts passes a raw ContrailConfig with a grouped relation and asserts count_rsvp_going = 1. Confirmed to fail without this patch. Reverts the Contrail.runPersistent() workaround in the postgres-devnet e2e tests — they now call the raw runPersistent export directly, same as any external library consumer would. --- apps/contrail-e2e/tests/cursor-resume.test.ts | 10 +- .../tests/ingest-roundtrip.test.ts | 7 +- packages/contrail/src/core/persistent.ts | 11 ++- packages/contrail/tests/persistent.test.ts | 95 ++++++++++++++++++- 4 files changed, 107 insertions(+), 16 deletions(-) diff --git a/apps/contrail-e2e/tests/cursor-resume.test.ts b/apps/contrail-e2e/tests/cursor-resume.test.ts index 1b01066..9161f5a 100644 --- a/apps/contrail-e2e/tests/cursor-resume.test.ts +++ b/apps/contrail-e2e/tests/cursor-resume.test.ts @@ -24,7 +24,7 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest"; import pg from "pg"; import { CredentialManager, Client } from "@atcute/client"; import "@atcute/atproto"; -import { Contrail } from "@atmo-dev/contrail"; +import { runPersistent } from "@atmo-dev/contrail"; import { createPostgresDatabase } from "@atmo-dev/contrail/postgres"; import { config as baseConfig } from "../config"; import { @@ -73,15 +73,11 @@ describe("cursor resume (ingester stops, events pile up, ingester restarts)", () it("resumes from saved cursor and picks up commits issued while down", async () => { const db = createPostgresDatabase(pool); - // Go through the Contrail wrapper so the config is resolved; raw - // runPersistent expects a resolved config. - const contrail = new Contrail({ ...baseConfig, db }); - await contrail.init(); const runOpts = { batchSize: 50, flushIntervalMs: 500 }; // ---- Phase 1: start ingester, publish A, verify indexed ---- const c1 = new AbortController(); - const ingest1 = contrail.runPersistent({ ...runOpts, signal: c1.signal }); + const ingest1 = runPersistent(db, baseConfig, { ...runOpts, signal: c1.signal }); const nameA = `A-${Date.now()}`; const aRes = await client.post("com.atproto.repo.createRecord", { @@ -164,7 +160,7 @@ describe("cursor resume (ingester stops, events pile up, ingester restarts)", () // ---- Phase 4: restart ingester, assert replay ---- const c2 = new AbortController(); - const ingest2 = contrail.runPersistent({ ...runOpts, signal: c2.signal }); + const ingest2 = runPersistent(db, baseConfig, { ...runOpts, signal: c2.signal }); try { const indexedB = await waitFor( diff --git a/apps/contrail-e2e/tests/ingest-roundtrip.test.ts b/apps/contrail-e2e/tests/ingest-roundtrip.test.ts index b597245..e9b67ce 100644 --- a/apps/contrail-e2e/tests/ingest-roundtrip.test.ts +++ b/apps/contrail-e2e/tests/ingest-roundtrip.test.ts @@ -19,7 +19,7 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest"; import pg from "pg"; import { CredentialManager, Client } from "@atcute/client"; import "@atcute/atproto"; -import { Contrail } from "@atmo-dev/contrail"; +import { Contrail, runPersistent } from "@atmo-dev/contrail"; import { createHandler } from "@atmo-dev/contrail/server"; import { createPostgresDatabase } from "@atmo-dev/contrail/postgres"; import { config as baseConfig } from "../config"; @@ -64,11 +64,8 @@ describe("ingest roundtrip (devnet PDS → Jetstream → Contrail)", () => { await contrail.init(); handle = createHandler(contrail); - // In-process ingester via Contrail so the config is resolved (raw - // runPersistent expects a resolved config — grouped counts silently - // don't update otherwise). ingestController = new AbortController(); - ingestPromise = contrail.runPersistent({ + ingestPromise = runPersistent(db, baseConfig, { batchSize: 50, flushIntervalMs: 500, signal: ingestController.signal, diff --git a/packages/contrail/src/core/persistent.ts b/packages/contrail/src/core/persistent.ts index 4dade65..c1e1c18 100644 --- a/packages/contrail/src/core/persistent.ts +++ b/packages/contrail/src/core/persistent.ts @@ -1,6 +1,6 @@ import type { JetstreamSubscription } from "@atcute/jetstream"; -import type { ContrailConfig, IngestEvent, Database, Logger } from "./types"; -import { getCollectionNsids, getDependentNsids, DEFAULT_FEED_MAX_ITEMS } from "./types"; +import type { ContrailConfig, IngestEvent, Database, Logger, ResolvedContrailConfig } from "./types"; +import { getCollectionNsids, getDependentNsids, DEFAULT_FEED_MAX_ITEMS, resolveConfig } from "./types"; import { initSchema, getLastCursor, saveCursor, applyEvents, pruneFeedItems } from "./db"; import { refreshStaleIdentities } from "./identity"; import { createIngestState } from "./jetstream"; @@ -29,6 +29,13 @@ export async function runPersistent( config: ContrailConfig, options?: PersistentIngestOptions, ): Promise { + // Internals (applyEvents, count updates, query planning) read `_resolved` + // and silently skip features when it's missing. The Contrail class resolves + // in its constructor; callers using this raw export must also get a resolved + // config, so do it defensively here. resolveConfig is idempotent. + if (!(config as ResolvedContrailConfig)._resolved) { + config = resolveConfig(config); + } const log = getLogger(config, options); const batchSize = options?.batchSize ?? 50; const flushIntervalMs = options?.flushIntervalMs ?? 5_000; diff --git a/packages/contrail/tests/persistent.test.ts b/packages/contrail/tests/persistent.test.ts index 5fba923..f248cf0 100644 --- a/packages/contrail/tests/persistent.test.ts +++ b/packages/contrail/tests/persistent.test.ts @@ -1,8 +1,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import type { Database } from "../src/core/types"; -import { createTestDbWithSchema, TEST_CONFIG } from "./helpers"; +import type { ContrailConfig, Database } from "../src/core/types"; +import { createTestDb, createTestDbWithSchema, TEST_CONFIG } from "./helpers"; import { runPersistent } from "../src/core/persistent"; import { getLastCursor, queryRecords } from "../src/core/db/records"; +import { initSchema } from "../src/core/db/schema"; // Mock identity resolution to avoid network calls in tests vi.mock("../src/core/identity", () => ({ @@ -201,6 +202,96 @@ describe("runPersistent", () => { await promise; }); + it("resolves config internally when given an unresolved ContrailConfig", async () => { + // Regression test for the silent grouped-count bug: passing a raw + // ContrailConfig (no `_resolved`) used to let total counts update while + // grouped count columns stayed at 0. runPersistent must defensively + // resolve so consumers of the raw export get the same behavior as those + // going through the Contrail class wrapper. + const rawConfig: ContrailConfig = { + namespace: "com.example", + collections: { + event: { + collection: "community.lexicon.calendar.event", + relations: { + rsvps: { + collection: "rsvp", + groupBy: "status", + groups: { + going: "community.lexicon.calendar.rsvp#going", + notgoing: "community.lexicon.calendar.rsvp#notgoing", + }, + }, + }, + }, + rsvp: { + collection: "community.lexicon.calendar.rsvp", + references: { + event: { collection: "event", field: "subject.uri" }, + }, + }, + }, + }; + // Sanity: the raw object must NOT have _resolved — that's the whole point. + expect((rawConfig as any)._resolved).toBeUndefined(); + + const freshDb = createTestDb(); + await initSchema(freshDb, rawConfig); + + const eventUri = "at://did:plc:alice/community.lexicon.calendar.event/evt1"; + const events = [ + { + kind: "commit" as const, + did: "did:plc:alice", + time_us: 9000, + commit: { + collection: "community.lexicon.calendar.event", + operation: "create", + rkey: "evt1", + cid: "cidE", + record: { name: "E", startsAt: "2026-04-01T10:00:00Z", mode: "online" }, + }, + }, + { + kind: "commit" as const, + did: "did:plc:alice", + time_us: 9001, + commit: { + collection: "community.lexicon.calendar.rsvp", + operation: "create", + rkey: "r1", + cid: "cidR", + record: { + subject: { uri: eventUri, cid: "cidE" }, + status: "community.lexicon.calendar.rsvp#going", + }, + }, + }, + ]; + + const controller = new AbortController(); + const promise = runPersistent(freshDb, rawConfig, { + batchSize: 100, + flushIntervalMs: 50, + signal: controller.signal, + createSubscription: () => mockSubscription(events) as any, + }); + await new Promise((r) => setTimeout(r, 300)); + controller.abort(); + await promise; + + // Query the raw count columns directly — using queryRecords would hide + // the bug since it hydrates via the resolved config anyway. + const row = await freshDb + .prepare(`SELECT count_rsvp, count_rsvp_going FROM records_event WHERE uri = ?`) + .bind(eventUri) + .first<{ count_rsvp: number; count_rsvp_going: number }>(); + expect(row).not.toBeNull(); + expect(row!.count_rsvp).toBe(1); + // This is the assertion that would fail before the fix. + expect(row!.count_rsvp_going).toBe(1); + }); + it("skips non-commit events", async () => { const events = [ { kind: "identity" as const, did: "did:plc:someone", time_us: 4000 }, -- 2.51.2 From ca8a7b36eda1aef2a9f017ece585aaa5f7b4916d Mon Sep 17 00:00:00 2001 From: Tom Scanlan Date: Fri, 24 Apr 2026 19:02:02 -0400 Subject: [PATCH 4/5] fix: surface AuthError description in spaces auth middleware verifier.verify returns a structured {error, description} AuthError on failure, but the middleware stringified it as `String(result.error)`, producing "[object Object]" in the 401 body. Real rejection reasons ("jwt lexicon method does not match", "invalid signature", "could not resolve did document", etc.) were invisible to callers. Surface the description (or the error name as fallback) so 401 bodies carry a human-readable reason. --- packages/contrail/src/core/spaces/auth.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/contrail/src/core/spaces/auth.ts b/packages/contrail/src/core/spaces/auth.ts index b005300..4ad723b 100644 --- a/packages/contrail/src/core/spaces/auth.ts +++ b/packages/contrail/src/core/spaces/auth.ts @@ -73,7 +73,14 @@ export function createServiceAuthMiddleware( const result = await verifier.verify(token, { lxm }); if (!result.ok) { - return c.json({ error: "AuthRequired", message: String(result.error) }, 401); + const err = result.error as { error?: string; description?: string } | undefined; + return c.json( + { + error: "AuthRequired", + message: err?.description ?? err?.error ?? String(result.error), + }, + 401, + ); } c.set("serviceAuth", { -- 2.51.2 From d02c0c68287e736f68c8269a7e6754e3cf863c0e Mon Sep 17 00:00:00 2001 From: Tom Scanlan Date: Fri, 24 Apr 2026 19:04:06 -0400 Subject: [PATCH 5/5] add e2e tests for spaces auth path and privacy invariants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new tests in apps/contrail-e2e, each running in-process against a real devnet PDS + PLC + Postgres with spaces enabled: - spaces-auth.test.ts: real service-auth JWT minted via com.atproto.server.getServiceAuth is accepted; wrong aud and wrong lxm binding are rejected. Exercises the full verifier path (PDS signs with the user's PLC-published key, Contrail resolves the key from devnet PLC, @atcute/xrpc-server verifies the signature) — the existing package-level spaces tests all use a fakeAuth header and never touch this path. - spaces-firehose-invisibility.test.ts: records written via {ns}.space.putRecord do not appear on the ATProto firehose. Uses a direct-to-PDS createRecord as a positive control so a silently broken subscriber can't make the negative assertion vacuous. - spaces-table-isolation.test.ts: a space putRecord lands in spaces_records_ with the public records_ table empty for the same caller — proves the store-level separation that the spaces privacy model depends on. helpers.ts gains CONTRAIL_SERVICE_DID, createDevnetResolver (PlcDid- DocumentResolver pointed at devnet PLC), and mintServiceAuthJwt. Adds @atcute/identity-resolver, @atcute/jetstream, and @atcute/lexicons as direct devDependencies. --- apps/contrail-e2e/package.json | 3 + apps/contrail-e2e/tests/helpers.ts | 52 +++++ apps/contrail-e2e/tests/spaces-auth.test.ts | 127 +++++++++++ .../spaces-firehose-invisibility.test.ts | 201 ++++++++++++++++++ .../tests/spaces-table-isolation.test.ts | 137 ++++++++++++ pnpm-lock.yaml | 9 + 6 files changed, 529 insertions(+) create mode 100644 apps/contrail-e2e/tests/spaces-auth.test.ts create mode 100644 apps/contrail-e2e/tests/spaces-firehose-invisibility.test.ts create mode 100644 apps/contrail-e2e/tests/spaces-table-isolation.test.ts diff --git a/apps/contrail-e2e/package.json b/apps/contrail-e2e/package.json index 2e42c0b..ed43978 100644 --- a/apps/contrail-e2e/package.json +++ b/apps/contrail-e2e/package.json @@ -16,6 +16,9 @@ "devDependencies": { "@atcute/atproto": "^3.1.10", "@atcute/client": "^4.2.1", + "@atcute/identity-resolver": "^1.2.2", + "@atcute/jetstream": "^1.1.2", + "@atcute/lexicons": "^1.3.0", "@types/pg": "^8.20.0", "typescript": "^5.9.3", "vitest": "^4.1.0" diff --git a/apps/contrail-e2e/tests/helpers.ts b/apps/contrail-e2e/tests/helpers.ts index f3965ef..d6e0c1d 100644 --- a/apps/contrail-e2e/tests/helpers.ts +++ b/apps/contrail-e2e/tests/helpers.ts @@ -6,16 +6,68 @@ * dogfooding ingester the developer might have running in another terminal. */ import pg from "pg"; +import type { Client } from "@atcute/client"; +import { + CompositeDidDocumentResolver, + PlcDidDocumentResolver, +} from "@atcute/identity-resolver"; +import type { Did as AtDid, Nsid } from "@atcute/lexicons"; export type Did = `did:${string}:${string}`; export const PDS_PORT = Number(process.env.DEVNET_PDS_PORT ?? 4000); export const PDS_URL = `http://localhost:${PDS_PORT}`; +export const PLC_PORT = Number(process.env.DEVNET_PLC_PORT ?? 2582); +export const PLC_URL = `http://localhost:${PLC_PORT}`; export const HANDLE_DOMAIN = process.env.DEVNET_HANDLE_DOMAIN ?? ".devnet.test"; export const PDS_ADMIN_PASSWORD = process.env.DEVNET_PDS_ADMIN_PASSWORD ?? "devnet-admin-password"; export const DATABASE_URL = process.env.DATABASE_URL ?? "postgresql://postgres:postgres@localhost:5433/contrail"; +/** + * Arbitrary service DID used for the test Contrail deployment. The only + * requirements: (a) the JWTs we mint via getServiceAuth use this as their + * `aud` claim, and (b) Contrail's SpacesConfig.serviceDid matches. The DID + * itself doesn't need to be resolvable — the verifier only resolves issuers + * (users), not the audience. + */ +export const CONTRAIL_SERVICE_DID = "did:web:contrail-test.devnet.test"; + +/** + * Resolver that points the PLC method at the local devnet PLC on :2582. + * Without this, the default resolver hits plc.directory and 404s on every + * devnet DID. + */ +export function createDevnetResolver() { + return new CompositeDidDocumentResolver({ + methods: { + plc: new PlcDidDocumentResolver({ apiUrl: PLC_URL }), + }, + }); +} + +/** + * Mint an atproto service-auth JWT via the PDS's getServiceAuth endpoint. + * Requires the client to already be authed for a user. Returns the raw JWT + * string suitable for `Authorization: Bearer `. + */ +export async function mintServiceAuthJwt( + client: Client, + opts: { aud: string; lxm?: string; expSeconds?: number }, +): Promise { + const params: { aud: AtDid; lxm?: Nsid; exp?: number } = { + aud: opts.aud as AtDid, + }; + if (opts.lxm) params.lxm = opts.lxm as Nsid; + if (opts.expSeconds) params.exp = Math.floor(Date.now() / 1000) + opts.expSeconds; + + const res = await client.get("com.atproto.server.getServiceAuth", { params }); + if (!res.ok) { + throw new Error(`getServiceAuth → ${res.status}: ${JSON.stringify(res.data)}`); + } + return res.data.token; +} + export type TestAccount = { handle: string; password: string; did: Did }; export async function createTestAccount(): Promise { diff --git a/apps/contrail-e2e/tests/spaces-auth.test.ts b/apps/contrail-e2e/tests/spaces-auth.test.ts new file mode 100644 index 0000000..5db1253 --- /dev/null +++ b/apps/contrail-e2e/tests/spaces-auth.test.ts @@ -0,0 +1,127 @@ +/** + * Service-auth JWT end-to-end against spaces XRPCs. + * + * 1. Alice mints a JWT via com.atproto.server.getServiceAuth, calls + * {ns}.space.createSpace → verifier accepts, space is created. + * 2. JWT with wrong audience → verifier rejects, 401. + * 3. JWT with lxm bound to one method, used on a different method → 401. + * + * The full auth path is exercised: PDS signs with Alice's PLC-published + * key, Contrail's resolver reads that key from devnet PLC, real verifier + * checks the signature. No mocks on the auth path. + */ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import pg from "pg"; +import { CredentialManager, Client } from "@atcute/client"; +import "@atcute/atproto"; +import { Contrail } from "@atmo-dev/contrail"; +import { createHandler } from "@atmo-dev/contrail/server"; +import { createPostgresDatabase } from "@atmo-dev/contrail/postgres"; +import { config as baseConfig } from "../config"; +import { + createTestAccount, + createIsolatedSchema, + createDevnetResolver, + mintServiceAuthJwt, + CONTRAIL_SERVICE_DID, + PDS_URL, + type TestAccount, +} from "./helpers"; + +const SPACE_TYPE = "rsvp.atmo.event.space"; + +describe("spaces auth (devnet PDS JWT → Contrail verifier)", () => { + let alice: TestAccount; + let aliceClient: Client; + let pool: pg.Pool; + let cleanupSchema: () => Promise; + let handle: (req: Request) => Promise; + + beforeAll(async () => { + alice = await createTestAccount(); + const creds = new CredentialManager({ service: PDS_URL }); + await creds.login({ identifier: alice.handle, password: alice.password }); + aliceClient = new Client({ handler: creds }); + + const iso = await createIsolatedSchema("test_spaces_auth"); + pool = iso.pool; + cleanupSchema = iso.cleanup; + const db = createPostgresDatabase(pool); + + const contrail = new Contrail({ + ...baseConfig, + db, + spaces: { + type: SPACE_TYPE, + serviceDid: CONTRAIL_SERVICE_DID, + resolver: createDevnetResolver(), + }, + }); + await contrail.init(); + handle = createHandler(contrail); + }); + + afterAll(async () => { + await cleanupSchema?.(); + }); + + async function callXrpc( + method: "GET" | "POST", + path: string, + opts: { token?: string; body?: unknown } = {}, + ): Promise { + const headers: Record = {}; + if (opts.token) headers["authorization"] = `Bearer ${opts.token}`; + if (opts.body !== undefined) headers["content-type"] = "application/json"; + return handle( + new Request(`http://test${path}`, { + method, + headers, + body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, + }), + ); + } + + it("accepts a real service-auth JWT and creates a space", async () => { + const token = await mintServiceAuthJwt(aliceClient, { + aud: CONTRAIL_SERVICE_DID, + lxm: "rsvp.atmo.space.createSpace", + }); + + const res = await callXrpc("POST", "/xrpc/rsvp.atmo.space.createSpace", { + token, + body: {}, + }); + const text = await res.clone().text().catch(() => ""); + expect(res.status, `createSpace → ${res.status}: ${text}`).toBe(200); + + const data = (await res.json()) as { space: { uri: string; ownerDid: string } }; + expect(data.space.uri).toMatch(/^at:\/\//); + expect(data.space.ownerDid).toBe(alice.did); + }); + + it("rejects a JWT minted with the wrong audience", async () => { + const token = await mintServiceAuthJwt(aliceClient, { + aud: "did:web:not-contrail.devnet.test", + }); + + const res = await callXrpc("POST", "/xrpc/rsvp.atmo.space.createSpace", { + token, + body: {}, + }); + expect(res.status).toBe(401); + }); + + it("rejects a JWT whose lxm binding mismatches the route", async () => { + const token = await mintServiceAuthJwt(aliceClient, { + aud: CONTRAIL_SERVICE_DID, + lxm: "rsvp.atmo.space.listSpaces", + }); + + const res = await callXrpc("POST", "/xrpc/rsvp.atmo.space.createSpace", { + token, + body: {}, + }); + expect(res.status).toBe(401); + }); +}); diff --git a/apps/contrail-e2e/tests/spaces-firehose-invisibility.test.ts b/apps/contrail-e2e/tests/spaces-firehose-invisibility.test.ts new file mode 100644 index 0000000..07f7827 --- /dev/null +++ b/apps/contrail-e2e/tests/spaces-firehose-invisibility.test.ts @@ -0,0 +1,201 @@ +/** + * Records put into a private space must not appear on the ATProto firehose. + * + * Protocol: + * 1. Subscribe to Jetstream filtered to Alice's DID + the event collection. + * 2. Alice publishes a control record directly to her PDS → MUST appear + * on the firehose (proves the subscriber works). + * 3. Alice puts a record into a space via {ns}.space.putRecord → MUST NOT + * appear on the firehose. + * + * Without (2) as a positive control, a silently broken subscriber would + * make (3) vacuously true. + */ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import pg from "pg"; +import { CredentialManager, Client } from "@atcute/client"; +import "@atcute/atproto"; +import { JetstreamSubscription, type JetstreamEvent } from "@atcute/jetstream"; +import type { Did as AtDid } from "@atcute/lexicons"; +import { Contrail } from "@atmo-dev/contrail"; +import { createHandler } from "@atmo-dev/contrail/server"; +import { createPostgresDatabase } from "@atmo-dev/contrail/postgres"; +import { config as baseConfig } from "../config"; +import { + createTestAccount, + createIsolatedSchema, + createDevnetResolver, + mintServiceAuthJwt, + CONTRAIL_SERVICE_DID, + PDS_URL, + type TestAccount, +} from "./helpers"; + +const EVENT_NSID = "community.lexicon.calendar.event"; +const SPACE_TYPE = "rsvp.atmo.event.space"; +const JETSTREAM_URL = process.env.JETSTREAM_URL ?? "ws://localhost:6008/subscribe"; +const PROPAGATION_MS = 2_500; + +describe("spaces firehose invisibility", () => { + let alice: TestAccount; + let aliceClient: Client; + let pool: pg.Pool; + let cleanupSchema: () => Promise; + let handle: (req: Request) => Promise; + + beforeAll(async () => { + alice = await createTestAccount(); + const creds = new CredentialManager({ service: PDS_URL }); + await creds.login({ identifier: alice.handle, password: alice.password }); + aliceClient = new Client({ handler: creds }); + + const iso = await createIsolatedSchema("test_firehose_invisibility"); + pool = iso.pool; + cleanupSchema = iso.cleanup; + const db = createPostgresDatabase(pool); + + const contrail = new Contrail({ + ...baseConfig, + db, + spaces: { + type: SPACE_TYPE, + serviceDid: CONTRAIL_SERVICE_DID, + resolver: createDevnetResolver(), + }, + }); + await contrail.init(); + handle = createHandler(contrail); + }); + + afterAll(async () => { + await cleanupSchema?.(); + }); + + async function callXrpc( + method: "GET" | "POST", + path: string, + opts: { token?: string; body?: unknown } = {}, + ): Promise { + const headers: Record = {}; + if (opts.token) headers["authorization"] = `Bearer ${opts.token}`; + if (opts.body !== undefined) headers["content-type"] = "application/json"; + return handle( + new Request(`http://test${path}`, { + method, + headers, + body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, + }), + ); + } + + it("publishes PDS writes to firehose; space writes stay invisible", async () => { + const observed = new Set(); + const ac = new AbortController(); + const opened = deferred(); + + const sub = new JetstreamSubscription({ + url: JETSTREAM_URL, + wantedCollections: [EVENT_NSID], + wantedDids: [alice.did as unknown as AtDid], + onConnectionOpen: () => opened.resolve(), + }); + + const collector = (async () => { + const iterator = sub[Symbol.asyncIterator](); + try { + while (!ac.signal.aborted) { + const result = await Promise.race([ + iterator.next(), + new Promise>((resolve) => { + ac.signal.addEventListener( + "abort", + () => resolve({ value: undefined, done: true }), + { once: true }, + ); + }), + ]); + if (result.done) break; + const ev = result.value; + if (ev.kind === "commit" && ev.did === alice.did) { + observed.add(ev.commit.rkey); + } + } + } finally { + await iterator.return?.(); + } + })(); + + await opened.promise; + + // Control: direct PDS write must land on the firehose. + const controlRes = await aliceClient.post("com.atproto.repo.createRecord", { + input: { + repo: alice.did, + collection: EVENT_NSID as never, + record: eventRecord("firehose-control"), + }, + }); + expect(controlRes.ok, `control createRecord: ${JSON.stringify(controlRes.data)}`).toBe(true); + if (!controlRes.ok) throw new Error("unreachable"); + const controlRkey = controlRes.data.uri.split("/").pop()!; + + // Private: space write must not. + const createToken = await mintServiceAuthJwt(aliceClient, { + aud: CONTRAIL_SERVICE_DID, + lxm: "rsvp.atmo.space.createSpace", + }); + const createRes = await callXrpc("POST", "/xrpc/rsvp.atmo.space.createSpace", { + token: createToken, + body: {}, + }); + expect(createRes.status).toBe(200); + const { space } = (await createRes.json()) as { space: { uri: string } }; + + const putToken = await mintServiceAuthJwt(aliceClient, { + aud: CONTRAIL_SERVICE_DID, + lxm: "rsvp.atmo.space.putRecord", + }); + const putRes = await callXrpc("POST", "/xrpc/rsvp.atmo.space.putRecord", { + token: putToken, + body: { + spaceUri: space.uri, + collection: EVENT_NSID, + record: eventRecord("private-in-space"), + }, + }); + const putText = await putRes.clone().text().catch(() => ""); + expect(putRes.status, `space.putRecord → ${putRes.status}: ${putText}`).toBe(200); + const { rkey: spaceRkey } = (await putRes.json()) as { rkey: string }; + + await new Promise((r) => setTimeout(r, PROPAGATION_MS)); + + ac.abort(); + await collector; + + expect( + observed.has(controlRkey), + `control PDS record ${controlRkey} must appear on firehose; observed: ${[...observed].join(",") || "(none)"}`, + ).toBe(true); + expect( + observed.has(spaceRkey), + `space record ${spaceRkey} must NOT appear on firehose`, + ).toBe(false); + }); +}); + +function eventRecord(name: string) { + return { + $type: EVENT_NSID, + name, + createdAt: new Date().toISOString(), + startsAt: new Date(Date.now() + 60_000).toISOString(), + mode: `${EVENT_NSID}#inperson`, + status: `${EVENT_NSID}#scheduled`, + }; +} + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void; + const promise = new Promise((r) => (resolve = r)); + return { promise, resolve }; +} diff --git a/apps/contrail-e2e/tests/spaces-table-isolation.test.ts b/apps/contrail-e2e/tests/spaces-table-isolation.test.ts new file mode 100644 index 0000000..9a3c8c8 --- /dev/null +++ b/apps/contrail-e2e/tests/spaces-table-isolation.test.ts @@ -0,0 +1,137 @@ +/** + * Records written via {ns}.space.putRecord land in `spaces_records_`, + * not `records_`. The public table must stay empty for the caller. + * + * 1. Alice creates a space and puts an event into it. + * 2. Query postgres directly: + * - records_event → 0 rows for alice.did + * - spaces_records_event → 1 row with matching rkey and space_uri + * + * A leak between these tables would silently expose private records to any + * public {ns}.event.getRecord / listRecords call. + */ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import pg from "pg"; +import { CredentialManager, Client } from "@atcute/client"; +import "@atcute/atproto"; +import { Contrail } from "@atmo-dev/contrail"; +import { createHandler } from "@atmo-dev/contrail/server"; +import { createPostgresDatabase } from "@atmo-dev/contrail/postgres"; +import { config as baseConfig } from "../config"; +import { + createTestAccount, + createIsolatedSchema, + createDevnetResolver, + mintServiceAuthJwt, + CONTRAIL_SERVICE_DID, + PDS_URL, + type TestAccount, +} from "./helpers"; + +const EVENT_NSID = "community.lexicon.calendar.event"; +const SPACE_TYPE = "rsvp.atmo.event.space"; + +describe("spaces table isolation", () => { + let alice: TestAccount; + let aliceClient: Client; + let pool: pg.Pool; + let cleanupSchema: () => Promise; + let handle: (req: Request) => Promise; + + beforeAll(async () => { + alice = await createTestAccount(); + const creds = new CredentialManager({ service: PDS_URL }); + await creds.login({ identifier: alice.handle, password: alice.password }); + aliceClient = new Client({ handler: creds }); + + const iso = await createIsolatedSchema("test_table_isolation"); + pool = iso.pool; + cleanupSchema = iso.cleanup; + const db = createPostgresDatabase(pool); + + const contrail = new Contrail({ + ...baseConfig, + db, + spaces: { + type: SPACE_TYPE, + serviceDid: CONTRAIL_SERVICE_DID, + resolver: createDevnetResolver(), + }, + }); + await contrail.init(); + handle = createHandler(contrail); + }); + + afterAll(async () => { + await cleanupSchema?.(); + }); + + async function callXrpc( + method: "GET" | "POST", + path: string, + opts: { token?: string; body?: unknown } = {}, + ): Promise { + const headers: Record = {}; + if (opts.token) headers["authorization"] = `Bearer ${opts.token}`; + if (opts.body !== undefined) headers["content-type"] = "application/json"; + return handle( + new Request(`http://test${path}`, { + method, + headers, + body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, + }), + ); + } + + it("space records live in spaces_records_event, not records_event", async () => { + const createToken = await mintServiceAuthJwt(aliceClient, { + aud: CONTRAIL_SERVICE_DID, + lxm: "rsvp.atmo.space.createSpace", + }); + const createRes = await callXrpc("POST", "/xrpc/rsvp.atmo.space.createSpace", { + token: createToken, + body: {}, + }); + expect(createRes.status).toBe(200); + const { space } = (await createRes.json()) as { space: { uri: string } }; + + const putToken = await mintServiceAuthJwt(aliceClient, { + aud: CONTRAIL_SERVICE_DID, + lxm: "rsvp.atmo.space.putRecord", + }); + const putRes = await callXrpc("POST", "/xrpc/rsvp.atmo.space.putRecord", { + token: putToken, + body: { + spaceUri: space.uri, + collection: EVENT_NSID, + record: { + $type: EVENT_NSID, + name: "isolation-target", + createdAt: new Date().toISOString(), + startsAt: new Date(Date.now() + 60_000).toISOString(), + mode: `${EVENT_NSID}#inperson`, + status: `${EVENT_NSID}#scheduled`, + }, + }, + }); + expect(putRes.status, await putRes.clone().text().catch(() => "")).toBe(200); + const { rkey } = (await putRes.json()) as { rkey: string }; + + const publicRows = await pool.query( + "SELECT COUNT(*)::int AS n FROM records_event WHERE did = $1", + [alice.did], + ); + expect(publicRows.rows[0].n, "records_event must not contain the space record").toBe(0); + + const spaceRows = await pool.query( + "SELECT rkey, space_uri, did FROM spaces_records_event WHERE did = $1", + [alice.did], + ); + expect(spaceRows.rows).toHaveLength(1); + expect(spaceRows.rows[0]).toMatchObject({ + rkey, + space_uri: space.uri, + did: alice.did, + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a51e70..f3c8172 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -61,6 +61,15 @@ importers: '@atcute/client': specifier: ^4.2.1 version: 4.2.1 + '@atcute/identity-resolver': + specifier: ^1.2.2 + version: 1.2.2(@atcute/identity@1.1.4) + '@atcute/jetstream': + specifier: ^1.1.2 + version: 1.1.2 + '@atcute/lexicons': + specifier: ^1.3.0 + version: 1.3.0 '@types/pg': specifier: ^8.20.0 version: 8.20.0