diff --git a/.forgejo/workflows/build-images.yml b/.forgejo/workflows/build-images.yml index 4e20c38..75ac87f 100644 --- a/.forgejo/workflows/build-images.yml +++ b/.forgejo/workflows/build-images.yml @@ -1,6 +1,7 @@ name: Build and Push Container Images on: + pull_request: push: branches: - main @@ -36,6 +37,39 @@ env: INFISICAL_MACHINE_IDENTITY_ID: ${{ vars.INFISICAL_MACHINE_IDENTITY_ID }} jobs: + verify: + # Legacy Docker-backed Forgejo runner. See the backend delivery runbook. + runs-on: personal + container: + image: node:26.5.0-bookworm + options: --user 0 + permissions: + contents: read + services: + postgres: + image: postgres:18.4-bookworm + env: + POSTGRES_DB: canvas_test + POSTGRES_USER: canvas_test + POSTGRES_HOST_AUTH_METHOD: trust + options: >- + --health-cmd "pg_isready -U canvas_test" + --health-interval 5s --health-timeout 5s --health-retries 10 + env: + CANVAS_TEST_DATABASE_URL: postgresql://canvas_test@postgres:5432/canvas_test + steps: + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 + with: + run_install: false + - uses: actions/setup-node@v7 + with: + node-version-file: .node-version + cache: pnpm + - run: pnpm install --frozen-lockfile --ignore-scripts + - run: pnpm exec playwright install --with-deps chromium + - run: bash scripts/verify.sh + changes: runs-on: personal if: github.ref == 'refs/heads/main' @@ -61,25 +95,7 @@ jobs: base="$(git rev-list --max-parents=0 "$head")" fi - changed="$(git diff --name-only "$base" "$head" || git diff --name-only HEAD~1 HEAD || true)" - - has_changed() { - printf '%s\n' "$changed" | grep -Eq "$1" - } - - set_output() { - name="$1" - pattern="$2" - if has_changed "$pattern"; then - echo "$name=true" >> "$GITHUB_OUTPUT" - else - echo "$name=false" >> "$GITHUB_OUTPUT" - fi - } - - set_output frontend '^(app|components|lib|public)/|^(\.dockerignore|package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|next\.config\.mjs|postcss\.config\.mjs|tailwind\.config\.ts|tsconfig\.json|biome\.json|components\.json|Dockerfile|docker-entrypoint\.sh)$' - set_output convex '^convex/|^convex\.json$' - set_output stream_canvas '^backend/stream-canvas/|^\.dockerignore$' + git diff --name-only "$base" "$head" | bash scripts/delivery-changes.sh >> "$GITHUB_OUTPUT" tag-convex-changes: runs-on: personal @@ -97,31 +113,15 @@ jobs: run: | set -euo pipefail - previous_tag=$( - git tag --sort=-version:refname \ - | grep -Fxv "$CURRENT_TAG" \ - | head -n 1 - ) - - if [ -z "$previous_tag" ]; then - echo "No previous release tag found; deploying Convex by default." - echo "convex=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - if git diff --quiet "$previous_tag..$CURRENT_SHA" -- convex; then - echo "convex=false" >> "$GITHUB_OUTPUT" - else - echo "convex=true" >> "$GITHUB_OUTPUT" - fi + bash scripts/delivery-changes.sh tag >> "$GITHUB_OUTPUT" deploy-convex: - needs: [changes, tag-convex-changes] + needs: [verify, changes, tag-convex-changes] if: | - always() && + always() && needs.verify.result == 'success' && github.event_name != 'pull_request' && ((github.ref == 'refs/heads/main' && needs.changes.outputs.convex == 'true') || (startsWith(github.ref, 'refs/tags/v') && needs.tag-convex-changes.outputs.convex == 'true') || - github.event.inputs.deploy_convex == 'true') + (github.ref == 'refs/heads/main' && github.event.inputs.deploy_convex == 'true')) runs-on: personal steps: - uses: actions/checkout@v7 @@ -192,9 +192,9 @@ jobs: CLERK_JWT_ISSUER_DOMAIN: ${{ steps.ci-secrets.outputs.CLERK_JWT_ISSUER_DOMAIN }} build-frontend: - needs: changes + needs: [verify, changes] if: | - always() && + always() && needs.verify.result == 'success' && github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || needs.changes.outputs.frontend == 'true' || github.event.inputs.build_frontend == 'true') @@ -290,9 +290,9 @@ jobs: NEXT_PUBLIC_TLDRAW_LICENSE_KEY=${{ steps.ci-secrets.outputs.NEXT_PUBLIC_TLDRAW_LICENSE_KEY }} build-stream-canvas: - needs: [changes, build-frontend] + needs: [verify, changes] if: | - always() && + always() && needs.verify.result == 'success' && github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || needs.changes.outputs.stream_canvas == 'true' || github.event.inputs.build_stream_canvas == 'true') diff --git a/.github/workflows/build-images.yml b/.github/workflows/build-images.yml new file mode 100644 index 0000000..ea71a45 --- /dev/null +++ b/.github/workflows/build-images.yml @@ -0,0 +1,76 @@ +name: Release container images + +on: + push: + tags: ["v*"] + workflow_dispatch: + +permissions: + contents: read + +jobs: + verify: + uses: ./.github/workflows/verify.yml + + deploy-convex: + needs: verify + if: startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main' + runs-on: arc-moddrop + steps: + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 + with: + run_install: false + - uses: actions/setup-node@v7 + with: + node-version-file: .node-version + cache: pnpm + - run: pnpm install --frozen-lockfile --ignore-scripts + - run: pnpm exec convex deploy + env: + CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }} + CONVEX_DEPLOYMENT: ${{ secrets.CONVEX_DEPLOYMENT }} + CLERK_JWT_ISSUER_DOMAIN: ${{ secrets.CLERK_JWT_ISSUER_DOMAIN }} + + build: + needs: [verify, deploy-convex] + if: startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main' + runs-on: arc-moddrop + permissions: + contents: read + packages: write + strategy: + matrix: + include: + - image: moddrop-frontend + dockerfile: Dockerfile + - image: moddrop-stream-canvas + dockerfile: backend/stream-canvas/Dockerfile + steps: + - uses: actions/checkout@v7 + - uses: docker/setup-buildx-action@v4 + with: + driver: remote + endpoint: tcp://buildkitd.arc-runners.svc.cluster.local:1234 + - uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/metadata-action@v6 + id: meta + with: + images: ghcr.io/${{ github.repository_owner }}/${{ matrix.image }} + tags: | + type=semver,pattern={{version}} + type=sha,prefix= + - uses: docker/build-push-action@v7 + with: + context: . + file: ${{ matrix.dockerfile }} + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + CONVEX_DEPLOYMENT=${{ secrets.CONVEX_DEPLOYMENT }} + NEXT_PUBLIC_TLDRAW_LICENSE_KEY=${{ secrets.NEXT_PUBLIC_TLDRAW_LICENSE_KEY }} diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..bf47db2 --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,37 @@ +name: Verify +on: + pull_request: + workflow_call: + workflow_dispatch: +permissions: + contents: read +jobs: + verify: + runs-on: arc-moddrop + container: + image: node:26.5.0-bookworm + options: --user 0 + services: + postgres: + image: postgres:18.4-bookworm + env: + POSTGRES_DB: canvas_test + POSTGRES_USER: canvas_test + POSTGRES_HOST_AUTH_METHOD: trust + options: >- + --health-cmd "pg_isready -U canvas_test" + --health-interval 5s --health-timeout 5s --health-retries 10 + env: + CANVAS_TEST_DATABASE_URL: postgresql://canvas_test@postgres:5432/canvas_test + steps: + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 + with: + run_install: false + - uses: actions/setup-node@v7 + with: + node-version-file: .node-version + cache: pnpm + - run: pnpm install --frozen-lockfile --ignore-scripts + - run: pnpm exec playwright install --with-deps chromium + - run: bash scripts/verify.sh diff --git a/.gitignore b/.gitignore index 7738271..e71d9a9 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ backend/stream-canvas/node_modules backend/stream-canvas/data backend/stream-canvas/bun.lockb coverage +playwright-report +test-results dist build out diff --git a/README.md b/README.md index ec02919..fbe391d 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,8 @@ Clerk restricts the production instance to `moddrop.live`; it requires the matching `pk_test_` / `sk_test_` pair instead. If needed, authenticate once with `infisical login --domain http://192.168.60.11:8080`. Set `MODDROP_DEV_DATABASE_URL` only when intentionally using a different -development PostgreSQL instance. +development PostgreSQL instance. With that override, startup and shutdown leave +the default Compose database alone. The default database port is loopback-only. - `https://moddrop.localhost:1355` - `https://moddrop-stream-canvas.localhost:1355` @@ -46,6 +47,10 @@ the data model, migration flow, and production storage design. ## Check ```bash -pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run build -pnpm --dir backend/stream-canvas run typecheck && pnpm --dir backend/stream-canvas run test +CANVAS_TEST_DATABASE_URL= bash scripts/verify.sh ``` + +Use the pinned Node/pnpm versions and a disposable PostgreSQL database, never an +application database. The canonical script checks formatting, lint, application +and test types, unit/integration tests, builds, compiled runtime configuration, +and browser flows. See the backend guide's verification section for setup. diff --git a/app/app/rooms/[roomId]/page.tsx b/app/app/rooms/[roomId]/page.tsx index 8388ce8..7c43da7 100644 --- a/app/app/rooms/[roomId]/page.tsx +++ b/app/app/rooms/[roomId]/page.tsx @@ -13,31 +13,67 @@ export default function StreamCanvasRoomPage() { const [twitchChannel, setTwitchChannel] = useState(null); const [youtubePolicy, setYouTubePolicy] = useState("preview_only"); + const [loadedRoomId, setLoadedRoomId] = useState(null); + const [error, setError] = useState(null); + const [attempt, setAttempt] = useState(0); // Fetch this room's Twitch channel from the accessible rooms list + // biome-ignore lint/correctness/useExhaustiveDependencies: attempt explicitly retries the same request useEffect(() => { if (!isLoaded || !isSignedIn) return; let cancelled = false; + setError(null); getAccessibleRooms(getToken) .then((rooms) => { const room = rooms.find((r) => r.id === roomId); - if (!cancelled && room) { + if (!room) + throw new Error( + "This room is unavailable or you no longer have access.", + ); + if (!cancelled) { setTwitchChannel(room.twitchChannel); setYouTubePolicy(room.youtubePolicy); + setLoadedRoomId(roomId); } }) .catch((err) => { if (!cancelled) - console.error("[stream-canvas] Failed to load room info:", err); + setError(err instanceof Error ? err.message : "Failed to load room"); }); return () => { cancelled = true; }; - }, [getToken, isLoaded, isSignedIn, roomId]); + }, [getToken, isLoaded, isSignedIn, roomId, attempt]); + + if (isLoaded && !isSignedIn) + return

Sign in to open this room.

; + if (error) + return ( +
+

{error}

+ +
+ ); + if (loadedRoomId !== roomId) + return ( +

+ Loading room… +

+ ); return (
{ + await closeAllRooms(false); + await closeDatabase(); +}); + +class Socket extends EventEmitter { + readonly OPEN = 1; + readyState = 1; + closeCode?: number; + terminated = 0; + pause() {} + resume() {} + close(code: number) { + this.closeCode = code; + this.readyState = 3; + } + terminate() { + this.terminated++; + this.readyState = 3; + } + get ws() { + return this as unknown as WebSocket; + } +} + +function request(roomId: string): IncomingMessage { + return { + url: `/ws?roomId=${roomId}&token=${mintCanvasWsToken(roomId, "user_owner")}`, + headers: { origin: "http://localhost:4310" }, + socket: { remoteAddress: "127.0.0.1" }, + } as IncomingMessage; +} + +test("socket error handling outlives rejected admission and session listeners", async () => { + leaderState.isLeader = false; + const socket = new Socket(); + await handleWebSocketUpgrade( + socket.ws, + request("11111111-1111-4111-8111-111111111111"), + ); + assert.equal(socket.closeCode, 1012); + assert.doesNotThrow(() => + socket.emit("error", new Error("controlled connection error")), + ); + assert.equal(socket.terminated, 1); +}); + +test("pending room loads reserve capacity across distinct room admission queues", async (t) => { + leaderState.isLeader = true; + let release: () => void = () => {}; + let started: () => void = () => {}; + const pending = new Promise((resolve) => { + release = resolve; + }); + const loading = new Promise((resolve) => { + started = resolve; + }); + let reads = 0; + const firstId = "11111111-1111-4111-8111-111111111111"; + const secondId = "22222222-2222-4222-8222-222222222222"; + t.mock.method(db.query.rooms, "findFirst", async () => ({ + ownerClerkId: "user_owner", + })); + t.mock.method(db.query.canvasDocuments, "findFirst", async () => { + reads++; + started(); + await pending; + return undefined; + }); + const first = new Socket(); + const admission = handleWebSocketUpgrade(first.ws, request(firstId)); + await loading; + try { + const second = new Socket(); + await handleWebSocketUpgrade(second.ws, request(secondId)); + assert.equal(second.closeCode, 1013); + assert.equal(reads, 1); + // An error during pending authentication/loading is also contained. + assert.doesNotThrow(() => + first.emit("error", new Error("controlled pending error")), + ); + } finally { + release(); + await admission; + await closeAllRooms(false); + leaderState.isLeader = false; + } +}); diff --git a/backend/stream-canvas/src/app.ts b/backend/stream-canvas/src/app.ts new file mode 100644 index 0000000..506264a --- /dev/null +++ b/backend/stream-canvas/src/app.ts @@ -0,0 +1,40 @@ +import { Hono } from "hono"; +import { cors } from "hono/cors"; +import { sql } from "drizzle-orm"; +import { config } from "./config.ts"; +import { withDeadline } from "./deadline.ts"; +import { db } from "./db.ts"; +import { leaderState } from "./leader.ts"; +import { objectStore } from "./object-store.ts"; +import { api } from "./routes.ts"; + +// Importable HTTP wiring, without starting listeners or leadership timers. +export const app = new Hono(); +app.use("*", cors({ origin: config.corsOrigins })); +app.get("/health", (c) => c.json({ status: "ok" })); +app.get("/ready", async (c) => { + if (!leaderState.isLeader) { + return c.json({ status: "standby", role: "standby" }, 503); + } + try { + await withDeadline( + Promise.all([db.execute(sql`SELECT 1`), objectStore.check()]), + 3_000, + "Readiness", + ); + if (!leaderState.isLeader) + return c.json({ status: "standby", role: "standby" }, 503); + return c.json({ status: "ready", role: "leader" }); + } catch (error) { + console.error("[readiness] dependency check failed", error); + return c.json({ status: "unavailable", role: "leader" }, 503); + } +}); +app.use("*", async (c, next) => { + if (!leaderState.isLeader) { + c.header("Retry-After", "2"); + return c.json({ error: "Canvas leader is changing" }, 503); + } + return next(); +}); +app.route("/", api); diff --git a/backend/stream-canvas/src/auth.ts b/backend/stream-canvas/src/auth.ts index 6954dfb..203a269 100644 --- a/backend/stream-canvas/src/auth.ts +++ b/backend/stream-canvas/src/auth.ts @@ -123,7 +123,9 @@ if (!config.clerkJwtKey && !config.clerkSecretKey) { } function getInternalTokenSigningSecret(): string { - return process.env.OBS_TOKEN_SIGNING_SECRET ?? randomBytes(32).toString("hex"); + return ( + process.env.OBS_TOKEN_SIGNING_SECRET ?? randomBytes(32).toString("hex") + ); } if (!normalizedClerkIssuer()) { if (config.nodeEnv === "production") { @@ -168,9 +170,13 @@ function verifySignedClaims(token: string): unknown | null { } /** Mint a short-lived OBS token for a given room. */ -export function mintObsToken(roomId: string): string { +export function mintObsToken( + roomId: string, + credentialVersion: string, +): string { const claims: ObsTokenClaims = { roomId, + credentialVersion, role: "obs", scope: "stream-canvas-ws", iat: Math.floor(Date.now() / 1000), @@ -215,7 +221,10 @@ export function verifyCanvasWsToken( } /** Mint a short-lived access token for an uploaded media file. */ -export function mintUploadAccessToken(roomId: string, uploadId: string): string { +export function mintUploadAccessToken( + roomId: string, + uploadId: string, +): string { const claims: UploadAccessTokenClaims = { roomId, uploadId, @@ -260,6 +269,8 @@ function isObsTokenClaims(value: unknown): value is ObsTokenClaims { typeof value === "object" && value !== null && "roomId" in value && + "credentialVersion" in value && + typeof value.credentialVersion === "string" && "role" in value && "scope" in value && "exp" in value && diff --git a/backend/stream-canvas/src/config.test.ts b/backend/stream-canvas/src/config.test.ts new file mode 100644 index 0000000..75669a3 --- /dev/null +++ b/backend/stream-canvas/src/config.test.ts @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; + +test("database pool reserves room for lock ownership and regular queries", () => { + const run = (size: string) => + spawnSync( + process.execPath, + ["--input-type=module", "-e", 'await import("./src/config.ts")'], + { + cwd: new URL("..", import.meta.url), + env: { NODE_ENV: "test", DATABASE_POOL_SIZE: size }, + encoding: "utf8", + timeout: 2_000, + }, + ); + const invalid = run("1"); + assert.equal(invalid.status, 1); + assert.match( + invalid.stderr, + /DATABASE_POOL_SIZE must be an integer between 2 and 100/, + ); + assert.equal(run("2").status, 0); +}); diff --git a/backend/stream-canvas/src/config.ts b/backend/stream-canvas/src/config.ts index ace2c14..2f47ea5 100644 --- a/backend/stream-canvas/src/config.ts +++ b/backend/stream-canvas/src/config.ts @@ -20,7 +20,8 @@ export const config = { databaseUrl: process.env.DATABASE_URL ?? "postgresql://moddrop:moddrop@127.0.0.1:5432/moddrop", - databasePoolSize: numberEnv("DATABASE_POOL_SIZE", 10, { min: 1, max: 100 }), + // Leadership retains one connection; requests need at least one more. + databasePoolSize: numberEnv("DATABASE_POOL_SIZE", 10, { min: 2, max: 100 }), objectStorageMode, uploadsDir, s3Endpoint: process.env.S3_ENDPOINT ?? "", diff --git a/backend/stream-canvas/src/db.ts b/backend/stream-canvas/src/db.ts index aeb5fd4..8000979 100644 --- a/backend/stream-canvas/src/db.ts +++ b/backend/stream-canvas/src/db.ts @@ -56,6 +56,8 @@ async function createPool(): Promise { max: config.databasePoolSize, connectionTimeoutMillis: 5_000, idleTimeoutMillis: 30_000, + statement_timeout: 10_000, + query_timeout: 12_000, application_name: "moddrop-stream-canvas", }); } diff --git a/backend/stream-canvas/src/deadline.ts b/backend/stream-canvas/src/deadline.ts new file mode 100644 index 0000000..a7b71ac --- /dev/null +++ b/backend/stream-canvas/src/deadline.ts @@ -0,0 +1,20 @@ +export async function withDeadline( + operation: Promise, + milliseconds: number, + label: string, +): Promise { + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + operation, + new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new Error(`${label} timed out`)), + milliseconds, + ); + }), + ]); + } finally { + clearTimeout(timer); + } +} diff --git a/backend/stream-canvas/src/import-legacy-sqlite.ts b/backend/stream-canvas/src/import-legacy-sqlite.ts index a97ce84..b0c2d3d 100644 --- a/backend/stream-canvas/src/import-legacy-sqlite.ts +++ b/backend/stream-canvas/src/import-legacy-sqlite.ts @@ -2,7 +2,7 @@ import { readFile } from "node:fs/promises"; import { basename, isAbsolute, join, relative, resolve } from "node:path"; import { DatabaseSync } from "node:sqlite"; import type { RoomSnapshot } from "@tldraw/sync-core"; -import { closeDatabase, db } from "./db.ts"; +import { closeDatabase, db } from "./maintenance-db.ts"; import { objectStore } from "./object-store.ts"; import { hashObsSecret, isHashedObsSecret } from "./obs-secret.ts"; import { canvasDocuments, roomMembers, rooms, uploads } from "./schema.ts"; diff --git a/backend/stream-canvas/src/index.ts b/backend/stream-canvas/src/index.ts index 91c7a49..2743e79 100644 --- a/backend/stream-canvas/src/index.ts +++ b/backend/stream-canvas/src/index.ts @@ -1,13 +1,10 @@ import { serve } from "@hono/node-server"; -import { Hono } from "hono"; -import { cors } from "hono/cors"; -import { sql } from "drizzle-orm"; import { WebSocketServer } from "ws"; import { config, validateServerConfig } from "./config.ts"; -import { closeDatabase, db } from "./db.ts"; +import { withDeadline } from "./deadline.ts"; +import { closeDatabase } from "./db.ts"; import { leaderState } from "./leader.ts"; -import { objectStore } from "./object-store.ts"; -import { api } from "./routes.ts"; +import { app } from "./app.ts"; import { closeAllRooms, handleWebSocketUpgrade } from "./ws-handler.ts"; // --------------------------------------------------------------------------- @@ -16,31 +13,6 @@ import { closeAllRooms, handleWebSocketUpgrade } from "./ws-handler.ts"; validateServerConfig(); -const app = new Hono(); - -app.use("*", cors({ origin: config.corsOrigins })); -app.get("/health", (c) => c.json({ status: "ok" })); -app.get("/ready", async (c) => { - if (!leaderState.isLeader) { - return c.json({ status: "standby", role: "standby" }, 503); - } - try { - await Promise.all([db.execute(sql`SELECT 1`), objectStore.check()]); - return c.json({ status: "ready", role: "leader" }); - } catch (error) { - console.error("[readiness] dependency check failed", error); - return c.json({ status: "unavailable", role: "leader" }, 503); - } -}); -app.use("*", async (c, next) => { - if (!leaderState.isLeader) { - c.header("Retry-After", "2"); - return c.json({ error: "Canvas leader is changing" }, 503); - } - return next(); -}); -app.route("/", api); - // --------------------------------------------------------------------------- // HTTP server + WebSocket upgrade // --------------------------------------------------------------------------- @@ -53,9 +25,9 @@ const wss = new WebSocketServer({ perMessageDeflate: false, }); -leaderState.onDemote(async () => { +leaderState.onDemote(async (persist) => { for (const client of wss.clients) client.close(1012, "Service restarting"); - await closeAllRooms(); + await closeAllRooms(persist); }); leaderState.start(); @@ -111,7 +83,11 @@ async function performShutdown(): Promise { } function requestShutdown(): void { - void shutdown().then( + void withDeadline( + shutdown(), + 25_000, + "Shutdown; pending data may not be durable", + ).then( () => process.exit(0), (error) => { console.error("[stream-canvas] shutdown failed", error); diff --git a/backend/stream-canvas/src/leader.ts b/backend/stream-canvas/src/leader.ts index 0dda070..39448c0 100644 --- a/backend/stream-canvas/src/leader.ts +++ b/backend/stream-canvas/src/leader.ts @@ -1,5 +1,8 @@ import type { PoolClient } from "pg"; +import type { RoomSnapshot } from "@tldraw/sync-core"; +import { drizzle } from "drizzle-orm/node-postgres"; import { pool } from "./db.ts"; +import { canvasDocuments } from "./schema.ts"; const LEADER_LOCK_KEY = 1_296_315_460; @@ -8,7 +11,19 @@ class LeaderState { private timer: NodeJS.Timeout | null = null; private tickPromise: Promise | null = null; private stopping = false; - private demoteHandlers = new Set<() => void | Promise>(); + private demoteHandlers = new Set< + (persist: boolean) => void | Promise + >(); + private demoting: Promise | null = null; + private connectionFailed = false; + + private readonly onClientError = (error: Error) => { + console.error("[leader] retained PostgreSQL connection failed", error); + this.connectionFailed = true; + void this.demote(false).catch((failure) => + console.error("[leader] demotion failed", failure), + ); + }; isLeader = false; @@ -17,7 +32,7 @@ class LeaderState { this.runTick(); } - onDemote(handler: () => void | Promise): () => void { + onDemote(handler: (persist: boolean) => void | Promise): () => void { this.demoteHandlers.add(handler); return () => this.demoteHandlers.delete(handler); } @@ -27,7 +42,31 @@ class LeaderState { if (this.timer) clearTimeout(this.timer); this.timer = null; await this.tickPromise; - await this.demote(); + await this.demote(true); + } + + async persistSnapshot(roomId: string, snapshot: RoomSnapshot): Promise { + const client = this.client; + if (!client || this.connectionFailed) + throw new Error("Canvas ownership was lost"); + // Writes use the lock-owning connection. A successor cannot acquire the + // advisory lock until this connection and its queued writes have ended. + await drizzle(client) + .insert(canvasDocuments) + .values({ + roomId, + snapshot, + revision: snapshot.documentClock, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: canvasDocuments.roomId, + set: { + snapshot, + revision: snapshot.documentClock, + updatedAt: new Date(), + }, + }); } private runTick(): void { @@ -44,6 +83,7 @@ class LeaderState { private async tick(): Promise { try { + await this.demoting; if (this.isLeader && this.client) { await this.client.query("SELECT 1"); } else { @@ -51,7 +91,8 @@ class LeaderState { } } catch (error) { console.error("[leader] leadership connection failed", error); - await this.demote(); + this.connectionFailed = true; + await this.demote(false); } finally { if (!this.stopping) { this.timer = setTimeout( @@ -67,6 +108,8 @@ class LeaderState { private async tryAcquire(): Promise { const client = await pool.connect(); + this.connectionFailed = false; + client.on("error", this.onClientError); let retained = false; let destroy = false; try { @@ -75,7 +118,7 @@ class LeaderState { [LEADER_LOCK_KEY], ); if (!result.rows[0]?.acquired) return; - if (this.stopping) { + if (this.stopping || this.connectionFailed) { await client.query("SELECT pg_advisory_unlock($1)", [LEADER_LOCK_KEY]); return; } @@ -87,22 +130,34 @@ class LeaderState { destroy = true; throw error; } finally { - if (!retained) client.release(destroy); + if (!retained) { + client.release(destroy || this.connectionFailed); + client.removeListener("error", this.onClientError); + } } } - private async demote(): Promise { - const wasLeader = this.isLeader; + private demote(persist: boolean): Promise { this.isLeader = false; + if (this.demoting) return this.demoting; + this.demoting = this.performDemotion(persist).finally(() => { + this.demoting = null; + }); + return this.demoting; + } + + private async performDemotion(persist: boolean): Promise { + const failures: unknown[] = []; const client = this.client; - this.client = null; - if (wasLeader) { + if (!persist) this.client = null; + if (client) { console.warn("[leader] relinquishing stream-canvas leadership"); const results = await Promise.allSettled( - [...this.demoteHandlers].map((handler) => handler()), + [...this.demoteHandlers].map((handler) => handler(persist)), ); for (const result of results) { if (result.status === "rejected") { + failures.push(result.reason); console.error("[leader] demotion handler failed", result.reason); } } @@ -113,9 +168,13 @@ class LeaderState { } catch { // The connection may already be gone; PostgreSQL releases session locks. } finally { + this.client = null; client.release(true); + client.removeListener("error", this.onClientError); } } + if (failures.length) + throw new AggregateError(failures, "Leadership demotion failed"); } } diff --git a/backend/stream-canvas/src/lifecycle.test.ts b/backend/stream-canvas/src/lifecycle.test.ts new file mode 100644 index 0000000..8689097 --- /dev/null +++ b/backend/stream-canvas/src/lifecycle.test.ts @@ -0,0 +1,371 @@ +import assert from "node:assert/strict"; +import { generateKeyPairSync, randomBytes, sign } from "node:crypto"; +import { once } from "node:events"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { setTimeout as delay } from "node:timers/promises"; + +test("PostgreSQL and WebSockets preserve canvas state, revoke sessions, and handle ownership loss", { + skip: + !process.env.CANVAS_TEST_DATABASE_URL && + "Set CANVAS_TEST_DATABASE_URL to an isolated PostgreSQL database", + timeout: 60_000, +}, async (t) => { + const directory = await mkdtemp(join(tmpdir(), "canvas-lifecycle-")); + const { publicKey, privateKey } = generateKeyPairSync("rsa", { + modulusLength: 2048, + }); + const origin = "http://localhost:4310"; + Object.assign(process.env, { + NODE_ENV: "test", + DATABASE_URL: process.env.CANVAS_TEST_DATABASE_URL, + UPLOADS_DIR: directory, + OBJECT_STORAGE_MODE: "filesystem", + CORS_ORIGINS: origin, + CLERK_JWT_KEY: publicKey.export({ type: "spki", format: "pem" }), + CLERK_JWT_ISSUER_DOMAIN: "https://identity.example", + OBS_TOKEN_SIGNING_SECRET: randomBytes(32).toString("hex"), + }); + const [ + { db, pool }, + { leaderState }, + { api }, + wsHandler, + { migrate }, + { serve }, + { WebSocket, WebSocketServer }, + sync, + tl, + ] = await Promise.all([ + import("./db.ts"), + import("./leader.ts"), + import("./routes.ts"), + import("./ws-handler.ts"), + import("drizzle-orm/node-postgres/migrator"), + import("@hono/node-server"), + import("ws"), + import("@tldraw/sync-core"), + import("tldraw"), + ]); + const { streamCanvasSchema } = await import("./tldraw-schema.ts"); + await migrate(db, { + migrationsFolder: new URL("../drizzle", import.meta.url).pathname, + }); + const server = serve({ fetch: api.fetch, port: 0, hostname: "127.0.0.1" }); + const wss = new WebSocketServer({ noServer: true }); + server.on("upgrade", (req, socket, head) => + wss.handleUpgrade(req, socket, head, (ws) => { + void wsHandler + .handleWebSocketUpgrade(ws, req) + .catch(() => ws.close(1011)); + }), + ); + if (!server.listening) await once(server, "listening"); + const address = server.address(); + assert.ok(address && typeof address !== "string"); + const base = `http://127.0.0.1:${address.port}`; + const sockets: InstanceType[] = []; + const createdRooms: string[] = []; + let demotions = 0; + const unsubscribe = leaderState.onDemote(async (persist) => { + demotions++; + await wsHandler.closeAllRooms(persist); + }); + t.after(async () => { + t.mock.restoreAll(); + for (const socket of sockets) socket.terminate(); + await leaderState.stop(); + unsubscribe(); + await new Promise((resolve) => wss.close(() => resolve())); + await new Promise((resolve) => server.close(() => resolve())); + for (const id of createdRooms) + await pool.query("DELETE FROM rooms WHERE id = $1", [id]); + await pool.end(); + await rm(directory, { recursive: true, force: true }); + }); + const owner = `user_${crypto.randomUUID().replaceAll("-", "")}`; + const member = `user_${crypto.randomUUID().replaceAll("-", "")}`; + function headers(user = owner) { + const now = Math.floor(Date.now() / 1000); + const head = Buffer.from( + JSON.stringify({ alg: "RS256", typ: "JWT", kid: "local-test" }), + ).toString("base64url"); + const body = Buffer.from( + JSON.stringify({ + sub: user, + iss: "https://identity.example", + azp: origin, + iat: now, + exp: now + 300, + }), + ).toString("base64url"); + const signature = sign( + "RSA-SHA256", + Buffer.from(`${head}.${body}`), + privateKey, + ).toString("base64url"); + return { + Origin: origin, + Authorization: `Bearer ${head}.${body}.${signature}`, + "Content-Type": "application/json", + }; + } + async function request( + path: string, + method = "GET", + body?: object, + user = owner, + ) { + const response = await fetch(base + path, { + method, + headers: headers(user), + body: body ? JSON.stringify(body) : undefined, + }); + assert.ok(response.ok, `${method} ${path}: ${response.status}`); + return response; + } + const created = await Promise.all( + Array.from({ length: 4 }, () => request("/api/rooms", "POST")), + ); + const rooms = await Promise.all( + created.map( + (response) => + response.json() as Promise<{ id: string; obsSetupSecret?: string }>, + ), + ); + const roomId = rooms[0]?.id; + assert.ok(roomId); + createdRooms.push(roomId); + assert.equal(new Set(rooms.map((room) => room.id)).size, 1); + assert.equal(created.filter((response) => response.status === 201).length, 1); + assert.equal(rooms.filter((room) => room.obsSetupSecret).length, 1); + const secret = rooms.find((room) => room.obsSetupSecret)?.obsSetupSecret; + assert.ok(secret); + await request(`/api/rooms/${roomId}`, "PATCH", { allowedUsers: [member] }); + leaderState.start(); + await until(() => leaderState.isLeader); + async function connect(token: string) { + const ws = new WebSocket( + `${base.replace("http:", "ws:")}/ws?roomId=${roomId}&token=${encodeURIComponent(token)}`, + { origin }, + ); + const messages: unknown[] = []; + let closed = false; + sockets.push(ws); + ws.on("close", () => { + closed = true; + }); + ws.on("message", (data) => { + messages.push(JSON.parse(data.toString())); + }); + await once(ws, "open"); + // Admission may perform database reads; ws buffers the protocol handshake. + ws.send( + JSON.stringify({ + type: "connect", + connectRequestId: crypto.randomUUID(), + lastServerClock: 0, + // The runtime exports this protocol helper but omits it from public declarations. + protocolVersion: ( + sync as typeof sync & { getTlsyncProtocolVersion(): number } + ).getTlsyncProtocolVersion(), + schema: streamCanvasSchema.serialize(), + }), + ); + await until( + () => messages.some((message) => isMessage(message, "connect")) || closed, + ); + return { + ws, + messages, + get closed() { + return closed; + }, + }; + } + async function editorToken(user = owner) { + return ( + (await ( + await request(`/api/rooms/${roomId}/ws-token`, "POST", undefined, user) + ).json()) as { token: string } + ).token; + } + const obsToken = ( + (await (await request("/obs/token", "POST", { secret })).json()) as { + token: string; + } + ).token; + const initialEditorToken = await editorToken(); + const loadSnapshot = t.mock.method(db.query.canvasDocuments, "findFirst"); + const findRoom = db.query.rooms.findFirst.bind(db.query.rooms); + let metadataReads = 0; + const readMetadata = t.mock.method( + db.query.rooms, + "findFirst", + (...args: Parameters) => { + if (++metadataReads === 2) throw new Error("Temporary metadata failure"); + return findRoom(...args); + }, + ); + t.mock.timers.enable({ apis: ["setTimeout"] }); + const failedAdmission = await connect(initialEditorToken); + assert.equal(failedAdmission.closed, true); + assert.equal(loadSnapshot.mock.callCount(), 1); + readMetadata.mock.restore(); + t.mock.timers.tick(30_000); + // Let the disposal's asynchronous flush finish before reconnecting. + await delay(20); + t.mock.timers.reset(); + const editor = await connect(initialEditorToken); + assert.equal(loadSnapshot.mock.callCount(), 2); + loadSnapshot.mock.restore(); + const collaborator = await connect(await editorToken(member)); + const mirror = await connect(obsToken); + for (const connection of [editor, collaborator, mirror]) { + await until(() => + connection.messages.some( + (message) => + isMessage(message, "custom") && + JSON.stringify(message).includes('"youtubePolicy":"preview_only"'), + ), + ); + const handshake = connection.messages.findIndex((message) => + isMessage(message, "connect"), + ); + const settings = connection.messages.findIndex((message) => + isMessage(message, "custom"), + ); + assert.ok( + settings > handshake, + "initial settings follow the completed handshake", + ); + } + assert.ok( + mirror.messages.some( + (message) => isMessage(message, "connect") && message.isReadonly === true, + ), + ); + await request(`/api/rooms/${roomId}`, "PATCH", { + youtubePolicy: "disabled", + allowedUsers: [], + }); + await until(() => collaborator.closed); + assert.equal(editor.closed, false); + assert.equal(mirror.closed, false); + await until(() => + mirror.messages.some( + (message) => + isMessage(message, "custom") && + JSON.stringify(message).includes('"youtubePolicy":"disabled"'), + ), + ); + + let releaseWrite: () => void = () => {}; + let writeStarted = false; + const held = new Promise((resolve) => { + releaseWrite = resolve; + }); + const persist = leaderState.persistSnapshot.bind(leaderState); + t.mock.method( + leaderState, + "persistSnapshot", + async (...args: Parameters) => { + writeStarted = true; + await held; + return persist(...args); + }, + ); + const page = tl.PageRecordType.create({ + name: "Latest durable canvas", + index: "a2" as import("tldraw").IndexKey, + }); + editor.ws.send( + JSON.stringify({ + type: "push", + clientClock: 1, + diff: { [page.id]: ["put", page] }, + }), + ); + await until(() => writeStarted); + await until(() => + mirror.messages.some((message) => + JSON.stringify(message).includes("Latest durable canvas"), + ), + ); + await request(`/api/rooms/${roomId}/regenerate-secret`, "POST"); + await until(() => mirror.closed); + assert.equal(editor.closed, false); + const obsolete = await connect(obsToken); + assert.equal(obsolete.closed, true); + editor.ws.close(); + await until(() => editor.closed); + // tldraw removes a disconnected session after its reconnect grace period. + await delay(6_000); + let disposalFinished = false; + const disposal = wsHandler.disposeInactiveRoom(roomId).then(() => { + disposalFinished = true; + }); + await delay(20); + assert.equal(disposalFinished, false); + const reopening = connect(await editorToken()); + await delay(20); + releaseWrite(); + await disposal; + const reopened = await reopening; + assert.ok( + reopened.messages.some((message) => + JSON.stringify(message).includes("Latest durable canvas"), + ), + ); + t.mock.restoreAll(); + const persisted = await pool.query<{ snapshot: unknown }>( + "SELECT snapshot FROM canvas_documents WHERE room_id = $1", + [roomId], + ); + assert.match( + JSON.stringify(persisted.rows[0]?.snapshot), + /Latest durable canvas/, + ); + const lock = await pool.query<{ pid: number }>( + "SELECT pid FROM pg_locks WHERE locktype = 'advisory' AND objid = 1296315460 AND database = (SELECT oid FROM pg_database WHERE datname = current_database()) AND granted", + ); + assert.equal(lock.rows.length, 1); + await pool.query("SELECT pg_terminate_backend($1)", [lock.rows[0]?.pid]); + await until(() => demotions === 1 && reopened.closed); + await until(() => leaderState.isLeader); + assert.equal(demotions, 1); + const recovered = await connect(await editorToken()); + await until(() => + recovered.messages.some( + (message) => + isMessage(message, "custom") && + JSON.stringify(message).includes('"youtubePolicy":"disabled"'), + ), + ); + assert.ok( + recovered.messages.some((message) => + JSON.stringify(message).includes("Latest durable canvas"), + ), + ); +}); + +function isMessage( + value: unknown, + type: string, +): value is Record { + return ( + value !== null && + typeof value === "object" && + "type" in value && + value.type === type + ); +} +async function until(predicate: () => boolean) { + const end = Date.now() + 10_000; + while (!predicate()) { + if (Date.now() >= end) throw new Error("Lifecycle condition timed out"); + await delay(10); + } +} diff --git a/backend/stream-canvas/src/maintenance-db.ts b/backend/stream-canvas/src/maintenance-db.ts new file mode 100644 index 0000000..89dfd90 --- /dev/null +++ b/backend/stream-canvas/src/maintenance-db.ts @@ -0,0 +1,22 @@ +import { drizzle } from "drizzle-orm/node-postgres"; +import { Pool } from "pg"; +import { config } from "./config.ts"; +import * as schema from "./schema.ts"; + +// Offline migration/import work can wait for locks and run long DDL/backfills. +// Keep it separate from the service pool's request and shutdown deadlines. +export const pool = new Pool({ + connectionString: config.databaseUrl, + max: config.databasePoolSize, + connectionTimeoutMillis: 5_000, + statement_timeout: 0, + query_timeout: 0, + application_name: "moddrop-stream-canvas-maintenance", +}); +pool.on("error", (error) => { + console.error("[maintenance] idle PostgreSQL connection failed", error); +}); +export const db = drizzle(pool, { schema }); +export async function closeDatabase(): Promise { + await pool.end(); +} diff --git a/backend/stream-canvas/src/maintenance.test.ts b/backend/stream-canvas/src/maintenance.test.ts new file mode 100644 index 0000000..1159550 --- /dev/null +++ b/backend/stream-canvas/src/maintenance.test.ts @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { setTimeout as delay } from "node:timers/promises"; +import test from "node:test"; + +test("maintenance migration waits beyond service deadlines for its advisory lock", { + skip: + !process.env.CANVAS_TEST_DATABASE_URL && + "Requires an isolated PostgreSQL database", + timeout: 30_000, +}, async () => { + process.env.DATABASE_URL = process.env.CANVAS_TEST_DATABASE_URL; + const { pool } = await import("./maintenance-db.ts"); + const client = await pool.connect(); + const lockKey = 1_296_315_461; + let migration: Promise | undefined; + try { + const timeout = await client.query("SHOW statement_timeout"); + assert.equal(timeout.rows[0]?.statement_timeout, "0"); + await client.query("SELECT pg_advisory_lock($1)", [lockKey]); + let completed = false; + migration = promisify(execFile)(process.execPath, ["src/migrate.ts"], { + env: { + ...process.env, + DATABASE_URL: process.env.CANVAS_TEST_DATABASE_URL, + }, + }).finally(() => { + completed = true; + }); + // Attach immediately so a regression fails assertions without an unhandled rejection. + void migration.catch(() => {}); + await delay(12_500); + assert.equal( + completed, + false, + "migration must keep waiting beyond both service limits", + ); + await client.query("SELECT pg_advisory_unlock($1)", [lockKey]); + await migration; + } finally { + await client.query("SELECT pg_advisory_unlock($1)", [lockKey]); + client.release(); + await migration?.catch(() => {}); + await pool.end(); + } +}); diff --git a/backend/stream-canvas/src/migrate.ts b/backend/stream-canvas/src/migrate.ts index 82501e6..dab4a84 100644 --- a/backend/stream-canvas/src/migrate.ts +++ b/backend/stream-canvas/src/migrate.ts @@ -1,6 +1,6 @@ import { fileURLToPath } from "node:url"; import { migrate } from "drizzle-orm/node-postgres/migrator"; -import { closeDatabase, db, pool } from "./db.ts"; +import { closeDatabase, db, pool } from "./maintenance-db.ts"; const MIGRATION_LOCK_KEY = 1_296_315_461; const migrationsFolder = fileURLToPath(new URL("../drizzle", import.meta.url)); diff --git a/backend/stream-canvas/src/object-store-transport.test.ts b/backend/stream-canvas/src/object-store-transport.test.ts new file mode 100644 index 0000000..b9a5451 --- /dev/null +++ b/backend/stream-canvas/src/object-store-transport.test.ts @@ -0,0 +1,111 @@ +import assert from "node:assert/strict"; +import { randomBytes } from "node:crypto"; +import { once } from "node:events"; +import { createServer, type IncomingMessage } from "node:http"; +import test from "node:test"; +import { Client } from "minio"; +import { ObjectStoreTransport } from "./object-store-transport.ts"; + +test("Minio rejection cancels an unconsumed error body", async (t) => { + let closed: Promise | undefined; + const server = createServer((_req, res) => { + closed = once(res, "close"); + res.writeHead(503); + res.write("partial error body"); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + t.after(() => { + server.closeAllConnections(); + server.close(); + }); + const address = server.address(); + assert.ok(address && typeof address !== "string"); + const requests = new ObjectStoreTransport(false); + const client = new Client({ + endPoint: "127.0.0.1", + port: address.port, + useSSL: false, + accessKey: randomBytes(16).toString("hex"), + secretKey: randomBytes(32).toString("hex"), + region: "fixture", + transport: requests.transport, + retryOptions: { disableRetry: true }, + }); + await assert.rejects( + requests.run(() => client.getObject("fixture", "media"), 1_000), + ); + assert.ok(closed); + await closed; +}); + +test("object-store deadlines destroy stalled requests and free a subsequent request", async (t) => { + let closed = false; + const server = createServer((req, res) => { + if (req.url === "/healthy") return res.end("ok"); + req.on("close", () => { + closed = true; + }); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + t.after(() => { + server.closeAllConnections(); + server.close(); + }); + const address = server.address(); + assert.ok(address && typeof address !== "string"); + const requests = new ObjectStoreTransport(false); + const request = (path: string) => + new Promise((resolve, reject) => { + const req = requests.transport.request( + { hostname: "127.0.0.1", port: address.port, path }, + resolve, + ); + req.on("error", reject); + req.end(); + }); + await assert.rejects( + requests.run(() => request("/stalled"), 30), + { name: "AbortError" }, + ); + const response = await requests.run(() => request("/healthy"), 1_000); + const chunks = []; + for await (const chunk of response) chunks.push(chunk); + assert.equal(Buffer.concat(chunks).toString(), "ok"); + assert.equal(closed, true); +}); + +test("object-store deadlines remain attached to a stalled response stream", async (t) => { + const server = createServer((_req, res) => { + res.writeHead(200); + res.write("partial"); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + t.after(() => { + server.closeAllConnections(); + server.close(); + }); + const address = server.address(); + assert.ok(address && typeof address !== "string"); + const requests = new ObjectStoreTransport(false); + const response = await requests.run( + () => + new Promise((resolve, reject) => { + const req = requests.transport.request( + { hostname: "127.0.0.1", port: address.port }, + resolve, + ); + req.on("error", reject); + req.end(); + }), + 30, + ); + await assert.rejects(async () => { + for await (const _chunk of response) { + /* Drain until cancellation. */ + } + }); + assert.equal(response.destroyed, true); +}); diff --git a/backend/stream-canvas/src/object-store-transport.ts b/backend/stream-canvas/src/object-store-transport.ts new file mode 100644 index 0000000..ff4f4f9 --- /dev/null +++ b/backend/stream-canvas/src/object-store-transport.ts @@ -0,0 +1,79 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import * as http from "node:http"; +import * as https from "node:https"; +import { Readable } from "node:stream"; +import { urlToHttpOptions } from "node:url"; + +/** A deadline shared by every request/retry belonging to one S3 operation. */ +export class ObjectStoreTransport { + private readonly operations = new AsyncLocalStorage(); + readonly transport: Pick; + + constructor(secure: boolean) { + const native = secure ? https : http; + const operations = this.operations; + function request( + options: string | URL | http.RequestOptions, + callback?: (response: http.IncomingMessage) => void, + ): http.ClientRequest; + function request( + url: string | URL, + options: http.RequestOptions, + callback?: (response: http.IncomingMessage) => void, + ): http.ClientRequest; + function request( + first: string | URL | http.RequestOptions, + second?: http.RequestOptions | ((response: http.IncomingMessage) => void), + third?: (response: http.IncomingMessage) => void, + ): http.ClientRequest { + const signal = operations.getStore(); + const callback = typeof second === "function" ? second : third; + const options = + typeof first === "string" || first instanceof URL + ? { + ...urlToHttpOptions(new URL(first)), + ...(typeof second === "object" ? second : {}), + } + : first; + const req = native.request({ ...options, signal }, (response) => { + // Cancellation can happen before the consumer attaches its listeners. + response.on("error", () => {}); + callback?.(response); + }); + // Node's signal destroys the request and its response, including streams + // already returned to the caller. It also cancels connection establishment. + return req; + } + this.transport = { request }; + } + + async run(operation: () => Promise, timeoutMs: number): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + timer.unref(); + let streaming = false; + try { + return await this.operations.run(controller.signal, async () => { + const result = await operation(); + if ( + result instanceof Readable && + !result.destroyed && + !result.readableEnded + ) { + streaming = true; + const clear = () => clearTimeout(timer); + result.once("end", clear); + result.once("close", clear); + result.once("error", clear); + } + return result; + }); + } finally { + if (!streaming) { + clearTimeout(timer); + // Minio can reject an HTTP status without consuming its response body. + controller.abort(); + } + } + } +} diff --git a/backend/stream-canvas/src/object-store.ts b/backend/stream-canvas/src/object-store.ts index d0229d9..6aa24ff 100644 --- a/backend/stream-canvas/src/object-store.ts +++ b/backend/stream-canvas/src/object-store.ts @@ -4,6 +4,7 @@ import { dirname, join } from "node:path"; import type { Readable } from "node:stream"; import { Client as MinioClient } from "minio"; import { config, validateObjectStorageConfig } from "./config.ts"; +import { ObjectStoreTransport } from "./object-store-transport.ts"; validateObjectStorageConfig(); @@ -66,10 +67,14 @@ class FilesystemObjectStore implements ObjectStore { class S3ObjectStore implements ObjectStore { private readonly client: MinioClient; + private readonly requests: ObjectStoreTransport; + private checking: Promise | null = null; constructor() { const endpoint = new URL(config.s3Endpoint); + this.requests = new ObjectStoreTransport(endpoint.protocol === "https:"); this.client = new MinioClient({ + transport: this.requests.transport, endPoint: endpoint.hostname, port: endpoint.port ? Number(endpoint.port) @@ -81,28 +86,47 @@ class S3ObjectStore implements ObjectStore { secretKey: config.s3SecretKey, region: config.s3Region, pathStyle: true, + retryOptions: { disableRetry: true }, }); } - async check(): Promise { - if (!(await this.client.bucketExists(config.s3Bucket))) { - throw new Error(`S3 bucket ${config.s3Bucket} does not exist`); - } + check(): Promise { + // Finish before /ready's overall deadline, cancelling actual S3 work. + this.checking ??= this.requests + .run(async () => { + if (!(await this.client.bucketExists(config.s3Bucket))) { + throw new Error(`S3 bucket ${config.s3Bucket} does not exist`); + } + }, 2_500) + .finally(() => { + this.checking = null; + }); + return this.checking; } async put(key: string, bytes: Buffer, contentType: string): Promise { - await this.client.putObject(config.s3Bucket, key, bytes, bytes.byteLength, { - "Content-Type": contentType, - }); + await this.requests.run( + () => + this.client.putObject(config.s3Bucket, key, bytes, bytes.byteLength, { + "Content-Type": contentType, + }), + 30_000, + ); } async remove(key: string): Promise { - await this.client.removeObject(config.s3Bucket, key); + await this.requests.run( + () => this.client.removeObject(config.s3Bucket, key), + 10_000, + ); } async stat(key: string): Promise { try { - const result = await this.client.statObject(config.s3Bucket, key); + const result = await this.requests.run( + () => this.client.statObject(config.s3Bucket, key), + 10_000, + ); return { size: result.size }; } catch (error) { if (isNotFound(error)) return null; @@ -115,14 +139,21 @@ class S3ObjectStore implements ObjectStore { range?: { start: number; length: number }, ): Promise { if (range) { - return this.client.getPartialObject( - config.s3Bucket, - key, - range.start, - range.length, + return this.requests.run( + () => + this.client.getPartialObject( + config.s3Bucket, + key, + range.start, + range.length, + ), + 120_000, ); } - return this.client.getObject(config.s3Bucket, key); + return this.requests.run( + () => this.client.getObject(config.s3Bucket, key), + 120_000, + ); } } diff --git a/backend/stream-canvas/src/obs-secret.ts b/backend/stream-canvas/src/obs-secret.ts index ef8d1fc..04d5175 100644 --- a/backend/stream-canvas/src/obs-secret.ts +++ b/backend/stream-canvas/src/obs-secret.ts @@ -19,6 +19,14 @@ export function isHashedObsSecret(value: string): boolean { return value.startsWith(OBS_SECRET_PREFIX); } +/** Public ticket version, separate from the stored bootstrap credential. */ +export function obsCredentialVersion(storedSecret: string): string { + return createHash("sha256") + .update("obs-ticket-version\0") + .update(storedSecret) + .digest("base64url"); +} + export function verifyObsSecret(secret: string, storedSecret: string): boolean { const expected = isHashedObsSecret(storedSecret) ? storedSecret diff --git a/backend/stream-canvas/src/rate-limit.ts b/backend/stream-canvas/src/rate-limit.ts index 9539f15..f0a8003 100644 --- a/backend/stream-canvas/src/rate-limit.ts +++ b/backend/stream-canvas/src/rate-limit.ts @@ -63,7 +63,10 @@ export class FixedWindowRateLimit { } private cleanup(now: number): void { - if (this.entries.size < 1000 && this.entries.size <= this.options.maxEntries) { + if ( + this.entries.size < 1000 && + this.entries.size <= this.options.maxEntries + ) { return; } diff --git a/backend/stream-canvas/src/room-operations.ts b/backend/stream-canvas/src/room-operations.ts new file mode 100644 index 0000000..f672838 --- /dev/null +++ b/backend/stream-canvas/src/room-operations.ts @@ -0,0 +1,17 @@ +// Configuration commits and session admission share this queue. A connection +// cannot authenticate against a membership set that is being replaced. +const operations = new Map>(); + +export async function withRoomOperation( + roomId: string, + run: () => Promise, +): Promise { + const previous = operations.get(roomId); + const current = (previous ?? Promise.resolve()).catch(() => {}).then(run); + operations.set(roomId, current); + try { + return await current; + } finally { + if (operations.get(roomId) === current) operations.delete(roomId); + } +} diff --git a/backend/stream-canvas/src/routes.integration.test.ts b/backend/stream-canvas/src/routes.integration.test.ts index 03179e3..815af61 100644 --- a/backend/stream-canvas/src/routes.integration.test.ts +++ b/backend/stream-canvas/src/routes.integration.test.ts @@ -1,10 +1,10 @@ import assert from "node:assert/strict"; -import { mkdtemp } from "node:fs/promises"; +import { generateKeyPairSync, randomBytes, sign } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import type { IncomingMessage } from "node:http"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import test from "node:test"; -import { generateKeyPairSync, sign } from "node:crypto"; -import type { IncomingMessage } from "node:http"; +import test, { after } from "node:test"; import { eq } from "drizzle-orm"; import { v4 as uuidv4 } from "uuid"; @@ -53,10 +53,13 @@ process.env.CLERK_JWT_KEY = publicKey.export({ }); process.env.CLERK_JWT_ISSUER_DOMAIN = issuer; process.env.CORS_ORIGINS = origin; -process.env.OBS_TOKEN_SIGNING_SECRET = - "test-signing-secret-with-at-least-32-bytes"; +process.env.OBS_TOKEN_SIGNING_SECRET = randomBytes(32).toString("hex"); const { db, pool } = await import("./db.ts"); +after(async () => { + await pool.end(); + await rm(tempRoot, { recursive: true, force: true }); +}); for (const statement of [ "CREATE TYPE youtube_policy AS ENUM ('disabled', 'preview_only', 'allow_on_air')", @@ -138,6 +141,88 @@ await db.insert(roomMembers).values({ clerkUserId: collaboratorUserId, }); +test("CORS permits the configured origin and bounded authorization preflight", async () => { + const { app } = await import("./app.ts"); + const response = await app.request("/api/rooms", { + method: "OPTIONS", + headers: { + Origin: origin, + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "authorization,content-type", + }, + }); + assert.equal(response.status, 204); + assert.equal(response.headers.get("access-control-allow-origin"), origin); + assert.match( + response.headers.get("access-control-allow-headers") ?? "", + /authorization/i, + ); + const other = await app.request("/api/rooms", { + method: "OPTIONS", + headers: { + Origin: "https://other.example", + "Access-Control-Request-Method": "POST", + }, + }); + assert.equal(other.headers.get("access-control-allow-origin"), null); +}); + +test("the production HTTP boundary rejects standby policy and secret mutations", async () => { + const { app } = await import("./app.ts"); + const before = await db.select().from(rooms).where(eq(rooms.id, roomId)); + for (const [path, method] of [ + [`/api/rooms/${roomId}`, "PATCH"], + [`/api/rooms/${roomId}/regenerate-secret`, "POST"], + ] as const) { + const response = await app.request(path, { method }); + assert.equal(response.status, 503); + assert.equal(response.headers.get("retry-after"), "2"); + } + assert.deepEqual( + await db.select().from(rooms).where(eq(rooms.id, roomId)), + before, + ); +}); + +test("object-write and metadata failures leave no successful upload record", async (t) => { + const { objectStore } = await import("./object-store.ts"); + const before = await db.select().from(uploads); + const upload = () => { + const body = new FormData(); + body.set( + "file", + new File([Uint8Array.from(minimalPng())], "fixture.png", { + type: "image/png", + }), + ); + return api.request(`/api/rooms/${roomId}/upload`, { + method: "POST", + headers: authHeaders(ownerUserId), + body, + }); + }; + t.mock.method(objectStore, "put", async () => { + throw new Error("test object store unavailable"); + }); + assert.equal((await upload()).status, 500); + assert.equal((await db.select().from(uploads)).length, before.length); + t.mock.restoreAll(); + const originalRemove = objectStore.remove.bind(objectStore); + const removed: string[] = []; + t.mock.method(objectStore, "remove", async (key: string) => { + removed.push(key); + await originalRemove(key); + }); + t.mock.method(db, "insert", () => { + throw new Error("test metadata insertion failed"); + }); + assert.equal((await upload()).status, 500); + assert.equal(removed.length, 1); + assert.equal(await objectStore.stat(removed[0] ?? ""), null); + assert.equal((await db.select().from(uploads)).length, before.length); + t.mock.restoreAll(); +}); + test("OBS token exchange accepts hashed room secrets", async () => { const requestBody = JSON.stringify({ secret: obsSecret }); const response = await api.request("/obs/token", { @@ -371,6 +456,24 @@ test("room creation and regeneration only reveal OBS secrets once", async () => assert.equal(rejectedOldSecret.status, 401); }); +test("invalid upload capabilities do not query metadata or object storage", async (t) => { + const { objectStore } = await import("./object-store.ts"); + const lookup = t.mock.method(db.query.uploads, "findFirst", async () => { + throw new Error("Unexpected metadata lookup"); + }); + const stat = t.mock.method(objectStore, "stat", async () => { + throw new Error("Unexpected object stat"); + }); + for (const suffix of ["", "?token=invalid"]) { + const response = await api.request( + `/uploads/${uuidv4()}/test.png${suffix}`, + ); + assert.equal(response.status, 401); + } + assert.equal(lookup.mock.callCount(), 0); + assert.equal(stat.mock.callCount(), 0); +}); + test("uploads use sniffed MIME type and sanitized filenames", async () => { const formData = new FormData(); formData.append( diff --git a/backend/stream-canvas/src/routes.ts b/backend/stream-canvas/src/routes.ts index 226fbdd..11f6573 100644 --- a/backend/stream-canvas/src/routes.ts +++ b/backend/stream-canvas/src/routes.ts @@ -16,9 +16,14 @@ import { import { config } from "./config.ts"; import { db } from "./db.ts"; import { parseSingleByteRange } from "./http-range.ts"; -import { generateObsSecret, hashObsSecret } from "./obs-secret.ts"; import { objectStore } from "./object-store.ts"; +import { + generateObsSecret, + hashObsSecret, + obsCredentialVersion, +} from "./obs-secret.ts"; import { FixedWindowRateLimit, rateLimitKeyFromHeaders } from "./rate-limit.ts"; +import { withRoomOperation } from "./room-operations.ts"; import { isValidRoomId, MAX_ROOM_CONFIG_BODY_BYTES, @@ -31,6 +36,7 @@ import { sniffUploadMime, validateUploadedMedia, } from "./upload-validation.ts"; +import { revokeObsSessions, roomConfigChanged } from "./ws-handler.ts"; // --------------------------------------------------------------------------- // Middleware: require Clerk JWT @@ -98,24 +104,25 @@ export const api = new Hono(); // Serve uploaded files (public) api.get("/uploads/:uploadId/:filename", async (c) => { + const token = c.req.query("token"); + const tokenClaims = token ? verifyUploadAccessToken(token) : null; + if (!tokenClaims || tokenClaims.uploadId !== c.req.param("uploadId")) { + return c.json({ error: "Unauthorized" }, 401); + } const upload = await db.query.uploads.findFirst({ where: eq(uploads.id, c.req.param("uploadId")), }); - const storedObject = upload ? await objectStore.stat(upload.objectKey) : null; - if (!upload || !storedObject) { + if (!upload) { return c.json({ error: "Not found" }, 404); } - const token = c.req.query("token"); - const tokenClaims = token ? verifyUploadAccessToken(token) : null; - if ( - !tokenClaims || - tokenClaims.uploadId !== upload.id || - tokenClaims.roomId !== upload.roomId - ) { + if (tokenClaims.roomId !== upload.roomId) { return c.json({ error: "Unauthorized" }, 401); } + const storedObject = await objectStore.stat(upload.objectKey); + if (!storedObject) return c.json({ error: "Not found" }, 404); + const fileSize = storedObject.size; const range = parseSingleByteRange(c.req.header("range"), fileSize); const contentType = upload.mimeType || "application/octet-stream"; @@ -180,7 +187,7 @@ api.post("/obs/token", async (c) => { return c.json({ error: "Invalid secret" }, 401); } - const token = mintObsToken(room.id); + const token = mintObsToken(room.id, obsCredentialVersion(room.obsSecret)); return c.json({ token, roomId: room.id, @@ -258,9 +265,16 @@ authed.post("/rooms", async (c) => { createdAt: now, updatedAt: now, }) + .onConflictDoNothing({ target: rooms.ownerClerkId }) .returning(); - if (!room) return c.json({ error: "Room creation failed" }, 500); + if (!room) { + const winner = await db.query.rooms.findFirst({ + where: eq(rooms.ownerClerkId, user.sub), + }); + if (!winner) return c.json({ error: "Room creation failed" }, 500); + return c.json(await ownerRoomResponse(winner)); + } return c.json(await ownerRoomResponse(room, obsSecret), 201); }); @@ -340,45 +354,44 @@ authed.patch("/rooms/:id", async (c) => { return c.json({ error: validation.error }, 400); } - await db.transaction(async (tx) => { - await tx - .update(rooms) - .set({ - ...(validation.value.twitchChannel !== undefined && { - twitchChannel: validation.value.twitchChannel, - }), - ...(validation.value.youtubePolicy !== undefined && { - youtubePolicy: validation.value.youtubePolicy, - youtubeRiskAcknowledgedAt: - validation.value.youtubePolicy === "allow_on_air" - ? new Date() - : null, - }), - updatedAt: new Date(), - }) - .where(eq(rooms.id, room.id)); - - if (validation.value.allowedUsers !== undefined) { - await tx.delete(roomMembers).where(eq(roomMembers.roomId, room.id)); - if (validation.value.allowedUsers.length > 0) { - await tx.insert(roomMembers).values( - validation.value.allowedUsers.map((clerkUserId) => ({ - roomId: room.id, - clerkUserId, - })), - ); + return withRoomOperation(room.id, async () => { + const updated = await db.transaction(async (tx) => { + const [updatedRoom] = await tx + .update(rooms) + .set({ + ...(validation.value.twitchChannel !== undefined && { + twitchChannel: validation.value.twitchChannel, + }), + ...(validation.value.youtubePolicy !== undefined && { + youtubePolicy: validation.value.youtubePolicy, + youtubeRiskAcknowledgedAt: + validation.value.youtubePolicy === "allow_on_air" + ? new Date() + : null, + }), + updatedAt: new Date(), + }) + .where(eq(rooms.id, room.id)) + .returning(); + if (!updatedRoom) throw new Error("Room disappeared during update"); + + if (validation.value.allowedUsers !== undefined) { + await tx.delete(roomMembers).where(eq(roomMembers.roomId, room.id)); + if (validation.value.allowedUsers.length > 0) { + await tx.insert(roomMembers).values( + validation.value.allowedUsers.map((clerkUserId) => ({ + roomId: room.id, + clerkUserId, + })), + ); + } } - } - }); + return updatedRoom; + }); - const updated = await db.query.rooms.findFirst({ - where: eq(rooms.id, room.id), + roomConfigChanged(updated, validation.value.allowedUsers); + return c.json(await ownerRoomResponse(updated)); }); - if (!updated) { - return c.json({ error: "Updated room could not be retrieved" }, 500); - } - - return c.json(await ownerRoomResponse(updated)); }); // Mint a short-lived editor WebSocket ticket. Raw Clerk JWTs never go in WS URLs. @@ -426,12 +439,15 @@ authed.post("/rooms/:id/regenerate-secret", async (c) => { if (!room) return c.json({ error: "Not found" }, 404); const newSecret = generateObsSecret(); - await db - .update(rooms) - .set({ obsSecret: hashObsSecret(newSecret), updatedAt: new Date() }) - .where(eq(rooms.id, room.id)); + return withRoomOperation(room.id, async () => { + await db + .update(rooms) + .set({ obsSecret: hashObsSecret(newSecret), updatedAt: new Date() }) + .where(eq(rooms.id, room.id)); - return c.json({ ok: true, obsSecret: newSecret }); + revokeObsSessions(room.id); + return c.json({ ok: true, obsSecret: newSecret }); + }); }); // OBS secrets are hashed at rest and cannot be revealed after creation. diff --git a/backend/stream-canvas/src/security.test.ts b/backend/stream-canvas/src/security.test.ts index 9c7043c..eefdee8 100644 --- a/backend/stream-canvas/src/security.test.ts +++ b/backend/stream-canvas/src/security.test.ts @@ -38,7 +38,7 @@ test("OBS secrets are hashed and verified without storing plaintext", () => { }); test("short-lived OBS and editor WebSocket tokens validate role, scope, and expiry", () => { - const obsToken = mintObsToken(roomId); + const obsToken = mintObsToken(roomId, "test-credential-version"); const obsClaims = verifyObsToken(obsToken); assert.equal(obsClaims?.roomId, roomId); assert.equal(obsClaims?.role, "obs"); diff --git a/backend/stream-canvas/src/snapshot-writer.test.ts b/backend/stream-canvas/src/snapshot-writer.test.ts new file mode 100644 index 0000000..fa14c20 --- /dev/null +++ b/backend/stream-canvas/src/snapshot-writer.test.ts @@ -0,0 +1,110 @@ +import assert from "node:assert/strict"; +import { setTimeout as delay } from "node:timers/promises"; +import test from "node:test"; +import type { RoomSnapshot } from "@tldraw/sync-core"; +import { RoomSnapshotWriter } from "./snapshot-writer.ts"; +import { withRoomOperation } from "./room-operations.ts"; + +const snapshot = (clock: number): RoomSnapshot => ({ + documentClock: clock, + documents: [], + tombstones: {}, + tombstoneHistoryStartsAtClock: 0, +}); + +test("snapshot creation is deferred and a burst writes only its latest state", async () => { + let clock = 0; + let snapshots = 0; + const written: number[] = []; + const writer = new RoomSnapshotWriter( + () => { + snapshots++; + return snapshot(clock); + }, + async (value) => { + written.push(value.documentClock ?? 0); + }, + "test", + ); + for (clock = 1; clock <= 100; clock++) writer.schedule(); + assert.equal(snapshots, 0); + await writer.flush(); + assert.deepEqual(written, [101]); + assert.equal(snapshots, 1); + writer.stop(); +}); + +test("flush drains changes arriving behind a pending write, without overlapping writers", async () => { + let clock = 1; + let release: () => void = () => {}; + const pending = new Promise((resolve) => { + release = resolve; + }); + const written: number[] = []; + const writer = new RoomSnapshotWriter( + () => snapshot(clock), + async (value) => { + if (!written.length) await pending; + written.push(value.documentClock ?? 0); + }, + "test", + ); + writer.schedule(); + const flush = writer.flush(); + clock = 2; + writer.schedule(); + release(); + await flush; + assert.deepEqual(written, [1, 2]); + writer.stop(); +}); + +test("failed flush is reported, keeps the latest state for retry, and respects a deadline", async () => { + let fail = true; + const written: number[] = []; + const writer = new RoomSnapshotWriter( + () => snapshot(3), + async (value) => { + if (fail) throw new Error("storage unavailable"); + written.push(value.documentClock ?? 0); + }, + "test", + ); + writer.schedule(); + await assert.rejects(writer.flush(), /storage unavailable/); + fail = false; + await writer.flush(); + assert.deepEqual(written, [3]); + writer.stop(); + const stalled = new RoomSnapshotWriter( + () => snapshot(1), + () => new Promise(() => {}), + "stalled", + ); + stalled.schedule(); + await assert.rejects(stalled.flush(10), /timed out/); + stalled.stop(); +}); + +test("room operations serialize a commit and admission, and recover from a failed operation", async () => { + const order: string[] = []; + await Promise.all([ + withRoomOperation("one", async () => { + await delay(5); + order.push("commit"); + }), + withRoomOperation("one", async () => { + order.push("admit"); + }), + ]); + assert.deepEqual(order, ["commit", "admit"]); + await assert.rejects( + withRoomOperation("one", async () => { + throw new Error("failed"); + }), + ); + assert.equal( + await withRoomOperation("one", async () => "recovered"), + "recovered", + ); +}); diff --git a/backend/stream-canvas/src/snapshot-writer.ts b/backend/stream-canvas/src/snapshot-writer.ts new file mode 100644 index 0000000..658e4ed --- /dev/null +++ b/backend/stream-canvas/src/snapshot-writer.ts @@ -0,0 +1,87 @@ +import type { RoomSnapshot } from "@tldraw/sync-core"; +import { withDeadline } from "./deadline.ts"; + +/** Coalesce changes before constructing the full snapshot; flush bypasses the delay. */ +export class RoomSnapshotWriter { + private dirty = false; + private stopped = false; + private timer: NodeJS.Timeout | undefined; + private draining: Promise | null = null; + private readonly snapshot: () => RoomSnapshot; + private readonly persist: (snapshot: RoomSnapshot) => Promise; + private readonly label: string; + private readonly debounceMs: number; + + constructor( + snapshot: () => RoomSnapshot, + persist: (snapshot: RoomSnapshot) => Promise, + label: string, + debounceMs = 100, + ) { + this.snapshot = snapshot; + this.persist = persist; + this.label = label; + this.debounceMs = debounceMs; + } + + schedule(): void { + if (this.stopped) return; + this.dirty = true; + this.arm(this.debounceMs); + } + + private arm(delay: number): void { + if (this.timer || this.draining || this.stopped) return; + this.timer = setTimeout(() => { + this.timer = undefined; + void this.drain().catch((error) => { + console.error( + `[canvas] ${this.label} persistence failed; retrying`, + error, + ); + this.arm(1_000); + }); + }, delay); + this.timer.unref(); + } + + private drain(): Promise { + if (this.draining) return this.draining; + this.draining = (async () => { + while (this.dirty && !this.stopped) { + this.dirty = false; + try { + await this.persist(this.snapshot()); + } catch (error) { + this.dirty = true; + throw error; + } + } + })().finally(() => { + this.draining = null; + if (this.dirty) this.arm(1_000); + }); + return this.draining; + } + + async flush(timeoutMs = 15_000): Promise { + clearTimeout(this.timer); + this.timer = undefined; + try { + await withDeadline( + this.drain(), + timeoutMs, + `${this.label} snapshot flush`, + ); + } catch (error) { + this.arm(1_000); + throw error; + } + } + + stop(): void { + this.stopped = true; + clearTimeout(this.timer); + this.timer = undefined; + } +} diff --git a/backend/stream-canvas/src/types.ts b/backend/stream-canvas/src/types.ts index f2f2426..9e89b50 100644 --- a/backend/stream-canvas/src/types.ts +++ b/backend/stream-canvas/src/types.ts @@ -13,6 +13,7 @@ export interface ClerkClaims { /** Decoded claims from a short-lived OBS token. */ export interface ObsTokenClaims { roomId: string; + credentialVersion: string; role: "obs"; scope: "stream-canvas-ws"; exp: number; diff --git a/backend/stream-canvas/src/ws-handler.ts b/backend/stream-canvas/src/ws-handler.ts index f551381..14418a6 100644 --- a/backend/stream-canvas/src/ws-handler.ts +++ b/backend/stream-canvas/src/ws-handler.ts @@ -3,6 +3,7 @@ import { InMemorySyncStorage, type RoomSnapshot, TLSocketRoom, + TLSyncErrorCloseEventReason, } from "@tldraw/sync-core"; import { and, eq } from "drizzle-orm"; import type { TLRecord } from "tldraw"; @@ -15,9 +16,12 @@ import { import { config } from "./config.ts"; import { db } from "./db.ts"; import { leaderState } from "./leader.ts"; +import { obsCredentialVersion } from "./obs-secret.ts"; import { FixedWindowRateLimit, rateLimitKeyFromHeaders } from "./rate-limit.ts"; +import { withRoomOperation } from "./room-operations.ts"; import { isValidRoomId } from "./room-validation.ts"; import { canvasDocuments, roomMembers, rooms } from "./schema.ts"; +import { RoomSnapshotWriter } from "./snapshot-writer.ts"; import { streamCanvasSchema } from "./tldraw-schema.ts"; import type { ConnectionRole } from "./types.ts"; @@ -25,6 +29,10 @@ interface ActiveRoom { room: TLSocketRoom; storage: InMemorySyncStorage; writer: RoomSnapshotWriter; + sessions: Map; + config?: ReturnType; + idleTimer?: NodeJS.Timeout; + disposing?: Promise; } const activeRooms = new Map(); @@ -35,74 +43,21 @@ const wsFailureLimiter = new FixedWindowRateLimit({ max: 30, }); -class RoomSnapshotWriter { - private pending: RoomSnapshot | null = null; - private drainPromise: Promise | null = null; - private readonly roomId: string; - - constructor(roomId: string) { - this.roomId = roomId; - } - - schedule(snapshot: RoomSnapshot): void { - this.pending = snapshot; - if (!this.drainPromise) { - this.drainPromise = this.drain() - .catch(async (error) => { - console.error( - `[canvas] snapshot persistence failed for room ${this.roomId}; retrying`, - error, - ); - await new Promise((resolve) => setTimeout(resolve, 1_000)); - }) - .finally(() => { - this.drainPromise = null; - if (this.pending) this.schedule(this.pending); - }); - } - } - - async flush(snapshot?: RoomSnapshot): Promise { - if (snapshot) this.schedule(snapshot); - while (this.drainPromise) await this.drainPromise; - } - - private async drain(): Promise { - while (this.pending) { - const snapshot = this.pending; - this.pending = null; - try { - await db - .insert(canvasDocuments) - .values({ - roomId: this.roomId, - snapshot, - revision: snapshot.documentClock, - updatedAt: new Date(), - }) - .onConflictDoUpdate({ - target: canvasDocuments.roomId, - set: { - snapshot, - revision: snapshot.documentClock, - updatedAt: new Date(), - }, - }); - } catch (error) { - // Keep a newer pending snapshot if one arrived while this write ran. - this.pending ??= snapshot; - throw error; - } - } - } -} - async function getOrCreateRoom(roomId: string): Promise { const existing = activeRooms.get(roomId); - if (existing) return existing; + if (existing?.disposing) { + await existing.disposing; + return getOrCreateRoom(roomId); + } + if (existing) { + clearTimeout(existing.idleTimer); + return existing; + } const loading = roomLoads.get(roomId); if (loading) return loading; + if (activeRooms.size + roomLoads.size >= config.maxActiveRooms) + throw new Error("Active room capacity reached"); const epoch = roomEpoch; const promise = loadRoom(roomId, epoch).finally(() => @@ -119,45 +74,155 @@ async function loadRoom(roomId: string, epoch: number): Promise { if (epoch !== roomEpoch) { throw new Error("Room load cancelled during leadership handover"); } - const writer = new RoomSnapshotWriter(roomId); + const writer = new RoomSnapshotWriter( + () => storage.getSnapshot(), + (snapshot) => leaderState.persistSnapshot(roomId, snapshot), + `room ${roomId}`, + ); const storage = new InMemorySyncStorage({ ...(persisted ? { snapshot: persisted.snapshot as RoomSnapshot } : {}), onChange() { - writer.schedule(storage.getSnapshot()); + writer.schedule(); }, }); const room = new TLSocketRoom({ storage, schema: streamCanvasSchema, - onSessionRemoved(_room, { numSessionsRemaining }) { - if (numSessionsRemaining !== 0) return; - setTimeout(() => void disposeInactiveRoom(roomId), 30_000); + onBeforeSendMessage({ sessionId }) { + const session = active.sessions.get(sessionId); + if (!session || session.initialConfigSent) return; + if ( + !room + .getSessions() + .some((s) => s.sessionId === sessionId && s.isConnected) + ) + return; + session.initialConfigSent = true; + // The first connected send is the handshake response. Send configuration + // after that frame, using the latest policy if it changed during admission. + queueMicrotask(() => { + if (active.config) room.sendCustomMessage(sessionId, active.config); + }); + }, + onSessionRemoved(_room, { sessionId, numSessionsRemaining }) { + active.sessions.delete(sessionId); + if (numSessionsRemaining !== 0 || activeRooms.get(roomId) !== active) + return; + scheduleIdleDisposal(roomId, active); }, }); - const active = { room, storage, writer }; + const active: ActiveRoom = { room, storage, writer, sessions: new Map() }; activeRooms.set(roomId, active); return active; } -async function disposeInactiveRoom(roomId: string): Promise { +function scheduleIdleDisposal(roomId: string, active: ActiveRoom): void { + if ( + activeRooms.get(roomId) !== active || + active.room.getNumActiveSessions() !== 0 + ) + return; + clearTimeout(active.idleTimer); + active.idleTimer = setTimeout(() => { + if (activeRooms.get(roomId) !== active) return; + void disposeInactiveRoom(roomId).catch((error) => { + console.error("[canvas] idle flush failed", error); + scheduleIdleDisposal(roomId, active); + }); + }, 30_000); + active.idleTimer.unref(); +} + +export async function disposeInactiveRoom(roomId: string): Promise { const active = activeRooms.get(roomId); if (active?.room.getNumActiveSessions() !== 0) return; - activeRooms.delete(roomId); - await active.writer.flush(active.storage.getSnapshot()); - active.room.close(); + if (active.disposing) return active.disposing; + clearTimeout(active.idleTimer); + active.disposing = (async () => { + await active.writer.flush(); + if (activeRooms.get(roomId) !== active) return; + activeRooms.delete(roomId); + active.writer.stop(); + active.room.close(); + })().finally(() => { + active.disposing = undefined; + }); + return active.disposing; } -export async function closeAllRooms(): Promise { +export async function closeAllRooms(persist = true): Promise { roomEpoch += 1; - await Promise.allSettled([...roomLoads.values()]); const entries = [...activeRooms.values()]; activeRooms.clear(); - await Promise.all( - entries.map(async ({ room, storage, writer }) => { - await writer.flush(storage.getSnapshot()); - room.close(); + // Stop accepting edits before taking the final snapshot, including sessions + // whose WebSocket close handshake has not completed. + for (const active of entries) { + clearTimeout(active.idleTimer); + active.room.close(); + if (!persist) active.writer.stop(); + } + await Promise.allSettled([...roomLoads.values()]); + const results = await Promise.allSettled( + entries.map(async (active) => { + try { + if (persist) await active.writer.flush(); + } finally { + active.writer.stop(); + } }), ); + const failures = results.filter((result) => result.status === "rejected"); + if (failures.length) + throw new AggregateError( + failures.map((result) => result.reason), + "Canvas flush failed", + ); +} + +export function roomConfigChanged( + room: typeof rooms.$inferSelect, + allowedUsers?: string[], +): void { + const active = activeRooms.get(room.id); + if (!active) return; + active.config = roomConfigMessage(room); + for (const [sessionId, auth] of active.sessions) { + if ( + auth.role === "editor" && + allowedUsers && + auth.userId !== room.ownerClerkId && + !allowedUsers.includes(auth.userId ?? "") + ) { + active.room.closeSession( + sessionId, + TLSyncErrorCloseEventReason.FORBIDDEN, + ); + } else { + active.room.sendCustomMessage(sessionId, roomConfigMessage(room)); + } + } +} + +export function revokeObsSessions(roomId: string): void { + const active = activeRooms.get(roomId); + if (!active) return; + for (const [sessionId, auth] of active.sessions) { + if (auth.role === "obs") + active.room.closeSession( + sessionId, + TLSyncErrorCloseEventReason.FORBIDDEN, + ); + } +} + +function roomConfigMessage( + room: Pick, +) { + return { + type: "room-config", + twitchChannel: room.twitchChannel, + youtubePolicy: room.youtubePolicy, + }; } interface AuthResult { @@ -204,7 +269,11 @@ export async function authenticateWebSocketUpgrade( const room = await db.query.rooms.findFirst({ where: eq(rooms.id, roomId), }); - if (!room) return null; + if ( + !room || + obsClaims.credentialVersion !== obsCredentialVersion(room.obsSecret) + ) + return null; return { role: "obs", roomId }; } @@ -214,6 +283,26 @@ export async function authenticateWebSocketUpgrade( export async function handleWebSocketUpgrade( ws: WebSocket, req: IncomingMessage, +): Promise { + // This listener outlives admission and tldraw's session listeners. Receiver + // errors are EventEmitter events, not rejections of the admission promise. + ws.on("error", () => { + console.warn("[ws] connection failed"); + ws.terminate(); + }); + // The client can send its connect frame immediately after HTTP upgrade. + // Keep it buffered until asynchronous admission installs room listeners. + ws.pause(); + try { + await admitWebSocket(ws, req); + } finally { + ws.resume(); + } +} + +async function admitWebSocket( + ws: WebSocket, + req: IncomingMessage, ): Promise { if (!leaderState.isLeader) { ws.close(1012, "Canvas leader is changing"); @@ -226,48 +315,82 @@ export async function handleWebSocketUpgrade( return; } - const auth = await authenticateWebSocketUpgrade(req); - if (!auth) { - wsFailureLimiter.consume(failureKey); + const roomId = new URL( + req.url ?? "", + "http://stream-canvas.local", + ).searchParams.get("roomId"); + if (!roomId || !isValidRoomId(roomId)) { ws.close(4001, "Unauthorized"); return; } - if (!leaderState.isLeader) { - ws.close(1012, "Canvas leader is changing"); - return; - } - - if ( - !activeRooms.has(auth.roomId) && - activeRooms.size >= config.maxActiveRooms - ) { - ws.close(1013, "Too many active rooms"); - return; - } - - let room: TLSocketRoom; - try { - ({ room } = await getOrCreateRoom(auth.roomId)); - } catch (error) { + await withRoomOperation(roomId, async () => { + const auth = await authenticateWebSocketUpgrade(req); + if (!auth) { + wsFailureLimiter.consume(failureKey); + ws.close(4001, "Unauthorized"); + return; + } if (!leaderState.isLeader) { ws.close(1012, "Canvas leader is changing"); return; } - throw error; - } - if (!leaderState.isLeader) { - ws.close(1012, "Canvas leader is changing"); - return; - } - if (room.getNumActiveSessions() >= config.maxWsSessionsPerRoom) { - ws.close(1013, "Room session limit reached"); - return; - } - room.handleSocketConnect({ - sessionId: crypto.randomUUID(), - socket: ws, - isReadonly: auth.role === "obs", + if ( + !activeRooms.has(auth.roomId) && + !roomLoads.has(auth.roomId) && + activeRooms.size + roomLoads.size >= config.maxActiveRooms + ) { + ws.close(1013, "Too many active rooms"); + return; + } + + let active: ActiveRoom; + try { + active = await getOrCreateRoom(auth.roomId); + } catch (error) { + if (!leaderState.isLeader) { + ws.close(1012, "Canvas leader is changing"); + return; + } + throw error; + } + try { + if (!leaderState.isLeader) { + ws.close(1012, "Canvas leader is changing"); + return; + } + const { room } = active; + const metadata = await db.query.rooms.findFirst({ + where: eq(rooms.id, auth.roomId), + }); + if ( + !metadata || + !leaderState.isLeader || + activeRooms.get(auth.roomId) !== active || + room.isClosed() || + ws.readyState !== ws.OPEN + ) { + ws.close(1012, "Canvas leader is changing"); + return; + } + clearTimeout(active.idleTimer); + if (room.getNumActiveSessions() >= config.maxWsSessionsPerRoom) { + ws.close(1013, "Room session limit reached"); + return; + } + + const sessionId = crypto.randomUUID(); + active.config = roomConfigMessage(metadata); + active.sessions.set(sessionId, auth); + room.handleSocketConnect({ + sessionId, + socket: ws, + isReadonly: auth.role === "obs", + }); + } finally { + // Failed admission has no session-removal callback to rearm cleanup. + scheduleIdleDisposal(auth.roomId, active); + } }); } diff --git a/backend/stream-canvas/tsconfig.test.json b/backend/stream-canvas/tsconfig.test.json new file mode 100644 index 0000000..495c6f9 --- /dev/null +++ b/backend/stream-canvas/tsconfig.test.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { "noEmit": true }, + "include": ["src/**/*.ts"], + "exclude": [] +} diff --git a/biome.json b/biome.json index 21a74eb..8a0423e 100644 --- a/biome.json +++ b/biome.json @@ -53,7 +53,9 @@ "**", "!.next", "!build", - "!dist", + "!**/dist", + "!test-results", + "!playwright-report", "!node_modules", "!convex/_generated", "!backend/stream-canvas/data", diff --git a/components/__tests__/recovery.test.tsx b/components/__tests__/recovery.test.tsx new file mode 100644 index 0000000..f5a7ebd --- /dev/null +++ b/components/__tests__/recovery.test.tsx @@ -0,0 +1,103 @@ +// @vitest-environment jsdom +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import { UserRecordBootstrap } from "../user-record-bootstrap"; +import { UserMultiSelect } from "../stream-canvas/UserMultiSelect"; +import { TwitchPreview } from "../stream-canvas/TwitchPreview"; + +const state = vi.hoisted(() => ({ bootstrap: vi.fn(), authenticated: true })); +vi.mock("convex/react", () => ({ + useConvexAuth: () => ({ isAuthenticated: state.authenticated }), + useMutation: () => state.bootstrap, + useQuery: () => [ + { userId: "user_ava", username: "Ava" }, + { userId: "user_ben", username: "Ben" }, + ], +})); +vi.mock("sonner", () => ({ toast: { error: vi.fn() } })); +vi.mock("../stream-canvas/media-preferences", () => ({ + useMediaPreference: () => ({ enabled: false, volume: 1 }), +})); +beforeEach(() => { + vi.useFakeTimers(); + state.bootstrap.mockReset(); + state.authenticated = true; +}); +afterEach(() => { + cleanup(); + vi.useRealTimers(); + delete window.Twitch; + document.querySelectorAll("script").forEach((script) => { + script.remove(); + }); +}); + +test("profile bootstrap retries transient failures and cancels after sign out", async () => { + state.bootstrap + .mockRejectedValueOnce(new Error("temporary")) + .mockResolvedValue({}); + const { rerender } = render(); + await act(async () => {}); + await act(() => vi.advanceTimersByTimeAsync(1_000)); + expect(state.bootstrap).toHaveBeenCalledTimes(2); + state.authenticated = false; + rerender(); + await act(() => vi.advanceTimersByTimeAsync(10_000)); + expect(state.bootstrap).toHaveBeenCalledTimes(2); +}); + +test("collaborator selection follows arrow keys and Enter without submitting settings", () => { + const select = vi.fn(); + const submit = vi.fn((event: React.FormEvent) => event.preventDefault()); + render( +
+ + , + ); + const input = screen.getByRole("combobox"); + fireEvent.change(input, { target: { value: "a" } }); + fireEvent.keyDown(input, { key: "ArrowDown" }); + expect(input.getAttribute("aria-activedescendant")).toBe("members-results-1"); + fireEvent.keyDown(input, { key: "Enter" }); + expect(select).toHaveBeenCalledWith(["user_ben"]); + expect(submit).not.toHaveBeenCalled(); +}); + +test("failed Twitch script is removed and visible retry initializes a fresh preview", async () => { + render( + , + ); + const first = document.querySelector("script"); + expect(first).not.toBeNull(); + await act(async () => first?.dispatchEvent(new Event("error"))); + expect(first?.isConnected).toBe(false); + fireEvent.click(screen.getByRole("button", { name: "Retry Twitch preview" })); + const second = document.querySelector("script"); + expect(second).not.toBe(first); + const mounted = vi.fn(); + class Embed { + static VIDEO = "video"; + static VIDEO_READY = "ready"; + constructor() { + mounted(); + } + addEventListener() {} + getPlayer() { + return { setMuted() {}, setVolume() {} }; + } + } + window.Twitch = { Embed }; + await act(async () => second?.dispatchEvent(new Event("load"))); + expect(mounted).toHaveBeenCalledOnce(); + expect(screen.queryByRole("alert")).toBeNull(); +}); diff --git a/components/__tests__/room-settings.test.tsx b/components/__tests__/room-settings.test.tsx new file mode 100644 index 0000000..50cf92d --- /dev/null +++ b/components/__tests__/room-settings.test.tsx @@ -0,0 +1,84 @@ +// @vitest-environment jsdom +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, expect, test, vi } from "vitest"; +import Settings from "@/app/app/settings/page"; +import RoomPage from "@/app/app/rooms/[roomId]/page"; + +const state = vi.hoisted(() => ({ + token: vi.fn(async () => "test-token"), + create: vi.fn(), + update: vi.fn(), + list: vi.fn(), + error: vi.fn(), + success: vi.fn(), +})); +vi.mock("@clerk/nextjs", () => ({ + useAuth: () => ({ isLoaded: true, isSignedIn: true, getToken: state.token }), + useClerk: () => ({}), +})); +vi.mock("next/navigation", () => ({ + useParams: () => ({ roomId: "room-fixture" }), +})); +vi.mock("sonner", () => ({ + toast: { error: state.error, success: state.success }, +})); +vi.mock("@/components/stream-canvas/UserMultiSelect", () => ({ + UserMultiSelect: () =>
Collaborators
, +})); +vi.mock("@/components/stream-canvas/CanvasEditor", () => ({ + CanvasEditor: () =>
Editor connected
, +})); +vi.mock("@/lib/stream-canvas/api", () => ({ + createRoom: state.create, + updateRoom: state.update, + getAccessibleRooms: state.list, + regenerateSecret: vi.fn(), +})); +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); +const room = { + id: "room-fixture", + allowedUsers: [], + twitchChannel: null, + youtubePolicy: "preview_only", +}; + +test("room metadata failure is visible and retry opens the editor", async () => { + state.list + .mockRejectedValueOnce(new Error("Room info unavailable")) + .mockResolvedValue([room]); + render(); + expect((await screen.findByRole("alert")).textContent).toContain( + "Room info unavailable", + ); + fireEvent.click( + screen.getByRole("button", { name: "Retry room connection" }), + ); + expect(await screen.findByText("Editor connected")).toBeTruthy(); +}); + +test("failed settings submission stays retryable and reports success only after saving", async () => { + state.create.mockResolvedValue(room); + state.update + .mockRejectedValueOnce(new Error("Save unavailable")) + .mockResolvedValue(room); + render(); + const save = await screen.findByRole("button", { name: "Save changes" }); + fireEvent.click(save); + await waitFor(() => + expect(state.error).toHaveBeenCalledWith("Save unavailable"), + ); + expect(state.success).not.toHaveBeenCalled(); + fireEvent.click(save); + await waitFor(() => + expect(state.success).toHaveBeenCalledWith("Settings saved"), + ); +}); diff --git a/components/stream-canvas/CanvasEditor.tsx b/components/stream-canvas/CanvasEditor.tsx index 19c788f..55de74f 100644 --- a/components/stream-canvas/CanvasEditor.tsx +++ b/components/stream-canvas/CanvasEditor.tsx @@ -25,6 +25,7 @@ import { DefaultToolbar, DiamondToolbarItem, DrawToolbarItem, + defaultHandleExternalEmbedContent, EditSubmenu, EllipseToolbarItem, EraserToolbarItem, @@ -70,6 +71,7 @@ import { uploadFile, } from "@/lib/stream-canvas/api"; import { getSyncedMediaPlaybackPosition } from "@/lib/stream-canvas/media-playback"; +import { readRoomConfigMessage } from "@/lib/stream-canvas/room-config"; import { getStreamZoneViewportPlacement, STREAM_ZONE, @@ -91,10 +93,12 @@ import { syncShapeUtils, } from "./shapes/shared"; import { + extractYouTubeId, type YouTubeEmbedShape, YouTubeInteractionCtx, YouTubePolicyCtx, } from "./shapes/youtube/YouTubeEmbedShape"; +import { TwitchPreview } from "./TwitchPreview"; const TLDRAW_LICENSE_KEY = process.env.NEXT_PUBLIC_TLDRAW_LICENSE_KEY; @@ -104,132 +108,7 @@ interface CanvasEditorProps { roomId: string; twitchChannel?: string | null; youtubePolicy: YouTubePolicy; -} - -interface TwitchPlayer { - setMuted(muted: boolean): void; - setVolume(volume: number): void; -} - -interface TwitchEmbedInstance { - addEventListener(event: string, callback: () => void): void; - getPlayer(): TwitchPlayer; -} - -interface TwitchEmbedConstructor { - new ( - element: HTMLElement, - options: Record, - ): TwitchEmbedInstance; - VIDEO: string; - VIDEO_READY: string; -} - -declare global { - interface Window { - Twitch?: { Embed: TwitchEmbedConstructor }; - } -} - -let twitchEmbedScriptPromise: Promise | null = null; - -function loadTwitchEmbed(): Promise { - if (window.Twitch?.Embed) return Promise.resolve(window.Twitch.Embed); - if (twitchEmbedScriptPromise) return twitchEmbedScriptPromise; - const promise = new Promise((resolve, reject) => { - const existing = document.querySelector( - 'script[src="https://embed.twitch.tv/embed/v1.js"]', - ); - const script = existing ?? document.createElement("script"); - const handleLoad = () => { - if (window.Twitch?.Embed) resolve(window.Twitch.Embed); - else reject(new Error("Twitch embed API did not initialize")); - }; - script.addEventListener("load", handleLoad, { once: true }); - script.addEventListener( - "error", - () => reject(new Error("Failed to load Twitch embed API")), - { once: true }, - ); - if (!existing) { - script.src = "https://embed.twitch.tv/embed/v1.js"; - script.async = true; - document.head.appendChild(script); - } - }).catch((error) => { - twitchEmbedScriptPromise = null; - throw error; - }); - twitchEmbedScriptPromise = promise; - return promise; -} - -function TwitchPreview({ - channel, - hostname, - interactive, -}: { - channel: string; - hostname: string; - interactive: boolean; -}) { - const hostRef = useRef(null); - const playerRef = useRef(null); - const preference = useMediaPreference("twitch-preview"); - const preferenceRef = useRef({ - enabled: preference.enabled, - volume: preference.volume, - }); - preferenceRef.current = { - enabled: preference.enabled, - volume: preference.volume, - }; - - useEffect(() => { - const host = hostRef.current; - if (!host) return; - let cancelled = false; - host.replaceChildren(); - void loadTwitchEmbed() - .then((Embed) => { - if (cancelled || !hostRef.current) return; - const embed = new Embed(hostRef.current, { - width: "100%", - height: "100%", - channel, - parent: [hostname], - layout: Embed.VIDEO, - autoplay: true, - muted: true, - }); - embed.addEventListener(Embed.VIDEO_READY, () => { - if (cancelled) return; - playerRef.current = embed.getPlayer(); - playerRef.current.setVolume(preferenceRef.current.volume); - playerRef.current.setMuted(!preferenceRef.current.enabled); - }); - }) - .catch((error) => console.error("[twitch] embed failed", error)); - return () => { - cancelled = true; - playerRef.current = null; - host.replaceChildren(); - }; - }, [channel, hostname]); - - useEffect(() => { - playerRef.current?.setVolume(preference.volume); - playerRef.current?.setMuted(!preference.enabled); - }, [preference.enabled, preference.volume]); - - return ( -
- ); + onMount?: import("tldraw").TLOnMountHandler; } /** @@ -805,9 +684,14 @@ function YouTubeInteractionController() { export function CanvasEditor({ roomId, - twitchChannel, - youtubePolicy, + twitchChannel: initialTwitchChannel, + youtubePolicy: initialYouTubePolicy, + onMount, }: CanvasEditorProps) { + const [{ twitchChannel, youtubePolicy }, setRoomConfig] = useState({ + twitchChannel: initialTwitchChannel ?? null, + youtubePolicy: initialYouTubePolicy, + }); const { getToken, userId } = useAuth(); const [interactiveShapeId, setInteractiveShapeId] = useState( null, @@ -861,6 +745,8 @@ export function CanvasEditor({ getToken, resolveUrl: (src: string, options?: { forceRefresh?: boolean }) => resolveEditorUploadUrl(roomId, src, getToken, options), + getRefreshDelayMs: (src: string) => + getEditorUploadUrlRefreshDelayMs(roomId, src), }), [roomId, getToken], ); @@ -894,6 +780,10 @@ export function CanvasEditor({ uri: getUri, assets, shapeUtils: syncShapeUtils, + onCustomMessageReceived(data: unknown) { + const roomConfig = readRoomConfigMessage(data); + if (roomConfig) setRoomConfig(roomConfig); + }, }); if (storeWithStatus.status === "loading") { @@ -920,6 +810,30 @@ export function CanvasEditor({ { + editor.registerExternalContentHandler( + "embed", + (content) => { + if (extractYouTubeId(content.url)) { + const point = + content.point ?? + editor.getViewportPageBounds().center; + editor.createShape({ + type: "youtube-embed", + x: point.x - 240, + y: point.y - 135, + props: { url: content.url, w: 480, h: 270 }, + }); + return; + } + return defaultHandleExternalEmbedContent( + editor, + content, + ); + }, + ); + return onMount?.(editor); + }} store={storeWithStatus.store} shapeUtils={customShapeUtils} tools={editorTools} diff --git a/components/stream-canvas/CanvasMirror.tsx b/components/stream-canvas/CanvasMirror.tsx index 73e2e1b..d49efe7 100644 --- a/components/stream-canvas/CanvasMirror.tsx +++ b/components/stream-canvas/CanvasMirror.tsx @@ -11,12 +11,13 @@ import { import "tldraw/tldraw.css"; import { buildObsWsUrl, - exchangeObsToken, + exchangeObsTokenWithRetry as exchangeObsToken, getObsUploadUrlRefreshDelayMs, resolveObsUploadUrl, } from "@/lib/stream-canvas/api"; import { STREAM_ZONE } from "@/lib/stream-canvas/stream-zone"; import type { YouTubePolicy } from "@/lib/stream-canvas/types"; +import { readRoomConfigMessage } from "@/lib/stream-canvas/room-config"; import { AudioUploadCtx } from "./shapes/audio/AudioPlayerShape"; import { CanvasMediaRefreshContext, @@ -72,7 +73,21 @@ function OBSSetup() { return null; } -export function CanvasMirror({ obsSecret }: CanvasMirrorProps) { +export function CanvasMirror(props: CanvasMirrorProps) { + const [attempt, setAttempt] = useState(0); + return ( + setAttempt((value) => value + 1)} + /> + ); +} + +function ConnectedCanvasMirror({ + obsSecret, + retry, +}: CanvasMirrorProps & { retry: () => void }) { const [error, setError] = useState(null); const [ready, setReady] = useState(false); const [roomId, setRoomId] = useState(null); @@ -143,6 +158,8 @@ export function CanvasMirror({ obsSecret }: CanvasMirrorProps) { getToken: async () => null, resolveUrl: (src: string, options?: { forceRefresh?: boolean }) => resolveObsUploadUrl(src, obsSecret, options), + getRefreshDelayMs: (src: string) => + getObsUploadUrlRefreshDelayMs(src), } : null, [obsSecret, roomId], @@ -158,12 +175,26 @@ export function CanvasMirror({ obsSecret }: CanvasMirrorProps) { uri: getUri, assets, shapeUtils: syncShapeUtils, + onCustomMessageReceived(data: unknown) { + const roomConfig = readRoomConfigMessage(data); + if (roomConfig) setYouTubePolicy(roomConfig.youtubePolicy); + }, }); if (error) { return ( -
+
{error} +
); } @@ -180,6 +211,13 @@ export function CanvasMirror({ obsSecret }: CanvasMirrorProps) { return (
OBS connection error. Refresh the source or regenerate the OBS URL. +
); } diff --git a/components/stream-canvas/MediaInspectorPanel.tsx b/components/stream-canvas/MediaInspectorPanel.tsx index 19e45aa..73529c9 100644 --- a/components/stream-canvas/MediaInspectorPanel.tsx +++ b/components/stream-canvas/MediaInspectorPanel.tsx @@ -241,6 +241,7 @@ function ToggleRow({ void): void; + getPlayer(): TwitchPlayer; +} + +interface TwitchEmbedConstructor { + new ( + element: HTMLElement, + options: Record, + ): TwitchEmbedInstance; + VIDEO: string; + VIDEO_READY: string; +} + +declare global { + interface Window { + Twitch?: { Embed: TwitchEmbedConstructor }; + } +} + +let twitchEmbedScriptPromise: Promise | null = null; + +export function loadTwitchEmbed(): Promise { + if (window.Twitch?.Embed) return Promise.resolve(window.Twitch.Embed); + if (twitchEmbedScriptPromise) return twitchEmbedScriptPromise; + const promise = new Promise((resolve, reject) => { + const existing = document.querySelector( + 'script[src="https://embed.twitch.tv/embed/v1.js"]', + ); + const script = existing ?? document.createElement("script"); + const cleanup = () => { + clearTimeout(timeout); + script.removeEventListener("load", handleLoad); + script.removeEventListener("error", handleError); + }; + const fail = (message: string) => { + cleanup(); + script.remove(); + reject(new Error(message)); + }; + const handleLoad = () => { + if (window.Twitch?.Embed) { + cleanup(); + resolve(window.Twitch.Embed); + } else fail("Twitch embed API did not initialize"); + }; + const handleError = () => fail("Failed to load Twitch embed API"); + const timeout = setTimeout( + () => fail("Twitch embed API timed out"), + 10_000, + ); + script.addEventListener("load", handleLoad, { once: true }); + script.addEventListener("error", handleError, { once: true }); + if (!existing) { + script.src = "https://embed.twitch.tv/embed/v1.js"; + script.async = true; + document.head.appendChild(script); + } + }).catch((error) => { + twitchEmbedScriptPromise = null; + throw error; + }); + twitchEmbedScriptPromise = promise; + return promise; +} + +export function TwitchPreview({ + channel, + hostname, + interactive, +}: { + channel: string; + hostname: string; + interactive: boolean; +}) { + const [error, setError] = useState(null); + const [attempt, setAttempt] = useState(0); + const hostRef = useRef(null); + const playerRef = useRef(null); + const preference = useMediaPreference("twitch-preview"); + const preferenceRef = useRef({ + enabled: preference.enabled, + volume: preference.volume, + }); + preferenceRef.current = { + enabled: preference.enabled, + volume: preference.volume, + }; + + // biome-ignore lint/correctness/useExhaustiveDependencies: attempt explicitly retries script initialization + useEffect(() => { + const host = hostRef.current; + if (!host) return; + let cancelled = false; + host.replaceChildren(); + setError(null); + void loadTwitchEmbed() + .then((Embed) => { + if (cancelled || !hostRef.current) return; + const embed = new Embed(hostRef.current, { + width: "100%", + height: "100%", + channel, + parent: [hostname], + layout: Embed.VIDEO, + autoplay: true, + muted: true, + }); + embed.addEventListener(Embed.VIDEO_READY, () => { + if (cancelled) return; + playerRef.current = embed.getPlayer(); + playerRef.current.setVolume(preferenceRef.current.volume); + playerRef.current.setMuted(!preferenceRef.current.enabled); + }); + }) + .catch((error: unknown) => { + if (!cancelled) + setError( + error instanceof Error ? error.message : "Twitch preview failed", + ); + }); + return () => { + cancelled = true; + playerRef.current = null; + host.replaceChildren(); + }; + }, [channel, hostname, attempt]); + + useEffect(() => { + playerRef.current?.setVolume(preference.volume); + playerRef.current?.setMuted(!preference.enabled); + }, [preference.enabled, preference.volume]); + + return ( +
+
+ {error && ( +
+

{error}

+ +
+ )} +
+ ); +} diff --git a/components/stream-canvas/UserMultiSelect.tsx b/components/stream-canvas/UserMultiSelect.tsx index 9e8e86e..d165f7c 100644 --- a/components/stream-canvas/UserMultiSelect.tsx +++ b/components/stream-canvas/UserMultiSelect.tsx @@ -27,6 +27,7 @@ export function UserMultiSelect({ }: UserMultiSelectProps) { const [search, setSearch] = useState(""); const [open, setOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(0); const generatedId = useId(); const listboxId = `${inputId ?? generatedId}-results`; const inputRef = useRef(null); @@ -85,6 +86,10 @@ export function UserMultiSelect({ results?.filter((user) => !value.includes(user.userId)) ?? []; const hasSearch = search.trim().length > 0; const showResults = open && hasSearch; + const activeOptionIndex = Math.min( + activeIndex, + Math.max(0, filteredResults.length - 1), + ); return (
@@ -140,15 +145,33 @@ export function UserMultiSelect({ aria-autocomplete="list" aria-expanded={showResults} aria-controls={listboxId} + aria-activedescendant={ + showResults && filteredResults.length + ? `${listboxId}-${activeOptionIndex}` + : undefined + } value={search} onChange={(event) => { setSearch(event.target.value); + setActiveIndex(0); setOpen(true); }} onFocus={() => { if (hasSearch) setOpen(true); }} onKeyDown={(event) => { + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + setOpen(true); + const count = filteredResults.length; + if (count) + setActiveIndex( + (activeOptionIndex + + (event.key === "ArrowDown" ? 1 : -1) + + count) % + count, + ); + } if (event.key === "Escape") { setOpen(false); } @@ -158,7 +181,7 @@ export function UserMultiSelect({ if (event.key === "Enter") { event.preventDefault(); if (showResults && filteredResults.length > 0) { - handleSelect(filteredResults[0]); + handleSelect(filteredResults[activeOptionIndex]); } } }} @@ -207,14 +230,20 @@ export function UserMultiSelect({
) : (
- {filteredResults.map((entry) => ( + {filteredResults.map((entry, index) => (
+ ) : null} {!isReadonly && !isInteractive ? ( + ); +} + +test.each(["read", "write"])( + "unavailable storage on %s keeps monitoring usable in memory", + (failure) => { + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + if (failure === "read") + throw new DOMException("Storage blocked", "SecurityError"); + return null; + }); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new DOMException("Storage full", "QuotaExceededError"); + }); + render( + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "Monitoring off" })); + expect(screen.getByRole("button", { name: "Monitoring on" })).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Monitoring on" })); + expect(screen.getByRole("button", { name: "Monitoring off" })).toBeTruthy(); + }, +); diff --git a/lib/stream-canvas/__tests__/upload-url-refresh.test.ts b/lib/stream-canvas/__tests__/upload-url-refresh.test.ts index cf44648..bd9d279 100644 --- a/lib/stream-canvas/__tests__/upload-url-refresh.test.ts +++ b/lib/stream-canvas/__tests__/upload-url-refresh.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { + CANVAS_API, getEditorUploadUrlRefreshDelayMs, resolveEditorUploadUrl, } from "@/lib/stream-canvas/api"; @@ -28,13 +29,19 @@ describe("upload URL refresh timing", () => { expect(getEditorUploadUrlRefreshDelayMs(roomId, src)).toBeNull(); const resolved = await resolveEditorUploadUrl(roomId, src, getToken); - expect(resolved).toBe( - `https://moddrop-stream-canvas.localhost:1355${src}?token=signed-token`, - ); + expect(resolved).toBe(`${CANVAS_API}${src}?token=signed-token`); const delay = getEditorUploadUrlRefreshDelayMs(roomId, src); expect(delay).toBeGreaterThan(0); expect(delay).toBeLessThanOrEqual(30_000); + expect(fetch).toHaveBeenCalledWith( + `${CANVAS_API}/api/rooms/${roomId}/uploads/${uploadId}/access-url`, + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer clerk-token", + }), + }), + ); }); it("does not schedule refreshes for external media URLs", () => { diff --git a/lib/stream-canvas/api.ts b/lib/stream-canvas/api.ts index 507bfea..8819d63 100644 --- a/lib/stream-canvas/api.ts +++ b/lib/stream-canvas/api.ts @@ -29,11 +29,11 @@ function normalizeConfiguredCanvasApi( if ( !value || value === "http://placeholder.canvas.local" || - value === "__NEXT_PUBLIC_CANVAS_API_URL__" + /^__[A-Z_]+__$/.test(value) ) { return undefined; } - return value; + return value.replace(/\/+$/, ""); } async function getClerkToken( @@ -170,13 +170,42 @@ export async function exchangeObsToken(secret: string): Promise<{ if (!res.ok) { const body = await res.json().catch(() => ({})); - throw new Error( + throw new ObsTokenExchangeError( + res.status, (body as { error?: string }).error ?? `Token exchange failed`, ); } return res.json(); } +export class ObsTokenExchangeError extends Error { + constructor( + readonly status: number, + message: string, + ) { + super(message); + } +} + +export async function exchangeObsTokenWithRetry(secret: string) { + for (let attempt = 0; ; attempt++) { + try { + return await exchangeObsToken(secret); + } catch (error) { + if ( + attempt >= 2 || + (error instanceof ObsTokenExchangeError && + error.status < 500 && + error.status !== 429) + ) + throw error; + await new Promise((resolve) => + setTimeout(resolve, (attempt + 1) * 1_000), + ); + } + } +} + /** Mint a short-lived OBS access URL for uploaded room media. */ export async function getObsUploadAccessUrl( uploadId: string, @@ -292,7 +321,7 @@ async function resolveCachedUploadAccessUrl( ) { return cached.url; } - if (!options.forceRefresh && cached?.pending) { + if (cached?.pending) { return cached.pending; } diff --git a/lib/stream-canvas/room-config.ts b/lib/stream-canvas/room-config.ts new file mode 100644 index 0000000..fee821f --- /dev/null +++ b/lib/stream-canvas/room-config.ts @@ -0,0 +1,28 @@ +import type { YouTubePolicy } from "./types"; + +export interface LiveRoomConfig { + twitchChannel: string | null; + youtubePolicy: YouTubePolicy; +} + +export function readRoomConfigMessage(value: unknown): LiveRoomConfig | null { + if ( + !value || + typeof value !== "object" || + !("type" in value) || + value.type !== "room-config" || + !("twitchChannel" in value) || + !( + value.twitchChannel === null || typeof value.twitchChannel === "string" + ) || + !("youtubePolicy" in value) || + !["disabled", "preview_only", "allow_on_air"].includes( + String(value.youtubePolicy), + ) + ) + return null; + return { + twitchChannel: value.twitchChannel, + youtubePolicy: value.youtubePolicy as YouTubePolicy, + }; +} diff --git a/package.json b/package.json index 27dbd10..f2396d7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "moddrop", "private": true, - "version": "0.6.1", + "version": "0.6.2", "type": "module", "packageManager": "pnpm@11.17.0+sha512.cca3cea332ad254bb84145f966d19f4879615210346fc92c79a047f23a0d7b3cca3c3792f0076ba1f1831d277efbcf0a9119b31a9a60eca7fb3d6231f331ef72", "scripts": { @@ -19,6 +19,8 @@ "start": "infisical run --silent --domain=http://192.168.60.11:8080 --projectId=b474ca10-dd7c-48d7-823c-ff9b92c6ccc1 --env=prod --path=/ -- next start", "typecheck": "tsc --noEmit", "test": "vitest run", + "test:browser": "playwright test", + "test:runtime-config": "node scripts/test-runtime-config.ts", "lint": "biome lint .", "format": "biome format --write .", "convex:gen": "CONVEX_AGENT_MODE=anonymous pnpm exec convex codegen", @@ -29,7 +31,7 @@ "@clerk/themes": "^2.4.57", "@tldraw/sync": "^5.2.5", "clsx": "^2.1.1", - "convex": "^1.42.3", + "convex": "^1.45.0", "lucide-react": "^1.26.0", "next": "16.2.11", "react": "19.2.8", @@ -40,15 +42,23 @@ }, "devDependencies": { "@biomejs/biome": "^2.5.5", + "@edge-runtime/vm": "^5.0.0", + "@playwright/test": "^1.62.1", "@tailwindcss/postcss": "4.3.3", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.3", "@types/node": "^26.1.1", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@typescript/native": "npm:typescript@7.0.2", "concurrently": "^10.0.3", + "convex-test": "^0.0.56", + "jsdom": "^30.0.1", "tailwindcss": "4.3.3", "typescript": "npm:@typescript/typescript6@^6.0.2", - "vitest": "^4.1.10" + "vite": "^8.2.2", + "vitest": "^4.1.10", + "yaml": "^2.9.0" }, "engines": { "node": "26.5.x", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..4b27960 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + timeout: 45_000, + workers: 1, + use: { + baseURL: "http://127.0.0.1:4310", + viewport: { width: 1280, height: 900 }, + screenshot: "only-on-failure", + }, + webServer: { + command: "exec node e2e/server.ts", + url: "http://127.0.0.1:4310", + reuseExistingServer: false, + timeout: 60_000, + gracefulShutdown: { signal: "SIGTERM", timeout: 35_000 }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd13c3c..6eed882 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,7 +17,7 @@ importers: dependencies: '@clerk/nextjs': specifier: ^7.6.1 - version: 7.6.1(next@16.2.11(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 7.6.1(next@16.2.11(@playwright/test@1.62.1)(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@clerk/themes': specifier: ^2.4.57 version: 2.4.57(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -28,14 +28,14 @@ importers: specifier: ^2.1.1 version: 2.1.1 convex: - specifier: ^1.42.3 - version: 1.42.3(@clerk/react@6.12.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) + specifier: ^1.45.0 + version: 1.45.0(@clerk/react@6.12.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) lucide-react: specifier: ^1.26.0 version: 1.26.0(react@19.2.8) next: specifier: 16.2.11 - version: 16.2.11(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 16.2.11(@playwright/test@1.62.1)(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: specifier: 19.2.8 version: 19.2.8 @@ -55,9 +55,21 @@ importers: '@biomejs/biome': specifier: ^2.5.5 version: 2.5.5 + '@edge-runtime/vm': + specifier: ^5.0.0 + version: 5.0.0 + '@playwright/test': + specifier: ^1.62.1 + version: 1.62.1 '@tailwindcss/postcss': specifier: 4.3.3 version: 4.3.3 + '@testing-library/dom': + specifier: ^10.4.1 + version: 10.4.1 + '@testing-library/react': + specifier: ^16.3.3 + version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@types/node': specifier: ^26.1.1 version: 26.1.1 @@ -73,15 +85,27 @@ importers: concurrently: specifier: ^10.0.3 version: 10.0.3 + convex-test: + specifier: ^0.0.56 + version: 0.0.56(convex@1.45.0(@clerk/react@6.12.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)) + jsdom: + specifier: ^30.0.1 + version: 30.0.1 tailwindcss: specifier: 4.3.3 version: 4.3.3 typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' + vite: + specifier: ^8.2.2 + version: 8.2.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) vitest: specifier: ^4.1.10 - version: 4.1.10(@types/node@26.1.1)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) + version: 4.1.10(@edge-runtime/vm@5.0.0)(@types/node@26.1.1)(jsdom@30.0.1)(vite@8.2.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + yaml: + specifier: ^2.9.0 + version: 2.9.0 backend/stream-canvas: dependencies: @@ -90,7 +114,7 @@ importers: version: 3.13.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@hono/node-server': specifier: ^2.0.11 - version: 2.0.11(hono@4.12.32) + version: 2.0.11(hono@4.13.7) '@tldraw/sync-core': specifier: ^5.2.5 version: 5.2.5(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -98,8 +122,8 @@ importers: specifier: ^0.45.2 version: 0.45.2(@types/pg@8.20.0)(pg@8.22.0) hono: - specifier: ^4.12.32 - version: 4.12.32 + specifier: ^4.13.7 + version: 4.13.7 minio: specifier: 8.0.7 version: 8.0.7 @@ -141,6 +165,26 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@asamuzakjp/css-color@6.0.7': + resolution: {integrity: sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==} + engines: {node: ^22.13.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@8.3.2': + resolution: {integrity: sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==} + engines: {node: ^22.13.0 || >=24.0.0} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@biomejs/biome@2.5.5': resolution: {integrity: sha512-r1S8nFsAG1MY+vJFZALzIvwXAJv6ejDQ0mxP21Tgr9YK3ZFtjrvbBwDdNhx1rUqvccEIeNg20cYCNzl6Cr69pQ==} engines: {node: '>=14.21.3'} @@ -198,6 +242,10 @@ packages: cpu: [x64] os: [win32] + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@clerk/backend@3.13.1': resolution: {integrity: sha512-iFsPemW1862vnciPC8qpherSG4M4/vnwCNGzZ9waZtco+h5UIDP8qXBruAbo/JYA3Z3DpSaFjhoakelONWku7Q==} engines: {node: '>=20.9.0'} @@ -245,21 +293,56 @@ packages: resolution: {integrity: sha512-Nb3bO79rMTU/MPVTC/dde6LG27/IgOMKIYi5KSvAmO4ZUHlj0OWufu6CMvz5OYVZ0YdyMnTBU2aPGRUiRzO+2w==} engines: {node: '>=18.17.0'} + '@csstools/color-helpers@6.1.1': + resolution: {integrity: sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.3.0': + resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.2.2': + resolution: {integrity: sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.12': + resolution: {integrity: sha512-3vLQK+dXxhBMR2Wx99PTCifE+vHtW2ndZWyla8yK813ev6oGhyn8Lja8jCyGAWTJ+LEYZK7EVtJxrDj8ztevJw==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} - '@emnapi/core@1.11.1': - resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + '@edge-runtime/primitives@6.0.0': + resolution: {integrity: sha512-FqoxaBT+prPBHBwE1WXS1ocnu/VLTQyZ6NMUBAdbP7N2hsFTTxMC/jMu2D/8GAlMQfxeuppcPuCUk/HO3fpIvA==} + engines: {node: '>=18'} - '@emnapi/runtime@1.11.1': - resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + '@edge-runtime/vm@5.0.0': + resolution: {integrity: sha512-NKBGBSIKUG584qrS1tyxVpX/AKJKQw5HgjYEnPLC0QsTw79JrGn+qUr8CXFb955Iy7GUdiiUv1rJ6JBGvaKb6w==} + engines: {node: '>=18'} '@emnapi/runtime@1.11.2': resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} - '@emnapi/wasi-threads@1.2.2': - resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} - '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} deprecated: 'Merged into tsx: https://tsx.hirok.io' @@ -424,6 +507,15 @@ packages: cpu: [x64] os: [win32] + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@floating-ui/core@1.8.0': resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} @@ -623,12 +715,6 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@napi-rs/wasm-runtime@1.1.6': - resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} - peerDependencies: - '@emnapi/core': ^1.7.1 - '@emnapi/runtime': ^1.7.1 - '@next/env@16.2.11': resolution: {integrity: sha512-0do5A3BJ2gxWr0ZCMcD6BhW+e595jyxdTl3rXTS6lOtD8ektMiW6CO+EPwt1Eca1DBnm90r/7GdiKWBKxH++DA==} @@ -687,8 +773,13 @@ packages: '@nodable/entities@3.0.0': resolution: {integrity: sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==} - '@oxc-project/types@0.139.0': - resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} + '@oxc-project/types@0.148.0': + resolution: {integrity: sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==} + + '@playwright/test@1.62.1': + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + engines: {node: '>=20'} + hasBin: true '@radix-ui/number@1.1.2': resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==} @@ -1380,97 +1471,98 @@ packages: '@radix-ui/rect@1.1.2': resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==} - '@rolldown/binding-android-arm64@1.1.5': - resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} + '@rolldown/binding-android-arm-eabi@1.2.7': + resolution: {integrity: sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.7': + resolution: {integrity: sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.1.5': - resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} + '@rolldown/binding-darwin-arm64@1.2.7': + resolution: {integrity: sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.1.5': - resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} + '@rolldown/binding-darwin-x64@1.2.7': + resolution: {integrity: sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.1.5': - resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} + '@rolldown/binding-freebsd-x64@1.2.7': + resolution: {integrity: sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.1.5': - resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} + '@rolldown/binding-linux-arm-gnueabihf@1.2.7': + resolution: {integrity: sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.1.5': - resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} + '@rolldown/binding-linux-arm64-gnu@1.2.7': + resolution: {integrity: sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.1.5': - resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} + '@rolldown/binding-linux-arm64-musl@1.2.7': + resolution: {integrity: sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.1.5': - resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} + '@rolldown/binding-linux-ppc64-gnu@1.2.7': + resolution: {integrity: sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.1.5': - resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} + '@rolldown/binding-linux-s390x-gnu@1.2.7': + resolution: {integrity: sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.1.5': - resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} + '@rolldown/binding-linux-x64-gnu@1.2.7': + resolution: {integrity: sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.1.5': - resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} + '@rolldown/binding-linux-x64-musl@1.2.7': + resolution: {integrity: sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.1.5': - resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} + '@rolldown/binding-openharmony-arm64@1.2.7': + resolution: {integrity: sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.1.5': - resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [wasm32] - - '@rolldown/binding-win32-arm64-msvc@1.1.5': - resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} + '@rolldown/binding-win32-arm64-msvc@1.2.7': + resolution: {integrity: sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.1.5': - resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} + '@rolldown/binding-win32-x64-msvc@1.2.7': + resolution: {integrity: sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -1582,160 +1674,179 @@ packages: '@tanstack/query-core@5.101.4': resolution: {integrity: sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==} - '@tiptap/core@3.28.0': - resolution: {integrity: sha512-gUuD5WAYfbDxNSSJya/emh2KSzXZXLUYKW4fEnc1AQ5FE2twzh4LJ9UlKFIawigrUCAksWI5Fy1hRbv+5m4ZdQ==} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.3': + resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@tiptap/core@3.31.3': + resolution: {integrity: sha512-Cz50pvciQrxdSxgTkHOVz0uD0Yl/8Xt0QatGD6ILm47jW8EzyHR9RkUGs/D5IqzXKuVPntfw1ttaT926vXfiRg==} peerDependencies: - '@tiptap/pm': 3.28.0 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-blockquote@3.28.0': - resolution: {integrity: sha512-y8Xi6Z3AQLXedz0J8Z3kDsu7SKBmL50gKVXx3RK1Oo1cuCGhNChm3syChkAz3PLAbrPUM5rvJDSZdF2jUrDnng==} + '@tiptap/extension-blockquote@3.31.3': + resolution: {integrity: sha512-fyY2XMbyDDDfOTQ1Qdrnqa1qwC9DWE4n7AfE0EKQI0G8MfLV8RaDlLDcOZDJ9JbMPY7/Gx7EjyK4NKxSW9n2hQ==} peerDependencies: - '@tiptap/core': 3.28.0 - '@tiptap/pm': 3.28.0 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-bold@3.28.0': - resolution: {integrity: sha512-JhZQmr0AU741bOwjVMfuwJdK4g0TQwwPbeca9aqKHv5zvZw4i4G9G6fESVyMFc3Yag1ffpnq5EtNldHKTnMhlw==} + '@tiptap/extension-bold@3.31.3': + resolution: {integrity: sha512-dIuYhKk8TitKU/FeDpoTeWZhU42YgDN5npgWNjAmMmRktPdoxnH3/wGSiwXlqZgJWjehN7kPWDePWpJeAImGpQ==} peerDependencies: - '@tiptap/core': 3.28.0 + '@tiptap/core': 3.31.3 - '@tiptap/extension-bubble-menu@3.28.0': - resolution: {integrity: sha512-7AUNoHj2K4XLKCgW4uspeD/ENejPu2BeHvLTsiOoIO+XXDNSi2j0bbaIS8f2s/qnFirscwp/sbznp1qph/76qg==} + '@tiptap/extension-bubble-menu@3.31.3': + resolution: {integrity: sha512-EV6ZnwKc++2OM/OcD54n8s1B7C9LP7GKAtdEPwu0t3BYJf3sG8Ueoinf7SgGPO9oMPg96GneOkDNm1urMV167g==} peerDependencies: - '@tiptap/core': 3.28.0 - '@tiptap/pm': 3.28.0 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-bullet-list@3.28.0': - resolution: {integrity: sha512-dSVuNiH7PFMmWGkNER9vfUb5bXUHDARvHbJ3l+APEb+ZiusVN1KFdM3nd/RsW/Rt5Gd3XwlIEU/c2Uf7cxYDcw==} + '@tiptap/extension-bullet-list@3.31.3': + resolution: {integrity: sha512-qEyyoPapPef4LO8XKaN83bxtaNzkJ4kFn/IxLnEKd4BJ3Mvi4MH2yJYlyDqwFnMoev4pMA1zBHRAt/C0THRmZw==} peerDependencies: - '@tiptap/extension-list': 3.28.0 + '@tiptap/extension-list': 3.31.3 - '@tiptap/extension-code-block@3.28.0': - resolution: {integrity: sha512-bHITDzT0umTLh+SiEVQzIXkCMt/TzbPM1+HQy9ZxgQDHAJj/vdmfNAnP3RCOrz4lETXyhNQ2b6kxeu3lWckxyA==} + '@tiptap/extension-code-block@3.31.3': + resolution: {integrity: sha512-nvknt4FhyJQjYcvxptmeUlFsIAc8ibua3E5BN4Pim374/9RWepH4cdE9X0/qUTtguHElLx0iKtSIY3rW2qGTFA==} peerDependencies: - '@tiptap/core': 3.28.0 - '@tiptap/pm': 3.28.0 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-code@3.28.0': - resolution: {integrity: sha512-AUw2Acof3CQE6Q6Y22saK4lyg0nkeJ4XXniU65TdAi/9TE66TGiO4NQJJNNPgu8+VMEwl5j8VsIFK8sfTcNYtg==} + '@tiptap/extension-code@3.31.3': + resolution: {integrity: sha512-SzxOqchrD2AcN3uT67PjKmRFEMOU3vNNiwNaamZJUbZrI6Hmy+bvdHJrc3jrIddyyCrAtsnAfRI0cmorW1jGfg==} peerDependencies: - '@tiptap/core': 3.28.0 + '@tiptap/core': 3.31.3 - '@tiptap/extension-document@3.28.0': - resolution: {integrity: sha512-5SAKtlB1Tr/eZFhISL86HqmUup6rItvPtuPyRjmZo/VgbMwXEwiyAh1sMgFp5VNaeSMM14vJSyQpjhSaOmaBqg==} + '@tiptap/extension-document@3.31.3': + resolution: {integrity: sha512-EexgmqnyDNyGlISxo7SMrp5MygpJYmqD+0cY5jB6L1U6L4CpKKRWUt8OO1sWzEHDE1+TTvwt+WIFoIWAziOtEA==} peerDependencies: - '@tiptap/core': 3.28.0 + '@tiptap/core': 3.31.3 - '@tiptap/extension-dropcursor@3.28.0': - resolution: {integrity: sha512-JA+dXQjjfJBpD0nVsZeddGVXRsmanESM9+8CtM35hI9wJnYMXay/B+5GUhswO20zhT3zhB2ZOTGe9knu6A8nIQ==} + '@tiptap/extension-dropcursor@3.31.3': + resolution: {integrity: sha512-NWomSfu5CSC7VacnMSDzKT8qm66SzMfZwVPEtwY5bPpRTJgTiT1rNK0neDrrzfMN27MfylGyKWWf7Q5Qf8w/fg==} peerDependencies: - '@tiptap/extensions': 3.28.0 + '@tiptap/extensions': 3.31.3 - '@tiptap/extension-floating-menu@3.28.0': - resolution: {integrity: sha512-59SECvJq3pQfeJBuydEdhQqrpl0oDlk8N1Ovs1Si3/fJa6rEQAJB2zIvcpBHky9Z+5JodI2eueDnv8eimiyGbg==} + '@tiptap/extension-floating-menu@3.31.3': + resolution: {integrity: sha512-rd4VJ9PGSP9Eop8ZTEwaLZcMzMXLuJKe3hUNf58rq+zANpwM+9fI+vB7g9MAc3eXwUNDxNDVACFIL2oeBqpQ3A==} peerDependencies: '@floating-ui/dom': ^1.0.0 - '@tiptap/core': 3.28.0 - '@tiptap/pm': 3.28.0 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-gapcursor@3.28.0': - resolution: {integrity: sha512-Wo73kM4q8z4Va9i4+0n1cO0UEMVUVBHPqM6cAqEYXjB4T48LQkKjRsiQydCezm185rQAsmm1dyi72gtvVQfdMg==} + '@tiptap/extension-gapcursor@3.31.3': + resolution: {integrity: sha512-EBXKb1FrVStsNYCcRGtd9jmzveCvR+eqgg1rVqoONrqFK6U7bga6LN+1dMKroP1kliDVgveYkP3vRYxqw+rFqg==} peerDependencies: - '@tiptap/extensions': 3.28.0 + '@tiptap/extensions': 3.31.3 - '@tiptap/extension-hard-break@3.28.0': - resolution: {integrity: sha512-JHIFjX1luJgQ4VFEkIeXZpbc54Qiw+69P8FmlazYTh7AIJ6iLCpMFgQFELnivEAZ+/vS0lMPQubg0rCeM3UrHw==} + '@tiptap/extension-hard-break@3.31.3': + resolution: {integrity: sha512-QAdCvNO4+yW9ATwsrej11NTkDYFqPLIEQr3ARNrKOK1qaiS7A0fia2SEukb/hrkP3A6mbozhoQt2r2RGUf/DpQ==} peerDependencies: - '@tiptap/core': 3.28.0 + '@tiptap/core': 3.31.3 - '@tiptap/extension-heading@3.28.0': - resolution: {integrity: sha512-rKjGG/Ik2lNaXBNVmONEDm5DBfGDLM1NWgSf5RRfBISrH8Lm1xqFruOB9tIaB0YUoSV3SBOiwTF9svmzvKSoRA==} + '@tiptap/extension-heading@3.31.3': + resolution: {integrity: sha512-rk5VHMAeQcg06SLauN6EGdD2jc0O2qY8QkZYPd0LxNvLblw2BxBx+lxUQSYwLAT9Ie5914gKIK2YbRyO2Ts3ig==} peerDependencies: - '@tiptap/core': 3.28.0 + '@tiptap/core': 3.31.3 - '@tiptap/extension-highlight@3.28.0': - resolution: {integrity: sha512-T8uvQ1aZs+u496XxycMk5zAIwA0DxN+SCdSAFWCU7bqCAen3ulzOsk6RXJ+Jx40GqTV/ShPyIcEuaJVNurXxbg==} + '@tiptap/extension-highlight@3.31.3': + resolution: {integrity: sha512-97m1nDzTAjJaJZWe5hVOBMC2rjBZvEwTXA39zhjnaE/snb8Cy4H9A3+/sw9HQJBXMoG7tdOPG9kUlM3weYtLcQ==} peerDependencies: - '@tiptap/core': 3.28.0 + '@tiptap/core': 3.31.3 - '@tiptap/extension-horizontal-rule@3.28.0': - resolution: {integrity: sha512-neBCMWprkppQUoLWyeJZDHqBw+xKX2oNhLD9MbFlhKIr092zgfP4mpCvQnSkn1OHl156KzZMp070+NyLBjgQ7A==} + '@tiptap/extension-horizontal-rule@3.31.3': + resolution: {integrity: sha512-YnHGy2KShRwvCseAmmxl9VP7R0qaj8QMp3DA6DJWZqp7r5gLGvDkAqhedxqqefqsE4Y43hjkBdjtB9Ce78LkIw==} peerDependencies: - '@tiptap/core': 3.28.0 - '@tiptap/pm': 3.28.0 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-italic@3.28.0': - resolution: {integrity: sha512-Yur/ELz6dNVKQC7m8wGjtoLFxamGjJmA0rUEEOacTH6J39aFmcSxYkEGNDkYHbZFyHFYqbej6eI3VE0h08O0mg==} + '@tiptap/extension-italic@3.31.3': + resolution: {integrity: sha512-ibGvdvAPyfxBMUVNRI43eb9h2/Jka1MRG5GtnGqbcCX/2+Y/y0EOfFrPQPkuYphiWQYyu9+FzPKMB/pjuaLuKQ==} peerDependencies: - '@tiptap/core': 3.28.0 + '@tiptap/core': 3.31.3 - '@tiptap/extension-link@3.28.0': - resolution: {integrity: sha512-hy/PqSeTyl317yseFcHGE+3XVfQKQYaL4uZuj27xlrcO7MZ15drt1h9sUZy1FTBF3mr8676pQu7aoEm/Ww4ZRA==} + '@tiptap/extension-link@3.31.3': + resolution: {integrity: sha512-986wOQzTL9Zr5lf84LCLpm+YOms8A0K39/8DVoqRfebqcOe0/eq4bnztmAlfabOd+kJY92g3AgZERFUx/w+dcw==} peerDependencies: - '@tiptap/core': 3.28.0 - '@tiptap/pm': 3.28.0 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-list-item@3.28.0': - resolution: {integrity: sha512-GbXrnMac6knSetZY33NHwOrgXFPqH28uCklQqmOZQlsrdGqezDKk31qoEMr4GsKW9n/lViAeuc07oIzwLwCMng==} + '@tiptap/extension-list-item@3.31.3': + resolution: {integrity: sha512-4QlKOriJJMvg95QJTEsy9BUYPBQ6UvJyb8WURRwdUtQUkkqb8h32lg/eyQUv2FzW9IT1AdUyNZfVaxH64kRfQA==} peerDependencies: - '@tiptap/extension-list': 3.28.0 + '@tiptap/extension-list': 3.31.3 - '@tiptap/extension-list-keymap@3.28.0': - resolution: {integrity: sha512-u9Wks8c/eQAv0RkULNggOyTKDD69WVbcV9H5cbasPDUbq5XbEFVa9ajxhEGfMmQ5et3EX0QL8qBi50K0GqbIjA==} + '@tiptap/extension-list-keymap@3.31.3': + resolution: {integrity: sha512-If8UOEdDZbPJU6iYTvLtH6DOp2KBy6BKxg9UELL1AevVetGHEF/7lW8hP50Gn1tHMVpPRqmhSzVrJRpEJJgb/Q==} peerDependencies: - '@tiptap/extension-list': 3.28.0 + '@tiptap/extension-list': 3.31.3 - '@tiptap/extension-list@3.28.0': - resolution: {integrity: sha512-zQ66i5DuhVOndmZ0d7H475Y5Yb+BMx+zfCjFkCzEc6WVef5MumtxBVTkGLQ0ibxUaD71s4QQZbjHWagpaTg0eQ==} + '@tiptap/extension-list@3.31.3': + resolution: {integrity: sha512-LoveGnC0FVdCV4jUNBaG1ZA+KWE07+adzV3kGy6uUYFcJEjbVUHTnPDrBOob3IwOSO3sCwIkvQb6EVYeXn/4yg==} peerDependencies: - '@tiptap/core': 3.28.0 - '@tiptap/pm': 3.28.0 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-ordered-list@3.28.0': - resolution: {integrity: sha512-OZNYrKKL9D01ySOujlNbKlLS9/3wGgKNYeoHZUg0e+Ta8WPVvL3W1zH6TgiU5sF2ZSGUBoq3w7mdaO/iROlUkw==} + '@tiptap/extension-ordered-list@3.31.3': + resolution: {integrity: sha512-mp3g11NgA/PYu8rj7J7Ez3l4qBy6WfTSmHIG4PZvEGG5w2oUAIkgb9DU7nPPzjmeme27oazFYZw+AtZA0+u4tw==} peerDependencies: - '@tiptap/extension-list': 3.28.0 + '@tiptap/extension-list': 3.31.3 - '@tiptap/extension-paragraph@3.28.0': - resolution: {integrity: sha512-8UMlQIjzqf6r2hSJ/VeJ00RqaOrOB1J5rTk02Vcw2pYeHkOqp+B0ff0HFu4abT9x1q4oXrBAgMsxRtgh/kR14Q==} + '@tiptap/extension-paragraph@3.31.3': + resolution: {integrity: sha512-+iPku7wJfy5hbNDNLX8dveFtYVsZMmh7vztjuq8hT3mipSC4IByDehDbU8fzUCjfXiEzmI7mQn7c8LGmHULuzA==} peerDependencies: - '@tiptap/core': 3.28.0 + '@tiptap/core': 3.31.3 - '@tiptap/extension-strike@3.28.0': - resolution: {integrity: sha512-VVj2ZZU9QYqiHLcjqMqRYvuHSsokj/AgUl+6TzLrKjlWwyZ18D4H2vkyI0g70iVTKnUpZ3sEy8WA/rqjgBjqBg==} + '@tiptap/extension-strike@3.31.3': + resolution: {integrity: sha512-G29bhKttYwcKHT+BI6emWVFol3RO/gUXxQVcmr/iT8LXXy7j8J6HFUnsKM+Kg5YlP1rxMRgsa65dbrQVahZi0A==} peerDependencies: - '@tiptap/core': 3.28.0 + '@tiptap/core': 3.31.3 - '@tiptap/extension-text@3.28.0': - resolution: {integrity: sha512-Jqp1LgfY1mnp4TQHoy+vnHyfn6qnnAM6nHgGwbY5zA8b/Xf1Etll6pziTx9p1J1qcfAgSrnjOyAmA++H7VQqww==} + '@tiptap/extension-text@3.31.3': + resolution: {integrity: sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg==} peerDependencies: - '@tiptap/core': 3.28.0 + '@tiptap/core': 3.31.3 - '@tiptap/extension-underline@3.28.0': - resolution: {integrity: sha512-rwxCS6vTh2DJkNIYQX7JSrrRSmm76e2y48aZeuZO5ShkvLWMuJ3/zqDHRxAQVDiJhuE2Cp8NrTmPYlUc9YrT0A==} + '@tiptap/extension-underline@3.31.3': + resolution: {integrity: sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw==} peerDependencies: - '@tiptap/core': 3.28.0 + '@tiptap/core': 3.31.3 - '@tiptap/extensions@3.28.0': - resolution: {integrity: sha512-DJT1khCK+O/pT1gQlAnoKAx6zwDkgv7GtnhSfkqm1/4KHC3x+SSJnBIgBl9oGHymA+DxWbW1EULU4V3d/kT2uQ==} + '@tiptap/extensions@3.31.3': + resolution: {integrity: sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA==} peerDependencies: - '@tiptap/core': 3.28.0 - '@tiptap/pm': 3.28.0 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/pm@3.28.0': - resolution: {integrity: sha512-ALcpwZMUdat9gjJKlpscpoqXStoLhU246LPEVBDvJdIsoUKvUu3MrzfXik2Y8mtSGfhjtm9O2TRkWxQiFVMwsQ==} + '@tiptap/pm@3.31.3': + resolution: {integrity: sha512-sZime0SWsz/k62W2WvHx5Ig7G2h7kVhrrmnqy+wEgIHfDwEfOlelRjaWCiBCFlF7dxGUntJusCh9FxlLhni0Ag==} - '@tiptap/react@3.28.0': - resolution: {integrity: sha512-BxzSAqaDEldQ97K/v6+GizU1/Dxx9VWkRh7PLQql6bXlAMiz6T1G3dGt25vvv1hQ1FoMt5ExWY5NkrPmR8G8ew==} + '@tiptap/react@3.31.3': + resolution: {integrity: sha512-QiwQqvaLFLm5EMFu5tg7nAgXJxCUiUTLD8EsK+TqVV5P4bqOoMOCM39khbhXTJyahCuYpiFWx5YOSDtC/JiPtg==} peerDependencies: - '@tiptap/core': 3.28.0 - '@tiptap/pm': 3.28.0 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 '@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tiptap/starter-kit@3.28.0': - resolution: {integrity: sha512-pqvFpValV+ONtlu3l4s73ROm/tEjoGMmt6uHmSJaLbqTcMHkdWuR3flJ/5brr4IDHe1foxmNicMbRxlje7yBYw==} + '@tiptap/starter-kit@3.31.3': + resolution: {integrity: sha512-WKof9RewdmGHvWJ1wn0/HVNG2mV+HOgVRyJkKekuM9fgr6BZAAH/xZsWE1eon+94JnQ+KtK2ThydXQM/qc6b2A==} '@tldraw/driver@5.2.5': resolution: {integrity: sha512-smu4NnqTdIM59/hHTvuXxqJWQ5aLpFUR59ibS//o30P9pgMAoN4v/7NhYx9g+V7RAeOzIpDqREouz3hZdoEt9Q==} @@ -1794,8 +1905,8 @@ packages: resolution: {integrity: sha512-FgFbMuKVdFUWyZFq0wr+ojZ/zeXlm4/Ed47duvYfDWoGkQ5r/j2q4vnfeB0MVyGHxGbvVMqDOgGbWPbCJoDspA==} engines: {node: '>=22.12.0'} - '@tybys/wasm-util@0.10.3': - resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -1979,10 +2090,18 @@ packages: '@vitest/utils@4.1.10': resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + ansi-regex@6.2.2: resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -1994,6 +2113,9 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -2006,6 +2128,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + block-stream2@2.1.0: resolution: {integrity: sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==} @@ -2067,9 +2192,14 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - convex@1.42.3: - resolution: {integrity: sha512-7Tz/lH6UlZdiqzLPF/zrlH6Rlo/DOp836dL+GZkUMEQ2jaGGNnBIiIDcOOiEqtfSehuQ65r/rdNMnb4LupowaA==} - engines: {node: '>=18.0.0', npm: '>=7.0.0'} + convex-test@0.0.56: + resolution: {integrity: sha512-dLYXlQjKFoGqvjAmPzyLgb2HsYI7iLo1iuNTU2DZmSrMRLWosYk94erjSU67GG5ovfP6+MjX8RJO+ebhmL6QYA==} + peerDependencies: + convex: ^1.43.0 + + convex@1.45.0: + resolution: {integrity: sha512-AV3B56Ptu/14d76g3urBJ50dwpiZa6uJaLT84OWox5aCwZIJiuAamdjv3lLHDzs8vv5kC31anEZohhUe3qJ2bA==} + engines: {node: '>=20.0.0', npm: '>=7.0.0'} hasBin: true peerDependencies: '@auth0/auth0-react': ^2.0.1 @@ -2086,12 +2216,23 @@ packages: react: optional: true + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-uri-component@0.2.2: resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} engines: {node: '>=0.10'} @@ -2114,6 +2255,9 @@ packages: discontinuous-range@1.0.0: resolution: {integrity: sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + drizzle-kit@0.31.10: resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} hasBin: true @@ -2221,6 +2365,10 @@ packages: resolution: {integrity: sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==} engines: {node: '>=10.13.0'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -2285,6 +2433,11 @@ packages: resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==} engines: {node: '>=0.10.0'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2340,10 +2493,14 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} - hono@4.12.32: - resolution: {integrity: sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==} + hono@4.13.7: + resolution: {integrity: sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==} engines: {node: '>=16.9.0'} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + idb@7.1.1: resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==} @@ -2361,6 +2518,9 @@ packages: resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-unsafe@2.0.0: resolution: {integrity: sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==} @@ -2375,6 +2535,18 @@ packages: resolution: {integrity: sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==} engines: {node: '>=20'} + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsdom@30.0.1: + resolution: {integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + peerDependencies: + canvas: ^3.2.3 + peerDependenciesMeta: + canvas: + optional: true + json-stable-stringify@1.3.0: resolution: {integrity: sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==} engines: {node: '>= 0.4'} @@ -2549,6 +2721,10 @@ packages: lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + lru-cache@6.0.0: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} @@ -2569,6 +2745,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -2591,8 +2770,8 @@ packages: resolution: {integrity: sha512-o6lpKiCxLeijK4hgsqfR6CNToPyRU3keKyyI6uwuHRvpRTbZ0wXw51WRgyldVugZqoJfkGFrjrIenYH3bfEO3Q==} engines: {node: ^14.0.0 || ^16.0.0 || >=18.0.0} - nanoid@3.3.16: - resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -2636,6 +2815,9 @@ packages: orderedmap@2.1.1: resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + path-expression-matcher@1.6.2: resolution: {integrity: sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==} engines: {node: '>=14.0.0'} @@ -2722,6 +2904,16 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + postcss@8.5.23: resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} engines: {node: ^10 || ^12 || >=14} @@ -2747,6 +2939,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + prosemirror-changeset@2.4.1: resolution: {integrity: sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==} @@ -2786,6 +2982,13 @@ packages: prosemirror-view@1.42.1: resolution: {integrity: sha512-rRqzZnRgkyh69XoOMrfFJHwauHscLBmHbq772kwbic1ymQAM8gXjzEbJse5j1ep2UO2HRIAQL0bY3kZ/RoqjVw==} + prosemirror-view@1.42.3: + resolution: {integrity: sha512-oTN7EtH+CpwxU9NrwEYWd0UZ4JUx7l048l5A2Xppm4p/60isZYLnth9QVQmC3VRIvdrIWCxwZSd+Uz791G31/w==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + query-string@7.1.3: resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==} engines: {node: '>=6'} @@ -2821,6 +3024,9 @@ packages: peerDependencies: react: ^19.2.8 + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -2859,6 +3065,10 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -2866,8 +3076,8 @@ packages: resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==} engines: {node: '>=0.12'} - rolldown@1.1.5: - resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} + rolldown@1.2.7: + resolution: {integrity: sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -2884,6 +3094,10 @@ packages: resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} engines: {node: '>=11.0.0'} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -2998,6 +3212,9 @@ packages: peerDependencies: react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tailwind-merge@3.6.0: resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} @@ -3033,6 +3250,21 @@ packages: react: ^18.2.0 || ^19.2.1 react-dom: ^18.2.0 || ^19.2.1 + tldts-core@7.4.11: + resolution: {integrity: sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg==} + + tldts@7.4.11: + resolution: {integrity: sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw==} + hasBin: true + + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -3058,6 +3290,10 @@ packages: undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + undici@8.10.1: + resolution: {integrity: sha512-YQ3WlbqjYMmNpdvDH64jAgLjxuAR9+649calDWhbshYaeQGO2bR4nI94ORJmwI3J9YhoKQnpyGOK+0zlWS5N5Q==} + engines: {node: '>=22.19.0'} + use-callback-ref@1.3.3: resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} @@ -3090,13 +3326,13 @@ packages: resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} hasBin: true - vite@8.1.5: - resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==} + vite@8.2.2: + resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.3.0 + '@vitejs/devtools': ^0.4.0 || ^0.5.0 esbuild: 0.28.1 jiti: '>=1.21.0' less: ^4.0.0 @@ -3177,6 +3413,26 @@ packages: w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + whatwg-url@17.1.0: + resolution: {integrity: sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==} + engines: {node: ^22.14.0 || >=24.0.0} + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -3210,6 +3466,10 @@ packages: utf-8-validate: optional: true + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + xml-naming@0.3.0: resolution: {integrity: sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==} engines: {node: '>=16.0.0'} @@ -3222,6 +3482,9 @@ packages: resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} engines: {node: '>=4.0'} + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -3233,6 +3496,11 @@ packages: yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@22.0.0: resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} engines: {node: ^20.19.0 || ^22.12.0 || >=23} @@ -3245,6 +3513,31 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@asamuzakjp/css-color@6.0.7': + dependencies: + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.2.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.5.2 + + '@asamuzakjp/dom-selector@8.3.2': + dependencies: + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/runtime@7.29.7': {} + '@biomejs/biome@2.5.5': optionalDependencies: '@biomejs/cli-darwin-arm64': 2.5.5 @@ -3280,6 +3573,10 @@ snapshots: '@biomejs/cli-win32-x64@2.5.5': optional: true + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@clerk/backend@3.13.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@clerk/shared': 4.25.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -3289,12 +3586,12 @@ snapshots: - react - react-dom - '@clerk/nextjs@7.6.1(next@16.2.11(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@clerk/nextjs@7.6.1(next@16.2.11(@playwright/test@1.62.1)(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@clerk/backend': 3.13.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@clerk/react': 6.12.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@clerk/shared': 4.25.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - next: 16.2.11(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next: 16.2.11(@playwright/test@1.62.1)(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) server-only: 0.0.1 @@ -3337,25 +3634,39 @@ snapshots: - react - react-dom - '@drizzle-team/brocli@0.10.2': {} + '@csstools/color-helpers@6.1.1': {} - '@emnapi/core@1.11.1': + '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': dependencies: - '@emnapi/wasi-threads': 1.2.2 - tslib: 2.8.1 - optional: true + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 - '@emnapi/runtime@1.11.1': + '@csstools/css-color-parser@4.2.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': dependencies: - tslib: 2.8.1 - optional: true + '@csstools/color-helpers': 6.1.1 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 - '@emnapi/runtime@1.11.2': + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': dependencies: - tslib: 2.8.1 - optional: true + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.12(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + + '@drizzle-team/brocli@0.10.2': {} - '@emnapi/wasi-threads@1.2.2': + '@edge-runtime/primitives@6.0.0': {} + + '@edge-runtime/vm@5.0.0': + dependencies: + '@edge-runtime/primitives': 6.0.0 + + '@emnapi/runtime@1.11.2': dependencies: tslib: 2.8.1 optional: true @@ -3448,6 +3759,8 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@exodus/bytes@1.15.1': {} + '@floating-ui/core@1.8.0': dependencies: '@floating-ui/utils': 0.2.12 @@ -3465,9 +3778,9 @@ snapshots: '@floating-ui/utils@0.2.12': {} - '@hono/node-server@2.0.11(hono@4.12.32)': + '@hono/node-server@2.0.11(hono@4.13.7)': dependencies: - hono: 4.12.32 + hono: 4.13.7 '@img/colour@1.1.0': optional: true @@ -3595,13 +3908,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': - dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 - '@tybys/wasm-util': 0.10.3 - optional: true - '@next/env@16.2.11': {} '@next/swc-darwin-arm64@16.2.11': @@ -3630,7 +3936,11 @@ snapshots: '@nodable/entities@3.0.0': {} - '@oxc-project/types@0.139.0': {} + '@oxc-project/types@0.148.0': {} + + '@playwright/test@1.62.1': + dependencies: + playwright: 1.62.1 '@radix-ui/number@1.1.2': {} @@ -4379,53 +4689,49 @@ snapshots: '@radix-ui/rect@1.1.2': {} - '@rolldown/binding-android-arm64@1.1.5': + '@rolldown/binding-android-arm-eabi@1.2.7': optional: true - '@rolldown/binding-darwin-arm64@1.1.5': + '@rolldown/binding-android-arm64@1.2.7': optional: true - '@rolldown/binding-darwin-x64@1.1.5': + '@rolldown/binding-darwin-arm64@1.2.7': optional: true - '@rolldown/binding-freebsd-x64@1.1.5': + '@rolldown/binding-darwin-x64@1.2.7': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + '@rolldown/binding-freebsd-x64@1.2.7': optional: true - '@rolldown/binding-linux-arm64-gnu@1.1.5': + '@rolldown/binding-linux-arm-gnueabihf@1.2.7': optional: true - '@rolldown/binding-linux-arm64-musl@1.1.5': + '@rolldown/binding-linux-arm64-gnu@1.2.7': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.1.5': + '@rolldown/binding-linux-arm64-musl@1.2.7': optional: true - '@rolldown/binding-linux-s390x-gnu@1.1.5': + '@rolldown/binding-linux-ppc64-gnu@1.2.7': optional: true - '@rolldown/binding-linux-x64-gnu@1.1.5': + '@rolldown/binding-linux-s390x-gnu@1.2.7': optional: true - '@rolldown/binding-linux-x64-musl@1.1.5': + '@rolldown/binding-linux-x64-gnu@1.2.7': optional: true - '@rolldown/binding-openharmony-arm64@1.1.5': + '@rolldown/binding-linux-x64-musl@1.2.7': optional: true - '@rolldown/binding-wasm32-wasi@1.1.5': - dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@rolldown/binding-openharmony-arm64@1.2.7': optional: true - '@rolldown/binding-win32-arm64-msvc@1.1.5': + '@rolldown/binding-win32-arm64-msvc@1.2.7': optional: true - '@rolldown/binding-win32-x64-msvc@1.1.5': + '@rolldown/binding-win32-x64-msvc@1.2.7': optional: true '@rolldown/pluginutils@1.0.1': {} @@ -4509,124 +4815,145 @@ snapshots: '@tanstack/query-core@5.101.4': {} - '@tiptap/core@3.28.0(@tiptap/pm@3.28.0)': + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/react@16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@tiptap/core@3.31.3(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/pm': 3.28.0 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-blockquote@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)': + '@tiptap/extension-blockquote@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0) - '@tiptap/pm': 3.28.0 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/extension-bold@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))': + '@tiptap/extension-bold@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-bubble-menu@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)': + '@tiptap/extension-bubble-menu@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: '@floating-ui/dom': 1.8.0 - '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0) - '@tiptap/pm': 3.28.0 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 optional: true - '@tiptap/extension-bullet-list@3.28.0(@tiptap/extension-list@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0))': + '@tiptap/extension-bullet-list@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extension-list': 3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-code-block@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)': + '@tiptap/extension-code-block@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0) - '@tiptap/pm': 3.28.0 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/extension-code@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))': + '@tiptap/extension-code@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-document@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))': + '@tiptap/extension-document@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-dropcursor@3.28.0(@tiptap/extensions@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0))': + '@tiptap/extension-dropcursor@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extensions': 3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0) + '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-floating-menu@3.28.0(@floating-ui/dom@1.8.0)(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)': + '@tiptap/extension-floating-menu@3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: '@floating-ui/dom': 1.8.0 - '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0) - '@tiptap/pm': 3.28.0 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 optional: true - '@tiptap/extension-gapcursor@3.28.0(@tiptap/extensions@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0))': + '@tiptap/extension-gapcursor@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extensions': 3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0) + '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-hard-break@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))': + '@tiptap/extension-hard-break@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-heading@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))': + '@tiptap/extension-heading@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-highlight@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))': + '@tiptap/extension-highlight@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-horizontal-rule@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)': + '@tiptap/extension-horizontal-rule@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0) - '@tiptap/pm': 3.28.0 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/extension-italic@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))': + '@tiptap/extension-italic@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-link@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)': + '@tiptap/extension-link@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0) - '@tiptap/pm': 3.28.0 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 linkifyjs: 4.3.3 - '@tiptap/extension-list-item@3.28.0(@tiptap/extension-list@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0))': + '@tiptap/extension-list-item@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extension-list': 3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-list-keymap@3.28.0(@tiptap/extension-list@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0))': + '@tiptap/extension-list-keymap@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extension-list': 3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-list@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)': + '@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0) - '@tiptap/pm': 3.28.0 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/extension-ordered-list@3.28.0(@tiptap/extension-list@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0))': + '@tiptap/extension-ordered-list@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extension-list': 3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-paragraph@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))': + '@tiptap/extension-paragraph@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-strike@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))': + '@tiptap/extension-strike@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-text@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))': + '@tiptap/extension-text@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-underline@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))': + '@tiptap/extension-underline@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extensions@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)': + '@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0) - '@tiptap/pm': 3.28.0 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/pm@3.28.0': + '@tiptap/pm@3.31.3': dependencies: prosemirror-changeset: 2.4.1 prosemirror-commands: 1.7.1 @@ -4640,12 +4967,12 @@ snapshots: prosemirror-state: 1.4.4 prosemirror-tables: 1.8.5 prosemirror-transform: 1.12.0 - prosemirror-view: 1.42.1 + prosemirror-view: 1.42.3 - '@tiptap/react@3.28.0(@floating-ui/dom@1.8.0)(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@tiptap/react@3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0) - '@tiptap/pm': 3.28.0 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) '@types/use-sync-external-store': 0.0.6 @@ -4654,37 +4981,37 @@ snapshots: react-dom: 19.2.8(react@19.2.8) use-sync-external-store: 1.6.0(react@19.2.8) optionalDependencies: - '@tiptap/extension-bubble-menu': 3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0) - '@tiptap/extension-floating-menu': 3.28.0(@floating-ui/dom@1.8.0)(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0) + '@tiptap/extension-bubble-menu': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-floating-menu': 3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) transitivePeerDependencies: - '@floating-ui/dom' - '@tiptap/starter-kit@3.28.0': - dependencies: - '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0) - '@tiptap/extension-blockquote': 3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0) - '@tiptap/extension-bold': 3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0)) - '@tiptap/extension-bullet-list': 3.28.0(@tiptap/extension-list@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)) - '@tiptap/extension-code': 3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0)) - '@tiptap/extension-code-block': 3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0) - '@tiptap/extension-document': 3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0)) - '@tiptap/extension-dropcursor': 3.28.0(@tiptap/extensions@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)) - '@tiptap/extension-gapcursor': 3.28.0(@tiptap/extensions@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)) - '@tiptap/extension-hard-break': 3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0)) - '@tiptap/extension-heading': 3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0)) - '@tiptap/extension-horizontal-rule': 3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0) - '@tiptap/extension-italic': 3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0)) - '@tiptap/extension-link': 3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0) - '@tiptap/extension-list': 3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0) - '@tiptap/extension-list-item': 3.28.0(@tiptap/extension-list@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)) - '@tiptap/extension-list-keymap': 3.28.0(@tiptap/extension-list@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)) - '@tiptap/extension-ordered-list': 3.28.0(@tiptap/extension-list@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)) - '@tiptap/extension-paragraph': 3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0)) - '@tiptap/extension-strike': 3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0)) - '@tiptap/extension-text': 3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0)) - '@tiptap/extension-underline': 3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0)) - '@tiptap/extensions': 3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0) - '@tiptap/pm': 3.28.0 + '@tiptap/starter-kit@3.31.3': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/extension-blockquote': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-bold': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-bullet-list': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-code': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-code-block': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-document': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-dropcursor': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-gapcursor': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-hard-break': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-heading': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-horizontal-rule': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-italic': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-link': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-list-item': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-list-keymap': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-ordered-list': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-paragraph': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-strike': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-text': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-underline': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 '@tldraw/driver@5.2.5(@floating-ui/dom@1.8.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: @@ -4699,9 +5026,9 @@ snapshots: '@tldraw/editor@5.2.5(@floating-ui/dom@1.8.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0) - '@tiptap/pm': 3.28.0 - '@tiptap/react': 3.28.0(@floating-ui/dom@1.8.0)(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + '@tiptap/react': 3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@tldraw/state': 5.2.5 '@tldraw/state-react': 5.2.5(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@tldraw/store': 5.2.5(react@19.2.8) @@ -4789,10 +5116,7 @@ snapshots: dependencies: '@tldraw/utils': 5.2.5 - '@tybys/wasm-util@0.10.3': - dependencies: - tslib: 2.8.1 - optional: true + '@types/aria-query@5.0.4': {} '@types/chai@5.2.3': dependencies: @@ -4900,13 +5224,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1))': + '@vitest/mocker@4.1.10(vite@8.2.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1) + vite: 8.2.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) '@vitest/pretty-format@4.1.10': dependencies: @@ -4932,8 +5256,12 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + ansi-regex@5.0.1: {} + ansi-regex@6.2.2: {} + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} anynum@1.0.1: {} @@ -4942,12 +5270,20 @@ snapshots: dependencies: tslib: 2.8.1 + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + assertion-error@2.0.1: {} async@3.2.6: {} baseline-browser-mapping@2.11.1: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + block-stream2@2.1.0: dependencies: readable-stream: 3.6.2 @@ -5006,7 +5342,11 @@ snapshots: convert-source-map@2.0.0: {} - convex@1.42.3(@clerk/react@6.12.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8): + convex-test@0.0.56(convex@1.45.0(@clerk/react@6.12.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)): + dependencies: + convex: 1.45.0(@clerk/react@6.12.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) + + convex@1.45.0(@clerk/react@6.12.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8): dependencies: esbuild: 0.28.1 prettier: 3.9.6 @@ -5018,10 +5358,24 @@ snapshots: - bufferutil - utf-8-validate + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + csstype@3.1.3: {} csstype@3.2.3: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + decimal.js@10.6.0: {} + decode-uri-component@0.2.2: {} define-data-property@1.1.4: @@ -5038,6 +5392,8 @@ snapshots: discontinuous-range@1.0.0: {} + dom-accessibility-api@0.5.16: {} + drizzle-kit@0.31.10: dependencies: '@drizzle-team/brocli': 0.10.2 @@ -5063,6 +5419,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.3 + entities@8.0.0: {} + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -5138,6 +5496,9 @@ snapshots: filter-obj@1.1.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -5189,7 +5550,13 @@ snapshots: dependencies: function-bind: 1.1.2 - hono@4.12.32: {} + hono@4.13.7: {} + + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' idb@7.1.1: {} @@ -5201,6 +5568,8 @@ snapshots: is-plain-object@5.0.0: {} + is-potential-custom-element-name@1.0.1: {} + is-unsafe@2.0.0: {} isarray@2.0.5: {} @@ -5209,6 +5578,34 @@ snapshots: js-cookie@3.0.7: {} + js-tokens@4.0.0: {} + + jsdom@30.0.1: + dependencies: + '@asamuzakjp/css-color': 6.0.7 + '@asamuzakjp/dom-selector': 8.3.2 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.12(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 8.10.1 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 17.1.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + json-stable-stringify@1.3.0: dependencies: call-bind: 1.0.9 @@ -5329,6 +5726,8 @@ snapshots: lodash@4.18.1: {} + lru-cache@11.5.2: {} + lru-cache@6.0.0: dependencies: yallist: 4.0.0 @@ -5345,6 +5744,8 @@ snapshots: math-intrinsics@1.1.0: {} + mdn-data@2.27.1: {} + mime-db@1.52.0: {} mime-types@2.1.35: @@ -5373,7 +5774,7 @@ snapshots: nanoevents@7.0.1: {} - nanoid@3.3.16: {} + nanoid@3.3.18: {} nearley@2.20.1: dependencies: @@ -5382,7 +5783,7 @@ snapshots: railroad-diagrams: 1.0.0 randexp: 0.4.6 - next@16.2.11(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + next@16.2.11(@playwright/test@1.62.1)(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: '@next/env': 16.2.11 '@swc/helpers': 0.5.15 @@ -5401,6 +5802,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.2.11 '@next/swc-win32-arm64-msvc': 16.2.11 '@next/swc-win32-x64-msvc': 16.2.11 + '@playwright/test': 1.62.1 sharp: 0.35.3(@types/node@26.1.1) transitivePeerDependencies: - '@babel/core' @@ -5415,6 +5817,10 @@ snapshots: orderedmap@2.1.1: {} + parse5@8.0.1: + dependencies: + entities: 8.0.0 + path-expression-matcher@1.6.2: {} pathe@2.0.3: {} @@ -5473,9 +5879,17 @@ snapshots: picomatch@4.0.5: {} + playwright-core@1.62.1: {} + + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + postcss@8.5.23: dependencies: - nanoid: 3.3.16 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -5491,6 +5905,12 @@ snapshots: prettier@3.9.6: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + prosemirror-changeset@2.4.1: dependencies: prosemirror-transform: 1.12.0 @@ -5565,6 +5985,14 @@ snapshots: prosemirror-state: 1.4.4 prosemirror-transform: 1.12.0 + prosemirror-view@1.42.3: + dependencies: + prosemirror-model: 1.25.11 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + + punycode@2.3.1: {} + query-string@7.1.3: dependencies: decode-uri-component: 0.2.2 @@ -5653,6 +6081,8 @@ snapshots: react: 19.2.8 scheduler: 0.27.0 + react-is@17.0.2: {} + react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.8): dependencies: react: 19.2.8 @@ -5688,30 +6118,32 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 + require-from-string@2.0.2: {} + resolve-pkg-maps@1.0.0: {} ret@0.1.15: {} - rolldown@1.1.5: + rolldown@1.2.7: dependencies: - '@oxc-project/types': 0.139.0 + '@oxc-project/types': 0.148.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm64': 1.1.5 - '@rolldown/binding-darwin-arm64': 1.1.5 - '@rolldown/binding-darwin-x64': 1.1.5 - '@rolldown/binding-freebsd-x64': 1.1.5 - '@rolldown/binding-linux-arm-gnueabihf': 1.1.5 - '@rolldown/binding-linux-arm64-gnu': 1.1.5 - '@rolldown/binding-linux-arm64-musl': 1.1.5 - '@rolldown/binding-linux-ppc64-gnu': 1.1.5 - '@rolldown/binding-linux-s390x-gnu': 1.1.5 - '@rolldown/binding-linux-x64-gnu': 1.1.5 - '@rolldown/binding-linux-x64-musl': 1.1.5 - '@rolldown/binding-openharmony-arm64': 1.1.5 - '@rolldown/binding-wasm32-wasi': 1.1.5 - '@rolldown/binding-win32-arm64-msvc': 1.1.5 - '@rolldown/binding-win32-x64-msvc': 1.1.5 + '@rolldown/binding-android-arm-eabi': 1.2.7 + '@rolldown/binding-android-arm64': 1.2.7 + '@rolldown/binding-darwin-arm64': 1.2.7 + '@rolldown/binding-darwin-x64': 1.2.7 + '@rolldown/binding-freebsd-x64': 1.2.7 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.7 + '@rolldown/binding-linux-arm64-gnu': 1.2.7 + '@rolldown/binding-linux-arm64-musl': 1.2.7 + '@rolldown/binding-linux-ppc64-gnu': 1.2.7 + '@rolldown/binding-linux-s390x-gnu': 1.2.7 + '@rolldown/binding-linux-x64-gnu': 1.2.7 + '@rolldown/binding-linux-x64-musl': 1.2.7 + '@rolldown/binding-openharmony-arm64': 1.2.7 + '@rolldown/binding-win32-arm64-msvc': 1.2.7 + '@rolldown/binding-win32-x64-msvc': 1.2.7 rope-sequence@1.3.4: {} @@ -5723,6 +6155,10 @@ snapshots: sax@1.6.0: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} semver@7.8.5: @@ -5845,6 +6281,8 @@ snapshots: react: 19.2.8 use-sync-external-store: 1.6.0(react@19.2.8) + symbol-tree@3.2.4: {} + tailwind-merge@3.6.0: {} tailwindcss@4.3.3: {} @@ -5868,13 +6306,13 @@ snapshots: tldraw@5.2.5(@floating-ui/dom@1.8.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0) - '@tiptap/extension-code': 3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0)) - '@tiptap/extension-highlight': 3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0)) - '@tiptap/extension-list': 3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0) - '@tiptap/pm': 3.28.0 - '@tiptap/react': 3.28.0(@floating-ui/dom@1.8.0)(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@tiptap/starter-kit': 3.28.0 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/extension-code': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-highlight': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + '@tiptap/react': 3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@tiptap/starter-kit': 3.31.3 '@tldraw/driver': 5.2.5(@floating-ui/dom@1.8.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@tldraw/editor': 5.2.5(@floating-ui/dom@1.8.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@tldraw/store': 5.2.5(react@19.2.8) @@ -5889,6 +6327,20 @@ snapshots: - '@types/react' - '@types/react-dom' + tldts-core@7.4.11: {} + + tldts@7.4.11: + dependencies: + tldts-core: 7.4.11 + + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.11 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + tree-kill@1.2.2: {} tslib@2.8.1: {} @@ -5926,6 +6378,8 @@ snapshots: undici-types@8.3.0: {} + undici@8.10.1: {} + use-callback-ref@1.3.3(@types/react@19.2.17)(react@19.2.8): dependencies: react: 19.2.8 @@ -5949,12 +6403,12 @@ snapshots: uuid@14.0.1: {} - vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1): + vite@8.2.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 postcss: 8.5.23 - rolldown: 1.1.5 + rolldown: 1.2.7 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 26.1.1 @@ -5962,11 +6416,12 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 tsx: 4.23.1 + yaml: 2.9.0 - vitest@4.1.10(@types/node@26.1.1)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)): + vitest@4.1.10(@edge-runtime/vm@5.0.0)(@types/node@26.1.1)(jsdom@30.0.1)(vite@8.2.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) + '@vitest/mocker': 4.1.10(vite@8.2.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -5983,15 +6438,41 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1) + vite: 8.2.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: + '@edge-runtime/vm': 5.0.0 '@types/node': 26.1.1 + jsdom: 30.0.1 transitivePeerDependencies: - msw w3c-keyname@2.2.8: {} + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + whatwg-url@17.1.0: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 @@ -6007,6 +6488,8 @@ snapshots: ws@8.21.1: {} + xml-name-validator@5.0.0: {} + xml-naming@0.3.0: {} xml2js@0.6.2: @@ -6016,12 +6499,16 @@ snapshots: xmlbuilder@11.0.1: {} + xmlchars@2.2.0: {} + xtend@4.0.2: {} y18n@5.0.8: {} yallist@4.0.0: {} + yaml@2.9.0: {} + yargs-parser@22.0.0: {} yargs@18.0.0: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7408121..8b9d76a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,7 +5,7 @@ allowBuilds: esbuild: true sharp: true minimumReleaseAgeExclude: - - hono@4.12.32 + - hono@4.12.32 || 4.13.7 - postcss@8.5.23 - '@clerk/backend@3.13.1' - '@clerk/nextjs@7.6.1' diff --git a/renovate.json b/renovate.json index 5db72dd..22a9943 100644 --- a/renovate.json +++ b/renovate.json @@ -1,6 +1,4 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": [ - "config:recommended" - ] + "extends": ["config:recommended"] } diff --git a/scripts/browser-fixture-cleanup.test.ts b/scripts/browser-fixture-cleanup.test.ts new file mode 100644 index 0000000..9a00941 --- /dev/null +++ b/scripts/browser-fixture-cleanup.test.ts @@ -0,0 +1,141 @@ +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { + mkdir, + mkdtemp, + readdir, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { expect, test } from "vitest"; + +test.each(["migration", "frontend", "cleanup", "signal"])( + "browser fixture releases partial startup resources after %s failure or interruption", + async (failure) => { + const root = await mkdtemp(join(tmpdir(), "browser-cleanup-test-")); + const storage = join(root, "temporary"); + const backendRoot = join(root, "backend/stream-canvas"); + const probe = createServer(); + probe.listen(0, "127.0.0.1"); + await once(probe, "listening"); + const address = probe.address(); + if (!address || typeof address === "string") + throw new Error("Missing port"); + const port = address.port; + await new Promise((resolve) => probe.close(() => resolve())); + let child: ReturnType | undefined; + try { + for (const directory of [ + "e2e", + "temporary", + "backend/stream-canvas/src", + "node_modules/vite", + "node_modules/pg", + ]) + await mkdir(join(root, directory), { recursive: true }); + await writeFile(join(root, "package.json"), '{"type":"module"}'); + // Run the real orchestrator with isolated local substitutes for its services. + await writeFile( + join(root, "e2e/server.ts"), + (await readFile("e2e/server.ts", "utf8")).replaceAll( + "4312", + String(port), + ), + ); + await writeFile( + join(backendRoot, "src/migrate.ts"), + failure === "signal" + ? 'import { writeFileSync } from "node:fs"; writeFileSync("migration-started", "yes"); setInterval(() => {}, 1000);' + : `process.exit(${failure === "migration" ? 1 : 0});`, + ); + await writeFile( + join(backendRoot, "src/index.ts"), + ` + import { createServer } from "node:http"; + import { writeFileSync } from "node:fs"; + const server = createServer((req, res) => { + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(req.url === "/ready" ? {} : { id: "fixture", obsSetupSecret: "synthetic" })); + }); + server.listen(Number(process.env.PORT), "127.0.0.1"); + process.on("SIGTERM", () => { writeFileSync("backend-stopped", "yes"); server.close(() => process.exit(0)); }); + `, + ); + await writeFile( + join(root, "node_modules/vite/package.json"), + '{"type":"module","exports":"./index.js"}', + ); + await writeFile( + join(root, "node_modules/vite/index.js"), + 'export async function createServer() { throw new Error("injected frontend startup failure"); }', + ); + await writeFile( + join(root, "node_modules/pg/package.json"), + '{"type":"module","exports":"./index.js"}', + ); + await writeFile( + join(root, "node_modules/pg/index.js"), + ` + import { writeFileSync } from "node:fs"; + export class Pool { + async query() { writeFileSync("database-cleanup-attempted", "yes"); ${failure === "cleanup" ? 'throw new Error("injected cleanup failure");' : ""} } + async end() {} + } + `, + ); + child = spawn(process.execPath, ["e2e/server.ts"], { + cwd: root, + env: { + NODE_ENV: "test", + PATH: process.env.PATH, + TMPDIR: storage, + TMP: storage, + TEMP: storage, + CANVAS_TEST_DATABASE_URL: "postgresql://unused.invalid/fixture", + }, + stdio: "ignore", + }); + const exited = once(child, "exit"); + if (failure === "signal") { + const deadline = Date.now() + 3_000; + while (!(await readdir(backendRoot)).includes("migration-started")) { + if (Date.now() > deadline) + throw new Error("Migration fixture did not start"); + await delay(10); + } + child.kill("SIGTERM"); + } + const [code, signal] = await exited; + expect(signal).toBeNull(); + expect(code).toBe(failure === "signal" ? 0 : 1); + expect(await readdir(storage)).toEqual([]); + if (failure === "frontend" || failure === "cleanup") { + expect( + await readFile(join(backendRoot, "backend-stopped"), "utf8"), + ).toBe("yes"); + expect( + await readFile( + join(backendRoot, "database-cleanup-attempted"), + "utf8", + ), + ).toBe("yes"); + } else { + expect(await readdir(backendRoot)).not.toContain( + "database-cleanup-attempted", + ); + } + } finally { + if (child && child.exitCode === null && child.signalCode === null) { + child.kill("SIGKILL"); + await once(child, "exit"); + } + await rm(root, { recursive: true, force: true }); + } + }, + 5_000, +); diff --git a/scripts/delivery-changes.sh b/scripts/delivery-changes.sh new file mode 100644 index 0000000..f1d9cd9 --- /dev/null +++ b/scripts/delivery-changes.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "${1:-}" = tag ]; then + # awk consumes the full list and succeeds when no previous tag exists. + previous_tag="$(git tag --sort=-version:refname --list 'v*' | awk -v current="$CURRENT_TAG" '$0 != current && !found { print; found=1 }')" + if [ -z "$previous_tag" ] || ! git diff --quiet "$previous_tag..$CURRENT_SHA" -- convex convex.json package.json pnpm-lock.yaml pnpm-workspace.yaml; then + echo 'convex=true' + else + echo 'convex=false' + fi + exit 0 +fi + +changed="$(cat)" +set_output() { + if printf '%s\n' "$changed" | grep -E "$2" > /dev/null; then + echo "$1=true" + else + echo "$1=false" + fi +} +shared='^(\.dockerignore|\.npmrc|package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml)$' +set_output frontend "$shared|^(app|components|lib|public)/|^(next\.config\.mjs|postcss\.config\.mjs|tailwind\.config\.ts|tsconfig\.json|biome\.json|components\.json|Dockerfile|docker-entrypoint\.sh)$" +set_output convex "$shared|^convex/|^convex\.json$" +set_output stream_canvas "$shared|^backend/stream-canvas/" diff --git a/scripts/delivery-changes.test.ts b/scripts/delivery-changes.test.ts new file mode 100644 index 0000000..5573950 --- /dev/null +++ b/scripts/delivery-changes.test.ts @@ -0,0 +1,144 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { expect, test } from "vitest"; +import { parse } from "yaml"; + +test("GitHub releases use the PR verification gate before production actions", () => { + const workflow = parse( + readFileSync(".github/workflows/build-images.yml", "utf8"), + ); + const verification = parse( + readFileSync(".github/workflows/verify.yml", "utf8"), + ); + expect(workflow.jobs.verify.uses).toBe("./.github/workflows/verify.yml"); + expect(verification.on).toHaveProperty("pull_request"); + expect(verification.on).toHaveProperty("workflow_call"); + expect(verification.jobs.verify["runs-on"]).toBe("arc-moddrop"); + expect(workflow.jobs.build.needs).toContain("verify"); + expect(workflow.jobs.build.needs).toContain("deploy-convex"); + expect(workflow.jobs["deploy-convex"].needs).toBe("verify"); + for (const job of [workflow.jobs.build, workflow.jobs["deploy-convex"]]) { + const allowed = (ref: string) => + Function( + "github", + "startsWith", + `return (${job.if})`, + )({ ref }, (value: string, prefix: string) => value.startsWith(prefix)); + expect(allowed("refs/heads/feature")).toBe(false); + expect(allowed("refs/heads/main")).toBe(true); + expect(allowed("refs/tags/v0.6.2")).toBe(true); + expect(job.if).not.toContain("always()"); + } +}); + +test.each(["pnpm-lock.yaml", "pnpm-workspace.yaml", "package.json", ".npmrc"])( + "shared install input %s rebuilds both images and Convex", + (path) => { + const output = execFileSync("bash", ["scripts/delivery-changes.sh"], { + input: path, + encoding: "utf8", + }); + expect(output).toContain("frontend=true"); + expect(output).toContain("stream_canvas=true"); + expect(output).toContain("convex=true"); + }, +); + +test("first release succeeds without a previous tag; subsequent unchanged release skips Convex", () => { + const directory = mkdtempSync(join(tmpdir(), "canvas-delivery-")); + const git = (...args: string[]) => + execFileSync("git", args, { + cwd: directory, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + try { + git("init"); + git("config", "user.name", "Fixture"); + git("config", "user.email", "fixture@example.invalid"); + writeFileSync(join(directory, "README.md"), "fixture\n"); + git("add", "README.md"); + git("commit", "-m", "fixture"); + git("tag", "v0.1.0"); + const run = (tag: string) => + execFileSync("bash", [resolve("scripts/delivery-changes.sh"), "tag"], { + cwd: directory, + encoding: "utf8", + env: { + ...process.env, + CURRENT_TAG: tag, + CURRENT_SHA: git("rev-parse", "HEAD"), + }, + }); + expect(run("v0.1.0")).toBe("convex=true\n"); + git("tag", "v0.2.0"); + expect(run("v0.2.0")).toBe("convex=false\n"); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +test("delivery requires successful verification and manual Convex deployment is restricted to main", () => { + const workflow = parse( + readFileSync(".forgejo/workflows/build-images.yml", "utf8"), + ) as { + jobs: Record; + }; + for (const name of [ + "deploy-convex", + "build-frontend", + "build-stream-canvas", + ]) { + const job = workflow.jobs[name]; + expect(job.needs).toContain("verify"); + const condition = job.if ?? ""; + const evaluate = ( + result: string, + ref: string, + event = "workflow_dispatch", + ) => { + const needs = { + verify: { result }, + changes: { outputs: {} }, + "tag-convex-changes": { outputs: {} }, + }; + const github = { + ref, + event_name: event, + event: { + inputs: { + deploy_convex: "true", + build_frontend: "true", + build_stream_canvas: "true", + }, + }, + }; + const expression = condition.replaceAll( + "needs.tag-convex-changes", + 'needs["tag-convex-changes"]', + ); + return Function( + "needs", + "github", + "always", + "startsWith", + `return (${expression});`, + )( + needs, + github, + () => true, + (a: string, b: string) => a.startsWith(b), + ); + }; + for (const result of ["failure", "cancelled", "skipped"]) + expect(evaluate(result, "refs/heads/main")).toBe(false); + expect(evaluate("success", "refs/heads/main", "pull_request")).toBe(false); + expect(evaluate("success", "refs/heads/main")).toBe(true); + if (name === "deploy-convex") { + expect(evaluate("success", "refs/heads/feature")).toBe(false); + expect(evaluate("success", "refs/tags/v1.0.0")).toBe(false); + } + } +}); diff --git a/scripts/dev-lifecycle.test.ts b/scripts/dev-lifecycle.test.ts new file mode 100644 index 0000000..8340d8f --- /dev/null +++ b/scripts/dev-lifecycle.test.ts @@ -0,0 +1,60 @@ +import { execFileSync } from "node:child_process"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { parse } from "yaml"; + +test.each([false, true])( + "alternate database %s controls only its own lifecycle", + (alternate) => { + const directory = mkdtempSync(join(tmpdir(), "moddrop-dev-test-")); + try { + mkdirSync(join(directory, "scripts")); + mkdirSync(join(directory, "bin")); + writeFileSync( + join(directory, "scripts/dev.ts"), + readFileSync("scripts/dev.ts"), + ); + for (const command of ["docker", "pnpm"]) { + writeFileSync( + join(directory, "bin", command), + '#!/bin/sh\nprintf "%s\\n" "$0 $*" >> "$DEV_TEST_LOG"\n', + { mode: 0o755 }, + ); + } + const log = join(directory, "commands"); + execFileSync(process.execPath, [join(directory, "scripts/dev.ts")], { + env: { + NODE_ENV: "test", + PATH: `${join(directory, "bin")}:${process.env.PATH}`, + DEV_TEST_LOG: log, + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: "pk_test_fixture", + CLERK_SECRET_KEY: ["sk", "test", "fixture"].join("_"), + CLERK_JWT_ISSUER_DOMAIN: "https://fixture.invalid", + ...(alternate + ? { MODDROP_DEV_DATABASE_URL: "postgresql://fixture.invalid/test" } + : {}), + }, + stdio: "pipe", + }); + const commands = readFileSync(log, "utf8"); + expect(commands.includes("docker")).toBe(!alternate); + expect(commands).toContain("db:migrate"); + expect(commands).toContain("dev:apps"); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }, +); + +test("development database is bound only to loopback", () => { + const compose = parse(readFileSync("compose.dev.yml", "utf8")); + expect(compose.services.postgres.ports).toEqual(["127.0.0.1:5432:5432"]); +}); diff --git a/scripts/dev.ts b/scripts/dev.ts index 26e1993..b363321 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -1,4 +1,4 @@ -import { spawn, type ChildProcess } from "node:child_process"; +import { type ChildProcess, spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; const projectRoot = fileURLToPath(new URL("..", import.meta.url)); @@ -32,11 +32,13 @@ let exitCode = 0; try { assertRequiredEnvironment(); - console.log( - "[dev] Starting PostgreSQL and waiting for it to become healthy...", - ); - composeAttempted = true; - await run("docker", [...composeArgs, "up", "--wait", "postgres"]); + if (!process.env.MODDROP_DEV_DATABASE_URL) { + console.log( + "[dev] Starting PostgreSQL and waiting for it to become healthy...", + ); + composeAttempted = true; + await run("docker", [...composeArgs, "up", "--wait", "postgres"]); + } throwIfStopping(); console.log("[dev] Applying Drizzle migrations..."); diff --git a/scripts/test-runtime-config.ts b/scripts/test-runtime-config.ts new file mode 100644 index 0000000..c292c11 --- /dev/null +++ b/scripts/test-runtime-config.ts @@ -0,0 +1,126 @@ +import assert from "node:assert/strict"; +import { readFile, readdir } from "node:fs/promises"; +import { join } from "node:path"; +import vm from "node:vm"; + +async function files(directory: string): Promise { + return ( + await Promise.all( + ( + await readdir(directory, { withFileTypes: true }) + ).map((entry) => + entry.isDirectory() + ? files(join(directory, entry.name)) + : [join(directory, entry.name)], + ), + ) + ).flat(); +} +const candidates = await files(".next/static/chunks"); +let source = ""; +for (const file of candidates.filter((file) => file.endsWith(".js"))) { + const text = await readFile(file, "utf8"); + if ( + text.includes("CANVAS_API") && + text.includes("__NEXT_PUBLIC_CANVAS_API_URL__") + ) { + source = text; + break; + } +} +assert.ok( + source, + "Expected the real Next.js API module built with the Docker marker", +); + +for (const configured of [ + "/canvas-api", + "https://canvas-runtime.invalid/alternate", + "/custom-canvas", +]) { + // Next.js 16.2.11 Turbopack chunk registration format. Keep this adapter in + // sync when upgrading Next; assertions still execute the actual built module. + const registrations: unknown[] = []; + const requests: string[] = []; + const context = vm.createContext({ + TURBOPACK: registrations, + document: { currentScript: null }, + window: { + location: { + protocol: "https:", + host: "frontend.invalid", + origin: "https://frontend.invalid", + }, + }, + process: { env: { NODE_ENV: "production" } }, + URL, + console, + fetch: async (url: string) => { + requests.push(url); + return { + ok: true, + status: 200, + json: async () => ({ url: "/uploads/fixture/file.png", expiresIn: 60 }), + }; + }, + }); + vm.runInContext( + source.split("__NEXT_PUBLIC_CANVAS_API_URL__").join(configured), + context, + { timeout: 2_000 }, + ); + function factories(value: unknown): ((runtime: unknown) => void)[] { + if (typeof value === "function") + return [value as (runtime: unknown) => void]; + return Array.isArray(value) ? value.flatMap(factories) : []; + } + const factory = factories(registrations).find((fn) => + fn.toString().includes('"buildEditorWsUrl"'), + ); + assert.ok( + factory, + "Compiled chunk format changed; update scripts/test-runtime-config.ts for this Next.js version", + ); + const exports: Record = {}; + factory({ + s(entries: unknown[]) { + for (let i = 0; i < entries.length; ) { + const name = entries[i++] as string; + const getter = entries[i++]; + exports[name] = typeof getter === "function" ? getter() : entries[i++]; + } + }, + }); + const ws = ( + exports.buildEditorWsUrl as (room: string, token: string) => string + )("fixture", "fixture-token"); + assert.equal( + ws, + `${configured.startsWith("/") ? `wss://frontend.invalid${configured}` : configured.replace("https:", "wss:")}/ws?roomId=fixture&token=fixture-token`, + ); + assert.equal( + await ( + exports.resolveEditorUploadUrl as ( + roomId: string, + src: string, + getToken: () => Promise, + ) => Promise + )( + "fixture-room", + "/uploads/fixture/file.png", + async () => "synthetic-test-token", + ), + `${configured}/uploads/fixture/file.png`, + ); + await ( + exports.createRoom as (getToken: () => Promise) => Promise + )(async () => "synthetic-test-token"); + assert.equal( + requests[0], + `${configured}/api/rooms/fixture-room/uploads/fixture/access-url`, + ); + assert.equal(requests[1], `${configured}/api/rooms`); +} +console.log( + "Compiled runtime API, WebSocket, and asset URLs passed for default, absolute, and alternate paths.", +); diff --git a/scripts/verify.sh b/scripts/verify.sh new file mode 100644 index 0000000..597f662 --- /dev/null +++ b/scripts/verify.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail +export NEXT_TELEMETRY_DISABLED=1 +export NEXT_PUBLIC_CONVEX_URL=https://placeholder.convex.cloud +export NEXT_PUBLIC_CONVEX_SITE_URL=https://placeholder.convex.site +export NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_placeholder_CLERK_KEY +export NEXT_PUBLIC_CANVAS_API_URL=__NEXT_PUBLIC_CANVAS_API_URL__ +export NEXT_PUBLIC_TLDRAW_LICENSE_KEY=tldraw-placeholder-key +pnpm run lint +pnpm exec biome format . +pnpm run typecheck +pnpm --dir backend/stream-canvas run typecheck +pnpm --dir backend/stream-canvas run typecheck:tests +NODE_ENV=test pnpm run test +NODE_ENV=test pnpm --dir backend/stream-canvas run test +pnpm --dir backend/stream-canvas run build +pnpm run build +pnpm run test:runtime-config +pnpm run test:browser diff --git a/vitest.config.ts b/vitest.config.ts index b8c7ff6..ff26559 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,6 +8,12 @@ export default defineConfig({ }, }, test: { - exclude: ["**/node_modules/**", "**/backend/**", "**/.next/**"], + env: { NEXT_PUBLIC_CANVAS_API_URL: "https://canvas-test.invalid" }, + exclude: [ + "**/node_modules/**", + "**/backend/**", + "**/.next/**", + "**/e2e/**", + ], }, });