snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-23 16:12:52 +05:00
commit a774ac673a
4541 changed files with 770499 additions and 0 deletions

2
apps/dotcom/sync-worker/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
build
.wrangler

View File

@@ -0,0 +1,38 @@
# @tldraw/dotcom-worker
The dotcom-worker is used for room management on tldraw.com (e.g. creating & joining rooms, readonly rooms, snapshots).
## Enable database persistence for local dev
The values for `env.SUPABASE_KEY` and `env.SUPABASE_URL` are stored in the Cloudflare Workers dashboard for this worker. However we use `--local` mode for local development, which doesn't read these values from the dashboard.
To workaround this, create a file called `.dev.vars` under `merge-server` with the required values (which you can currently find at https://app.supabase.com/project/bfcjbbjqflgfzxhskwct/settings/api). This will be read by `wrangler dev --local` and used to populate the environment variables.
```
SUPABASE_URL=<url>
SUPABASE_KEY=<key>
```
## License
The code in this folder is Copyright (c) 2024-present tldraw Inc. The tldraw SDK is provided under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md).
## Trademarks
Copyright (c) 2024-present tldraw Inc. The tldraw name and logo are trademarks of tldraw. Please see our [trademark guidelines](https://github.com/tldraw/tldraw/blob/main/TRADEMARKS.md) for info on acceptable usage.
## Distributions
You can find tldraw on npm [here](https://www.npmjs.com/package/@tldraw/tldraw?activeTab=versions).
## Contribution
Found a bug? Please [submit an issue](https://github.com/tldraw/tldraw/issues/new).
## Community
Have questions, comments or feedback? [Join our discord](https://discord.tldraw.com/?utm_source=github&utm_medium=readme&utm_campaign=sociallink). For the latest news and release notes, visit [tldraw.dev](https://tldraw.dev).
## Contact
Find us on Twitter/X at [@tldraw](https://twitter.com/tldraw).

5
apps/dotcom/sync-worker/dev.sh Executable file
View File

@@ -0,0 +1,5 @@
#! /bin/bash
set -eux
yarn run -T tsx ./dev.ts

View File

@@ -0,0 +1,49 @@
{
"name": "@tldraw/dotcom-worker",
"description": "tldraw infinite canvas SDK (merge server).",
"version": "0.0.0",
"private": true,
"author": {
"name": "tldraw GB Ltd.",
"email": "hello@tldraw.com"
},
"main": "./src/worker.ts",
"type": "module",
"/* GOTCHA */": "files will include ./dist and index.d.ts by default, add any others you want to include in here",
"files": [],
"scripts": {
"dev": "yarn run -T tsx ../../../internal/scripts/workers/dev.ts --persist-to .wrangler/state-dev --var ASSET_UPLOAD_ORIGIN:http://localhost:8788 --var USER_CONTENT_URL:http://localhost:8789",
"test-ci": "yarn run -T vitest run --passWithNoTests",
"test": "yarn run -T vitest --passWithNoTests",
"clean": "rm -rf .wrangler/state .wrangler/state-*",
"test-coverage": "yarn run -T vitest run --coverage --passWithNoTests",
"check-bundle-size": "yarn run -T tsx ../../../internal/scripts/check-worker-bundle.ts --entry src/worker.ts --size-limit-bytes 1550000",
"reset-db": "./reset-db.sh",
"lint": "yarn run -T tsx ../../../internal/scripts/lint.ts"
},
"dependencies": {
"@clerk/backend": "^3.17.1",
"@rocicorp/zero": "1.9.0",
"@supabase/supabase-js": "^2.48.1",
"@tldraw/dotcom-shared": "workspace:*",
"@tldraw/store": "workspace:*",
"@tldraw/sync-collaboration": "workspace:*",
"@tldraw/sync-core": "workspace:*",
"@tldraw/tlschema": "workspace:*",
"@tldraw/utils": "workspace:*",
"@tldraw/worker-shared": "workspace:*",
"itty-router": "^5.0.18",
"kysely": "^0.28.12",
"p-queue": "^9.3.0",
"pg": "^8.16.3"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260302.0",
"@types/pg": "^8.11.11",
"esbuild": "^0.28.0",
"lazyrepo": "0.0.0-alpha.27",
"typescript": "^7.0.2",
"vitest": "^5.0.0",
"wrangler": "^4.75.0"
}
}

View File

@@ -0,0 +1,45 @@
import { createSentry } from '@tldraw/worker-shared'
import { Environment, isDebugLogging } from './types'
import { getLogger } from './utils/durableObjects'
export class Logger {
readonly logger
constructor(
env: Environment,
private prefix: string,
private sentry?: ReturnType<typeof createSentry>
) {
if (isDebugLogging(env)) {
this.logger = getLogger(env)
}
}
private outgoing: string[] = []
private isRunning = false
debug(...args: any[]) {
if (!this.logger && !this.sentry) return
const msg = `[${this.prefix} ${new Date().toISOString()}]: ${args.map((a) => (typeof a === 'object' ? JSON.stringify(a) : a)).join(' ')}`
this.outgoing.push(msg)
// A failed debug RPC must not become an unhandled rejection in the caller.
this.processQueue().catch(() => {})
}
private async processQueue() {
if (this.isRunning) return
this.isRunning = true
try {
while (this.outgoing.length) {
const batch = this.outgoing
this.outgoing = []
await this.logger?.debug(batch)
for (const message of batch) {
// eslint-disable-next-line @typescript-eslint/no-deprecated
this.sentry?.addBreadcrumb({ message })
}
}
} finally {
this.isRunning = false
}
}
}

View File

@@ -0,0 +1,231 @@
import { TlaEffectOutbox } from '@tldraw/dotcom-shared'
import { createSentry } from '@tldraw/worker-shared'
import { DurableObject } from 'cloudflare:workers'
import { sql } from 'kysely'
import { FileEffectDeps, processFileEffect } from './fileEffects'
import {
EFFECT_TIMEOUT_MS,
MAX_ATTEMPTS,
computeNextAlarm,
drainOutbox,
formatOutboxError,
shouldReportEffectFailure,
} from './outboxDrain'
import { createPostgresConnectionPool } from './postgres'
import { Analytics, Environment } from './types'
import { EventData, writeDataPoint } from './utils/analytics'
import { getRoomDurableObject } from './utils/durableObjects'
import { publishSnapshot, unpublishSnapshot } from './utils/publishSnapshots'
const SWEEP_INTERVAL_MS = 30_000
// Singleton DO that drains effect_outbox. Poked by the Zero push endpoint and
// admin routes after file writes; the alarm sweep catches PG-trigger writes
// (workspace-delete cascade) and lost pokes.
export class TLFileEffectProcessor extends DurableObject<Environment> {
private drainPromise: Promise<void> | null = null
private sentry
private measure: Analytics | undefined
constructor(ctx: DurableObjectState, env: Environment) {
super(ctx, env)
this.sentry = createSentry(ctx, env)
this.measure = env.MEASURE
// Keep the sweep chain alive across restarts/evictions: if the persisted alarm is
// missing or past-due (a past-due alarm can't be trusted to fire), re-arm it. Note this
// runs only once something instantiates the DO — the first poke() ever is what starts
// the chain; until then, outbox rows wait.
ctx.blockConcurrencyWhile(async () => {
const scheduled = await ctx.storage.getAlarm()
if (scheduled === null || scheduled <= Date.now()) {
await ctx.storage.setAlarm(Date.now() + SWEEP_INTERVAL_MS)
}
})
}
// eslint-disable-next-line tldraw/prefer-class-methods
private captureException = (exception: unknown, extras?: Record<string, unknown>) => {
// eslint-disable-next-line @typescript-eslint/no-deprecated
this.sentry?.withScope((scope) => {
if (extras) scope.setExtras(extras)
// eslint-disable-next-line @typescript-eslint/no-deprecated
this.sentry?.captureException(exception) as any
})
if (!this.sentry) {
console.error(`[TLFileEffectProcessor]: `, exception)
}
}
// eslint-disable-next-line tldraw/prefer-class-methods
private writeEvent = (name: string, eventData: EventData) => {
writeDataPoint(this.sentry, this.measure, this.env, name, eventData)
}
async poke() {
// Always (re)arm: a past-due persisted alarm can't be trusted to fire (the runtime can
// drop one, e.g. across restarts), and re-setting an imminent alarm is a harmless
// storage write.
await this.ctx.storage.setAlarm(Date.now())
}
override async alarm() {
try {
await this.drain()
} catch (e) {
// row-level failures are handled inside the drain; this catches drain-level
// crashes like an unreachable database
this.captureException(e)
} finally {
// Re-arm the sweep without swallowing a mid-drain poke: a poke that arrived during
// the drain set alarm(now), which is past-due by now; computeNextAlarm re-arms it at
// ~1s (a past-due alarm can't be trusted to fire on its own). null => leave as-is.
const scheduled = await this.ctx.storage.getAlarm()
const next = computeNextAlarm(scheduled, Date.now(), SWEEP_INTERVAL_MS)
if (next !== null) await this.ctx.storage.setAlarm(next)
}
}
private drain() {
// Coalesce: one drain in flight; a poke during a drain lands on the ~1s follow-up alarm
// computeNextAlarm arms after this drain finishes.
if (!this.drainPromise) {
const promise = this._drain().finally(() => {
// Identity guard: only clear the latch if it's still ours.
if (this.drainPromise === promise) this.drainPromise = null
})
this.drainPromise = promise
}
return this.drainPromise
}
private async _drain() {
const drainStart = Date.now()
let processed = 0
let failed = 0
const db = createPostgresConnectionPool(this.env, 'TLFileEffectProcessor')
const fileDeps: FileEffectDeps = {
getCurrentFile: (fileId) =>
db.selectFrom('file').selectAll().where('id', '=', fileId).executeTakeFirst(),
notifyInsert: (file) => getRoomDurableObject(this.env, file.id).appFileRecordCreated(file),
notifyUpdate: (file) => getRoomDurableObject(this.env, file.id).appFileRecordDidUpdate(file),
notifyDelete: (fileRow) =>
getRoomDurableObject(this.env, fileRow.id).appFileRecordDidDelete(fileRow),
publish: (file) =>
publishSnapshot(this.env, file, (error) =>
this.captureException(error, { publishThumbnailEnqueue: true })
),
unpublish: (file) => unpublishSnapshot(this.env, file),
}
// One handler per source table. Future effect sources (e.g. notifications)
// register here alongside their trigger.
const handlers: Record<string, (row: TlaEffectOutbox) => Promise<void>> = {
file: (row) => processFileEffect(fileDeps, row),
}
try {
await drainOutbox({
getBatch: () =>
db
.selectFrom('effect_outbox')
.selectAll()
.where('attempts', '<', MAX_ATTEMPTS)
.where((eb) =>
eb.or([eb('nextRetryAt', 'is', null), eb('nextRetryAt', '<=', sql<Date>`now()`)])
)
.orderBy('id')
.limit(50)
.execute(),
deleteRow: async (id) => {
await db.deleteFrom('effect_outbox').where('id', '=', id).execute()
processed++
},
bumpAttempts: async (row, error) => {
failed++
// Exponential backoff from the row's current attempt count, capped at 5 minutes.
// The base IS the effect timeout, so the first retry can't land before a
// timed-out RPC's window closes (the RPC keeps running — it can't be cancelled),
// avoiding an overlapping retry. This relies on the RPC actually finishing within
// the window: a publish effect's awaitPersist used to retry for up to ~200s
// (PERSIST_RETRIES_MAX * 2s), well past this 30s timeout, so a zombie publish
// could still be running when a retried attempt started. awaitPersist now caps
// its throwing retries at ~20s (PERSIST_RETRIES_MAX_THROWING), so it can't.
const backoffSeconds = Math.min(2 ** row.attempts * (EFFECT_TIMEOUT_MS / 1000), 300)
const backoff = sql<Date>`now() + (${backoffSeconds} || ' seconds')::interval`
// (a) Back off the failed row itself and bump its attempt count.
await db
.updateTable('effect_outbox')
.set((eb) => ({
attempts: eb('attempts', '+', 1),
nextRetryAt: backoff,
lastError: formatOutboxError(error),
}))
.where('id', '=', row.id)
.execute()
// (b) Defer the failed row's later same-entity siblings so per-entity ordering
// holds across drains: they must not run while this row is backing off. Do NOT
// touch their attempts (they haven't been tried), and never shrink an existing
// later nextRetryAt (GREATEST keeps the furthest-out schedule).
await db
.updateTable('effect_outbox')
.set({
nextRetryAt: sql<Date>`GREATEST("nextRetryAt", ${backoff})`,
})
.where('tableName', '=', row.tableName)
.where('entityId', '=', row.entityId)
.where('id', '>', row.id)
.execute()
},
deleteParkedRowsOlderThan: async (days) => {
await db
.deleteFrom('effect_outbox')
.where('attempts', '>=', MAX_ATTEMPTS)
.where('createdAt', '<', sql<Date>`now() - (${days} || ' days')::interval`)
.execute()
},
process: async (row) => {
const handler = handlers[row.tableName]
if (!handler) {
// trigger + handler ship together; this only catches mistakes
this.captureException(new Error('effect_outbox row for unhandled table, dropping'), {
tableName: row.tableName,
outboxId: row.id,
})
return
}
await handler(row)
// End-to-end effect latency: time from the row landing in the outbox (its
// transaction commit) to the effect completing. This is the user-facing number —
// how long after a file change the room notification / publish actually lands.
this.writeEvent('outbox_effect', {
blobs: [row.tableName, row.command],
doubles: [Date.now() - new Date(row.createdAt).getTime()],
})
},
onError: (error, row) => {
// Report the first failure (immediate visibility) and the parking failure (data
// loss), but not every retry in between - under an outage, one row can burn through
// MAX_ATTEMPTS attempts, and reporting each would eat Sentry's rate limit and drop
// the parking events that matter most. See shouldReportEffectFailure's doc comment.
const isParking = row.attempts + 1 >= MAX_ATTEMPTS
if (shouldReportEffectFailure(row.attempts)) {
this.captureException(error, {
tableName: row.tableName,
entityId: row.entityId,
command: row.command,
outboxId: row.id,
attempts: row.attempts + 1,
})
}
if (isParking) {
this.writeEvent('outbox_parked', { blobs: [row.tableName, row.command] })
}
},
})
} finally {
await db.destroy()
// Per-drain summary: throughput (processed/failed) and how long the drain took.
// Drains coalesce, so this is low-volume; the sweep also emits an empty summary every
// ~30s, which doubles as a liveness heartbeat for the processor.
this.writeEvent('outbox_drain', { doubles: [processed, failed, Date.now() - drainStart] })
}
}
}

View File

@@ -0,0 +1,67 @@
import { DurableObject } from 'cloudflare:workers'
import { IRequest } from 'itty-router'
import { Environment, isDebugLogging } from './types'
export class TLLoggerDurableObject extends DurableObject<Environment> {
private readonly isDebugEnv
private readonly db
constructor(ctx: DurableObjectState, env: Environment) {
super(ctx, env)
this.isDebugEnv = isDebugLogging(env)
this.db = this.ctx.storage.sql
this.db.exec(
`CREATE TABLE IF NOT EXISTS logs (
message TEXT NOT NULL
);
CREATE TRIGGER IF NOT EXISTS limit_logs
AFTER INSERT ON logs
BEGIN
DELETE FROM logs WHERE rowid NOT IN (
SELECT rowid FROM logs ORDER BY rowid DESC LIMIT 20000
);
END;`
)
}
private sockets = new Set<WebSocket>()
async debug(messages: string[]) {
if (!this.isDebugEnv) return
for (const message of messages) {
this.db.exec(`INSERT INTO logs (message) VALUES (?)`, message)
}
for (const message of messages) {
for (const socket of this.sockets) {
socket.send(message + '\n')
}
}
}
getFullHistory() {
return this.db
.exec('SELECT message FROM logs ORDER BY rowid ASC')
.toArray()
.map((row) => row.message)
}
async clear() {
this.db.exec('DELETE FROM logs')
}
override async fetch(_req: IRequest) {
if (!this.isDebugEnv) return new Response('Not Found', { status: 404 })
const { 0: clientWebSocket, 1: serverWebSocket } = new WebSocketPair()
serverWebSocket.accept()
this.sockets.add(serverWebSocket)
const cleanup = () => {
this.sockets.delete(serverWebSocket)
serverWebSocket.close()
}
serverWebSocket.addEventListener('close', cleanup)
serverWebSocket.addEventListener('error', cleanup)
serverWebSocket.send('Connected to logger\n' + this.getFullHistory().join('\n'))
return new Response(null, { status: 101, webSocket: clientWebSocket })
}
}

View File

@@ -0,0 +1,263 @@
import { StatusError } from 'itty-router'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { adminRoutes } from './adminRoutes'
import { Environment } from './types'
// The admin feature-flag routes, which are now the only way onto the `mcp_server_access` allowlist —
// the bespoke KV list this PR deleted was the other one. adminRoutes.ts had no test file at all, so
// nothing covered the path that decides who may drive the MCP server.
//
// Postgres and Clerk are mocked; the flag store is a real in-memory KV stand-in, so what a save
// actually writes is what the assertions read back.
vi.mock('./postgres', () => ({ createPostgresConnectionPool: vi.fn() }))
vi.mock('./utils/tla/getAuth', () => ({
requireAuth: vi.fn(async () => ({ userId: 'user_admin' })),
requireAdminAccess: vi.fn(async () => {}),
getClerkClient: vi.fn(),
}))
const { createPostgresConnectionPool } = await import('./postgres')
let kv: Map<string, string>
/** Stands in for the `user` table lookups both the save and the admin GET make. */
function mockUsers(rows: Array<{ id: string; email: string }>) {
vi.mocked(createPostgresConnectionPool).mockReturnValue({
selectFrom: () => ({
select: () => ({ where: () => ({ execute: async () => rows }) }),
}),
destroy: async () => {},
} as any)
}
function makeEnv() {
kv = new Map()
return {
FEATURE_FLAGS: {
get: async (key: string) => kv.get(key) ?? null,
put: async (key: string, value: string) => void kv.set(key, value),
},
} as unknown as Environment
}
/**
* The router throws `StatusError` and the worker's `handleApiRequest` turns it into a response, so a
* test driving the router directly has to do the same or every refusal reads as an unhandled throw.
*/
async function fetchAdmin(request: Request, env: Environment) {
try {
return await adminRoutes.fetch(request, env)
} catch (error: any) {
if (error instanceof StatusError) {
return Response.json({ error: error.message }, { status: error.status })
}
throw error
}
}
async function post(env: Environment, body: unknown) {
return fetchAdmin(
new Request('https://sync.tldraw.xyz/app/admin/feature-flags', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
}),
env
)
}
function storedFlag(key: string) {
return JSON.parse(kv.get(key)!)
}
beforeEach(() => {
vi.clearAllMocks()
mockUsers([])
})
describe('POST /app/admin/feature-flags', () => {
it('resolves emails to user ids and stores the pair', async () => {
const env = makeEnv()
mockUsers([{ id: 'user_1', email: 'One@tldraw.com' }])
const response = await post(env, {
flag: 'mcp_server_access',
emails: 'one@tldraw.com',
})
expect(response.status).toBe(200)
// The address as the database has it, not as the admin typed it, so the panel shows the real one.
expect(storedFlag('mcp_server_access').users).toEqual([
{ userId: 'user_1', email: 'One@tldraw.com' },
])
})
// The server's parser, not the client's: a comma-separated paste is one line and several people.
// The admin panel used to count these differently from the worker and report "1 user" for five.
it('accepts a comma-separated paste as several addresses', async () => {
const env = makeEnv()
mockUsers([
{ id: 'user_1', email: 'one@tldraw.com' },
{ id: 'user_2', email: 'two@tldraw.com' },
])
await post(env, { flag: 'mcp_server_access', emails: 'one@tldraw.com, two@tldraw.com' })
expect(storedFlag('mcp_server_access').users).toHaveLength(2)
})
// An address with no account would be stored as an entry that looks like a grant and matches
// nobody, so it fails the save instead — at the point someone can still fix the typo.
it('refuses an email with no tldraw account', async () => {
const env = makeEnv()
mockUsers([])
const response = await post(env, { flag: 'mcp_server_access', emails: 'ghost@tldraw.com' })
expect(response.status).toBe(400)
expect(await response.text()).toContain('ghost@tldraw.com')
expect(kv.has('mcp_server_access')).toBe(false)
})
it('refuses input that is not an email address at all', async () => {
const env = makeEnv()
const response = await post(env, { flag: 'mcp_server_access', emails: 'tldraw.com' })
expect(response.status).toBe(400)
expect(kv.has('mcp_server_access')).toBe(false)
})
// The field means nothing for a percentage flag. It used to be dropped in silence while the
// response still answered `{success: true, users: […]}`, so an admin could be told a save had
// landed that stored nothing anywhere.
it('refuses emails sent for a flag that has no allowlist', async () => {
const env = makeEnv()
const response = await post(env, { flag: 'rum_enabled', emails: 'one@tldraw.com' })
expect(response.status).toBe(400)
expect(await response.text()).toContain('emails do not apply')
expect(kv.has('rum_enabled')).toBe(false)
})
it('refuses a percentage sent for an allowlist flag', async () => {
const env = makeEnv()
const response = await post(env, { flag: 'mcp_server_access', percentage: 50 })
expect(response.status).toBe(400)
expect(kv.has('mcp_server_access')).toBe(false)
})
it('refuses an unknown flag', async () => {
const env = makeEnv()
expect((await post(env, { flag: 'not_a_flag', enabled: true })).status).toBe(400)
})
// A save replaces the list rather than merging into it, so removing someone is an ordinary save.
it('replaces the list wholesale', async () => {
const env = makeEnv()
mockUsers([
{ id: 'user_1', email: 'one@tldraw.com' },
{ id: 'user_2', email: 'two@tldraw.com' },
])
await post(env, { flag: 'mcp_server_access', emails: 'one@tldraw.com\ntwo@tldraw.com' })
mockUsers([{ id: 'user_2', email: 'two@tldraw.com' }])
await post(env, { flag: 'mcp_server_access', emails: 'two@tldraw.com' })
expect(storedFlag('mcp_server_access').users).toEqual([
{ userId: 'user_2', email: 'two@tldraw.com' },
])
})
// The list lives in one KV value that every evaluation reads, so a pasted address book is worth
// refusing rather than storing.
it('refuses a list past the cap', async () => {
const env = makeEnv()
const emails = Array.from({ length: 201 }, (_, i) => `user${i}@tldraw.com`).join('\n')
const response = await post(env, { flag: 'mcp_server_access', emails })
expect(response.status).toBe(400)
expect(kv.has('mcp_server_access')).toBe(false)
})
it('toggles the master switch without touching the list', async () => {
const env = makeEnv()
mockUsers([{ id: 'user_1', email: 'one@tldraw.com' }])
await post(env, { flag: 'mcp_server_access', emails: 'one@tldraw.com' })
await post(env, { flag: 'mcp_server_access', enabled: true })
expect(storedFlag('mcp_server_access')).toMatchObject({
enabled: true,
users: [{ userId: 'user_1', email: 'one@tldraw.com' }],
})
})
})
describe('GET /app/admin/feature-flags', () => {
async function get(env: Environment) {
const response = await fetchAdmin(
new Request('https://sync.tldraw.xyz/app/admin/feature-flags'),
env
)
return (await response.json()) as any
}
// The stored email is written once at save time and never updated, so it rots. Re-resolving on read
// costs the admin panel one query and keeps the list re-saveable.
it('refreshes the email labels from the database', async () => {
const env = makeEnv()
kv.set(
'mcp_server_access',
JSON.stringify({
type: 'allowlist',
enabled: true,
users: [{ userId: 'user_1', email: 'old-address@tldraw.com' }],
})
)
mockUsers([{ id: 'user_1', email: 'new-address@tldraw.com' }])
const flags = await get(env)
expect(flags.mcp_server_access.users).toEqual([
{ userId: 'user_1', email: 'new-address@tldraw.com' },
])
})
// A deleted account leaves an entry that still looks like a live grant and matches nobody, and
// re-saving the list as displayed would 400 on a line the admin cannot pick out from the rest.
it('marks an entry whose user id no longer resolves', async () => {
const env = makeEnv()
kv.set(
'mcp_server_access',
JSON.stringify({
type: 'allowlist',
enabled: true,
users: [{ userId: 'user_gone', email: 'gone@tldraw.com' }],
})
)
mockUsers([])
const flags = await get(env)
expect(flags.mcp_server_access.users).toEqual([
{ userId: 'user_gone', email: 'gone@tldraw.com', missing: true },
])
})
// The defaults table is the schema; KV holds only state. A hand-edited value with a typo'd type
// must not reach the panel — or the request path — as a shape neither of them recognises.
it('reports the type from the defaults, not from KV', async () => {
const env = makeEnv()
kv.set('mcp_server_access', JSON.stringify({ type: 'allowList', enabled: true }))
const flags = await get(env)
expect(flags.mcp_server_access.type).toBe('allowlist')
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest'
import { collectAssetAssociationChanges } from './assetAssociation'
const SLUG = 'file-slug-123'
const USER_CONTENT = 'https://tldrawusercontent.com'
function imageAsset(overrides: {
id?: string
src?: string | null
fileId?: string | null
type?: string
}) {
return {
typeName: 'asset',
type: overrides.type ?? 'image',
id: overrides.id ?? 'asset:1',
props: { src: overrides.src },
meta: overrides.fileId === undefined ? {} : { fileId: overrides.fileId },
}
}
function collect(records: unknown[], skipObjectNames?: ReadonlySet<string>) {
return collectAssetAssociationChanges(records, {
slug: SLUG,
userContentUrl: USER_CONTENT,
skipObjectNames,
})
}
describe('collectAssetAssociationChanges', () => {
it('queues a copy for an asset from another file, preserving the file type suffix', () => {
const { assetsToReplace, assetsToMigrate } = collect([
imageAsset({ src: `${USER_CONTENT}/abc123-png`, fileId: 'other-file' }),
])
expect(assetsToMigrate).toEqual([])
expect(assetsToReplace).toEqual([
{
assetId: 'asset:1',
objectName: 'abc123-png',
newObjectName: expect.stringMatching(/-png$/),
newSrc: expect.stringMatching(new RegExp(`^${USER_CONTENT}/.*-png$`)),
},
])
expect(assetsToReplace[0].newObjectName).not.toBe('abc123-png')
})
it('queues a copy for an unassociated asset without a file type suffix', () => {
const { assetsToReplace } = collect([imageAsset({ src: `${USER_CONTENT}/abc123` })])
expect(assetsToReplace).toHaveLength(1)
expect(assetsToReplace[0].objectName).toBe('abc123')
})
it('migrates old-format urls for assets already associated with this file', () => {
const { assetsToReplace, assetsToMigrate } = collect([
imageAsset({ src: 'https://old-host.tldraw.com/uploads/abc123-png', fileId: SLUG }),
])
expect(assetsToReplace).toEqual([])
expect(assetsToMigrate).toEqual([{ assetId: 'asset:1', newSrc: `${USER_CONTENT}/abc123-png` }])
})
it('leaves associated assets with current-format urls alone', () => {
const result = collect([imageAsset({ src: `${USER_CONTENT}/abc123-png`, fileId: SLUG })])
expect(result).toEqual({ assetsToReplace: [], assetsToMigrate: [] })
})
it('skips bookmark assets — their src is the bookmarked page url, not an upload', () => {
const result = collect([
imageAsset({ type: 'bookmark', src: 'https://example.com/products/melvin' }),
imageAsset({ type: 'bookmark', src: 'https://example.com/page', fileId: SLUG }),
])
expect(result).toEqual({ assetsToReplace: [], assetsToMigrate: [] })
})
it('skips data: and missing srcs', () => {
const result = collect([
imageAsset({ src: 'data:image/png;base64,AAAA' }),
imageAsset({ src: null }),
imageAsset({ src: '' }),
])
expect(result).toEqual({ assetsToReplace: [], assetsToMigrate: [] })
})
it('skips object names R2 would reject', () => {
const longName = 'a'.repeat(2000)
const result = collect([
imageAsset({ src: `https://example.com/${longName}` }),
imageAsset({ src: 'https://example.com/trailing/' }),
])
expect(result).toEqual({ assetsToReplace: [], assetsToMigrate: [] })
})
it('does not migrate old-format urls whose object name R2 would reject', () => {
const longName = 'a'.repeat(2000)
const result = collect([
imageAsset({ src: `https://old-host.tldraw.com/uploads/${longName}`, fileId: SLUG }),
])
expect(result).toEqual({ assetsToReplace: [], assetsToMigrate: [] })
})
it('skips object names a previous pass found missing from the bucket', () => {
const { assetsToReplace } = collect(
[
imageAsset({ id: 'asset:1', src: `${USER_CONTENT}/gone-png` }),
imageAsset({ id: 'asset:2', src: `${USER_CONTENT}/present-png` }),
],
new Set(['gone-png'])
)
expect(assetsToReplace.map((a) => a.assetId)).toEqual(['asset:2'])
})
it('ignores non-asset records', () => {
const result = collect([{ typeName: 'shape', type: 'image', props: {} }])
expect(result).toEqual({ assetsToReplace: [], assetsToMigrate: [] })
})
})

View File

@@ -0,0 +1,86 @@
import { uniqueId } from '@tldraw/utils'
import { isValidR2ObjectName } from '@tldraw/worker-shared'
export interface AssetToReplace {
objectName: string
newObjectName: string
newSrc: string
assetId: string
}
export interface AssetToMigrate {
assetId: string
newSrc: string
}
/**
* The uploads-bucket object name an asset's src points at, or null for assets the
* association pass can never act on: bookmark assets (their src is the bookmarked page URL,
* not an uploads object), non-http srcs, and object names R2 would reject. Shared with the
* admin asset diagnostics so its `external` count matches what the pass actually skips.
*/
export function getUploadObjectName(asset: {
type?: string
props?: { src?: string | null }
}): string | null {
if (asset.type === 'bookmark') return null
const src = asset.props?.src
if (!src || !src.startsWith('http')) return null
const objectName = src.split('/').pop()
if (!objectName || !isValidR2ObjectName(objectName)) return null
return objectName
}
/**
* Decides which asset records an association pass should act on. Assets `getUploadObjectName`
* rejects are skipped entirely so the pass doesn't re-attempt them on every run; `skipObjectNames`
* (objects a previous pass already found missing from the uploads bucket) only gates the copy
* path — URL migration is a string rewrite and needs no R2 access.
*/
export function collectAssetAssociationChanges(
records: Iterable<unknown>,
{
slug,
userContentUrl,
skipObjectNames,
}: {
slug: string
userContentUrl: string
skipObjectNames?: ReadonlySet<string>
}
): { assetsToReplace: AssetToReplace[]; assetsToMigrate: AssetToMigrate[] } {
const assetsToReplace: AssetToReplace[] = []
const assetsToMigrate: AssetToMigrate[] = []
for (const record of records) {
if ((record as any).typeName !== 'asset') continue
const asset = record as any
const objectName = getUploadObjectName(asset)
if (!objectName) continue
const meta = asset.meta
const src = asset.props.src
// Migrate old-format HTTP URLs to tldrawusercontent.com (same R2 bucket, no copy needed)
if (meta?.fileId === slug && !src.startsWith(userContentUrl)) {
assetsToMigrate.push({
assetId: asset.id,
newSrc: `${userContentUrl}/${objectName}`,
})
continue
}
if (meta?.fileId === slug) continue
if (skipObjectNames?.has(objectName)) continue
const split = objectName.split('-')
const fileType = split.length > 1 ? split.pop() : null
const id = uniqueId()
const newObjectName = fileType ? `${id}-${fileType}` : id
assetsToReplace.push({
objectName,
newObjectName,
assetId: asset.id,
newSrc: `${userContentUrl}/${newObjectName}`,
})
}
return { assetsToReplace, assetsToMigrate }
}

View File

@@ -0,0 +1,211 @@
import {
TLComment,
TLNoteShape,
TLPageId,
TLShape,
createComment,
createCommentThread,
createShapeId,
toRichText,
} from '@tldraw/tlschema'
import { describe, expect, it } from 'vitest'
import { SessionMeta, authorizeFileRecord } from './authorizeFileRecord'
const pageId = 'page:test' as TLPageId
function session(
userId: string | null,
isReadonly = false
): {
sessionId: string
isReadonly: boolean
meta: SessionMeta
} {
return { sessionId: 's1', isReadonly, meta: { storeId: 'store', userId } }
}
describe('authorizeFileRecord', () => {
// The comment/thread policy itself is tested in @tldraw/sync-collaboration; these two only
// verify the factory is wired in with getUserId mapped to session.meta.userId.
describe('comment authorizer wiring', () => {
const thread = createCommentThread({
pageId,
anchor: { type: 'page' },
createdBy: 'real-bob',
})
it('stamps authorId from the session user id', () => {
const next = createComment({
threadId: thread.id,
pageId,
authorId: 'client-claims-alice',
body: toRichText('hi'),
})
const result = authorizeFileRecord.comment!({
session: session('real-bob'),
type: 'create',
prev: null,
next,
}) as TLComment
expect(result.authorId).toBe('real-bob')
})
it('vetoes thread hard-deletes (deletion is soft)', () => {
expect(
authorizeFileRecord['comment-thread']!({
session: session('real-bob'),
type: 'delete',
prev: thread,
next: null,
})
).toBeNull()
})
it('vetoes a comment create from a canvas read-only session', () => {
const next = createComment({
threadId: thread.id,
pageId,
authorId: 'real-bob',
body: toRichText('hi'),
})
expect(
authorizeFileRecord.comment!({
session: session('real-bob', true),
type: 'create',
prev: null,
next,
})
).toBeNull()
})
})
describe('shape', () => {
const authorize = authorizeFileRecord.shape!
function makeNote(textLastEditedBy: string | null): TLNoteShape {
return {
id: createShapeId('note1'),
typeName: 'shape',
type: 'note',
x: 0,
y: 0,
rotation: 0,
index: 'a1' as TLNoteShape['index'],
parentId: 'page:test' as TLNoteShape['parentId'],
isLocked: false,
opacity: 1,
meta: {},
props: {
color: 'black',
richText: toRichText('hello'),
size: 'm',
font: 'draw',
align: 'middle',
verticalAlign: 'middle',
labelColor: 'black',
growY: 0,
fontSizeAdjustment: 1,
url: '',
scale: 1,
textLastEditedBy,
},
}
}
function makeGeo(): TLShape {
const note = makeNote(null)
const { textLastEditedBy: _, ...props } = note.props
return { ...note, id: createShapeId('geo1'), type: 'geo', props } as unknown as TLShape
}
it('vetoes a create with foreign attribution (duplication now re-stamps the copy)', () => {
const next = makeNote('real-alice')
expect(
authorize({ session: session('real-bob'), type: 'create', prev: null, next })
).toBeNull()
})
it('allows a create attributed to the session user', () => {
const next = makeNote('real-bob')
expect(authorize({ session: session('real-bob'), type: 'create', prev: null, next })).toBe(
next
)
})
it('allows a create with no attribution (empty or anonymous note)', () => {
const next = makeNote(null)
expect(authorize({ session: session('real-bob'), type: 'create', prev: null, next })).toBe(
next
)
expect(authorize({ session: session(null), type: 'create', prev: null, next })).toBe(next)
})
it('vetoes an anonymous session creating a note with any attribution', () => {
const next = makeNote('real-alice')
expect(authorize({ session: session(null), type: 'create', prev: null, next })).toBeNull()
})
it('allows updates that do not touch attribution, from any user', () => {
const prev = makeNote('real-alice')
const next = { ...prev, x: 100 }
expect(authorize({ session: session('real-bob'), type: 'update', prev, next })).toBe(next)
expect(authorize({ session: session(null), type: 'update', prev, next })).toBe(next)
})
it('allows changing attribution to the session user', () => {
const prev = makeNote('real-alice')
const next = { ...prev, props: { ...prev.props, textLastEditedBy: 'real-bob' } }
expect(authorize({ session: session('real-bob'), type: 'update', prev, next })).toBe(next)
})
it('allows clearing attribution to null (text emptied, or anonymous edit)', () => {
const prev = makeNote('real-alice')
const next = { ...prev, props: { ...prev.props, textLastEditedBy: null } }
expect(authorize({ session: session('real-bob'), type: 'update', prev, next })).toBe(next)
expect(authorize({ session: session(null), type: 'update', prev, next })).toBe(next)
})
it('vetoes changing attribution to someone else', () => {
const prev = makeNote('real-bob')
const next = { ...prev, props: { ...prev.props, textLastEditedBy: 'real-alice' } }
expect(authorize({ session: session('real-bob'), type: 'update', prev, next })).toBeNull()
})
it('vetoes an anonymous session setting any non-null attribution', () => {
const prev = makeNote(null)
const next = { ...prev, props: { ...prev.props, textLastEditedBy: 'real-alice' } }
expect(authorize({ session: session(null), type: 'update', prev, next })).toBeNull()
})
it('vetoes smuggling attribution in via a shape-type change', () => {
const prev = makeGeo()
const next = { ...makeNote('real-alice'), id: prev.id }
expect(authorize({ session: session('real-bob'), type: 'update', prev, next })).toBeNull()
})
it('ignores non-note shapes', () => {
const prev = makeGeo()
const next = { ...prev, x: 50 }
expect(authorize({ session: session(null), type: 'update', prev, next })).toBe(next)
// creates of non-note shapes carry no attribution to enforce, so they pass through
expect(authorize({ session: session(null), type: 'create', prev: null, next: prev })).toBe(
prev
)
})
it('allows deletes', () => {
const prev = makeNote('real-alice')
expect(authorize({ session: session('real-bob'), type: 'delete', prev, next: null })).toBe(
prev
)
})
it('allows a legal note update from a canvas read-only session (gated at the protocol level, not here)', () => {
const prev = makeNote('real-alice')
const next = { ...prev, x: 100 }
expect(authorize({ session: session('real-bob', true), type: 'update', prev, next })).toBe(
next
)
})
})
})

View File

@@ -0,0 +1,62 @@
import { createCommentAuthorizers } from '@tldraw/sync-collaboration'
import { TLRecordAuthorizer, TLRecordAuthorizers } from '@tldraw/sync-core'
import {
TLComment,
TLCommentReaction,
TLCommentThread,
TLNoteShape,
TLRecord,
TLShape,
} from '@tldraw/tlschema'
/** Per-session metadata attached to each socket: the local store id and the authenticated user (if any). */
export interface SessionMeta {
storeId: string
userId: string | null
}
// The file room's record union, widened to include the object-lane comment records so each
// authorizer is typed to its record — renaming an attribution field fails to compile here.
type FileRecord = TLRecord | TLComment | TLCommentThread | TLCommentReaction
/**
* A note's text attribution, or `undefined` when the shape isn't carrying one (non-note shapes).
* Authorizers see records at the server's schema version, so only the current field name exists.
*/
function getNoteAttribution(shape: TLShape): string | null | undefined {
if (shape.type !== 'note') return undefined
return (shape as TLNoteShape).props.textLastEditedBy
}
/**
* Notes stamp `textLastEditedBy` client-side (the editor on text edit, `null` when emptied or
* anonymous). Enforce it here on both create and update: the attribution may only be `null` or
* the session's own user. Duplication and paste re-stamp the copy to the current user (via
* `NoteShapeUtil.onBeforeDuplicate`), so a legitimate create never carries another user's
* attribution and we can reject one that does — closing the forged-create loophole the create
* passthrough previously had to accept. Updates still allow an unchanged attribution from any
* user, so anyone with access can edit a note's other props.
*/
const authorizeShape: TLRecordAuthorizer<TLShape, SessionMeta> = ({
session,
type,
prev,
next,
}) => {
if (type === 'delete') return prev
// On create there's no prior attribution; on update an unchanged attribution is always allowed.
const before = type === 'create' ? null : getNoteAttribution(prev)
const after = getNoteAttribution(next)
if (after !== before && after != null && after !== session.meta.userId) return null
return next
}
/**
* Force comment, thread, and reaction authorship from the session's identity (via the shared
* commenting authorizers), and guard note text attribution, so nothing can be posted, pinned,
* or reacted in someone else's name.
*/
export const authorizeFileRecord = {
...createCommentAuthorizers<SessionMeta>({ getUserId: (session) => session.meta.userId }),
shape: authorizeShape,
} satisfies TLRecordAuthorizers<FileRecord, SessionMeta>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,595 @@
import { DB, extractMentionIds } from '@tldraw/dotcom-shared'
import { RoomSnapshot } from '@tldraw/sync-core'
import {
TLComment,
TLCommentAnchor,
TLCommentReaction,
TLCommentThread,
commentReactionRecordConfig,
commentRecordConfig,
commentThreadRecordConfig,
isCommentId,
isCommentReactionId,
isCommentThreadId,
} from '@tldraw/tlschema'
import { JsonObject } from '@tldraw/utils'
import { Kysely } from 'kysely'
/**
* Conversions between the room's comment records and their Postgres rows. Postgres is the sole
* durable store, so the columns carry every record field and the DO can rebuild records losslessly
* on cold start. Timestamps and clocks go through Number() in case the global int8-to-number parser
* in `postgres.ts` ever goes away.
*/
// A Postgres foreign-key violation (SQLSTATE 23503) on one of the named constraints.
function isFkViolation(error: unknown, constraints: readonly string[]): boolean {
if (typeof error !== 'object' || error === null) return false
const { code, constraint } = error as { code?: unknown; constraint?: unknown }
return code === '23503' && typeof constraint === 'string' && constraints.includes(constraint)
}
/**
* The author's user row is gone — deleting a user cascades their comment rows away, so a warm room
* is still holding records for a deleted author. Retrying can't succeed; the caller prunes.
*/
export function isCommentAuthorFkViolation(error: unknown): boolean {
return isFkViolation(error, ['comment_author_id_fkey'])
}
/**
* The thread's file row doesn't exist: the file was deleted, or a client pushed comments into a
* room whose slug was never a `file` row. Neither can succeed on retry, so the caller prunes.
*/
export function isCommentThreadFkViolation(error: unknown): boolean {
return isFkViolation(error, ['comment_thread_file_id_fkey'])
}
/** The comment's file row is gone, cascading every comment row with it. The caller prunes. */
export function isCommentFileFkViolation(error: unknown): boolean {
return isFkViolation(error, ['comment_file_id_fkey'])
}
/**
* The comment's thread row doesn't exist. Unlike the other FK matchers this is NOT on its own
* sufficient to prune: threads upsert before comments in the same drain, so a thread whose own
* upsert just failed transiently also fails its comments' FK, and it's still queued to retry.
* The caller only prunes when the threadId is also absent from the room's lane.
*/
export function isCommentThreadIdFkViolation(error: unknown): boolean {
return isFkViolation(error, ['comment_thread_id_fkey'])
}
/**
* A `comment_mention` insert hit either of its foreign keys: the mentioned user is gone, or the
* comment was deleted between its upsert and the mention write. The caller skips the row.
*/
export function isCommentMentionFkViolation(error: unknown): boolean {
return isFkViolation(error, ['comment_mention_user_id_fkey', 'comment_mention_comment_id_fkey'])
}
/**
* A reaction's comment, thread, or user row is gone, cascading the reaction rows with it. The
* caller prunes.
*/
export function isCommentReactionFkViolation(error: unknown): boolean {
return isFkViolation(error, [
'comment_reaction_comment_id_fkey',
'comment_reaction_thread_id_fkey',
'comment_reaction_user_id_fkey',
])
}
/** One comment's desired `comment_mention` rows: the full set its body currently mentions. */
export interface CommentMentionReconcile {
commentId: string
userIds: string[]
}
/**
* The desired end state of `comment_mention` for a drain's upserted comments. The caller
* reconciles Postgres to this (delete rows not in the set, insert the rest with ON CONFLICT DO
* NOTHING), so an at-least-once replay is a no-op rather than WAL churn for Zero.
*/
export function planMentionReconciles(commentRows: DB['comment'][]): CommentMentionReconcile[] {
return commentRows.map((row) => ({
commentId: row.id,
userIds: extractMentionIds(row.body),
}))
}
export function threadRecordToRow(
record: TLCommentThread,
fileId: string,
lastChangedClock: number
): DB['comment_thread'] {
return {
id: record.id,
fileId,
pageId: record.pageId,
anchor: record.anchor,
shapeId: record.anchor.type === 'shape' ? record.anchor.shapeId : null,
resolvedAt: record.resolved?.at ?? null,
resolvedBy: record.resolved?.by ?? null,
isDeleted: record.isDeleted,
createdBy: record.createdBy,
createdAt: record.createdAt,
meta: record.meta,
lastChangedClock,
}
}
export function reactionRecordToRow(
record: TLCommentReaction,
fileId: string,
lastChangedClock: number
): DB['comment_reaction'] {
return {
id: record.id,
fileId,
commentId: record.commentId,
threadId: record.threadId,
pageId: record.pageId,
userId: record.userId,
// stamped by Postgres (set_comment_reaction_user_name_trigger); value here is ignored
userName: '',
emoji: record.emoji,
// client-dated; clamp so it can't outrun the server-clamped comment_read.readAt watermark,
// which would make a "reacted to your comment" notification impossible to mark read
createdAt: Math.min(record.createdAt, Date.now()),
meta: record.meta,
lastChangedClock,
}
}
export function commentRecordToRow(
record: TLComment,
fileId: string,
lastChangedClock: number
): DB['comment'] {
return {
id: record.id,
fileId,
threadId: record.threadId,
pageId: record.pageId,
authorId: record.authorId,
// placeholders — a Postgres trigger stamps the real values on insert (migration 040)
authorName: '',
authorColor: '',
authorAvatar: '',
// TLRichText's content is unknown[], not structurally a zero ReadonlyJSONValue
body: record.body as DB['comment']['body'],
// client-dated placeholder — a Postgres trigger re-stamps it with server arrival time on
// insert (migration 046), and the upsert's conflict branch never writes this column
createdAt: record.createdAt,
editedAt: record.editedAt,
isDeleted: record.isDeleted,
updatedAt: record.editedAt ?? record.createdAt,
meta: record.meta,
lastChangedClock,
}
}
export function rowToThreadRecord(row: DB['comment_thread']): TLCommentThread {
const record: TLCommentThread = {
id: row.id as TLCommentThread['id'],
typeName: 'comment-thread',
pageId: row.pageId as TLCommentThread['pageId'],
anchor: row.anchor as TLCommentAnchor,
createdBy: row.createdBy,
createdAt: Number(row.createdAt),
resolved: row.resolvedAt != null ? { at: Number(row.resolvedAt), by: row.resolvedBy! } : null,
isDeleted: row.isDeleted,
meta: (row.meta ?? {}) as JsonObject,
}
// The fields above are raw casts, so validate: a corrupt row should fail the room open rather
// than seed an invalid record.
return commentThreadRecordConfig.validator.validate(record) as TLCommentThread
}
export function rowToCommentRecord(row: DB['comment']): TLComment {
const record: TLComment = {
id: row.id as TLComment['id'],
typeName: 'comment',
threadId: row.threadId as TLComment['threadId'],
pageId: row.pageId as TLComment['pageId'],
authorId: row.authorId,
createdAt: Number(row.createdAt),
editedAt: row.editedAt != null ? Number(row.editedAt) : null,
body: row.body as TLComment['body'],
isDeleted: row.isDeleted,
meta: (row.meta ?? {}) as JsonObject,
}
// The fields above are raw casts, so validate: a corrupt row should fail the room open rather
// than seed an invalid record.
return commentRecordConfig.validator.validate(record) as TLComment
}
export function rowToReactionRecord(row: DB['comment_reaction']): TLCommentReaction {
const record: TLCommentReaction = {
id: row.id as TLCommentReaction['id'],
typeName: 'comment-reaction',
commentId: row.commentId as TLCommentReaction['commentId'],
threadId: row.threadId as TLCommentReaction['threadId'],
pageId: row.pageId as TLCommentReaction['pageId'],
userId: row.userId,
emoji: row.emoji,
createdAt: Number(row.createdAt),
meta: (row.meta ?? {}) as JsonObject,
}
// See rowToCommentRecord.
return commentReactionRecordConfig.validator.validate(record) as TLCommentReaction
}
/**
* Rebuild object-lane snapshot documents from Postgres rows, for merging into the room snapshot
* on load.
*/
export function rowsToSnapshotDocuments(
threadRows: DB['comment_thread'][],
commentRows: DB['comment'][],
reactionRows: DB['comment_reaction'][] = []
): RoomSnapshot['documents'] {
return [
...threadRows.map((row) => ({
state: rowToThreadRecord(row),
lastChangedClock: Number(row.lastChangedClock),
})),
...commentRows.map((row) => ({
state: rowToCommentRecord(row),
lastChangedClock: Number(row.lastChangedClock),
})),
...reactionRows.map((row) => ({
state: rowToReactionRecord(row),
lastChangedClock: Number(row.lastChangedClock),
})),
]
}
/** The room-seedable subset of a file's comment rows; see {@link liveCommentDocuments}. */
export interface CommentLoadResult {
documents: RoomSnapshot['documents']
/**
* The highest `lastChangedClock` across ALL of the file's comment rows, including soft-deleted
* ones dropped from `documents` — a dropped row can hold the file's highest clock, and seeding
* the room below it would make future edits emit clocks the drain's guard rejects.
*/
clockFloor: number
}
/**
* Build the room-seedable comment documents from a file's Postgres rows. Soft-deleted records stay
* in Postgres for recovery and Zero-side filtering but never re-enter a room.
*
* A live thread whose comments are all soft-deleted is dropped too — the durable backstop for the
* drain's emptied-thread prune, whose isDeleted stamp rides an outbox entry in DO SQLite. A thread
* with no comment rows at all is kept: that's a new thread whose first comment hasn't drained.
*/
export function liveCommentDocuments(
threadRows: DB['comment_thread'][],
commentRows: DB['comment'][],
reactionRows: DB['comment_reaction'][] = []
): CommentLoadResult {
const threadIdsWithComments = new Set(commentRows.map((row) => row.threadId))
const threadIdsWithLiveComments = new Set(
commentRows.filter((row) => !row.isDeleted).map((row) => row.threadId)
)
const liveThreadRows = threadRows.filter(
(row) =>
!row.isDeleted &&
(!threadIdsWithComments.has(row.id) || threadIdsWithLiveComments.has(row.id))
)
const liveThreadIds = new Set(liveThreadRows.map((row) => row.id))
const liveCommentRows = commentRows.filter(
(row) => !row.isDeleted && liveThreadIds.has(row.threadId)
)
const liveCommentIds = new Set(liveCommentRows.map((row) => row.id))
const liveReactionRows = reactionRows.filter((row) => liveCommentIds.has(row.commentId))
let clockFloor = 0
for (const row of threadRows) clockFloor = Math.max(clockFloor, Number(row.lastChangedClock))
for (const row of commentRows) clockFloor = Math.max(clockFloor, Number(row.lastChangedClock))
for (const row of reactionRows) clockFloor = Math.max(clockFloor, Number(row.lastChangedClock))
return {
documents: rowsToSnapshotDocuments(liveThreadRows, liveCommentRows, liveReactionRows),
clockFloor,
}
}
/**
* Load a file's comment rows over a single checked-out connection. Each `execute()` on the bare
* Kysely instance checks out its own connection, and `TLPostgresPool` dials a fresh socket per
* checkout, so three parallel-looking queries would otherwise cost three sequential dials.
*/
export async function loadCommentDocuments(
db: Kysely<DB>,
fileId: string
): Promise<CommentLoadResult> {
const [threadRows, commentRows, reactionRows] = await db
.connection()
.execute((conn) =>
Promise.all([
conn.selectFrom('comment_thread').where('fileId', '=', fileId).selectAll().execute(),
conn.selectFrom('comment').where('fileId', '=', fileId).selectAll().execute(),
conn.selectFrom('comment_reaction').where('fileId', '=', fileId).selectAll().execute(),
])
)
// Soft-deleted threads and their comments never re-enter a room, and neither do reactions
// whose comment doesn't; their rows stay in Postgres only (see liveCommentDocuments).
return liveCommentDocuments(threadRows, commentRows, reactionRows)
}
/** A row of the DO's `comment_outbox` table: a monotonic sequence number and the touched record id. */
export interface CommentOutboxEntry {
seq: number
recordId: string
}
/**
* Postgres writes grouped by table and operation. The delete buckets carry lane-absent ids, and
* "delete" means stamping `isDeleted` — the drain never hard-deletes comment-lane rows.
*/
export interface CommentDrainPlan {
threadUpserts: DB['comment_thread'][]
commentUpserts: DB['comment'][]
reactionUpserts: DB['comment_reaction'][]
threadDeletes: string[]
commentDeletes: string[]
reactionDeletes: string[]
/** Ids of no known comment record type — a bug or a corrupted outbox row. */
unknownIds: string[]
/**
* Reactions naming a parent comment this room doesn't have. A reaction is the one comment record
* keyed to a parent it doesn't own — its id is derived from (comment, user, emoji), so a forged
* `commentId` still mints a unique id and lands as a plain insert, where the upserts' `fileId`
* conflict guard never fires. These prune rather than retry: the parent is either gone (a
* reaction racing its comment's deletion) or was never here, and neither resolves on a retry.
*/
orphanedReactionIds: string[]
}
/**
* The pure planning half of the comment outbox drain (see drainCommentOutbox). The outbox stores
* only ids; upsert-vs-delete is decided by presence in the object `lane` at plan time, so multiple
* entries for one record coalesce into a single write and a create-then-prune nets out to a delete.
*
* `lane` must already carry the parent comment of every outboxed reaction — a reaction's parent is
* usually not outboxed alongside it, and an absent parent is read here as a foreign or orphaned
* `commentId` (see `orphanedReactionIds`). The caller seeds them.
*/
export function planCommentDrain(
entries: CommentOutboxEntry[],
lane: ReadonlyMap<string, { state: unknown; lastChangedClock: number }>,
fileId: string
): CommentDrainPlan {
const plan: CommentDrainPlan = {
threadUpserts: [],
commentUpserts: [],
reactionUpserts: [],
threadDeletes: [],
commentDeletes: [],
reactionDeletes: [],
unknownIds: [],
orphanedReactionIds: [],
}
const pendingIds = new Set(entries.map((e) => e.recordId))
for (const id of pendingIds) {
const doc = lane.get(id)
if (isCommentThreadId(id)) {
if (doc) {
plan.threadUpserts.push(
threadRecordToRow(doc.state as TLCommentThread, fileId, doc.lastChangedClock)
)
} else {
plan.threadDeletes.push(id)
}
} else if (isCommentReactionId(id)) {
if (doc) {
const reaction = doc.state as TLCommentReaction
if (lane.has(reaction.commentId)) {
plan.reactionUpserts.push(reactionRecordToRow(reaction, fileId, doc.lastChangedClock))
} else {
plan.orphanedReactionIds.push(id)
}
} else {
plan.reactionDeletes.push(id)
}
} else if (isCommentId(id)) {
if (doc) {
plan.commentUpserts.push(
commentRecordToRow(doc.state as TLComment, fileId, doc.lastChangedClock)
)
} else {
plan.commentDeletes.push(id)
}
} else {
plan.unknownIds.push(id)
}
}
return plan
}
/**
* The drain's three comment upserts, as query builders.
*
* Every one conflicts on the primary key and is guarded on `fileId`. A comment record id is only
* addressable from the room that owns it, so a Postgres row carrying that id under a different
* `fileId` is a row this room has no business writing — without the guard, a client that learned
* another file's record ids could push records under them and have that file's drain overwrite the
* original rows. The conflict target has to be the primary key (it's the unique index the upsert
* needs), so the ownership check rides along in the update predicate instead: a mismatch updates
* nothing rather than rewriting the row. The `lastChangedClock` guard alone doesn't cover this — a
* forged record just carries a higher clock.
*
* They live here rather than inline in the drain so the guard can be asserted against the compiled
* SQL without a database (see commentRows.test.ts).
*/
export function upsertCommentThreadRows(db: Kysely<DB>, rows: DB['comment_thread'][]) {
return db
.insertInto('comment_thread')
.values(rows)
.onConflict((oc) =>
oc
.column('id')
.doUpdateSet((eb) => ({
pageId: eb.ref('excluded.pageId'),
anchor: eb.ref('excluded.anchor'),
shapeId: eb.ref('excluded.shapeId'),
resolvedAt: eb.ref('excluded.resolvedAt'),
resolvedBy: eb.ref('excluded.resolvedBy'),
isDeleted: eb.ref('excluded.isDeleted'),
meta: eb.ref('excluded.meta'),
lastChangedClock: eb.ref('excluded.lastChangedClock'),
}))
.whereRef('comment_thread.fileId', '=', 'excluded.fileId')
.whereRef('comment_thread.lastChangedClock', '<', 'excluded.lastChangedClock')
)
}
/**
* "createdAt" is deliberately absent from the update set: Postgres stamps it on first insert
* (migration 046) and the stamp must survive at-least-once replays and edits. The trigger side of
* that is covered by zero-cache/stamp_comment_created_at.test.ts, against a fixture table with no
* fileId column — so it exercises the createdAt behavior, not this builder's ownership guard.
*/
export function upsertCommentRows(db: Kysely<DB>, rows: DB['comment'][]) {
return db
.insertInto('comment')
.values(rows)
.onConflict((oc) =>
oc
.column('id')
.doUpdateSet((eb) => ({
threadId: eb.ref('excluded.threadId'),
pageId: eb.ref('excluded.pageId'),
body: eb.ref('excluded.body'),
editedAt: eb.ref('excluded.editedAt'),
isDeleted: eb.ref('excluded.isDeleted'),
// excluded.* has been through the BEFORE INSERT stamp trigger, which
// lifts updatedAt to the (server) attempt stamp — so this can never
// regress updatedAt below the row's createdAt
updatedAt: eb.ref('excluded.updatedAt'),
meta: eb.ref('excluded.meta'),
lastChangedClock: eb.ref('excluded.lastChangedClock'),
}))
.whereRef('comment.fileId', '=', 'excluded.fileId')
.whereRef('comment.lastChangedClock', '<', 'excluded.lastChangedClock')
)
}
/**
* A reaction's id is derived from its whole (comment, user, emoji) triple, so reacting with a
* different emoji mints a different id and arrives as a plain insert — a conflict here is a replay
* of the same triple, not an edit. The update set still lists every mutable column so a replay
* carrying a corrected denormalized field (pageId follows its thread between pages) lands rather
* than being silently dropped.
*/
export function upsertCommentReactionRows(db: Kysely<DB>, rows: DB['comment_reaction'][]) {
return db
.insertInto('comment_reaction')
.values(rows)
.onConflict((oc) =>
oc
.column('id')
.doUpdateSet((eb) => ({
commentId: eb.ref('excluded.commentId'),
threadId: eb.ref('excluded.threadId'),
pageId: eb.ref('excluded.pageId'),
emoji: eb.ref('excluded.emoji'),
createdAt: eb.ref('excluded.createdAt'),
meta: eb.ref('excluded.meta'),
lastChangedClock: eb.ref('excluded.lastChangedClock'),
}))
.whereRef('comment_reaction.fileId', '=', 'excluded.fileId')
.whereRef('comment_reaction.lastChangedClock', '<', 'excluded.lastChangedClock')
)
}
/**
* Which outbox entries a finished drain may delete. `failedIds` stay queued for the next drain;
* everything else clears. `clearAll` is the fast path when nothing failed.
*/
export function outboxEntriesToClear(
entries: CommentOutboxEntry[],
failedIds: ReadonlySet<string>
): { clearAll: boolean; seqs: number[] } {
if (failedIds.size === 0) {
return { clearAll: true, seqs: [] }
}
return {
clearAll: false,
seqs: entries.filter((e) => !failedIds.has(e.recordId)).map((e) => e.seq),
}
}
/**
* Of the just-pruned comments' threads, which no longer have any comments. Callers delete those
* threads in the same transaction: an emptied thread never renders, and no client may delete a
* thread it didn't create, so the ordinary "delete the last comment" path needs this prune.
*
* `remaining` must be transaction-time, not an earlier lane snapshot — a reply committed in
* between has to keep its thread alive.
*/
export function findEmptiedCommentThreads(
candidateThreadIds: ReadonlySet<string>,
remaining: { keys(): Iterable<string>; get(id: string): unknown }
): string[] {
if (candidateThreadIds.size === 0) return []
const emptied = new Set(candidateThreadIds)
for (const id of remaining.keys()) {
if (!isCommentId(id)) continue
const threadId = (remaining.get(id) as TLComment | undefined)?.threadId
if (threadId !== undefined && emptied.delete(threadId) && emptied.size === 0) break
}
return [...emptied]
}
/**
* Reactions pointing at just-pruned comments. Postgres already cascaded these rows away, so this
* only catches the warm room's SQLite up.
*/
export function findOrphanedReactions(
prunedCommentIds: ReadonlySet<string>,
remaining: { keys(): Iterable<string>; get(id: string): unknown }
): string[] {
if (prunedCommentIds.size === 0) return []
const orphaned: string[] = []
for (const id of remaining.keys()) {
if (!isCommentReactionId(id)) continue
const commentId = (remaining.get(id) as TLCommentReaction | undefined)?.commentId
if (commentId !== undefined && prunedCommentIds.has(commentId)) orphaned.push(id)
}
return orphaned
}
/**
* Merge rehydrated comment documents into a room snapshot, clamping the snapshot's clocks up to
* the load's `clockFloor`. Comments push to Postgres per-commit while the snapshot persists on a
* throttle, so after a storage loss the comment clocks can be ahead — seeding the room below them
* would make future edits emit clocks the drain's guard silently rejects.
*
* The clamp works off `documentClock ?? clock ?? 0`, matching how `SQLiteSyncStorage` seeds.
* `tombstoneHistoryStartsAtClock` rises with it: the tombstone history for that range lived only
* in the lost SQLite, so reconnecting clients must full-resync rather than take a partial diff.
*/
export function mergeCommentDocumentsIntoSnapshot(
snapshot: RoomSnapshot,
load: CommentLoadResult
): void {
const { documents: commentDocs, clockFloor } = load
if (commentDocs.length === 0 && clockFloor === 0) return
if (commentDocs.length > 0) {
snapshot.documents = [...snapshot.documents, ...commentDocs]
}
// a loop rather than `Math.max(clockFloor, ...clocks)`, which overflows the stack past ~100k docs
let maxClock = clockFloor
for (const doc of commentDocs) {
if (doc.lastChangedClock > maxClock) maxClock = doc.lastChangedClock
}
const effectiveClock = snapshot.documentClock ?? snapshot.clock ?? 0
if (effectiveClock >= maxClock) return
snapshot.documentClock = maxClock
if (snapshot.clock !== undefined && snapshot.clock < maxClock) {
snapshot.clock = maxClock
}
snapshot.tombstoneHistoryStartsAtClock = maxClock
}

View File

@@ -0,0 +1,266 @@
// Tuning constants for the sync worker, gathered here so the dials live in one place.
//
// Several of these come out to 60 seconds. That is coincidence, not coupling: each one is derived
// from its own constraint (production persist timings, capture p90, Cloudflare's fixed rate limit
// period), and each can move without the others. The one genuine interdependency in this file is
// OG_PENDING_MARKER_TTL_MS against the retry chain (OG_MAX_RENDER_ATTEMPTS, OG_RETRY_DELAY_SECONDS
// and the capture timeout), and a test in ogImageQueue.test.ts pins that inequality.
/**
* How often we the document to R2?
* 8 seconds.
*/
export const PERSIST_INTERVAL_MS = 8_000
/**
* How long a board must go without persisting before its thumbnail is rendered. 60 seconds.
*
* A debounce, not a throttle or an interval: each persist pushes the render further out, so a board
* renders once its editing *settles*, which is the state a thumbnail is meant to show. Note that a
* fixed window would be the wrong shape here whatever its length — production's mean gap between one
* board's persists is ~39s, so a window shorter than that suppresses almost nothing and a longer one
* costs thumbnail freshness. See "Why a debounce and not a throttle" in
* apps/dotcom/browser-run-thumbnails.md for the measurements and the cost table.
*
* 60s rather than 30s because replaying both values over six hours of production `persist_success`
* timings costs ~35.7 renders/min at 30s against ~32.3 at 60s. Note how small that is: raising this
* alone cannot save much, because a merged session then runs long enough to hit
* `OG_RENDER_MAX_WAIT_MS` and trades a settle render for a max-wait one. The two constants have to
* move together to matter — see "What it costs" in the doc for the grid.
*/
export const OG_RENDER_DEBOUNCE_MS = 60_000
/**
* Ceiling on how long a continuously edited board can go without its thumbnail being refreshed.
* 5 minutes.
*
* Bounds the one failure mode of a pure debounce: a board that never stops being edited would
* otherwise never render, since every persist pushes the deadline out again. Measured from the first
* persist after a render.
*
* Raising it does not affect the far more common short-burst board, which costs one render either way.
*
* Measured against six hours of production persists, this is now the constant that governs total
* spend, and it governs it jointly with `OG_RENDER_DEBOUNCE_MS`: at a 60s debounce, taking this from
* 5 to 10 minutes goes from ~32.3 to ~26.7 renders/min, while leaving it at 5 minutes makes the
* debounce itself nearly inert. Left at 5 minutes deliberately — a board edited without pause holds a
* five minute old thumbnail rather than a ten minute old one — but this is the first dial to turn if
* spend needs to come down.
*
* Its floor is OG_PENDING_MARKER_TTL_MS. Shared files have no follow-up render: a max-wait fire is
* the one ask the debounce doesn't bound, and it is safe only because it lands at or past the TTL of
* any marker that could turn it away. Below that floor, an ask can be dropped with nothing left to
* re-ask (the inequality is pinned in ogImageQueue.test.ts).
*/
export const OG_RENDER_MAX_WAIT_MS = 5 * 60_000
/**
* Suppresses a duplicate OG render enqueue while one is queued or in flight. A single-flight, not a
* rate limit: the consumer clears it as soon as a render lands, so on a healthy board it never
* reaches this TTL, which exists only so a consumer dying cannot wedge a board permanently. To
* change how often a board renders, change OG_RENDER_DEBOUNCE_MS.
*
* Must outlive a job's worst-case retry chain — every capture running to its timeout plus every
* backoff delay — or the marker lapses while the job is still alive, a fresh ask enqueues a second
* job for the same board, and the two clobber each other's per-board render token record
* (renderTokens.ts is keyed on the single-flight this marker provides). A test in
* ogImageQueue.test.ts pins the inequality against OG_MAX_RENDER_ATTEMPTS, OG_RETRY_DELAY_SECONDS
* and THUMBNAIL_RENDER_TIMEOUT_MS.
*/
export const OG_PENDING_MARKER_TTL_MS = 5 * 60_000
/**
* Retries are bounded by max_retries in wrangler.toml too; this lower cap keeps thumbnail jobs from
* burning Browser Run capacity on a persistently failing board.
*/
export const OG_MAX_RENDER_ATTEMPTS = 3
export const OG_RETRY_DELAY_SECONDS = 30
/**
* How long the OG route's published-board repair (`repairMissingPublishedImage` in getOgImage.ts)
* stays quiet after a crawler-triggered render job for that board has burnt its whole retry budget.
* 1 hour.
*
* The repair is the one render ask an unauthenticated request can cause, and without this it re-arms
* as soon as the failed job clears its pending marker: a published board that deterministically fails
* to render — a huge board that times out, say — would burn a full retry chain of Browser Run every
* ~4 minutes for as long as anything crawls its URL. With it, such a board costs one chain per hour.
*
* Only the *crawler-reason* give-up arms it, so a publish-triggered render that fails transiently
* still gets one immediate repair on the next crawl — the case the repair exists for — and the
* cooldown only meters the attempts after that one has also failed. The publish trigger clears it,
* because an in-place republish reuses the slug: the cooldown is evidence about the snapshot that
* failed, and it must not outlive that snapshot and block the new one's repair.
*/
export const OG_REPAIR_COOLDOWN_MS = 60 * 60_000
/**
* Cache lifetimes the OG image route hands to callers, in seconds because they land in
* `cache-control` headers.
*
* Short, and deliberately not chosen for cost or freshness. Neither is the binding constraint:
* R2 reads are a rounding error next to rendering, and unfurl platforms cache a card their own side
* for days whatever we say. What the lifetime actually decides is how long an image can be served
* without the share gate being consulted — nothing deletes a board's image when it stops being
* public, so the route re-checking the gate per request is the only thing keeping an unshared
* board's thumbnail off the internet. Every second of cache lifetime is a second that check does not
* happen, so these are minutes, and revalidation is made cheap with an etag instead.
*/
export const OG_FRESH_IMAGE_MAX_AGE_SECONDS = 5 * 60
export const OG_STALE_IMAGE_MAX_AGE_SECONDS = 5 * 60
export const OG_FALLBACK_MAX_AGE_SECONDS = 60
/**
* How long a minted render token stays valid.
*
* Sized against what a capture needs rather than generously, because this token is what stands between
* an HMAC signature and a *private* board's full document: thumbnails are rendered for every board, not
* only public ones. Measured renders run 4s at p50 and 12-17s at p90, so 60s is several times p90.
*
* Queue linger and retry backoff do not eat into it — a token is minted immediately before the
* `quickAction` call, not at enqueue. A render slower than this fails and retries with a fresh token.
*/
export const THUMBNAIL_RENDER_TOKEN_TTL_MS = 60_000
/**
* MCP rate limits: the only rate limiting anywhere in the thumbnail pipeline, applied in
* mcpServer.ts. The MCP endpoint is the one Browser Run-spending surface an outside
* caller can drive directly, so a rogue or looping agent is the threat being bounded, and only the
* calls that actually spend Browser Run are limited — `get_board_info` does the same work the
* ordinary board routes do for anyone. `search_boards` has its own budget, bounding a different
* resource: it spends no Browser Run, but it drives the Postgres that serves all of dotcom, and
* its matching is the one part no index reaches — `name ILIKE '%term%'` over the caller's whole
* in-scope set, so a query matching nothing costs the most. See "MCP tools" in
* browser-run-thumbnails.md for the measured query plans.
* The global limit is applied per Cloudflare location, so it bounds a caller rather than the
* account. See "Request limits" in browser-run-thumbnails.md.
*
* These constants are only the isolate-local fallback for local dev and tests. Deployed environments
* are governed by the Cloudflare rate limit bindings in wrangler.toml, so changing a number here
* alone changes nothing in production. Each budget has its own binding, and the pairs must move
* together:
*
* MCP_PER_USER_RATE_LIMIT -> MCP_SCREENSHOT_RATE_LIMITER (limit = 10)
* MCP_PER_BOARD_RATE_LIMIT -> MCP_SERVER_BOARD_RATE_LIMITER (limit = 2)
* MCP_GLOBAL_BROWSER_RUN_RATE_LIMIT -> MCP_SERVER_BROWSER_RATE_LIMITER (limit = 20)
* MCP_SEARCH_PER_USER_RATE_LIMIT -> MCP_SERVER_SEARCH_RATE_LIMITER (limit = 60)
*
* The first of these keyed on client IP until the endpoint required authentication. An account is
* the better key in both directions: a proxy pool no longer buys a caller more budget, and everyone
* behind one NAT no longer shares a single one. IP limits still matter on endpoints with no caller
* identity — the render page's snapshot route, and anything the OAuth flow exposes.
*
* Per-user counts *tool calls that can spend Browser Run*, not captures, and it is one budget across
* every such tool — see `perUserRateLimitKey`. That distinction matters because the clustering tools
* each run a measure render, which costs a full browser session: the ordinary one-screenshot flow is
* get_page_info, get_cluster_info, get_cluster_screenshot, so 10 calls a minute is nearer 3 finished
* screenshots a minute than 10. Worth revisiting on the binding before the flag is widened; the
* number here alone changes nothing deployed.
*
* Per-board is far below per-user on purpose: no single board may absorb more than 2 captures a
* minute. Cache misses only, so this does not bound the usual "screenshot several pages of one board"
* flow — a repeated capture of the same page is a cache hit. Measures are deliberately *not* counted
* against it: three of them land on one board in the flow above, so a limit of 2 would refuse the
* documented path rather than an abusive one. The global limiter is what bounds measure spend.
*
* The window matches the period configured on the Cloudflare bindings, which only support 60s (or
* 10s) periods — this is the one number here that is Cloudflare's rather than ours.
*/
/**
* The `search_boards` budget, separate from the Browser Run one above because it bounds a different
* cost and deserves its own number rather than a Browser Run number inherited.
*
* Set where a legitimate caller never meets it and a runaway loop is still capped. Paging is what
* spends this, not abuse: the page size is fixed at 20, so a caller in an 8000-board workspace needs
* 400 calls to walk it, and an agent that reformulates a failed query spends a few more. A refusal
* mid-page has no good recovery either — a model cannot wait, so it retries or reports a partial
* answer as a complete one, which is worse than the load it would have prevented. Against that, the
* worst measured call is ~9ms of database time (30,000 rows over three 10k-board workspaces), so 60
* a minute is well under a second of database time per caller per minute.
*
* Per account, and only per account. It does not bound many accounts looping at once; that would
* need a global search limiter alongside it, the way MCP_SERVER_BROWSER_RATE_LIMITER sits under the
* per-user screenshot budget. Deliberately left until there is real traffic to size it against.
*/
export const MCP_SEARCH_PER_USER_RATE_LIMIT = 60
export const MCP_PER_USER_RATE_LIMIT = 10
export const MCP_PER_BOARD_RATE_LIMIT = 2
export const MCP_GLOBAL_BROWSER_RUN_RATE_LIMIT = 20
export const MCP_RATE_LIMIT_WINDOW_MS = 60_000
/**
* Version-chain tuning. The version cache stores a full keyframe, then deltas until one of these
* limits cuts the next keyframe. All three trade storage against restore latency, and the numbers
* come from the 2026-08-25 measurement over 16 production rooms (1,661 versions).
*
* Keyframe every 64 deltas: the measured knee. 16 gives up ~3.5x of storage for little latency
* gain; 256 buys ~15% more compression while quadrupling worst-case replay. Count matters because
* time alone fails hot boards — at the ~39s mean persist gap an active day is ~2,000 versions.
*/
export const MAX_DELTAS_PER_CHAIN = 64
/**
* Keyframe at least daily. Time matters because count alone leaves a cold board's chain open
* indefinitely, so a single lost keyframe would strand an unbounded run of deltas.
*/
export const MAX_CHAIN_AGE_MS = 24 * 60 * 60 * 1000
/**
* A delta larger than this fraction of its keyframe cuts a keyframe instead: it is a mass rewrite
* (a wipe, a paste of a whole board) and not worth chaining from. Compared compressed against
* compressed — a raw delta against a gzipped keyframe would trip on boards that simply compress well.
*/
export const MAX_DELTA_SIZE_RATIO = 0.5
/**
* An open segment that would grow past this fraction of its keyframe cuts a keyframe instead. The
* delta rule above bounds one write; this bounds what accumulates: a run of edits each just under
* half the board packs up to `SEGMENT_CAP / 2` keyframes' worth into one segment, all of it decoded
* in the durable object's memory and re-serialized on every persist — past the isolate's 128MB on
* a board near the room size limit. A segment worth more than its keyframe is worth a keyframe.
*/
export const MAX_SEGMENT_SIZE_RATIO = 1
/**
* The size rules only fire above this. Gzip's fixed overhead makes a tiny delta comparable to a
* tiny keyframe, so without a floor a near-empty board cuts a keyframe on every persist — and a
* sub-4KB delta or segment is never the mass rewrite or the memory hazard the rules exist to catch.
*/
export const MIN_SIZE_RULE_DELTA_BYTES = 4096
/**
* A retired chain is only verified when its keyframe is at most this many (gzipped) bytes.
*
* Verification reconstructs the whole chain inside the live durable object: the keyframe decoded,
* every segment decoded, and the replayed snapshot, all held alongside the SQLite board, the live
* room, and the two persisted snapshots the write already keeps. That is four or five board-sized
* copies at once, and the segment size rule bounds only the open segment, not this. Gzip takes
* tldraw JSON down roughly 5-10x and the decoded objects are a few times the JSON again, so a 1MB
* keyframe is tens of MB per copy — comfortably inside the isolate's 128MB with room for the rest,
* where a keyframe near the 25MB room limit would not be. The chain itself is still cut and served
* as usual; only the check is skipped, and it says so (`version_chain_verify` with outcome
* `skipped`) so the threshold can be tuned against how many boards it excludes.
*/
export const MAX_VERIFY_KEYFRAME_BYTES = 1024 * 1024
/**
* Deltas per segment object. Bounds three things at once: how much the durable object rewrites on
* each persist (~140KB worst case at this cap), how many GETs a restore costs (1 keyframe + 4
* segments at kf64), and how many ISO timestamps have to fit in the segment's R2 custom metadata
* (~400 bytes at 16). Much above 32 and the metadata budget starts to bind.
*
* Restore fetches the keyframe and every segment in parallel, and a Worker may have six
* connections waiting for headers at once; a seventh queues. `1 + MAX_DELTAS_PER_CHAIN /
* SEGMENT_CAP` must stay at or under six or restores start serializing — a test in
* versionChain.test.ts pins it.
*/
export const SEGMENT_CAP = 16
/** Cloudflare's per-invocation ceiling on connections simultaneously waiting for headers. */
export const WORKER_MAX_SIMULTANEOUS_CONNECTIONS = 6
/**
* The URL of the PostHog instance to use.
*/
export const POSTHOG_URL = 'https://eu.i.posthog.com'

View File

@@ -0,0 +1,237 @@
import { TlaEffectOutbox, TlaFile } from '@tldraw/dotcom-shared'
import { describe, expect, it } from 'vitest'
import { FileEffectDeps, getPublishTransition, processFileEffect } from './fileEffects'
function file(partial: Partial<TlaFile>): TlaFile {
return {
id: 'f1',
name: 'file',
ownerName: '',
thumbnail: '',
shared: true,
sharedLinkType: 'edit',
published: false,
lastPublished: 0,
publishedSlug: 'slug-1',
createdAt: 0,
updatedAt: 0,
isEmpty: false,
isDeleted: false,
createSource: null,
owningGroupId: null,
...partial,
}
}
describe('getPublishTransition', () => {
it('returns null for inserts and deletes', () => {
expect(
getPublishTransition({
command: 'insert',
payload: file({ published: true }),
prevPayload: null,
})
).toBe(null)
expect(
getPublishTransition({
command: 'delete',
payload: file({ published: true }),
prevPayload: null,
})
).toBe(null)
})
it('returns publish when file becomes published', () => {
expect(
getPublishTransition({
command: 'update',
payload: file({ published: true, lastPublished: 100 }),
prevPayload: file({ published: false, lastPublished: 0 }),
})
).toBe('publish')
})
it('returns publish when file is republished with newer timestamp', () => {
expect(
getPublishTransition({
command: 'update',
payload: file({ published: true, lastPublished: 200 }),
prevPayload: file({ published: true, lastPublished: 100 }),
})
).toBe('publish')
})
it('returns null when file remains published with same timestamp', () => {
expect(
getPublishTransition({
command: 'update',
payload: file({ published: true, lastPublished: 100 }),
prevPayload: file({ published: true, lastPublished: 100 }),
})
).toBe(null)
})
it('returns unpublish when file becomes unpublished', () => {
expect(
getPublishTransition({
command: 'update',
payload: file({ published: false, lastPublished: 100 }),
prevPayload: file({ published: true, lastPublished: 100 }),
})
).toBe('unpublish')
})
it('returns null for a plain update with no publication change', () => {
expect(
getPublishTransition({
command: 'update',
payload: file({ name: 'renamed' }),
prevPayload: file({}),
})
).toBe(null)
})
})
function effectRow(partial: Partial<TlaEffectOutbox>): TlaEffectOutbox {
return {
id: 1,
tableName: 'file',
entityId: 'f1',
command: 'update',
payload: file({}),
prevPayload: null,
attempts: 0,
createdAt: new Date(0),
nextRetryAt: null,
lastError: null,
...partial,
}
}
function makeFileDeps(current: Record<string, TlaFile | undefined>): FileEffectDeps & {
calls: string[]
} {
const calls: string[] = []
return {
calls,
getCurrentFile: async (fileId) => current[fileId],
notifyInsert: async (f) => {
calls.push(`insert:${f.id}`)
},
notifyUpdate: async (f) => {
calls.push(`update:${f.id}`)
},
notifyDelete: async (f) => {
calls.push(`delete:${f.id}`)
},
publish: async (f) => {
calls.push(`publish:${f.id}`)
},
unpublish: async (f) => {
calls.push(`unpublish:${f.id}`)
},
}
}
describe('processFileEffect', () => {
it('skips insert/update effects when the file no longer exists (staleness guard)', async () => {
const deps = makeFileDeps({})
await processFileEffect(deps, effectRow({ command: 'insert' }))
await processFileEffect(deps, effectRow({ command: 'update' }))
expect(deps.calls).toEqual([])
})
it('delete command uses the outbox payload, not current state', async () => {
const deps = makeFileDeps({})
await processFileEffect(deps, effectRow({ command: 'delete', payload: file({ id: 'f1' }) }))
expect(deps.calls).toEqual(['delete:f1'])
})
it('notifies with the freshly-read row, not the outbox payload', async () => {
const current = file({ id: 'f1', name: 'newest' })
const deps = makeFileDeps({ f1: current })
await processFileEffect(
deps,
effectRow({
command: 'update',
payload: file({ id: 'f1', name: 'stale' }),
prevPayload: file({ id: 'f1' }),
})
)
expect(deps.calls).toEqual(['update:f1'])
})
it('publishes only when current state still matches the transition', async () => {
const published = file({ id: 'f1', published: true, lastPublished: 100 })
const deps = makeFileDeps({ f1: published })
await processFileEffect(
deps,
effectRow({
command: 'update',
payload: published,
prevPayload: file({ id: 'f1', published: false, lastPublished: 0 }),
})
)
expect(deps.calls).toEqual(['update:f1', 'publish:f1'])
})
it('skips a stale publish superseded by a newer publish (flapping collapses)', async () => {
const current = file({ id: 'f1', published: true, lastPublished: 500 })
const deps = makeFileDeps({ f1: current })
await processFileEffect(
deps,
effectRow({
command: 'update',
payload: file({ id: 'f1', published: true, lastPublished: 100 }),
prevPayload: file({ id: 'f1', published: false, lastPublished: 0 }),
})
)
expect(deps.calls).toEqual(['update:f1'])
})
it('skips a stale unpublish when the file is currently published', async () => {
const current = file({ id: 'f1', published: true, lastPublished: 500 })
const deps = makeFileDeps({ f1: current })
await processFileEffect(
deps,
effectRow({
command: 'update',
payload: file({ id: 'f1', published: false, lastPublished: 100 }),
prevPayload: file({ id: 'f1', published: true, lastPublished: 100 }),
})
)
expect(deps.calls).toEqual(['update:f1'])
})
})
describe('processFileEffect on trashed files', () => {
it('still notifies update but skips publish when current file is soft-deleted', async () => {
const current = file({ id: 'f1', published: true, lastPublished: 100, isDeleted: true })
const deps = makeFileDeps({ f1: current })
await processFileEffect(
deps,
effectRow({
id: 1,
command: 'update',
payload: file({ id: 'f1', published: true, lastPublished: 100, isDeleted: true }),
prevPayload: file({ id: 'f1', published: false, lastPublished: 0 }),
})
)
expect(deps.calls).toEqual(['update:f1'])
})
it('still unpublishes when current file is soft-deleted', async () => {
const current = file({ id: 'f1', published: false, isDeleted: true })
const deps = makeFileDeps({ f1: current })
await processFileEffect(
deps,
effectRow({
id: 2,
command: 'update',
payload: file({ id: 'f1', published: false, isDeleted: true }),
prevPayload: file({ id: 'f1', published: true, lastPublished: 100 }),
})
)
expect(deps.calls).toEqual(['update:f1', 'unpublish:f1'])
})
})

View File

@@ -0,0 +1,64 @@
import { TlaEffectOutbox, TlaFile } from '@tldraw/dotcom-shared'
// An effect_outbox row whose tableName is 'file'.
export interface FileEffectRow extends TlaEffectOutbox {
payload: TlaFile
prevPayload: TlaFile | null
}
// A file's `update` row is a publish when it becomes published (or republishes with a new
// lastPublished timestamp) and an unpublish when it stops being published.
export function getPublishTransition(
row: Pick<FileEffectRow, 'command' | 'payload' | 'prevPayload'>
): 'publish' | 'unpublish' | null {
if (row.command !== 'update' || !row.prevPayload) return null
const file = row.payload
const previous = row.prevPayload
if (file.published && (!previous.published || file.lastPublished !== previous.lastPublished)) {
return 'publish'
}
if (!file.published && previous.published) {
return 'unpublish'
}
return null
}
export interface FileEffectDeps {
getCurrentFile(fileId: string): Promise<TlaFile | undefined>
notifyInsert(file: TlaFile): Promise<void> // room DO appFileRecordCreated
notifyUpdate(file: TlaFile): Promise<void> // room DO appFileRecordDidUpdate (fresh row)
notifyDelete(fileRow: TlaFile): Promise<void> // room DO appFileRecordDidDelete (outbox payload)
publish(file: TlaFile): Promise<void>
unpublish(file: TlaFile): Promise<void>
}
export async function processFileEffect(deps: FileEffectDeps, genericRow: TlaEffectOutbox) {
// Invariant: caller must route only tableName === 'file' rows here.
const row = genericRow as FileEffectRow
if (row.command === 'delete') {
// Terminal: appFileRecordDidDelete is self-guarded, no staleness check needed.
await deps.notifyDelete(row.payload)
return
}
// Staleness guard: act on present truth, treat the row as a wake-up signal.
const current = await deps.getCurrentFile(row.entityId)
if (!current) return // hard-deleted since; a delete row follows or already ran
if (row.command === 'insert') {
await deps.notifyInsert(current)
return
}
await deps.notifyUpdate(current)
const transition = getPublishTransition(row)
if (
transition === 'publish' &&
// Publishing a trashed file is a race artifact; never publish it. Skipping is safe:
// undeleteFile bumps lastPublished on restore, so a fresh publish runs then.
!current.isDeleted &&
current.published &&
current.lastPublished === row.payload.lastPublished
) {
await deps.publish(current)
} else if (transition === 'unpublish' && !current.published) {
await deps.unpublish(current)
}
}

View File

@@ -0,0 +1,342 @@
import { RoomSnapshot } from '@tldraw/sync-core'
import { describe, expect, it } from 'vitest'
import { countRichTextCharacters, summarizeSnapshotDocuments } from './fileStats'
function documents(records: unknown[]): RoomSnapshot['documents'] {
return records.map((state, i) => ({ state, lastChangedClock: i }) as any)
}
function summarize(records: unknown[]) {
return summarizeSnapshotDocuments(documents(records))
}
function page(id: string) {
return { typeName: 'page', id, name: 'Page', index: 'a1' }
}
function shape(overrides: Record<string, unknown> = {}) {
return {
typeName: 'shape',
id: 'shape:1',
type: 'geo',
parentId: 'page:1',
x: 0,
y: 0,
rotation: 0,
isLocked: false,
props: {},
...overrides,
}
}
function richText(text: string) {
return { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text }] }] }
}
describe('summarizeSnapshotDocuments', () => {
it('reports an empty snapshot as all zeroes rather than failing', () => {
expect(summarize([])).toEqual({
recordsByTypeName: {},
pages: { total: 0, maxShapesOnAPage: 0, empty: 0 },
shapes: {
total: 0,
byType: {},
maxDepth: 0,
locked: 0,
rotated: 0,
orphaned: 0,
extent: null,
},
text: { shapesWithText: 0, totalCharacters: 0, longestCharacters: 0 },
bindings: {
total: 0,
byType: {},
arrows: { boundBothEnds: 0, boundOneEnd: 0, unbound: 0, dangling: 0 },
},
styles: {},
assets: { total: 0, byType: {}, totalSizeBytes: 0 },
})
})
it('counts every record by type name, including ones it has no other opinion about', () => {
const { recordsByTypeName } = summarize([
page('page:1'),
shape({ id: 'shape:1' }),
shape({ id: 'shape:2' }),
{ typeName: 'document', id: 'document:document' },
{ typeName: 'camera', id: 'camera:1' },
])
expect(recordsByTypeName).toEqual({ page: 1, shape: 2, document: 1, camera: 1 })
})
it('tallies shapes by type, locked, and rotated', () => {
const { shapes } = summarize([
page('page:1'),
shape({ id: 'shape:1', type: 'geo', isLocked: true }),
shape({ id: 'shape:2', type: 'geo', rotation: 0.5 }),
shape({ id: 'shape:3', type: 'arrow' }),
])
expect(shapes.byType).toEqual({ geo: 2, arrow: 1 })
expect(shapes.total).toBe(3)
expect(shapes.locked).toBe(1)
expect(shapes.rotated).toBe(1)
})
describe('page and nesting resolution', () => {
it('follows parent chains through frames and groups to find each shape’s page', () => {
const { pages, shapes } = summarize([
page('page:1'),
shape({ id: 'shape:frame', type: 'frame', parentId: 'page:1' }),
shape({ id: 'shape:group', type: 'group', parentId: 'shape:frame' }),
shape({ id: 'shape:leaf', parentId: 'shape:group' }),
])
expect(pages).toEqual({ total: 1, maxShapesOnAPage: 3, empty: 0 })
expect(shapes.maxDepth).toBe(3)
expect(shapes.orphaned).toBe(0)
})
it('reports the busiest page and counts pages with nothing on them', () => {
const { pages } = summarize([
page('page:1'),
page('page:2'),
page('page:3'),
shape({ id: 'shape:1', parentId: 'page:1' }),
shape({ id: 'shape:2', parentId: 'page:2' }),
shape({ id: 'shape:3', parentId: 'page:2' }),
])
expect(pages).toEqual({ total: 3, maxShapesOnAPage: 2, empty: 1 })
})
it('counts a shape whose parent is missing from the snapshot as orphaned', () => {
const { shapes, pages } = summarize([
page('page:1'),
shape({ id: 'shape:1', parentId: 'shape:gone' }),
])
expect(shapes.orphaned).toBe(1)
expect(pages).toEqual({ total: 1, maxShapesOnAPage: 0, empty: 1 })
})
it('terminates on a parent cycle instead of recursing forever', () => {
const { shapes } = summarize([
page('page:1'),
shape({ id: 'shape:a', parentId: 'shape:b' }),
shape({ id: 'shape:b', parentId: 'shape:a' }),
])
expect(shapes.orphaned).toBe(2)
expect(shapes.total).toBe(2)
})
})
describe('extent', () => {
it('covers the top-level shapes, using width and height where the shape has them', () => {
const { shapes } = summarize([
page('page:1'),
shape({ id: 'shape:1', x: -100, y: -50, props: { w: 100, h: 50 } }),
shape({ id: 'shape:2', x: 400, y: 200, props: { w: 100, h: 100 } }),
])
expect(shapes.extent).toEqual({ width: 600, height: 350 })
})
it('ignores nested shapes, whose coordinates are relative to their parent', () => {
const { shapes } = summarize([
page('page:1'),
shape({ id: 'shape:frame', type: 'frame', x: 0, y: 0, props: { w: 100, h: 100 } }),
// would blow the extent up to 100,000 wide if it were treated as page-space
shape({ id: 'shape:leaf', parentId: 'shape:frame', x: 100000, y: 0, props: {} }),
])
expect(shapes.extent).toEqual({ width: 100, height: 100 })
})
it('is null when a snapshot has pages but no shapes on them', () => {
expect(summarize([page('page:1')]).shapes.extent).toBeNull()
})
})
describe('text', () => {
it('measures rich text length across nested nodes without keeping the text', () => {
const { text } = summarize([
page('page:1'),
shape({ id: 'shape:1', props: { richText: richText('hello') } }),
shape({
id: 'shape:2',
props: {
richText: {
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'one' }] },
{ type: 'paragraph', content: [{ type: 'text', text: 'twotwo' }] },
],
},
},
}),
])
expect(text).toEqual({ shapesWithText: 2, totalCharacters: 14, longestCharacters: 9 })
})
it('falls back to the pre-rich-text `text` prop that old snapshots still carry', () => {
const { text } = summarize([
page('page:1'),
shape({ id: 'shape:1', type: 'text', props: { text: 'legacy' } }),
])
expect(text).toEqual({ shapesWithText: 1, totalCharacters: 6, longestCharacters: 6 })
})
it('does not count a shape whose rich text is empty', () => {
const { text } = summarize([
page('page:1'),
shape({ id: 'shape:1', props: { richText: richText('') } }),
])
expect(text).toEqual({ shapesWithText: 0, totalCharacters: 0, longestCharacters: 0 })
})
})
describe('styles', () => {
it('tallies each allowlisted style prop by value', () => {
const { styles } = summarize([
page('page:1'),
shape({ id: 'shape:1', props: { color: 'black', fill: 'solid', size: 'm' } }),
shape({ id: 'shape:2', props: { color: 'black', fill: 'none', size: 'l' } }),
shape({ id: 'shape:3', props: { color: 'red' } }),
])
expect(styles).toEqual({
color: { black: 2, red: 1 },
fill: { solid: 1, none: 1 },
size: { m: 1, l: 1 },
})
})
it('leaves out props that could hold something a user typed', () => {
const { styles } = summarize([
page('page:1'),
shape({
id: 'shape:1',
props: { color: 'black', text: 'a secret', url: 'https://example.com', name: 'Q3 plan' },
}),
])
expect(styles).toEqual({ color: { black: 1 } })
})
})
describe('bindings', () => {
function arrowBinding(id: string, fromId: string, toId: string, terminal: string) {
return { typeName: 'binding', type: 'arrow', id, fromId, toId, props: { terminal } }
}
it('splits arrows by how many ends are bound', () => {
const { bindings } = summarize([
page('page:1'),
shape({ id: 'shape:box', type: 'geo' }),
shape({ id: 'shape:both', type: 'arrow' }),
shape({ id: 'shape:one', type: 'arrow' }),
shape({ id: 'shape:free', type: 'arrow' }),
arrowBinding('binding:1', 'shape:both', 'shape:box', 'start'),
arrowBinding('binding:2', 'shape:both', 'shape:box', 'end'),
arrowBinding('binding:3', 'shape:one', 'shape:box', 'end'),
])
expect(bindings).toEqual({
total: 3,
byType: { arrow: 3 },
arrows: { boundBothEnds: 1, boundOneEnd: 1, unbound: 1, dangling: 0 },
})
})
// The three bound/unbound counts partition arrows by how many terminals have a binding
// record; whether the target still exists is the separate `dangling` signal.
it('counts a binding pointing at a shape that is not in the snapshot as dangling', () => {
const { bindings } = summarize([
page('page:1'),
shape({ id: 'shape:arrow', type: 'arrow' }),
arrowBinding('binding:1', 'shape:arrow', 'shape:deleted', 'end'),
])
expect(bindings.arrows).toEqual({
boundBothEnds: 0,
boundOneEnd: 1,
unbound: 0,
dangling: 1,
})
})
it('tallies non-arrow bindings by type too', () => {
const { bindings } = summarize([
page('page:1'),
{ typeName: 'binding', type: 'layout', id: 'binding:1', fromId: 'a', toId: 'b', props: {} },
])
expect(bindings.total).toBe(1)
expect(bindings.byType).toEqual({ layout: 1 })
})
})
describe('assets', () => {
it('tallies assets by type and sums their declared sizes', () => {
const { assets } = summarize([
{ typeName: 'asset', type: 'image', id: 'asset:1', props: { fileSize: 1000 } },
{ typeName: 'asset', type: 'image', id: 'asset:2', props: { fileSize: 2000 } },
{ typeName: 'asset', type: 'bookmark', id: 'asset:3', props: {} },
])
expect(assets).toEqual({
total: 3,
byType: { image: 2, bookmark: 1 },
totalSizeBytes: 3000,
})
})
it('ignores the -1 file size that assets use for unknown', () => {
const { assets } = summarize([
{ typeName: 'asset', type: 'image', id: 'asset:1', props: { fileSize: -1 } },
])
expect(assets.totalSizeBytes).toBe(0)
})
})
it('reads a malformed record defensively rather than throwing', () => {
const { shapes, recordsByTypeName } = summarize([
page('page:1'),
// no props, non-numeric coordinates, missing parent
{ typeName: 'shape', id: 'shape:1', type: 'geo', x: null, y: undefined },
{ id: 'nope' },
null,
])
expect(shapes.total).toBe(1)
expect(recordsByTypeName).toEqual({ page: 1, shape: 1 })
})
it('buckets a record with no type under `unknown` rather than tallying it as "undefined"', () => {
const { shapes, bindings, assets } = summarize([
page('page:1'),
shape({ id: 'shape:1', type: undefined }),
{ typeName: 'binding', id: 'binding:1', fromId: 'shape:1', toId: 'shape:1' },
{ typeName: 'asset', id: 'asset:1', props: { fileSize: 10 } },
])
expect(shapes.byType).toEqual({ unknown: 1 })
expect(bindings.byType).toEqual({ unknown: 1 })
expect(assets.byType).toEqual({ unknown: 1 })
})
})
describe('countRichTextCharacters', () => {
it('sums text nodes at any depth', () => {
expect(
countRichTextCharacters({
type: 'doc',
content: [
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'abc' }] }],
},
],
},
{ type: 'paragraph', content: [{ type: 'text', text: 'de' }] },
],
})
).toBe(5)
})
it('returns 0 for anything that is not a node', () => {
expect(countRichTextCharacters(null)).toBe(0)
expect(countRichTextCharacters(undefined)).toBe(0)
expect(countRichTextCharacters('not a node')).toBe(0)
})
})

View File

@@ -0,0 +1,274 @@
import { AdminFileStatsResponseBody } from '@tldraw/dotcom-shared'
import { RoomSnapshot } from '@tldraw/sync-core'
/**
* Shape props tallied by the board-stats report. These are the enum style props (`StyleProp.defineEnum`
* in tlschema) plus `align`, their pre-`horizontalAlign` name, which old snapshots still carry. The
* list is an allowlist rather than "every short string prop" on purpose: it's what keeps a stats
* report free of anything a user typed or pasted.
*/
const STYLE_PROP_NAMES = [
'align',
'arrowheadEnd',
'arrowheadStart',
'color',
'dash',
'fill',
'font',
'geo',
'horizontalAlign',
'kind',
'labelColor',
'size',
'spline',
'textAlign',
'verticalAlign',
] as const
/** Shapes nest, so a parent chain this long is a corrupted snapshot, not a deeply grouped board. */
const MAX_PARENT_DEPTH = 100
/**
* A record's `type` as a tally key. Snapshots are read unvalidated, so a broken record can carry no
* type at all; those go in one bucket rather than becoming a literal `"undefined"` row in the report.
*/
function typeKey(type: unknown): string {
return typeof type === 'string' ? type : 'unknown'
}
/**
* Total length of the text in a rich text document, without ever holding the text itself. Rich text
* is a ProseMirror document (see TLRichText), so the text lives in `text` nodes at arbitrary depth.
*/
export function countRichTextCharacters(node: unknown): number {
if (!node || typeof node !== 'object') return 0
const { text, content } = node as { text?: unknown; content?: unknown }
let total = typeof text === 'string' ? text.length : 0
if (Array.isArray(content)) {
for (const child of content) total += countRichTextCharacters(child)
}
return total
}
/** The parts of a board-stats report that come from the snapshot itself. */
export type FileSnapshotStats = Pick<
AdminFileStatsResponseBody,
'pages' | 'shapes' | 'text' | 'bindings' | 'styles' | 'assets'
> & { recordsByTypeName: Record<string, number> }
/**
* Counts, tallies, and sizes for the records in a board's snapshot — never their contents. Records
* are read defensively rather than through the schema's validators: this runs on whatever is
* actually in R2, including snapshots too old or too broken to parse, which are exactly the ones
* worth asking about.
*/
export function summarizeSnapshotDocuments(
documents: RoomSnapshot['documents']
): FileSnapshotStats {
const pageIds = new Set<string>()
const shapeIds = new Set<string>()
const parentIdByShapeId = new Map<string, string>()
const recordsByTypeName: Record<string, number> = {}
const shapesByType: Record<string, number> = {}
const bindingsByType: Record<string, number> = {}
const assetsByType: Record<string, number> = {}
const styles: Record<string, Record<string, number>> = {}
const arrowBindings: Array<{ fromId: unknown; toId: unknown; terminal: unknown }> = []
// x/y/w/h per shape, for the extent below. Kept until parents are resolved, since a shape's
// coordinates only mean something once we know whether it sits on a page.
const boxes: Array<{ id: string; x: number; y: number; w: number; h: number }> = []
let totalBindings = 0
let locked = 0
let rotated = 0
let shapesWithText = 0
let totalCharacters = 0
let longestCharacters = 0
let assetsTotal = 0
let assetSizeBytes = 0
for (const { state } of documents) {
const record = state as any
if (!record || typeof record !== 'object') continue
if (typeof record.typeName === 'string') {
recordsByTypeName[record.typeName] = (recordsByTypeName[record.typeName] ?? 0) + 1
}
switch (record.typeName) {
case 'page':
pageIds.add(record.id)
break
case 'shape': {
shapeIds.add(record.id)
if (typeof record.parentId === 'string') {
parentIdByShapeId.set(record.id, record.parentId)
}
const shapeType = typeKey(record.type)
shapesByType[shapeType] = (shapesByType[shapeType] ?? 0) + 1
if (record.isLocked) locked++
if (record.rotation) rotated++
const props = record.props ?? {}
for (const name of STYLE_PROP_NAMES) {
const value = props[name]
if (typeof value !== 'string') continue
const tally = (styles[name] ??= {})
tally[value] = (tally[value] ?? 0) + 1
}
// Shapes carried plain `text` before rich text; old snapshots still have it
const characters = props.richText
? countRichTextCharacters(props.richText)
: typeof props.text === 'string'
? props.text.length
: 0
if (characters > 0) {
shapesWithText++
totalCharacters += characters
longestCharacters = Math.max(longestCharacters, characters)
}
boxes.push({
id: record.id,
x: typeof record.x === 'number' ? record.x : 0,
y: typeof record.y === 'number' ? record.y : 0,
// Only some shapes have a width and height; the rest count as points
w: typeof props.w === 'number' ? props.w : 0,
h: typeof props.h === 'number' ? props.h : 0,
})
break
}
case 'binding': {
totalBindings++
const bindingType = typeKey(record.type)
bindingsByType[bindingType] = (bindingsByType[bindingType] ?? 0) + 1
if (bindingType === 'arrow') {
arrowBindings.push({
fromId: record.fromId,
toId: record.toId,
terminal: record.props?.terminal,
})
}
break
}
case 'asset': {
assetsTotal++
const assetType = typeKey(record.type)
assetsByType[assetType] = (assetsByType[assetType] ?? 0) + 1
const size = record.props?.fileSize
if (typeof size === 'number' && size > 0) assetSizeBytes += size
break
}
}
}
// Shapes nest inside frames and groups, so a shape's page sits at the top of its parent chain.
// Memoized, and capped so a snapshot with a parent cycle can't spin the worker.
const resolvedByShapeId = new Map<string, { depth: number; pageId: string | null }>()
function resolveShape(shapeId: string, hops: number): { depth: number; pageId: string | null } {
const cached = resolvedByShapeId.get(shapeId)
if (cached) return cached
const parentId = parentIdByShapeId.get(shapeId)
let resolved: { depth: number; pageId: string | null }
if (parentId !== undefined && pageIds.has(parentId)) {
resolved = { depth: 1, pageId: parentId }
} else if (
parentId === undefined ||
!parentIdByShapeId.has(parentId) ||
hops >= MAX_PARENT_DEPTH
) {
resolved = { depth: 1, pageId: null }
} else {
const parent = resolveShape(parentId, hops + 1)
resolved = { depth: parent.depth + 1, pageId: parent.pageId }
}
resolvedByShapeId.set(shapeId, resolved)
return resolved
}
const shapeCountByPageId = new Map<string, number>()
let maxDepth = 0
let orphaned = 0
for (const shapeId of shapeIds) {
const { depth, pageId } = resolveShape(shapeId, 0)
maxDepth = Math.max(maxDepth, depth)
if (pageId === null) {
orphaned++
} else {
shapeCountByPageId.set(pageId, (shapeCountByPageId.get(pageId) ?? 0) + 1)
}
}
let emptyPages = 0
let maxShapesOnAPage = 0
for (const pageId of pageIds) {
const count = shapeCountByPageId.get(pageId) ?? 0
if (count === 0) emptyPages++
maxShapesOnAPage = Math.max(maxShapesOnAPage, count)
}
// Only shapes parented to a page: a nested shape's x/y is relative to its frame or group, so
// mixing the two would give a meaningless box. Rotation is ignored — this is the extent of the
// unrotated boxes, which is all it needs to be for "is this board absurdly spread out".
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const box of boxes) {
const resolved = resolvedByShapeId.get(box.id)
if (!resolved || resolved.depth !== 1 || resolved.pageId === null) continue
minX = Math.min(minX, box.x)
minY = Math.min(minY, box.y)
maxX = Math.max(maxX, box.x + box.w)
maxY = Math.max(maxY, box.y + box.h)
}
const extent =
minX === Infinity ? null : { width: Math.round(maxX - minX), height: Math.round(maxY - minY) }
// An arrow has one binding per bound terminal, so the terminals seen per arrow say whether it's
// bound at both ends, one, or neither
const terminalsByArrowId = new Map<string, Set<string>>()
let danglingBindings = 0
for (const binding of arrowBindings) {
if (typeof binding.toId !== 'string' || !shapeIds.has(binding.toId)) danglingBindings++
if (typeof binding.fromId !== 'string' || !shapeIds.has(binding.fromId)) continue
let terminals = terminalsByArrowId.get(binding.fromId)
if (!terminals) {
terminals = new Set<string>()
terminalsByArrowId.set(binding.fromId, terminals)
}
if (typeof binding.terminal === 'string') terminals.add(binding.terminal)
}
let boundBothEnds = 0
let boundOneEnd = 0
for (const terminals of terminalsByArrowId.values()) {
if (terminals.size >= 2) boundBothEnds++
else if (terminals.size === 1) boundOneEnd++
}
return {
recordsByTypeName,
pages: { total: pageIds.size, maxShapesOnAPage, empty: emptyPages },
shapes: {
total: shapeIds.size,
byType: shapesByType,
maxDepth,
locked,
rotated,
orphaned,
extent,
},
text: { shapesWithText, totalCharacters, longestCharacters },
bindings: {
total: totalBindings,
byType: bindingsByType,
arrows: {
boundBothEnds,
boundOneEnd,
unbound: Math.max(0, (shapesByType.arrow ?? 0) - boundBothEnds - boundOneEnd),
dangling: danglingBindings,
},
},
styles,
assets: { total: assetsTotal, byType: assetsByType, totalSizeBytes: assetSizeBytes },
}
}

View File

@@ -0,0 +1,241 @@
import { createRouter, notFound } from '@tldraw/worker-shared'
import { sql } from 'kysely'
import { MAX_ATTEMPTS } from './outboxDrain'
import { createPostgresConnectionPool } from './postgres'
import { isDebugLogging, type Environment } from './types'
import { getClerkClient } from './utils/tla/getAuth'
function isAuthorized(req: Request, env: Environment) {
const auth = req.headers.get('Authorization')
const bearer = auth?.split('Bearer ')[1]
return bearer && bearer === env.HEALTH_CHECK_BEARER_TOKEN
}
export const healthCheckRoutes = createRouter<Environment>()
.all('/health-check/*', (req, env) => {
if (isDebugLogging(env) || isAuthorized(req, env)) return undefined
return new Response('Unauthorized', { status: 401 })
})
.get('/health-check/clerk', async (_, env) => {
const clerk = getClerkClient(env)
try {
const result = await clerk.users.getCount()
if (!result || typeof result !== 'number') {
return new Response('Could not reach clerk', { status: 500 })
}
return new Response('ok', { status: 200 })
} catch (_e) {
return new Response('Could not reach clerk', { status: 500 })
}
})
.get('/health-check/db', async (_, env) => {
const db = createPostgresConnectionPool(env, '/health-check/db')
try {
await db
.selectFrom('user')
.select('name')
.where('email', '=', 'mitja@tldraw.com')
.executeTakeFirstOrThrow()
return new Response('ok', { status: 200 })
} catch (_e) {
return new Response('Could not reach the database', { status: 500 })
} finally {
await db.destroy()
}
})
.get('/health-check/zero-replicator', async (_, env) => {
const db = createPostgresConnectionPool(env, '/health-check/zero-replicator')
try {
const result = await sql<{ status: string }>`
SELECT
CASE
WHEN write_lsn IS NULL THEN 'STALLED'
WHEN write_lag > interval '1 minute' THEN 'LAGGING'
ELSE 'HEALTHY'
END AS status
FROM pg_stat_replication
WHERE application_name = 'zero-replicator'
`.execute(db)
if (result.rows.length === 0) {
return new Response('zero-replicator not connected', { status: 500 })
}
const status = result.rows[0].status
if (status !== 'HEALTHY') {
return new Response(`zero-replicator: ${status}`, { status: 500 })
}
return new Response('ok', { status: 200 })
} catch (_e) {
return new Response('Could not check zero-replicator status', { status: 500 })
} finally {
await db.destroy()
}
})
// Combined postgres health check: db size, changelog size, WAL retention, and replication slots.
// Grouped into a single endpoint because updown.io charges per check invocation.
// Failures include the sub-check name so alerts remain distinguishable.
.get('/health-check/postgres', async (_, env) => {
const db = createPostgresConnectionPool(env, '/health-check/postgres')
const failures: string[] = []
const okDetails: string[] = []
try {
// db-size
try {
const thresholdGb = parseFloat(env.HEALTH_CHECK_DB_SIZE_THRESHOLD_GB ?? '4')
const result = await sql<{ size_bytes: string }>`
SELECT pg_database_size(current_database()) AS size_bytes
`.execute(db)
const sizeGb = parseInt(result.rows[0].size_bytes, 10) / (1024 * 1024 * 1024)
if (sizeGb > thresholdGb) {
failures.push(`db-size: ${sizeGb.toFixed(2)} GB > ${thresholdGb} GB threshold`)
} else {
okDetails.push(`db: ${sizeGb.toFixed(2)} GB`)
}
} catch (_e) {
failures.push('db-size: query failed')
}
// changelog-size
try {
const thresholdMb = parseFloat(env.HEALTH_CHECK_CHANGELOG_SIZE_THRESHOLD_MB ?? '1024')
const result = await sql<{ size_bytes: string }>`
SELECT pg_total_relation_size('"zero_0/cdc"."changeLog"') AS size_bytes
`.execute(db)
const sizeMb = parseInt(result.rows[0].size_bytes, 10) / (1024 * 1024)
if (sizeMb > thresholdMb) {
failures.push(`changelog-size: ${sizeMb.toFixed(0)} MB > ${thresholdMb} MB threshold`)
} else {
okDetails.push(`changelog: ${sizeMb.toFixed(0)} MB`)
}
} catch (_e) {
failures.push('changelog-size: query failed')
}
// wal-size
try {
const thresholdMb = parseFloat(env.HEALTH_CHECK_WAL_SIZE_THRESHOLD_MB ?? '1024')
const result = await sql<{
slot_name: string
retained_bytes: string
}>`
SELECT slot_name, pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) AS retained_bytes
FROM pg_replication_slots
`.execute(db)
const overThreshold = result.rows.filter(
(row) => parseInt(row.retained_bytes, 10) / (1024 * 1024) > thresholdMb
)
if (overThreshold.length > 0) {
const details = overThreshold
.map((r) => {
const mb = (parseInt(r.retained_bytes, 10) / (1024 * 1024)).toFixed(0)
return `${r.slot_name}: ${mb} MB`
})
.join(', ')
failures.push(`wal-size: ${details} > ${thresholdMb} MB threshold`)
} else {
const maxMb = result.rows.reduce(
(max, r) => Math.max(max, parseInt(r.retained_bytes, 10) / (1024 * 1024)),
0
)
okDetails.push(`wal: ${maxMb.toFixed(0)} MB`)
}
} catch (_e) {
failures.push('wal-size: query failed')
}
// replication-slots
try {
const result = await sql<{
slot_name: string
active: boolean
wal_status: string | null
}>`
SELECT slot_name, active, wal_status
FROM pg_replication_slots
WHERE slot_name LIKE 'zero_%'
`.execute(db)
const unhealthy = result.rows.filter(
(row) => row.wal_status === 'lost' || row.wal_status === 'unreserved'
)
if (unhealthy.length > 0) {
const details = unhealthy
.map((r) => `${r.slot_name}: wal_status=${r.wal_status}`)
.join(', ')
failures.push(`replication-slots: ${details}`)
} else {
okDetails.push(`slots: ${result.rows.length} ok`)
}
} catch (_e) {
failures.push('replication-slots: query failed')
}
if (failures.length > 0) {
return new Response(`FAIL ${failures.join('; ')}`, { status: 500 })
}
return new Response(`ok (${okDetails.join(', ')})`, { status: 200 })
} finally {
await db.destroy()
}
})
// Split from /health-check/postgres so the monitor name identifies the outbox subsystem
// directly instead of burying it in a combined postgres failure string. There's no lag
// alert here: backoff rows are expected to sit with a future nextRetryAt, so alerting on
// lag alone would trip on healthy backoff; Sentry already covers individual sub-parking
// failures. Parked and stalled below cover the two failure modes that actually matter.
.get('/health-check/outbox', async (_, env) => {
const db = createPostgresConnectionPool(env, '/health-check/outbox')
const failures: string[] = []
const okDetails: string[] = []
try {
// outbox-parked
try {
const result = await sql<{ parked: string }>`
SELECT count(*) FILTER (WHERE attempts >= ${sql.raw(String(MAX_ATTEMPTS))}) AS parked
FROM effect_outbox
`.execute(db)
const row = result.rows[0]
const parked = row?.parked ? parseInt(row.parked, 10) : 0
if (parked > 0) {
failures.push(`outbox-parked: ${parked} rows parked`)
} else {
okDetails.push('outbox: 0 parked')
}
} catch (e) {
console.error('health-check outbox:', e)
failures.push('outbox: query failed')
}
// outbox-stalled: parked-only detection needs the drain to run at all. If the alarm
// chain died, unparked rows sit untouched forever and never reach the parked
// threshold, so the check above stays green. A row is stalled when it has been
// ELIGIBLE to process (created, or past its backoff) for 15+ minutes: a live drain
// sweeps every 30s and backoff caps at 5 minutes, so it would have deleted, bumped,
// or re-deferred any eligible row long before that.
try {
const result = await sql<{ stalled: string }>`
SELECT count(*) AS stalled
FROM effect_outbox
WHERE attempts < ${sql.raw(String(MAX_ATTEMPTS))}
AND GREATEST("createdAt", coalesce("nextRetryAt", "createdAt")) < now() - interval '15 minutes'
`.execute(db)
const row = result.rows[0]
const stalled = row?.stalled ? parseInt(row.stalled, 10) : 0
if (stalled > 0) {
failures.push(`outbox-stalled: ${stalled} rows untouched > 15m`)
} else {
okDetails.push('outbox: 0 stalled')
}
} catch (e) {
console.error('health-check outbox:', e)
failures.push('outbox-stalled: query failed')
}
if (failures.length > 0) {
return new Response(`FAIL ${failures.join('; ')}`, { status: 500 })
}
return new Response(`ok (${okDetails.join(', ')})`, { status: 200 })
} finally {
await db.destroy()
}
})
.all('*', notFound)

View File

@@ -0,0 +1,104 @@
import { DatabaseSync } from 'node:sqlite'
import { describe, expect, it } from 'vitest'
import {
McpClusterIndexSql,
ensureMcpClusterIndexTable,
pruneMcpClusterIndexRows,
readMcpClusterIndexRow,
writeMcpClusterIndexRow,
} from './mcpClusterIndexStorage'
import { McpClusterIndexKey } from './types'
// Run against a real SQLite database, because the primary key is the whole design here and the fake
// durable object the rest of the tests use would accept a key that SQLite would not. It is also what
// keeps that fake honest: if these two disagree, the tool-level tests are proving nothing.
// A stand-in for Cloudflare's `SqlStorage`: same shape, a positional-binding exec returning a cursor.
function makeSql(): McpClusterIndexSql {
const db = new DatabaseSync(':memory:')
return {
exec(query: string, ...bindings: unknown[]) {
const statement = db.prepare(query)
if (/^\s*select/i.test(query)) {
return { toArray: () => statement.all(...(bindings as any[])) }
}
statement.run(...(bindings as any[]))
return { toArray: () => [] }
},
}
}
const KEY: McpClusterIndexKey = { kind: 'shared_file', pageId: 'page:a', version: 'etag-1' }
function countRows(sql: McpClusterIndexSql) {
const rows = sql.exec('SELECT COUNT(*) AS count FROM mcp_cluster_index').toArray() as {
count: number
}[]
return rows[0].count
}
describe('the cluster index table', () => {
it('reads back what it wrote, for that content version only', () => {
const sql = makeSql()
ensureMcpClusterIndexTable(sql)
writeMcpClusterIndexRow(sql, KEY, '{"v":1}')
expect(readMcpClusterIndexRow(sql, KEY)).toBe('{"v":1}')
expect(readMcpClusterIndexRow(sql, { ...KEY, version: 'etag-2' })).toBeNull()
expect(readMcpClusterIndexRow(sql, { ...KEY, pageId: 'page:b' })).toBeNull()
})
it('keeps one row per page, whatever the version', () => {
const sql = makeSql()
ensureMcpClusterIndexTable(sql)
writeMcpClusterIndexRow(sql, KEY, '{"v":1,"n":1}')
writeMcpClusterIndexRow(sql, { ...KEY, version: 'etag-2' }, '{"v":1,"n":2}')
writeMcpClusterIndexRow(sql, { ...KEY, pageId: 'page:b' }, '{"v":1,"n":3}')
// An edited board replaces its page's row rather than adding one, so a file that is edited
// hourly for a year still holds one row per page.
expect(countRows(sql)).toBe(2)
expect(readMcpClusterIndexRow(sql, { ...KEY, version: 'etag-2' })).toBe('{"v":1,"n":2}')
expect(readMcpClusterIndexRow(sql, KEY)).toBeNull()
})
it('keeps a file and the board published from it apart', () => {
const sql = makeSql()
ensureMcpClusterIndexTable(sql)
// One file is two boards, and they cluster differently as soon as the live document moves past
// the frozen snapshot — so `kind` has to be enough to separate them on its own.
writeMcpClusterIndexRow(sql, KEY, '{"v":1,"n":"live"}')
writeMcpClusterIndexRow(sql, { ...KEY, kind: 'published' }, '{"v":1,"n":"frozen"}')
expect(countRows(sql)).toBe(2)
expect(readMcpClusterIndexRow(sql, KEY)).toBe('{"v":1,"n":"live"}')
})
it('drops the rows for pages the board no longer has', () => {
const sql = makeSql()
ensureMcpClusterIndexTable(sql)
writeMcpClusterIndexRow(sql, KEY, '{"v":1,"n":"a"}')
writeMcpClusterIndexRow(sql, { ...KEY, pageId: 'page:gone' }, '{"v":1,"n":"gone"}')
writeMcpClusterIndexRow(sql, { ...KEY, kind: 'published', pageId: 'page:gone' }, '{"v":1}')
pruneMcpClusterIndexRows(sql, 'shared_file', ['page:a'])
// Without this the table is bounded by every page id the board has ever had: a page deleted
// after it was indexed leaves a row nothing reads, replaces, or expires.
expect(readMcpClusterIndexRow(sql, { ...KEY, pageId: 'page:gone' })).toBeNull()
expect(readMcpClusterIndexRow(sql, KEY)).toBe('{"v":1,"n":"a"}')
// Scoped to the kind it was told about: the published board's pages are a different list.
expect(readMcpClusterIndexRow(sql, { ...KEY, kind: 'published', pageId: 'page:gone' })).toBe(
'{"v":1}'
)
})
it('is safe to ensure repeatedly, which every call does', () => {
const sql = makeSql()
ensureMcpClusterIndexTable(sql)
writeMcpClusterIndexRow(sql, KEY, '{"v":1}')
ensureMcpClusterIndexTable(sql)
expect(readMcpClusterIndexRow(sql, KEY)).toBe('{"v":1}')
})
})

View File

@@ -0,0 +1,87 @@
import { McpClusterIndexKey, ThumbnailBoardKind } from './types'
// The SQL behind the MCP cluster index cache (see mcpClusterIndex.ts for what it is for), kept apart
// from TLFileDurableObject so it can be run against a real SQLite database in a test — otherwise
// these statements would only ever execute in production.
/** The subset of Cloudflare's `SqlStorage` these statements need. */
export interface McpClusterIndexSql {
exec(query: string, ...bindings: unknown[]): { toArray(): unknown[] }
}
/**
* The primary key deliberately excludes `version`: a page holds one row, and new content replaces the
* row for the old content. That is what bounds a file's cache by its page count rather than by its
* edit history, with no expiry policy to run. There is no slug either — the object is already
* per-file, and a published slug can be rotated, which would strand a row nothing ever replaces.
* `kind` stays, because one file is two boards: the live shared file and the frozen published
* snapshot, which cluster differently once they have drifted apart. Unpublishing leaves the
* `published` rows behind — bounded by the page count, and replaced by version on a republish.
*/
export function ensureMcpClusterIndexTable(sql: McpClusterIndexSql) {
sql.exec(
`CREATE TABLE IF NOT EXISTS mcp_cluster_index (
kind TEXT NOT NULL,
pageId TEXT NOT NULL,
version TEXT NOT NULL,
payload TEXT NOT NULL,
PRIMARY KEY (kind, pageId)
)`
)
}
/**
* One page's stored index, or null when the row is missing *or* is for content that has since moved
* on. The version is matched in the statement rather than handed back for the caller to check, so a
* stale payload cannot leave the object at all.
*/
export function readMcpClusterIndexRow(
sql: McpClusterIndexSql,
key: McpClusterIndexKey
): string | null {
const rows = sql
.exec(
'SELECT payload FROM mcp_cluster_index WHERE kind = ? AND pageId = ? AND version = ?',
key.kind,
key.pageId,
key.version
)
.toArray() as { payload: string }[]
return rows[0]?.payload ?? null
}
/**
* Drops the rows for pages this board no longer has.
*
* Without this the table is bounded by the page ids a board has *ever* had rather than by the pages
* it has now: a page deleted after it was indexed leaves a row nothing reads, replaces, or expires.
* Run on every write, which is the only moment the current page list is in hand anyway.
*/
export function pruneMcpClusterIndexRows(
sql: McpClusterIndexSql,
kind: ThumbnailBoardKind,
pageIds: string[]
) {
// A board always has at least the page being written, so the list is never empty and the generated
// `NOT IN ()` is never the syntax error an empty one would be.
const placeholders = pageIds.map(() => '?').join(', ')
sql.exec(
`DELETE FROM mcp_cluster_index WHERE kind = ? AND pageId NOT IN (${placeholders})`,
kind,
...pageIds
)
}
export function writeMcpClusterIndexRow(
sql: McpClusterIndexSql,
key: McpClusterIndexKey,
payload: string
) {
sql.exec(
'INSERT OR REPLACE INTO mcp_cluster_index (kind, pageId, version, payload) VALUES (?, ?, ?, ?)',
key.kind,
key.pageId,
key.version,
payload
)
}

View File

@@ -0,0 +1,326 @@
import { TlaEffectOutbox } from '@tldraw/dotcom-shared'
import { describe, expect, it, vi } from 'vitest'
import {
MAX_ATTEMPTS,
OutboxDeps,
computeNextAlarm,
drainOutbox,
formatOutboxError,
shouldReportEffectFailure,
} from './outboxDrain'
function row(partial: Partial<TlaEffectOutbox>): TlaEffectOutbox {
return {
id: 1,
tableName: 'file',
entityId: 'f1',
command: 'update',
payload: {},
prevPayload: null,
attempts: 0,
createdAt: new Date(0),
nextRetryAt: null,
lastError: null,
...partial,
}
}
interface TestDeps extends OutboxDeps {
calls: string[]
bumped: Array<{ id: number; attempts: number; error: unknown }>
}
function makeDeps(rows: TlaEffectOutbox[], timeoutMs = 30_000): TestDeps {
const calls: string[] = []
const bumped: Array<{ id: number; attempts: number; error: unknown }> = []
let batch = rows
return {
calls,
bumped,
timeoutMs,
getBatch: async () => {
const b = batch
batch = []
return b
},
deleteRow: async (id) => {
calls.push(`deleteRow:${id}`)
},
bumpAttempts: async (r, error) => {
calls.push(`bump:${r.id}`)
bumped.push({ id: r.id, attempts: r.attempts, error })
},
deleteParkedRowsOlderThan: async () => {},
process: async (r) => {
calls.push(`process:${r.id}`)
},
onError: vi.fn(),
}
}
describe('drainOutbox', () => {
it('processes rows in id order and deletes them', async () => {
const deps = makeDeps([row({ id: 1 }), row({ id: 2 })])
await drainOutbox(deps)
expect(deps.calls).toEqual(['process:1', 'deleteRow:1', 'process:2', 'deleteRow:2'])
})
it('on failure bumps attempts and skips later rows for the same entity only', async () => {
const deps = makeDeps([
row({ id: 1, entityId: 'f1' }),
row({ id: 2, entityId: 'f1' }),
row({ id: 3, entityId: 'f2' }),
])
deps.process = async (r) => {
if (r.entityId === 'f1') throw new Error('room DO unavailable')
deps.calls.push(`process:${r.id}`)
}
await drainOutbox(deps)
// f1 and f2 are separate entities processed concurrently, so ordering across them
// is not guaranteed; assert on membership instead.
expect(deps.calls).toContain('bump:1')
expect(deps.calls).toContain('process:3')
expect(deps.calls).toContain('deleteRow:3')
expect(deps.calls).not.toContain('process:2')
expect(deps.calls).not.toContain('bump:2')
expect(deps.onError).toHaveBeenCalledTimes(1)
})
it('same entityId in different tables does not cross-skip', async () => {
const deps = makeDeps([
row({ id: 1, tableName: 'file', entityId: 'x' }),
row({ id: 2, tableName: 'comment', entityId: 'x' }),
])
deps.process = async (r) => {
if (r.tableName === 'file') throw new Error('boom')
deps.calls.push(`process:${r.id}`)
}
await drainOutbox(deps)
expect(deps.calls).toContain('bump:1')
expect(deps.calls).toContain('process:2')
expect(deps.calls).toContain('deleteRow:2')
})
it('stops instead of spinning when the same failing batch keeps coming back', async () => {
const rows = [row({ id: 1, entityId: 'f1' }), row({ id: 2, entityId: 'f2' })]
const deps = makeDeps([])
let getBatchCalls = 0
deps.getBatch = async () => {
getBatchCalls++
return rows
}
deps.process = async () => {
throw new Error('always fails')
}
await drainOutbox(deps)
// Each entity bumped exactly once, then the loop breaks instead of refetching forever.
expect(deps.calls.filter((c) => c === 'bump:1')).toHaveLength(1)
expect(deps.calls.filter((c) => c === 'bump:2')).toHaveLength(1)
expect(getBatchCalls).toBe(1)
})
it('bumps a failing entity at most once per drain even if later batches resurface it', async () => {
const batch1 = [row({ id: 1, entityId: 'bad' }), row({ id: 2, entityId: 'good' })]
// Simulates the failing row still being in range (attempts < MAX_ATTEMPTS) and reappearing
// in the next getBatch call alongside a new healthy row.
const batch2 = [row({ id: 1, entityId: 'bad' }), row({ id: 3, entityId: 'good2' })]
const batches = [batch1, batch2, []]
const deps = makeDeps([])
deps.getBatch = async () => batches.shift() ?? []
deps.process = async (r) => {
if (r.entityId === 'bad') throw new Error('always fails')
deps.calls.push(`process:${r.id}`)
}
await drainOutbox(deps)
expect(deps.calls).toContain('process:2')
expect(deps.calls).toContain('deleteRow:2')
expect(deps.calls).toContain('process:3')
expect(deps.calls).toContain('deleteRow:3')
expect(deps.calls.filter((c) => c === 'bump:1')).toHaveLength(1)
})
it('processes independent entities concurrently: a hung entity does not block another', async () => {
// entity "slow" row never resolves; entity "fast" must still complete because groups
// run concurrently and the per-effect timeout unsticks the slow group.
const deps = makeDeps(
[row({ id: 1, entityId: 'slow' }), row({ id: 2, entityId: 'fast' })],
5 // short injected timeout so the hung row fails fast
)
deps.process = (r) => {
deps.calls.push(`process:${r.id}`)
if (r.entityId === 'slow') return new Promise<void>(() => {}) // never resolves
return Promise.resolve()
}
await drainOutbox(deps)
// fast entity completed
expect(deps.calls).toContain('process:2')
expect(deps.calls).toContain('deleteRow:2')
// slow entity timed out => failure path
expect(deps.calls).toContain('bump:1')
expect(deps.onError).toHaveBeenCalledTimes(1)
})
it('processes rows within a single entity strictly sequentially even under concurrency', async () => {
const order: string[] = []
const deps = makeDeps([
row({ id: 1, entityId: 'e' }),
row({ id: 2, entityId: 'e' }),
row({ id: 3, entityId: 'e' }),
])
deps.process = async (r) => {
order.push(`start:${r.id}`)
await new Promise((res) => setTimeout(res, 1))
order.push(`end:${r.id}`)
}
await drainOutbox(deps)
// each row fully completes (start then end) before the next starts
expect(order).toEqual(['start:1', 'end:1', 'start:2', 'end:2', 'start:3', 'end:3'])
})
it('a per-effect timeout counts as a failure: bumps and reports onError with a timeout error', async () => {
const deps = makeDeps([row({ id: 1 })], 5)
deps.process = () => new Promise<void>(() => {}) // never resolves
await drainOutbox(deps)
expect(deps.calls).toContain('bump:1')
expect(deps.calls).not.toContain('deleteRow:1')
expect(deps.onError).toHaveBeenCalledTimes(1)
const err = (deps.onError as any).mock.calls[0][0]
expect(String(err)).toMatch(/timed out/i)
expect(formatOutboxError(deps.bumped[0].error)).toBe(
'EffectTimeoutError: effect for file:f1 (outbox 1) timed out after 5ms'
)
})
it('reports the effect error before bumping so a failing bump cannot hide it', async () => {
const deps = makeDeps([row({ id: 1 })])
const effectError = new Error('effect failed')
deps.process = async () => {
throw effectError
}
deps.bumpAttempts = async () => {
throw new Error('db down')
}
await expect(drainOutbox(deps)).rejects.toThrow('db down')
expect(deps.onError).toHaveBeenCalledWith(effectError, expect.objectContaining({ id: 1 }))
})
it('late resolution after a timeout does not double-delete or double-bump', async () => {
let resolveLate!: () => void
const deps = makeDeps([row({ id: 1 })], 5)
deps.process = () =>
new Promise<void>((res) => {
resolveLate = res
})
await drainOutbox(deps)
// timeout already recorded a failure
expect(deps.calls.filter((c) => c === 'bump:1')).toHaveLength(1)
expect(deps.calls).not.toContain('deleteRow:1')
// now the underlying RPC finally resolves; must not trigger a delete
resolveLate()
await new Promise((r) => setTimeout(r, 10))
expect(deps.calls).not.toContain('deleteRow:1')
expect(deps.calls.filter((c) => c === 'bump:1')).toHaveLength(1)
})
it('passes the row current attempts and the error to bumpAttempts', async () => {
const deps = makeDeps([row({ id: 7, attempts: 3 })])
const error = new Error('fail')
deps.process = async () => {
throw error
}
await drainOutbox(deps)
expect(deps.bumped).toEqual([{ id: 7, attempts: 3, error }])
})
it('passes the FULL failed row to bumpAttempts so the impl can defer later siblings', async () => {
// bumpAttempts is the only hook the DO has to defer the failed row's later same-entity
// siblings (set their nextRetryAt) and preserve per-entity ordering across drains, so it
// must receive the whole row (tableName + entityId + id), not just id/attempts.
const failed = row({ id: 5, tableName: 'file', entityId: 'f1', attempts: 2 })
let received: TlaEffectOutbox | undefined
const deps = makeDeps([failed, row({ id: 6, tableName: 'file', entityId: 'f1' })])
deps.bumpAttempts = async (r) => {
received = r
}
deps.process = async () => {
throw new Error('fail')
}
await drainOutbox(deps)
expect(received).toEqual(failed)
// the later sibling (id 6) is NOT processed this drain — the DO's sibling deferral keeps it
// out of the next drain too, but here we only assert the in-drain skip.
expect(deps.calls).not.toContain('process:6')
})
})
describe('formatOutboxError', () => {
it('formats an Error as name: message', () => {
expect(formatOutboxError(new TypeError('cannot connect'))).toBe('TypeError: cannot connect')
})
it('stringifies non-Error values', () => {
expect(formatOutboxError('proxy request failed')).toBe('proxy request failed')
expect(formatOutboxError({ code: 42 })).toBe('{"code":42}')
expect(formatOutboxError(undefined)).toBe('undefined')
})
it('appends one level of cause', () => {
const inner = new Error('connect ECONNREFUSED', { cause: new Error('deeper') })
expect(formatOutboxError(new TypeError('fetch failed', { cause: inner }))).toBe(
'TypeError: fetch failed (cause: Error: connect ECONNREFUSED)'
)
expect(formatOutboxError(new Error('x', { cause: { code: 'ETIMEDOUT' } }))).toBe(
'Error: x (cause: {"code":"ETIMEDOUT"})'
)
})
it('never throws and strips NUL bytes so the attempts bump cannot fail on it', () => {
const circular: any = {}
circular.self = circular
expect(formatOutboxError(circular)).toBe('[unformattable object]')
expect(formatOutboxError({ toJSON: () => undefined })).toBe('[object Object]')
expect(formatOutboxError(new Error('x', { cause: circular }))).toBe('[unformattable object]')
expect(formatOutboxError(new Error('a\0b'))).toBe('Error: ab')
})
it('truncates to 500 chars', () => {
const out = formatOutboxError(new Error('x'.repeat(1000)))
expect(out).toHaveLength(500)
expect(out.startsWith('Error: xxx')).toBe(true)
})
})
describe('computeNextAlarm', () => {
const SWEEP = 30_000
const now = 1_000_000
it('arms the next sweep when no alarm is persisted', () => {
expect(computeNextAlarm(null, now, SWEEP)).toBe(now + SWEEP)
})
it('arms the next sweep when the persisted alarm is further out than the next sweep', () => {
expect(computeNextAlarm(now + SWEEP + 5_000, now, SWEEP)).toBe(now + SWEEP)
})
it('honors a mid-drain poke (past-due alarm) by arming now+1s, not the full sweep', () => {
// a poke during the drain set alarm(now); it is past-due in the finally and can't be
// trusted to fire on its own, so re-arm with a small delay instead of swallowing it.
expect(computeNextAlarm(now, now, SWEEP)).toBe(now + 1_000)
expect(computeNextAlarm(now - 5_000, now, SWEEP)).toBe(now + 1_000)
})
it('leaves a sooner future alarm (imminent poke) untouched', () => {
expect(computeNextAlarm(now + 1_000, now, SWEEP)).toBe(null)
})
})
describe('shouldReportEffectFailure', () => {
it.each([
[0, true],
[1, false],
[MAX_ATTEMPTS - 2, false],
[MAX_ATTEMPTS - 1, true],
])('attempts=%i -> %s', (attempts, expected) => {
expect(shouldReportEffectFailure(attempts)).toBe(expected)
})
})

View File

@@ -0,0 +1,188 @@
import { TlaEffectOutbox } from '@tldraw/dotcom-shared'
export const MAX_ATTEMPTS = 10
export const PARKED_ROW_TTL_DAYS = 7
// Groups (one per entity) processed at once. A slow publish/cold-boot only stalls its own
// group; other entities keep draining. Small so a burst can't fan out to unbounded RPCs.
export const MAX_CONCURRENT_ENTITIES = 5
// Per-effect ceiling. The underlying room-DO RPC can't be cancelled, but the drain stops
// waiting on it and moves on; a timed-out effect is treated as a failure and retried later.
export const EFFECT_TIMEOUT_MS = 30_000
// Gates which failed attempts get reported (e.g. to Sentry): the first (immediate visibility)
// and the one that parks the row (data loss). Every attempt in between is a retry-in-progress,
// not new information, and under a sustained outage reporting all of them would burn the
// Sentry rate limit and could crowd out the parking events that matter most.
export function shouldReportEffectFailure(attempts: number): boolean {
return attempts === 0 || attempts + 1 >= MAX_ATTEMPTS
}
// Row-stored form of a failed attempt's error. Capped so a runaway message (a stringified
// response body, say) can't bloat the table.
export const MAX_LAST_ERROR_LENGTH = 500
export function formatOutboxError(error: unknown): string {
let text: string
try {
text = describeError(error)
// One level of cause: pg and fetch errors put the useful part (ECONNREFUSED, the dial
// target) there, and for attempts 2..n this column is the only record of it.
if (error instanceof Error && error.cause !== undefined) {
text += ` (cause: ${describeError(error.cause)})`
}
} catch {
text = `[unformattable ${typeof error}]`
}
// Written in the same UPDATE as the attempts bump, so it must never be what fails it:
// PG TEXT rejects NUL bytes.
return text.replaceAll('\u0000', '').slice(0, MAX_LAST_ERROR_LENGTH)
}
function describeError(error: unknown): string {
if (error instanceof Error) return `${error.name}: ${error.message}`
// JSON.stringify returns undefined (not a string) when toJSON() returns undefined.
if (typeof error === 'object' && error !== null) return JSON.stringify(error) ?? String(error)
return String(error)
}
export interface OutboxDeps {
getBatch(): Promise<TlaEffectOutbox[]> // WHERE attempts < MAX_ATTEMPTS AND ("nextRetryAt" IS NULL OR "nextRetryAt" <= now()) ORDER BY id LIMIT 50
deleteRow(id: number): Promise<void>
// Called with the FAILED row (its `attempts` is the current count, so backoff can be derived).
// The implementation must both back off the failed row AND defer its later same-entity
// siblings (see the drainOutbox comment) so per-entity ordering holds across drains, not just
// within one.
// `error` is stored on the row as lastError (see migration 051 for why).
bumpAttempts(row: TlaEffectOutbox, error: unknown): Promise<void>
deleteParkedRowsOlderThan(days: number): Promise<void>
process(row: TlaEffectOutbox): Promise<void> // dispatches by tableName (wired in the DO)
onError(error: unknown, row: TlaEffectOutbox): void
// Overridable so tests don't wait the full timeout in real time.
timeoutMs?: number
}
// Decides what to (re)arm the sweep alarm to after a drain finishes, given the currently
// persisted alarm. Returns null to mean "leave the persisted alarm unchanged".
// - scheduled === null || scheduled > nextSweep: nothing sooner is pending → arm the next sweep.
// - scheduled <= now: a poke that arrived MID-drain set alarm(now) and is now past-due; a
// past-due alarm can't be trusted to fire on its own, so honor the poke by arming now+1s
// (a small delay, still never trusting the stale past-due alarm).
// - otherwise (future, sooner than nextSweep): a legit imminent poke → leave it.
export function computeNextAlarm(
scheduled: number | null,
now: number,
sweepIntervalMs: number
): number | null {
const nextSweep = now + sweepIntervalMs
if (scheduled === null || scheduled > nextSweep) return nextSweep
if (scheduled <= now) return now + 1_000
return null
}
class EffectTimeoutError extends Error {
constructor(row: TlaEffectOutbox, ms: number) {
super(`effect for ${row.tableName}:${row.entityId} (outbox ${row.id}) timed out after ${ms}ms`)
this.name = 'EffectTimeoutError'
}
}
// Races process(row) against a timeout. Resolves true on success, false on failure/timeout.
// A late resolution of the underlying promise after a timeout is swallowed so it can't
// double-act; only the first settlement of the race drives delete/bump.
async function processWithTimeout(deps: OutboxDeps, row: TlaEffectOutbox): Promise<boolean> {
const ms = deps.timeoutMs ?? EFFECT_TIMEOUT_MS
let timer: ReturnType<typeof setTimeout> | undefined
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new EffectTimeoutError(row, ms)), ms)
})
// Swallow late rejections from BOTH branches: only the race's first settlement drives
// delete/bump. Without this, a timeout firing while deleteRow is still awaited (or a late
// work rejection) becomes an unhandled rejection.
timeout.catch(() => {})
const work = deps.process(row)
work.catch(() => {})
try {
await Promise.race([work, timeout])
// Stop the clock before the follow-up await so a slow deleteRow can't trip the timeout.
if (timer) clearTimeout(timer)
await deps.deleteRow(row.id)
return true
} catch (error) {
// Report first: if the bump UPDATE itself fails (DB down), the effect's own error would
// otherwise never be reported with row context.
deps.onError(error, row)
await deps.bumpAttempts(row, error)
return false
} finally {
if (timer) clearTimeout(timer)
}
}
// Runs tasks with a bounded concurrency, preserving nothing about order (callers that need
// ordering enforce it inside a single task).
async function runWithConcurrency(tasks: Array<() => Promise<void>>, limit: number) {
let next = 0
async function worker() {
while (next < tasks.length) {
const task = tasks[next++]
await task()
}
}
const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => worker())
await Promise.all(workers)
}
const entityKey = (row: TlaEffectOutbox) => `${row.tableName}:${row.entityId}`
export async function drainOutbox(deps: OutboxDeps) {
// Per-entity ordering is strict WITHIN and ACROSS drains:
// - within a drain: each entity is one sequential group; a failure stops its group and (via
// failedEntities below) skips any of its rows that resurface in later batches.
// - across drains: when a row fails, bumpAttempts also defers its later same-entity siblings
// (sets their nextRetryAt) so they can't run in a subsequent drain while the failed row is
// still backing off. The failed row itself always retries no later than its siblings.
// Exception at the parking boundary: once a row parks (attempts >= MAX_ATTEMPTS) getBatch stops
// returning it, so it no longer defers its siblings and they run. Liveness deliberately wins
// over strict ordering here — parking is already a Sentry-reported data-loss event.
//
// failedEntities is hoisted above the batch loop so one failure skips the entity for the rest
// of THIS drain call (not just the rest of its batch): it caps a stuck entity at one attempt
// per drain, so a single drain can't burn all its attempts to the parking threshold. It does
// not by itself provide cross-drain ordering — the sibling deferral in bumpAttempts does that.
const failedEntities = new Set<string>()
while (true) {
const rows = await deps.getBatch()
if (rows.length === 0) break
// Group rows by entity, preserving id order within each group. Different entities have
// no ordering constraint between them, so their groups run concurrently.
const groups = new Map<string, TlaEffectOutbox[]>()
for (const row of rows) {
const key = entityKey(row)
if (failedEntities.has(key)) continue
let group = groups.get(key)
if (!group) groups.set(key, (group = []))
group.push(row)
}
let processedAny = false
const tasks = Array.from(groups.entries()).map(([key, group]) => async () => {
for (const row of group) {
processedAny = true
const ok = await processWithTimeout(deps, row)
if (!ok) {
// A failed entity skips its remaining rows this drain and, via failedEntities,
// any rows that resurface in later batches.
failedEntities.add(key)
return
}
}
})
await runWithConcurrency(tasks, MAX_CONCURRENT_ENTITIES)
// If every row in the batch was already-failed or failed just now, we'd spin refetching
// the same rows; stop and let the alarm retry after backoff.
if (!processedAny || rows.every((r) => failedEntities.has(entityKey(r)))) break
}
await deps.deleteParkedRowsOlderThan(PARKED_ROW_TTL_DAYS)
}

View File

@@ -0,0 +1,199 @@
import { DB } from '@tldraw/dotcom-shared'
import { promiseWithResolve } from '@tldraw/utils'
import { Kysely, PostgresDialect, PostgresPool, PostgresPoolClient } from 'kysely'
import * as pg from 'pg'
import { Logger } from './Logger'
import { Environment } from './types'
import { writeDataPoint } from './utils/analytics'
const int8TypeId = 20
pg.types.setTypeParser(int8TypeId, (val) => {
return parseInt(val, 10)
})
const CONNECT_TIMEOUT_MS = 10_000
export function createPostgresConnectionPool(env: Environment, name: string, max: number = 1) {
class LoggingClient extends pg.Client {
constructor(config?: string | pg.ClientConfig) {
super(config)
this.on('end', () => {
writeDataPoint(undefined, env.MEASURE, env, 'postgres_client_end', {
blobs: [name],
})
})
this.on('error', () => {
writeDataPoint(undefined, env.MEASURE, env, 'postgres_client_error', {
blobs: [name],
})
})
}
override connect(callback?: any): any {
writeDataPoint(undefined, env.MEASURE, env, 'postgres_client_connect', {
blobs: [name],
})
// The start event above and the timed completion below are deliberately separate
// datapoints: a dial that hangs never completes, so hung connections are only
// countable as starts minus dones. Completion durations cover TCP + TLS + auth
// (through pgbouncer) — the layer where the 2026-08-10 connection stalls lived.
const dialStart = Date.now()
const done = (outcome: 'ok' | 'error') => {
writeDataPoint(undefined, env.MEASURE, env, 'postgres_client_connect_done', {
blobs: [name, outcome],
doubles: [Date.now() - dialStart],
})
}
if (typeof callback === 'function') {
return super.connect((err: Error) => {
done(err ? 'error' : 'ok')
callback(err)
})
}
const promise = super.connect()
promise.then(
() => done('ok'),
() => done('error')
)
return promise
}
}
const pool = new pg.Pool({
connectionString: env.BOTCOM_POSTGRES_POOLED_CONNECTION_STRING,
application_name: name,
idleTimeoutMillis: 5_000,
max,
Client: LoggingClient,
})
// Checkout wait is distinct from dial time: with all `max` clients busy, an acquire queues
// without dialing at all, which client-level events can't see. Kysely acquires through the
// promise overload; the callback overload passes through untimed.
const poolConnect = pool.connect.bind(pool)
pool.connect = function (callback?: any): any {
if (typeof callback === 'function') return poolConnect(callback)
const acquireStart = Date.now()
const acquired = (outcome: 'ok' | 'error') => {
writeDataPoint(undefined, env.MEASURE, env, 'postgres_pool_acquire', {
blobs: [name, outcome],
doubles: [Date.now() - acquireStart],
})
}
const promise = poolConnect()
promise.then(
() => acquired('ok'),
() => acquired('error')
)
return promise
} as typeof pool.connect
const dialect = new PostgresDialect({ pool })
const db = new Kysely<DB>({
dialect,
log: ['error'],
})
return db
}
/**
* A pool that creates a fresh pg.Client per connect() call and tears it down
* on release, so no idle connections or background timers linger between
* requests. This lets the Durable Object hibernate properly.
*/
export class TLPostgresPool implements PostgresPool {
private _lock: Promise<void> = Promise.resolve()
constructor(
private env: Environment,
private log: Logger
) {}
async connect(): Promise<PostgresPoolClient> {
const acquireStart = Date.now()
const prevLock = this._lock
const released = promiseWithResolve<void>()
this._lock = released
await prevLock
const client = new pg.Client({
connectionString: this.env.BOTCOM_POSTGRES_POOLED_CONNECTION_STRING,
application_name: 'user-do',
keepAlive: false,
// pg waits forever by default. Checkouts here are serialized behind one lock, so a
// single stalled dial would wedge every later query in this durable object, and its
// teardown with them.
connectionTimeoutMillis: CONNECT_TIMEOUT_MS,
})
// Mirror LoggingClient's end/error accounting: the connection-events panel balances
// connects against ends to spot leaks, so a client that records a connect but never an
// end would read as a permanent leak there.
client.on('end', () => {
writeDataPoint(undefined, this.env.MEASURE, this.env, 'postgres_client_end', {
blobs: ['user-do'],
})
})
client.on('error', () => {
writeDataPoint(undefined, this.env.MEASURE, this.env, 'postgres_client_error', {
blobs: ['user-do'],
})
})
// Same start/done pairing as LoggingClient above: a hung dial only ever shows up as a
// start with no matching done, and this fresh-client-per-checkout path is otherwise
// completely invisible in MEASURE.
writeDataPoint(undefined, this.env.MEASURE, this.env, 'postgres_client_connect', {
blobs: ['user-do'],
})
const dialStart = Date.now()
try {
await client.connect()
} catch (e) {
writeDataPoint(undefined, this.env.MEASURE, this.env, 'postgres_client_connect_done', {
blobs: ['user-do', 'error'],
doubles: [Date.now() - dialStart],
})
// A failed dial is also a failed checkout, matching the pool wrapper above — without
// this, error acquires drop their lock-wait + dial duration for user-do.
writeDataPoint(undefined, this.env.MEASURE, this.env, 'postgres_pool_acquire', {
blobs: ['user-do', 'error'],
doubles: [Date.now() - acquireStart],
})
released.resolve(undefined)
throw e
}
writeDataPoint(undefined, this.env.MEASURE, this.env, 'postgres_client_connect_done', {
blobs: ['user-do', 'ok'],
doubles: [Date.now() - dialStart],
})
// Lock wait + dial together: this pool serializes checkouts, so a slow holder shows up
// here as acquire time even when the dial itself is fast.
writeDataPoint(undefined, this.env.MEASURE, this.env, 'postgres_pool_acquire', {
blobs: ['user-do', 'ok'],
doubles: [Date.now() - acquireStart],
})
return {
query: (...args: any[]) => (client.query as any)(...args),
release() {
client.end().catch(() => {})
// Forcefully destroy the TCP socket so it doesn't linger
// and block Durable Object hibernation. The graceful end()
// above sends the PG Terminate message; destroy() ensures
// the socket handle is removed from the event loop immediately.
const stream = (client as any).connection?.stream
if (stream && typeof stream.destroy === 'function') {
stream.destroy()
}
released.resolve(undefined)
},
} as PostgresPoolClient
}
async end() {
await this._lock
}
}

View File

@@ -0,0 +1,111 @@
import { RoomSnapshot } from '@tldraw/sync-core'
// Shape of legacy snapshot objects in ROOM_SNAPSHOTS; nothing writes new ones since the
// snapshot-link UI was removed, but existing objects are still read.
export interface R2Snapshot {
parent_slug?: string
drawing: RoomSnapshot
}
export function getR2KeyForRoom({ slug, isApp }: { slug: string; isApp: boolean }) {
return `${isApp ? 'app_rooms' : 'public_rooms'}/${slug}`
}
export function getR2KeyForSnapshot({
parentSlug,
snapshotSlug,
isApp,
}: {
parentSlug: string | undefined | null
snapshotSlug: string
isApp: boolean
}) {
// We might not have a parent slug. This happens when creating a snapshot from a local room.
const slug = parentSlug ? `${parentSlug}/${snapshotSlug}` : snapshotSlug
return getR2KeyForRoom({ slug, isApp })
}
/**
* Runs one R2 operation. Operations default to running inline; a caller inside a shared connection
* budget (the durable object's R2 queue) passes its queue, so each operation is one budgeted slot
* and a fan-out never holds more connections than the budget allows.
*/
export type R2ReadScheduler = <T>(read: () => Promise<T>) => Promise<T>
export function runInline<T>(read: () => Promise<T>): Promise<T> {
return read()
}
// R2 has honored `include` on list() since compat date 2022-08-04 (this worker's is far past it),
// but the repo's ambient workers-types entrypoint predates the option — declared locally, same
// pattern as types.ts.
type R2ListOptionsWithInclude = R2ListOptions & {
include?: Array<'httpMetadata' | 'customMetadata'>
}
/**
* Every object under `prefix` with its custom metadata, plus the number of list calls the walk
* spent — callers inside a read budget account for the listing too.
*/
export async function listAllObjects(
bucket: R2Bucket,
prefix: string,
schedule: R2ReadScheduler = runInline
): Promise<{ objects: R2Object[]; ops: number }> {
const objects: R2Object[] = []
let cursor: string | undefined
let ops = 0
do {
// Including metadata makes R2 return shorter pages, so a short page does not mean the
// listing is done — `truncated` is the only safe stop condition.
const options: R2ListOptionsWithInclude = { prefix, cursor, include: ['customMetadata'] }
const page = await schedule(() => bucket.list(options as R2ListOptions))
ops++
objects.push(...page.objects)
cursor = page.truncated ? page.cursor : undefined
} while (cursor)
return { objects, ops }
}
/**
* Every key under `prefix`, or the first `limit` of them. The limit is passed to R2 too, so a
* capped listing is a single page rather than a full walk sliced afterwards.
*/
export async function listAllObjectKeys(
bucket: R2Bucket,
prefix: string,
limit?: number,
schedule: R2ReadScheduler = runInline
): Promise<string[]> {
const keys: string[] = []
let cursor: string | undefined
do {
const options = limit === undefined ? { prefix, cursor } : { prefix, cursor, limit }
const result = await schedule(() => bucket.list(options))
keys.push(...result.objects.map((o) => o.key))
if (limit !== undefined && keys.length >= limit) return keys.slice(0, limit)
cursor = result.truncated ? result.cursor : undefined
} while (cursor)
return keys
}
// R2 deletes at most 1000 keys per call, so deleting everything a `list` returned in one go
// throws once a board has more history objects than that (one per persist) — and the cleanup
// that called it is left half-done.
const MAX_R2_DELETE_KEYS = 1000
export async function deleteAllObjectsWithPrefix(
bucket: R2Bucket,
prefix: string,
schedule: R2ReadScheduler = runInline
) {
const keys = await listAllObjectKeys(bucket, prefix, undefined, schedule)
for (let i = 0; i < keys.length; i += MAX_R2_DELETE_KEYS) {
const batch = keys.slice(i, i + MAX_R2_DELETE_KEYS)
await schedule(() => bucket.delete(batch))
}
}

View File

@@ -0,0 +1,107 @@
import { TlaFile } from '@tldraw/dotcom-shared'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
FileEffectStallError,
RoomNotFoundError,
settleWithin,
shouldSkipMissingRoomEffect,
} from './roomEffectHelpers'
function file(partial: Partial<TlaFile>): TlaFile {
return {
id: 'f1',
name: 'file',
ownerName: '',
thumbnail: '',
shared: true,
sharedLinkType: 'edit',
published: false,
lastPublished: 0,
publishedSlug: 'slug-1',
createdAt: 0,
updatedAt: 0,
isEmpty: false,
isDeleted: false,
createSource: null,
owningGroupId: null,
...partial,
}
}
describe('shouldSkipMissingRoomEffect', () => {
it('skips a RoomNotFoundError for a deleted file', () => {
expect(
shouldSkipMissingRoomEffect(new RoomNotFoundError('f1'), file({ isDeleted: true }))
).toBe(true)
})
it('does not skip a RoomNotFoundError for a live file', () => {
expect(
shouldSkipMissingRoomEffect(new RoomNotFoundError('f1'), file({ isDeleted: false }))
).toBe(false)
})
it('does not skip a different error for a deleted file', () => {
expect(shouldSkipMissingRoomEffect(new Error('boom'), file({ isDeleted: true }))).toBe(false)
})
})
describe('settleWithin', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('resolves settled when the promise resolves in time', async () => {
const result = settleWithin(Promise.resolve('done'), 1000)
await expect(result).resolves.toBe('settled')
})
it('resolves settled when the promise rejects in time', async () => {
const result = settleWithin(Promise.reject(new Error('boom')), 1000)
await expect(result).resolves.toBe('settled')
})
it('resolves timeout when the promise is still pending', async () => {
const result = settleWithin(new Promise(() => {}), 1000)
vi.advanceTimersByTime(1000)
await expect(result).resolves.toBe('timeout')
})
it('swallows a rejection that lands after the timeout', async () => {
let reject!: (e: Error) => void
const pending = new Promise((_, r) => {
reject = r
})
const result = settleWithin(pending, 1000)
vi.advanceTimersByTime(1000)
await expect(result).resolves.toBe('timeout')
reject(new Error('late'))
// A late rejection must not become an unhandled rejection.
await vi.runAllTimersAsync()
})
})
describe('FileEffectStallError', () => {
it('names the boot sub-stage and its age when the boot is still in progress', () => {
const error = new FileEffectStallError(
'slug-1',
'insert',
'storage-load:comments',
25000,
30000
)
expect(error.message).toBe(
'file insert effect for slug-1 still pending after 30000ms at boot stage storage-load:comments (25000ms in stage)'
)
})
it('reports post-boot work when there is no boot stage', () => {
const error = new FileEffectStallError('slug-1', 'update', null, null, 30000)
expect(error.message).toBe(
'file update effect for slug-1 still pending after 30000ms in post-boot work'
)
})
})

View File

@@ -0,0 +1,92 @@
import { TlaFile } from '@tldraw/dotcom-shared'
// A real Error so Sentry shows the slug and a stack; checks are DO-local instanceof.
// Lives outside TLFileDurableObject.ts (which pulls in cloudflare:workers) so it can be
// imported from plain Node tests.
export class RoomNotFoundError extends Error {
constructor(slug: string) {
super(`Room not found: ${slug}`)
this.name = 'RoomNotFoundError'
}
}
// A trashed file's room may never have existed; for live files a missing room is
// usually the duplicate-from-source race, which the outbox retry heals.
export function shouldSkipMissingRoomEffect(error: unknown, file: TlaFile): boolean {
return error instanceof RoomNotFoundError && file.isDeleted
}
// Reported (not thrown) when an outbox file effect is still pending just under the drain's
// 30s effect timeout, so Sentry names the cause before the drain bumps the row. Without it a
// wedged effect parks a row as a bare EffectTimeoutError (#10541). The guaranteed fact is
// that the effect stalled; a boot stage, when present, says where the room boot is stuck —
// a null stage means the boot settled and the stall is in post-boot work (e.g. the
// per-session permission refresh).
export type BootStage =
| 'storage-load:sqlite-init'
| 'storage-load:r2'
| 'storage-load:file-record'
| 'storage-load:comments'
| 'storage-load:supabase'
| 'storage-load:kv-rollout'
| 'source-await-persist'
| 'source-r2-fetch'
| 'source-welcome'
| 'source-legacy'
| 'source-published'
| 'source-r2-put'
| 'room-create'
export class FileEffectStallError extends Error {
constructor(
slug: string,
command: 'insert' | 'update',
stage: BootStage | null,
stageAgeMs: number | null,
ms: number
) {
super(
`file ${command} effect for ${slug} still pending after ${ms}ms` +
(stage !== null
? ` at boot stage ${stage} (${stageAgeMs}ms in stage)`
: ' in post-boot work')
)
this.name = 'FileEffectStallError'
}
}
// Reported when a duplicate-from-source boot gives up waiting for the source room's persist
// and proceeds with its last persisted snapshot.
export class SourcePersistTimeoutError extends Error {
constructor(sourceSlug: string, ms: number) {
super(
`awaitPersist on source ${sourceSlug} did not settle within ${ms}ms; copying its last persisted snapshot`
)
this.name = 'SourcePersistTimeoutError'
}
}
/**
* Await `promise` for at most `ms`; 'timeout' means it was still pending. The promise's
* eventual settlement (including rejection) is swallowed — callers use this for best-effort
* work they are prepared to proceed without.
*/
export async function settleWithin(
promise: Promise<unknown>,
ms: number
): Promise<'settled' | 'timeout'> {
let timer: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([
promise.then(
() => 'settled' as const,
() => 'settled' as const
),
new Promise<'timeout'>((resolve) => {
timer = setTimeout(() => resolve('timeout'), ms)
}),
])
} finally {
clearTimeout(timer)
}
}

View File

@@ -0,0 +1,30 @@
import { assert } from '@tldraw/utils'
import { handleExtractBookmarkMetadataRequest } from '@tldraw/worker-shared'
import { IRequest } from 'itty-router'
import { Environment } from '../types'
export async function extractBookmarkMetadata(request: IRequest, env: Environment) {
if (request.method === 'GET') {
// legacy route: extract metadata without saving image
return handleExtractBookmarkMetadataRequest({ request })
}
return handleExtractBookmarkMetadataRequest({
request,
uploadImage: async (headers, body, objectName) => {
assert(env.ASSET_UPLOAD_ORIGIN, 'ASSET_UPLOAD_ORIGIN is required')
const url = `${env.ASSET_UPLOAD_ORIGIN}/uploads/${objectName}`
const response = await fetch(url, {
method: 'POST',
headers,
body,
})
if (!response.ok) {
throw new Error('Failed to upload image')
}
return url
},
})
}

View File

@@ -0,0 +1,27 @@
import { RoomSnapshot } from '@tldraw/sync-core'
// Leaf helper for reading a board's display name out of a room snapshot. Lives in its own module
// (rather than getSocialPreview.ts) so the thumbnail/OG surfaces can read board names without
// importing getSocialPreview, which itself depends on the thumbnail modules — keeping the import
// graph acyclic.
/**
* Reads the board name from a room snapshot's `document` record, returning null if there's no
* usable name.
*/
export function getDocumentNameFromSnapshot(
snapshot: RoomSnapshot | undefined | null
): string | null {
if (!snapshot?.documents) return null
for (const { state } of snapshot.documents) {
if (state && (state as any).typeName === 'document') {
return cleanName((state as any).name)
}
}
return null
}
export function cleanName(name: string | null | undefined): string | null {
const trimmed = name?.trim()
return trimmed ? trimmed : null
}

View File

@@ -0,0 +1,30 @@
import { GetReadonlySlugResponseBody } from '@tldraw/dotcom-shared'
import { lns } from '@tldraw/utils'
import { IRequest } from 'itty-router'
import { Environment } from '../types'
// Return a URL to a readonly version of the room
export async function getReadonlySlug(request: IRequest, env: Environment): Promise<Response> {
const roomId = request.params.roomId
if (!roomId) {
return new Response('Bad request', {
status: 400,
})
}
let slug = await env.SLUG_TO_READONLY_SLUG.get(roomId)
let isLegacy = false
if (!slug) {
// For all newly created rooms we add the readonly slug to the KV store.
// If it does not exist there it means we are trying to get a slug for an old room.
slug = lns(roomId)
isLegacy = true
}
return new Response(
JSON.stringify({
slug,
isLegacy,
} satisfies GetReadonlySlugResponseBody)
)
}

View File

@@ -0,0 +1,112 @@
import { IRequest } from 'itty-router'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createFakeR2 } from '../test/fakeR2'
import { Environment } from '../types'
import { segmentCustomMetadata } from '../versionChain'
import { listVersionTimestamps } from '../versionChainRead'
import { getMonthPrefix, getPreviousMonth, getRoomHistory } from './getRoomHistory'
vi.mock('../utils/tla/getAuth', () => ({ requireAdminAccessToRequest: vi.fn() }))
describe('history listing during the transition', () => {
it('shows legacy versions, keyframes and every version inside a segment as one list', async () => {
const chainBucket = createFakeR2()
const legacyBucket = createFakeR2()
const roomKey = 'app_rooms/slug'
await legacyBucket.put(`${roomKey}/2026-08-01T00:00:00.000Z`, '{}')
await chainBucket.put(`${roomKey}/2026-09-01T00:00:00.000Z.k`, '{}')
// One segment object standing for two versions — the listing must expand it, not report
// the segment's own key as a version.
await chainBucket.put(`${roomKey}/2026-09-01T00:00:08.000Z.s`, '{}', {
customMetadata: segmentCustomMetadata({
keyframeKey: `${roomKey}/2026-09-01T00:00:00.000Z.k`,
firstSeq: 1,
timestamps: ['2026-09-01T00:00:08.000Z', '2026-09-01T00:00:16.000Z'],
}),
})
expect(
await listVersionTimestamps({ chainBucket, legacyBucket, roomKey, prefix: '2026-0' })
).toEqual([
'2026-09-01T00:00:16.000Z',
'2026-09-01T00:00:08.000Z',
'2026-09-01T00:00:00.000Z',
'2026-08-01T00:00:00.000Z',
])
})
})
describe('getMonthPrefix', () => {
it('uses the UTC month at a local month boundary', () => {
expect(getMonthPrefix(new Date('2026-03-01T00:30:00+02:00'))).toBe('2026-02')
})
})
describe('getPreviousMonth', () => {
it.each([
['2026-03-29T12:00:00Z', '2026-02-01T00:00:00.000Z'],
['2026-03-30T12:00:00Z', '2026-02-01T00:00:00.000Z'],
['2026-03-31T12:00:00Z', '2026-02-01T00:00:00.000Z'],
['2024-03-31T12:00:00Z', '2024-02-01T00:00:00.000Z'],
['2026-01-31T12:00:00Z', '2025-12-01T00:00:00.000Z'],
['2026-03-01T00:30:00+02:00', '2026-01-01T00:00:00.000Z'],
])('steps from %s to %s without changing the input', (input, expected) => {
const date = new Date(input)
const originalTime = date.getTime()
expect(getPreviousMonth(date).toISOString()).toBe(expected)
expect(date.getTime()).toBe(originalTime)
})
})
async function listHistory(timestamps: string[], offset: string) {
const list = vi.fn(async ({ prefix }: { prefix: string }) => ({
objects: timestamps
.map((timestamp) => ({ key: `app_rooms/board/${timestamp}` }))
.filter(({ key }) => key.startsWith(prefix)),
truncated: false,
}))
const response = await getRoomHistory(
{ params: { roomId: 'board' }, query: { offset } } as unknown as IRequest,
{
// An empty chain bucket: these tests cover the month walk over legacy copies alone.
ROOMS_HISTORY: { list: async () => ({ objects: [], truncated: false }) },
ROOMS_HISTORY_EPHEMERAL: { list },
} as unknown as Environment,
true
)
expect(response.status).toBe(200)
return response.json()
}
describe('getRoomHistory', () => {
afterEach(() => vi.useRealTimers())
it('collects consecutive months once and excludes the pagination offset', async () => {
const timestamps = [
'2026-03-31T12:00:00.000Z',
'2026-03-30T10:00:00.000Z',
'2026-02-28T10:00:00.000Z',
]
await expect(listHistory(timestamps, timestamps[0])).resolves.toEqual({
timestamps: timestamps.slice(1),
hasMore: false,
})
})
it.each(['!', 'not-a-date'])(
'uses now for scanning and filtering invalid offset %s',
async (offset) => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-03-31T12:00:00Z'))
const timestamps = [
'2026-03-31T13:00:00.000Z',
'2026-03-30T10:00:00.000Z',
'2026-02-28T10:00:00.000Z',
]
await expect(listHistory(timestamps, offset)).resolves.toEqual({
timestamps: timestamps.slice(1),
hasMore: false,
})
}
)
})

View File

@@ -0,0 +1,172 @@
import { HistoryResponseBody } from '@tldraw/dotcom-shared'
import { notFound } from '@tldraw/worker-shared'
import { IRequest } from 'itty-router'
import { getR2KeyForRoom } from '../r2'
import { Environment } from '../types'
import { isRoomIdTooLong, roomIdIsTooLong } from '../utils/roomIdIsTooLong'
import { requireAdminAccessToRequest } from '../utils/tla/getAuth'
import { isTestFile } from '../utils/tla/isTestFile'
import { ChainIndexEntry, listVersionTimestamps, loadChainIndex } from '../versionChainRead'
export function getMonthPrefix(date: Date): string {
return date.toISOString().split('T')[0].substring(0, 7)
}
export function getPreviousMonth(date: Date): Date {
// Use day 1 to avoid overflowing shorter months and scanning the same month twice.
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() - 1, 1))
}
/**
* The chain index is listed once per request and threaded through: this handler probes up to
* three dozen month prefixes, and each probe re-listing the whole chain (plus an uncapped legacy
* walk) turned a ~7-listing request into hundreds on rooms with long histories.
*/
interface HistorySource {
env: Environment
roomKey: string
index: ChainIndexEntry[]
}
async function fetchTimestampsForPrefix(
source: HistorySource,
prefix: string,
limit?: number
): Promise<string[]> {
return await listVersionTimestamps({
chainBucket: source.env.ROOMS_HISTORY,
legacyBucket: source.env.ROOMS_HISTORY_EPHEMERAL,
roomKey: source.roomKey,
prefix,
index: source.index,
limit,
})
}
async function monthHasEntries(source: HistorySource, monthPrefix: string): Promise<boolean> {
const timestamps = await fetchTimestampsForPrefix(source, monthPrefix, 1)
return timestamps.length > 0
}
export async function getRoomHistory(
request: IRequest,
env: Environment,
isApp: boolean
): Promise<Response> {
const roomId = request.params.roomId
if (!roomId) return notFound()
if (isRoomIdTooLong(roomId)) return roomIdIsTooLong()
await requireAdminAccessToRequest(request, env)
if (isTestFile(roomId)) {
return new Response('Not found', { status: 404 })
}
let offset = request.query?.offset as string // offset is the earliest timestamp from the previous page
const bucketKey = getR2KeyForRoom({ slug: roomId, isApp })
const source: HistorySource = {
env,
roomKey: bucketKey,
index: (await loadChainIndex(env.ROOMS_HISTORY, bucketKey)).entries,
}
let allTimestamps: string[] = []
let currentMonth = new Date()
let monthsChecked = 0
const maxMonthsToCheck = 12
const targetEntryCount = 1000
if (offset) {
// Invalid dates would throw in toISOString when building the month prefix.
const parsed = new Date(offset)
currentMonth = Number.isNaN(parsed.getTime()) ? new Date() : parsed
offset = currentMonth.toISOString()
} else {
// If we don't have an offset we can check if the room doesn't have too many entries
const allTimestampsForRoom = await fetchTimestampsForPrefix(source, '', 1000)
// If we have fewer than 1000 entries, return them all
if (allTimestampsForRoom.length < 1000) {
const roomHistory: HistoryResponseBody = {
timestamps: allTimestampsForRoom,
hasMore: false,
}
return new Response(JSON.stringify(roomHistory), {
headers: { 'content-type': 'application/json' },
})
}
}
// Find the first month with entries
let foundMonthWithEntries = false
while (!foundMonthWithEntries && monthsChecked < maxMonthsToCheck) {
const monthPrefix = getMonthPrefix(currentMonth)
const hasEntries = await monthHasEntries(source, monthPrefix)
if (hasEntries) {
foundMonthWithEntries = true
} else {
currentMonth = getPreviousMonth(currentMonth)
monthsChecked++
}
}
// If we found a month with entries, start collecting timestamps
if (foundMonthWithEntries) {
let monthsCollected = 0
const maxMonthsToCollect = 12
// Collect timestamps from multiple months until we reach the target count
while (allTimestamps.length < targetEntryCount && monthsCollected < maxMonthsToCollect) {
const monthPrefix = getMonthPrefix(currentMonth)
const monthTimestamps = await fetchTimestampsForPrefix(source, monthPrefix)
let filteredTimestamps = monthTimestamps
if (offset) {
// Only include timestamps that are strictly less than the offset
// This ensures no duplicates when paginating
filteredTimestamps = monthTimestamps.filter((ts) => ts < offset)
}
allTimestamps.push(...filteredTimestamps)
currentMonth = getPreviousMonth(currentMonth)
monthsCollected++
}
allTimestamps = allTimestamps.sort((a, b) => b.localeCompare(a))
}
let hasMore = false
if (allTimestamps.length > 0) {
// Check if there are more entries in previous months
let checkMonth = currentMonth
let monthsToCheck = 12
while (monthsToCheck > 0) {
const previousMonthPrefix = getMonthPrefix(checkMonth)
const hasMoreEntries = await monthHasEntries(source, previousMonthPrefix)
if (hasMoreEntries) {
hasMore = true
break
}
checkMonth = getPreviousMonth(checkMonth)
monthsToCheck--
}
}
const response: HistoryResponseBody = {
timestamps: allTimestamps,
hasMore,
}
return new Response(JSON.stringify(response), {
headers: { 'content-type': 'application/json' },
})
}

View File

@@ -0,0 +1,136 @@
import { UnknownRecord } from '@tldraw/store'
import { RoomSnapshot } from '@tldraw/sync-core'
import { IRequest } from 'itty-router'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createFakeR2 } from '../test/fakeR2'
import { Environment } from '../types'
import { segmentCustomMetadata, versionKey } from '../versionChain'
import { encodeVersionBody } from '../versionChainCodec'
import { buildSnapshotDelta } from '../versionDelta'
import { getRoomHistorySnapshot } from './getRoomHistorySnapshot'
vi.mock('../utils/tla/getAuth', () => ({ requireAdminAccessToRequest: vi.fn() }))
const captureException = vi.fn()
vi.mock('@tldraw/worker-shared', async (importOriginal) => ({
...(await importOriginal<typeof import('@tldraw/worker-shared')>()),
createSentry: vi.fn(() => ({ captureException })),
}))
const roomKey = 'app_rooms/board'
function snapshot(clock: number, ids: string[]): RoomSnapshot {
return {
clock,
documentClock: clock,
documents: ids.map((id) => ({
state: { id, typeName: 'shape' } as UnknownRecord,
lastChangedClock: clock,
})),
tombstones: {},
tombstoneHistoryStartsAtClock: 0,
schema: { schemaVersion: 2, sequences: {} } as any,
}
}
function isoAt(i: number) {
return `2026-09-01T00:00:${String(i).padStart(2, '0')}.000Z`
}
async function fetchSnapshot(env: Environment, timestamp: string, ctx?: ExecutionContext) {
return await getRoomHistorySnapshot(
{ params: { roomId: 'board', timestamp } } as unknown as IRequest,
env,
true,
ctx
)
}
describe('getRoomHistorySnapshot', () => {
beforeEach(() => captureException.mockClear())
it('counts the chain listing in x-version-chain-ops on both serve paths', async () => {
const chainBucket = createFakeR2()
const legacyBucket = createFakeR2()
const env = {
ROOMS_HISTORY: chainBucket,
ROOMS_HISTORY_EPHEMERAL: legacyBucket,
} as unknown as Environment
// A keyframe at iso 0 and one segment holding deltas for isos 1 and 2.
const versions = [snapshot(1, ['shape:a']), snapshot(2, ['shape:a', 'shape:b'])]
const keyframeKey = versionKey(roomKey, isoAt(0), 'keyframe')
const encodedKeyframe = await encodeVersionBody(versions[0])
await chainBucket.put(keyframeKey, encodedKeyframe.body, {
customMetadata: encodedKeyframe.metadata,
})
const encodedSegment = await encodeVersionBody({
v: 1,
deltas: [{ t: isoAt(1), delta: buildSnapshotDelta(versions[0], versions[1]) }],
})
await chainBucket.put(versionKey(roomKey, isoAt(1), 'segment'), encodedSegment.body, {
customMetadata: {
...encodedSegment.metadata,
...segmentCustomMetadata({ keyframeKey, firstSeq: 1, timestamps: [isoAt(1)] }),
},
})
// Whole-object path: one listing page plus the keyframe get.
const whole = await fetchSnapshot(env, isoAt(0))
expect({
status: whole.status,
ops: whole.headers.get('x-version-chain-ops'),
depth: whole.headers.get('x-version-chain-depth'),
}).toEqual({ status: 200, ops: '2', depth: '0' })
expect(await whole.json()).toEqual(versions[0])
// Replay path: the listing page, the keyframe and one segment.
const replayed = await fetchSnapshot(env, isoAt(1))
expect({
status: replayed.status,
ops: replayed.headers.get('x-version-chain-ops'),
depth: replayed.headers.get('x-version-chain-depth'),
}).toEqual({ status: 200, ops: '3', depth: '1' })
expect(await replayed.json()).toEqual(versions[1])
})
it('serves the legacy copy and reports when the chain read throws', async () => {
const legacyBucket = createFakeR2()
const timestamp = isoAt(3)
await legacyBucket.put(`${roomKey}/${timestamp}`, JSON.stringify(snapshot(3, ['shape:a'])))
const boom = new Error('chain listing failed')
const env = {
ROOMS_HISTORY: {
list: vi.fn(async () => {
throw boom
}),
},
ROOMS_HISTORY_EPHEMERAL: legacyBucket,
} as unknown as Environment
const response = await fetchSnapshot(env, timestamp, {} as ExecutionContext)
expect({
status: response.status,
ops: response.headers.get('x-version-chain-ops'),
body: await response.json(),
}).toEqual({ status: 200, ops: null, body: snapshot(3, ['shape:a']) })
expect(captureException).toHaveBeenCalledWith(boom)
})
it('rethrows without reporting when there is no legacy copy to serve', async () => {
const boom = new Error('chain listing failed')
const env = {
ROOMS_HISTORY: {
list: vi.fn(async () => {
throw boom
}),
},
ROOMS_HISTORY_EPHEMERAL: createFakeR2(),
} as unknown as Environment
// The worker's catch-all reports what escapes, so a capture here would file it twice.
await expect(fetchSnapshot(env, isoAt(4), {} as ExecutionContext)).rejects.toBe(boom)
expect(captureException).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,93 @@
import { createSentry, notFound } from '@tldraw/worker-shared'
import { IRequest } from 'itty-router'
import { getR2KeyForRoom } from '../r2'
import { Environment } from '../types'
import { isRoomIdTooLong, roomIdIsTooLong } from '../utils/roomIdIsTooLong'
import { requireAdminAccessToRequest } from '../utils/tla/getAuth'
import { isTestFile } from '../utils/tla/isTestFile'
import { loadChainIndex, openWholeVersionStream, reconstructVersion } from '../versionChainRead'
// Get a snapshot of the room at a given point in time
export async function getRoomHistorySnapshot(
request: IRequest,
env: Environment,
isApp: boolean,
ctx?: ExecutionContext
): Promise<Response> {
const roomId = request.params.roomId
if (!roomId) return notFound()
if (isRoomIdTooLong(roomId)) return roomIdIsTooLong()
await requireAdminAccessToRequest(request, env)
if (isTestFile(roomId)) {
return new Response('Not found', { status: 404 })
}
const timestamp = request.params.timestamp
const roomKey = getR2KeyForRoom({ slug: roomId, isApp })
const buckets = { chainBucket: env.ROOMS_HISTORY, legacyBucket: env.ROOMS_HISTORY_EPHEMERAL }
let result
let listOps = 0
try {
const { entries: index, ops } = await loadChainIndex(env.ROOMS_HISTORY, roomKey)
// The listing is part of this request's R2 cost: reconstructVersion counts zero listing
// ops for a pre-loaded index, so leaving these out under-reports the header below.
listOps = ops
// Keyframes and legacy full copies stream straight through — openWholeVersionStream states
// the parse-cost rationale. Only a delta replay materializes.
const whole = await openWholeVersionStream({ ...buckets, roomKey, timestamp, index })
if (whole) {
return new Response(whole, {
headers: {
'content-type': 'application/json',
// The listing plus the one whole-object get, so the metric reads the same
// across both serve paths.
'x-version-chain-ops': String(listOps + 1),
'x-version-chain-depth': '0',
},
})
}
result = await reconstructVersion({ ...buckets, roomKey, timestamp, index })
} catch (error) {
// A broken chain must not take history down while the legacy full copies still exist.
// Serve the copy — the verifier is how the chain gets fixed.
const legacy = await env.ROOMS_HISTORY_EPHEMERAL.get(`${roomKey}/${timestamp}`)
if (!legacy) throw error
// Only the served fallback swallows the error, so only it has to report: a chain that
// stopped reconstructing would stay invisible for as long as the copies last. The rethrow
// above reaches the worker's catch-all, which reports it; capturing here too files it twice.
try {
// No ctx in unit tests, and createSentry throws when its env vars are unset; neither may
// turn the degraded-but-fine fallback into a 500.
const sentry = ctx ? createSentry(ctx, env) : null
if (sentry) {
// eslint-disable-next-line @typescript-eslint/no-deprecated
sentry.captureException(error)
} else {
console.error(error)
}
} catch {
console.error(error)
}
return new Response(legacy.body, {
headers: { 'content-type': 'application/json' },
})
}
if (!result) {
return new Response('Not found', { status: 404 })
}
return new Response(JSON.stringify(result.snapshot), {
headers: {
'content-type': 'application/json',
// Replay cost is a product metric once history is user-facing: the segment cap is the
// lever, and this is what says whether it needs moving.
'x-version-chain-ops': String(listOps + result.ops),
'x-version-chain-depth': String(result.deltaCount),
},
})
}

View File

@@ -0,0 +1,67 @@
import { RoomSnapshot } from '@tldraw/sync-core'
import { notFound } from '@tldraw/worker-shared'
import { IRequest } from 'itty-router'
import { getR2KeyForSnapshot, R2Snapshot } from '../r2'
import { Environment } from '../types'
import { createSupabaseClient, noSupabaseSorry } from '../utils/createSupabaseClient'
import { getSnapshotsTable } from '../utils/getSnapshotsTable'
export async function getSnapshotFromR2(
env: Environment,
roomId: string
): Promise<RoomSnapshot | undefined> {
const parentSlug = await env.SNAPSHOT_SLUG_TO_PARENT_SLUG.get(roomId)
const object = await env.ROOM_SNAPSHOTS.get(
getR2KeyForSnapshot({ parentSlug, snapshotSlug: roomId, isApp: false })
)
if (!object) return undefined
return ((await object.json()) as R2Snapshot)?.drawing
}
// Snapshots created before the R2 store live in Supabase.
export async function getSnapshotFromSupabase(
supabase: NonNullable<ReturnType<typeof createSupabaseClient>>,
env: Environment,
roomId: string
): Promise<RoomSnapshot | undefined> {
const result = await supabase
.from(getSnapshotsTable(env))
.select('drawing')
.eq('slug', roomId)
.maybeSingle()
return result.data?.drawing as RoomSnapshot | undefined
}
function generateReponse(roomId: string, data: RoomSnapshot) {
return new Response(
JSON.stringify({
roomId,
records: data.documents.map((d) => d.state),
schema: data.schema,
error: false,
}),
{
headers: { 'content-type': 'application/json' },
}
)
}
// Returns a snapshot of the room at a given point in time
export async function getRoomSnapshot(request: IRequest, env: Environment): Promise<Response> {
const roomId = request.params.roomId
if (!roomId) return notFound()
const r2Data = await getSnapshotFromR2(env, roomId)
if (r2Data) return generateReponse(roomId, r2Data)
// If we can't find the snapshot in R2 then fallback to Supabase
// Create a supabase client
const supabase = createSupabaseClient(env)
if (!supabase) return noSupabaseSorry()
const data = await getSnapshotFromSupabase(supabase, env, roomId)
if (!data) return notFound()
// Send back the snapshot!
return generateReponse(roomId, data)
}

View File

@@ -0,0 +1,117 @@
import { RoomSnapshot } from '@tldraw/sync-core'
import { getDocumentNameFromSnapshot } from './getDocumentNameFromSnapshot'
import { renderSocialPreview } from './getSocialPreview'
function snapshotWithDocumentName(name: string): RoomSnapshot {
return {
schema: {} as any,
clock: 0,
documents: [
{
state: { typeName: 'document', id: 'document:document', name, meta: {} } as any,
lastChangedClock: 0,
},
{
state: { typeName: 'shape', id: 'shape:a' } as any,
lastChangedClock: 0,
},
],
tombstones: {},
}
}
describe('renderSocialPreview', () => {
test('uses "<name> • tldraw.com" as the title when there is a name', () => {
const html = renderSocialPreview('My board')
expect(html).toContain('<title>My board • tldraw.com</title>')
expect(html).toContain('<meta property="og:title" content="My board • tldraw.com" />')
expect(html).toContain('<meta name="twitter:title" content="My board • tldraw.com" />')
})
test('falls back to "tldraw.com" when there is no name', () => {
const html = renderSocialPreview(null)
expect(html).toContain('<title>tldraw.com</title>')
expect(html).toContain('<meta property="og:title" content="tldraw.com" />')
})
test('keeps the shared description and image', () => {
const html = renderSocialPreview('My board')
expect(html).toContain('A free and instant collaborative whiteboarding tool.')
expect(html).toContain('https://www.tldraw.com/social-og.png')
expect(html).toContain('<meta name="twitter:card" content="summary" />')
})
test('uses the board og-image with a large card when a board image URL is given', () => {
const html = renderSocialPreview(
'My board',
'https://www.tldraw.com/api/app/social-preview/f/board-slug/image'
)
expect(html).toContain(
'<meta property="og:image" content="https://www.tldraw.com/api/app/social-preview/f/board-slug/image" />'
)
expect(html).toContain(
'<meta name="twitter:image" content="https://www.tldraw.com/api/app/social-preview/f/board-slug/image" />'
)
expect(html).toContain('<meta name="twitter:card" content="summary_large_image" />')
expect(html).toContain('<meta property="og:image:width" content="1200" />')
expect(html).toContain('<meta property="og:image:height" content="630" />')
expect(html).not.toContain('https://www.tldraw.com/social-og.png')
expect(html).not.toContain('https://www.tldraw.com/social-twitter.png')
})
test('escapes the board image URL in the meta attributes', () => {
const html = renderSocialPreview('My board', 'https://example.com/og?a=1&b="2"')
expect(html).toContain('content="https://example.com/og?a=1&amp;b=&quot;2&quot;"')
})
test('escapes names so they cannot break out of the meta attributes', () => {
const html = renderSocialPreview('<script>"&')
expect(html).toContain(
'<meta property="og:title" content="&lt;script&gt;&quot;&amp; • tldraw.com" />'
)
expect(html).not.toContain('<script>"&')
// the only script in the page is our own bypass redirect
expect(html.match(/<script>/g)).toHaveLength(1)
})
test('redirects humans back to the board with the bypass param', () => {
// In-app browsers with a crawler token in their user-agent (WhatsApp, Pinterest) get routed
// here too. The script sends them back to the board; the param makes the Vercel crawler
// route not match on the second request.
const html = renderSocialPreview('My board')
expect(html).toContain(`url.searchParams.set("no_preview", '1')`)
expect(html).toContain('location.replace(url)')
expect(html).toContain('<a href="?no_preview=1">Open this board</a>')
// the redirect is guarded so it can never reload this page forever, even if the
// routing layer serves the stub to a request that already carries the param
expect(html).toContain('if (!url.searchParams.get("no_preview"))')
})
})
describe('getDocumentNameFromSnapshot', () => {
test('reads the name from the document record', () => {
expect(getDocumentNameFromSnapshot(snapshotWithDocumentName('My board'))).toBe('My board')
})
test('trims surrounding whitespace', () => {
expect(getDocumentNameFromSnapshot(snapshotWithDocumentName(' My board '))).toBe('My board')
})
test('returns null for an empty or whitespace-only name', () => {
expect(getDocumentNameFromSnapshot(snapshotWithDocumentName(''))).toBe(null)
expect(getDocumentNameFromSnapshot(snapshotWithDocumentName(' '))).toBe(null)
})
test('returns null when there is no snapshot or document record', () => {
expect(getDocumentNameFromSnapshot(null)).toBe(null)
expect(getDocumentNameFromSnapshot(undefined)).toBe(null)
expect(
getDocumentNameFromSnapshot({
schema: {} as any,
clock: 0,
documents: [{ state: { typeName: 'shape', id: 'shape:a' } as any, lastChangedClock: 0 }],
tombstones: {},
})
).toBe(null)
})
})

View File

@@ -0,0 +1,257 @@
import {
DEFAULT_THUMBNAIL_HEIGHT,
DEFAULT_THUMBNAIL_WIDTH,
FILE_PREFIX,
PUBLISH_PREFIX,
READ_ONLY_LEGACY_PREFIX,
READ_ONLY_PREFIX,
ROOM_OPEN_MODE,
ROOM_PREFIX,
RoomOpenMode,
SNAPSHOT_PREFIX,
SOCIAL_PREVIEW_BYPASS_PARAM,
} from '@tldraw/dotcom-shared'
import { IRequest } from 'itty-router'
import { createPostgresConnectionPool } from '../postgres'
import { Environment, ThumbnailBoardKind } from '../types'
import { createSupabaseClient } from '../utils/createSupabaseClient'
import { getPublicOrigin } from '../utils/getPublicOrigin'
import { getSlug } from '../utils/roomOpenMode'
import { cleanName, getDocumentNameFromSnapshot } from './getDocumentNameFromSnapshot'
import { getSnapshotFromR2, getSnapshotFromSupabase } from './getRoomSnapshot'
import { getFileSnapshot } from './tla/getFileSnapshot'
import { getPublishedRoomSnapshot } from './tla/getPublishedFile'
import { isFileAnonymouslyViewable } from './tla/getSharedFile'
import { resolveThumbnailBoard } from './tla/thumbnailRender'
// These mirror the static social preview metadata in apps/dotcom/client/index.html. They are used
// as fallbacks so that bot-rendered previews stay consistent with the rest of the site.
const SOCIAL_PREVIEW_DESCRIPTION = 'A free and instant collaborative whiteboarding tool.'
const SOCIAL_PREVIEW_IMAGE = 'https://www.tldraw.com/social-og.png'
const SOCIAL_PREVIEW_IMAGE_ALT = 'A collaborative whiteboarding tool interface'
const SOCIAL_PREVIEW_TWITTER_IMAGE = 'https://www.tldraw.com/social-twitter.png'
/** Formats the preview title for a board: `<name> • tldraw.com`, or `tldraw.com` when unnamed. */
function formatSocialTitle(name: string | null): string {
return name ? `${name} • tldraw.com` : 'tldraw.com'
}
/**
* Serves a minimal HTML document with social preview (Open Graph / Twitter card) metadata for a
* board. Social crawlers (Slack, Discord, Twitter, Facebook, etc.) don't run JavaScript, so the
* single-page app's runtime title updates never reach them. Instead, Vercel routes those crawlers
* here based on their user-agent, and we render the board's name into the preview title. tldraw's
* own link-unfurl bot is routed here the same way, so pasting a board link into a tldraw canvas
* picks the name up from this page's metadata too.
*
* Humans are never routed here — they get the regular single-page app.
*/
export async function getSocialPreview(request: IRequest, env: Environment): Promise<Response> {
const { prefix, slug } = request.params
if (!prefix || !slug) {
return html(renderSocialPreview(null))
}
// Look up the name and resolve the thumbnail URL in parallel. Both are best-effort: if either
// throws we fall back to the generic preview rather than failing the request — a crawler should
// always get _something_.
const [name, boardImageUrl] = await Promise.all([
getBoardName(env, prefix, slug).catch(() => null),
getBoardOgImageUrl(request, env, prefix, slug).catch(() => null),
])
return html(renderSocialPreview(name, boardImageUrl))
}
// Shared files (`/f/`) and published boards (`/p/`) get a live board thumbnail as their preview
// image. We only emit the thumbnail URL when the board actually resolves to a public, renderable
// board — the same gate the image route itself applies (resolveThumbnailBoard). For everything else
// that route has no picture of its own to serve and redirects to the site-wide default, so pointing
// at it would cost a crawler an extra hop to reach the image this file can name directly.
async function getBoardOgImageUrl(
request: IRequest,
env: Environment,
prefix: string,
slug: string
): Promise<string | null> {
const kind = ogBoardKindForPrefix(prefix)
if (!kind) return null
const resolved = await resolveThumbnailBoard(env, kind, slug, { access: 'public' })
if (!resolved.ok) return null
return `${getPublicOrigin(request, env)}/api/app/social-preview/${prefix}/${encodeURIComponent(slug)}/image`
}
function ogBoardKindForPrefix(prefix: string): ThumbnailBoardKind | null {
if (prefix === FILE_PREFIX) return 'shared_file'
if (prefix === PUBLISH_PREFIX) return 'published'
return null
}
async function getBoardName(
env: Environment,
prefix: string,
slug: string
): Promise<string | null> {
switch (prefix) {
case FILE_PREFIX:
return await getTlaFileName(env, slug)
case PUBLISH_PREFIX:
return await getPublishedFileName(env, slug)
case SNAPSHOT_PREFIX:
return await getSnapshotName(env, slug)
case ROOM_PREFIX:
return await getLegacyRoomName(env, slug, ROOM_OPEN_MODE.READ_WRITE)
case READ_ONLY_PREFIX:
return await getLegacyRoomName(env, slug, ROOM_OPEN_MODE.READ_ONLY)
case READ_ONLY_LEGACY_PREFIX:
return await getLegacyRoomName(env, slug, ROOM_OPEN_MODE.READ_ONLY_LEGACY)
default:
return null
}
}
// Live multiplayer files (`/f/:slug`). Only surface the name for shared files so we don't leak the
// names of private boards into link previews.
async function getTlaFileName(env: Environment, slug: string): Promise<string | null> {
const db = createPostgresConnectionPool(env, 'getSocialPreview')
let file
try {
file = await db
.selectFrom('file')
.select(['id', 'name', 'shared', 'isDeleted'])
.where('id', '=', slug)
.executeTakeFirst()
} finally {
await db.destroy()
}
// The same gate the OG image on this card goes through, so a trashed (or test) board can't keep
// unfurling its name after the room itself started answering not-found.
if (!file || !isFileAnonymouslyViewable(file)) return null
const name = cleanName(file.name)
if (name) return name
// The file has no name set, so fall back to the editor document name — this matches the in-app
// title, which uses the file name and then the document name. It lives on the document record in
// the file's persisted room snapshot.
return getDocumentNameFromSnapshot(await getFileSnapshot(env, slug, true))
}
// Published files (`/p/:slug`). The published page serves a frozen snapshot from R2, and that's what
// viewers see, so we take the name from the snapshot's document record rather than the live
// `file.name` (which can differ after a rename without republish). getPublishedRoomSnapshot also
// enforces that the file is actually published.
async function getPublishedFileName(env: Environment, slug: string): Promise<string | null> {
const snapshot = await getPublishedRoomSnapshot(env, slug)
return getDocumentNameFromSnapshot(snapshot)
}
// Snapshot links (`/s/:slug`). The name lives in the snapshot's document record. Mirrors
// getRoomSnapshot: read from R2 first, then fall back to Supabase for older snapshots.
async function getSnapshotName(env: Environment, slug: string): Promise<string | null> {
const data = await getSnapshotFromR2(env, slug)
if (data) return getDocumentNameFromSnapshot(data)
const supabase = createSupabaseClient(env)
if (!supabase) return null
return getDocumentNameFromSnapshot(await getSnapshotFromSupabase(supabase, env, slug))
}
// Legacy multiplayer rooms (`/r/`, `/ro/`, `/v/`). The persisted room snapshot lives in R2 and the
// name is stored on its document record.
async function getLegacyRoomName(
env: Environment,
slug: string,
roomOpenMode: RoomOpenMode
): Promise<string | null> {
const roomId = await getSlug(env, slug, roomOpenMode)
if (!roomId) return null
return getDocumentNameFromSnapshot(await getFileSnapshot(env, roomId, false))
}
/**
* Builds the social preview HTML for a board. When the board has a name we use `<name> •
* tldraw.com` as the title, otherwise we fall back to `tldraw.com`. When the board kind supports
* rendered OG images (`boardImageUrl` is set) the preview uses the board's own thumbnail with a
* large image card; otherwise it keeps the static site-wide images.
*
* The body redirects to the board with the bypass param set: some in-app browsers used by real
* people carry a crawler token in their user-agent (WhatsApp, Pinterest), so they land here too.
* The crawlers we target don't run JavaScript, so they only see the metadata; humans get bounced
* straight back to the board. Deliberately a script rather than a meta refresh — several crawlers
* follow meta refresh redirects without running JavaScript, which would send them to the app shell
* and lose the board name.
*/
export function renderSocialPreview(
name: string | null,
boardImageUrl: string | null = null
): string {
const escapedTitle = escapeHtml(formatSocialTitle(name))
const description = escapeHtml(SOCIAL_PREVIEW_DESCRIPTION)
const ogImage = escapeHtml(boardImageUrl ?? SOCIAL_PREVIEW_IMAGE)
const twitterImage = escapeHtml(boardImageUrl ?? SOCIAL_PREVIEW_TWITTER_IMAGE)
const twitterCard = boardImageUrl ? 'summary_large_image' : 'summary'
const ogImageDimensions = boardImageUrl
? `
<meta property="og:image:width" content="${DEFAULT_THUMBNAIL_WIDTH}" />
<meta property="og:image:height" content="${DEFAULT_THUMBNAIL_HEIGHT}" />`
: ''
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>${escapedTitle}</title>
<meta name="description" content="${description}" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="tldraw" />
<meta property="og:title" content="${escapedTitle}" />
<meta property="og:description" content="${description}" />
<meta property="og:image" content="${ogImage}" />${ogImageDimensions}
<meta property="og:image:alt" content="${escapeHtml(SOCIAL_PREVIEW_IMAGE_ALT)}" />
<meta name="twitter:card" content="${twitterCard}" />
<meta name="twitter:title" content="${escapedTitle}" />
<meta name="twitter:description" content="${description}" />
<meta name="twitter:image" content="${twitterImage}" />
<meta name="twitter:image:alt" content="${escapeHtml(SOCIAL_PREVIEW_IMAGE_ALT)}" />
<meta name="twitter:creator" content="@tldraw" />
</head>
<body>
<a href="?${SOCIAL_PREVIEW_BYPASS_PARAM}=1">Open this board</a>
<script>
var url = new URL(location.href)
// only redirect if the bypass param isn't already set, so a routing misconfiguration
// can't reload this page forever.
if (!url.searchParams.get(${JSON.stringify(SOCIAL_PREVIEW_BYPASS_PARAM)})) {
// the '1' is load-bearing: an empty value reads as "param absent" to Vercel's
// missing-matching and the redirect would loop. See SOCIAL_PREVIEW_BYPASS_PARAM.
url.searchParams.set(${JSON.stringify(SOCIAL_PREVIEW_BYPASS_PARAM)}, '1')
location.replace(url)
}
</script>
</body>
</html>
`
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function html(body: string): Response {
return new Response(body, {
headers: {
'content-type': 'text/html; charset=utf-8',
// Allow crawlers to cache previews briefly. This means a board's name can be served for
// up to two minutes after it's unshared — accepted, because crawler-side preview caches
// (Facebook, Slack, Discord) independently retain unfurled names for days regardless of
// this header, so shortening it buys no real privacy. Vercel's edge does not cache these
// responses (plain max-age without s-maxage passes through), so this only affects the
// requesting client's own cache.
'cache-control': 'public, max-age=120',
},
})
}

View File

@@ -0,0 +1,26 @@
import { RoomOpenMode } from '@tldraw/dotcom-shared'
import { notFound } from '@tldraw/worker-shared'
import { IRequest } from 'itty-router'
import { Environment } from '../types'
import { getRoomDurableObjectId } from '../utils/durableObjects'
import { isRoomIdTooLong, roomIdIsTooLong } from '../utils/roomIdIsTooLong'
import { getSlug } from '../utils/roomOpenMode'
export async function joinExistingRoom(
request: IRequest,
env: Environment,
roomOpenMode: RoomOpenMode
): Promise<Response> {
const roomId = await getSlug(env, request.params.roomId, roomOpenMode)
if (!roomId) return notFound()
if (isRoomIdTooLong(roomId)) return roomIdIsTooLong()
// This needs to be a websocket request!
if (request.headers.get('upgrade')?.toLowerCase() === 'websocket') {
// Set up the durable object for this room
const id = getRoomDurableObjectId(env, roomId)
return env.TLDR_DOC.get(id).fetch(request)
}
return notFound()
}

View File

@@ -0,0 +1,224 @@
import { MAX_PROBLEM_DESCRIPTION_LENGTH, SubmitFeedbackRequestBody } from '@tldraw/dotcom-shared'
import { assert } from '@tldraw/utils'
import { IRequest } from 'itty-router'
import { createPostgresConnectionPool } from '../postgres'
import { Environment } from '../types'
import { isRateLimited } from '../utils/rateLimit'
import { requireAuth } from '../utils/tla/getAuth'
export async function submitFeedback(req: IRequest, env: Environment) {
const webhookUrl = env.DISCORD_FEEDBACK_WEBHOOK_URL
if (!webhookUrl) {
assert(env.IS_LOCAL, 'DISCORD_FEEDBACK_WEBHOOK_URL is not set')
}
const auth = await requireAuth(req, env)
const isLimited = await isRateLimited(env, `submit-feedback:${auth.userId}`)
if (isLimited) {
return new Response('Rate limited', { status: 429 })
}
let description: string
let allowContact: boolean
let url: string
try {
const data = (await req.json()) as SubmitFeedbackRequestBody
description = data.description?.trim()
allowContact = data.allowContact
url = data.url
if (typeof description !== 'string') {
throw new Error('Invalid description')
}
if (typeof allowContact !== 'boolean') {
throw new Error('Invalid allowContact')
}
if (typeof url !== 'string') {
throw new Error('Invalid url')
}
} catch (_e) {
return new Response('Invalid JSON', { status: 400 })
}
const userId = allowContact ? await getUserEmail(env, auth.userId) : '~' + auth.userId.slice(-4)
// Create a Plain thread if configured. Failures are caught so the Discord post still goes through.
let plainThreadUrl: string | null = null
if (env.PLAIN_API_KEY) {
try {
const email = allowContact ? userId : null
plainThreadUrl = await createPlainThread(env, { description, url, email })
} catch (e) {
console.error('Failed to create Plain thread:', e)
}
}
const extraLines = [
url ? `URL: ${url}` : null,
plainThreadUrl ? `Plain: ${plainThreadUrl}` : null,
].filter(Boolean)
const embedDescription = extraLines.length
? `${description}\n\n${extraLines.join('\n')}`
: description
const payload = {
username: `Feedback (${env.WORKER_NAME ?? 'localhost'})`,
content: `User (${userId}) reported a problem`,
embeds:
description.length > MAX_PROBLEM_DESCRIPTION_LENGTH
? undefined
: [
{
description: embedDescription,
},
],
}
if (webhookUrl) {
const formData = new FormData()
formData.append('payload_json', JSON.stringify(payload))
if (description.length > MAX_PROBLEM_DESCRIPTION_LENGTH) {
const descriptionBlob = new Blob([description], { type: 'text/plain' })
formData.append('description.txt', descriptionBlob)
}
const res = await fetch(webhookUrl, {
method: 'POST',
body: formData,
})
if (!res.ok) {
throw new Error(`Failed to send feedback to Discord: ${res.status} ${await res.text()}`)
}
} else {
// eslint-disable-next-line no-console
console.log('Feedback submitted:', payload)
}
return new Response('OK')
}
async function getUserEmail(env: Environment, userId: string) {
const pg = createPostgresConnectionPool(env, 'submitFeedback')
try {
const { email } = await pg
.selectFrom('user')
.where('id', '=', userId)
.select('email')
.executeTakeFirstOrThrow()
return email
} finally {
await pg.destroy()
}
}
const UPSERT_CUSTOMER_MUTATION = `
mutation upsertCustomer($input: UpsertCustomerInput!) {
upsertCustomer(input: $input) {
customer { id }
error { message code }
}
}
`
const CREATE_THREAD_MUTATION = `
mutation createThread($input: CreateThreadInput!) {
createThread(input: $input) {
thread { id }
error { message code }
}
}
`
interface PlainMutationResult {
error?: { message: string; code: string }
}
async function plainMutation<T extends PlainMutationResult>(
env: Environment,
query: string,
variables: Record<string, unknown>,
resultKey: string
): Promise<T> {
const res = await fetch('https://core-api.uk.plain.com/graphql/v1', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${env.PLAIN_API_KEY}`,
},
body: JSON.stringify({ query, variables }),
})
if (!res.ok) {
throw new Error(`Plain API returned ${res.status}: ${await res.text()}`)
}
const body = (await res.json()) as {
data?: Record<string, unknown>
errors?: { message: string }[]
}
if (body.errors?.length) {
throw new Error(`Plain GraphQL error: ${body.errors[0].message}`)
}
const result = body.data?.[resultKey] as T | undefined
if (result?.error) {
throw new Error(`Plain ${resultKey} error: ${result.error.message} (${result.error.code})`)
}
if (!result) {
throw new Error(`Plain ${resultKey} returned no result`)
}
return result
}
async function upsertPlainCustomer(env: Environment, email: string): Promise<string> {
const result = await plainMutation<PlainMutationResult & { customer?: { id: string } }>(
env,
UPSERT_CUSTOMER_MUTATION,
{
input: {
identifier: { emailAddress: email },
onCreate: { fullName: email, email: { email, isVerified: false } },
onUpdate: {},
},
},
'upsertCustomer'
)
if (!result.customer?.id) {
throw new Error('Plain upsertCustomer returned no customer id')
}
return result.customer.id
}
async function createPlainThread(
env: Environment,
{ description, url, email }: { description: string; url: string; email: string | null }
): Promise<string> {
const customerEmail = email ?? 'anonymous-feedback@tldraw.com'
const customerId = await upsertPlainCustomer(env, customerEmail)
const result = await plainMutation<PlainMutationResult & { thread?: { id: string } }>(
env,
CREATE_THREAD_MUTATION,
{
input: {
customerIdentifier: { customerId },
title: 'tldraw.com feedback',
description: description.length > 200 ? description.slice(0, 200) + '…' : description,
components: [
{ componentText: { text: url ? `${description}\n\nURL: ${url}` : description } },
],
...(env.PLAIN_LABEL_TYPE_ID && { labelTypeIds: [env.PLAIN_LABEL_TYPE_ID] }),
},
},
'createThread'
)
if (!result.thread?.id) {
throw new Error('Plain createThread returned no thread id')
}
const threadId = result.thread.id
if (env.PLAIN_WORKSPACE_ID) {
return `https://app.plain.com/workspace/${env.PLAIN_WORKSPACE_ID}/thread/${threadId}/`
}
return `https://app.plain.com/thread/${threadId}`
}

View File

@@ -0,0 +1,281 @@
import { UnknownRecord } from '@tldraw/store'
import { RoomSnapshot } from '@tldraw/sync-core'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createFakeR2 } from '../test/fakeR2'
import { Environment } from '../types'
import { ChainState, PendingDelta } from '../versionChain'
import { writeVersionChainEntry } from '../versionChainWrite'
import { sweepVersionChains } from './sweepVersionChains'
const files = vi.hoisted(() => ({ rows: [] as Array<{ id: string; updatedAt: number }> }))
const destroy = vi.hoisted(() => vi.fn())
// Lets one test spend the whole read budget on a single room. Reaching 800 reads for real would
// mean seeding ~800 versions; everything else runs the real verifier.
const verifier = vi.hoisted(() => ({ stub: null as null | (() => Promise<any>) }))
vi.mock('./verifyVersionChain', async (importOriginal) => {
const actual = await importOriginal<typeof import('./verifyVersionChain')>()
return {
...actual,
verifyRoomVersions: async (args: any) =>
verifier.stub ? verifier.stub() : actual.verifyRoomVersions(args),
}
})
// The sweep's only Postgres use is one keyset page of the file table, so the pool is faked down to
// the builder methods that page uses.
vi.mock('../postgres', () => ({
createPostgresConnectionPool: () => {
const builder: any = {
select: () => builder,
where: () => builder,
orderBy: () => builder,
limit: (n: number) => {
builder._limit = n
return builder
},
execute: async () => files.rows.slice(0, builder._limit ?? files.rows.length),
}
return { selectFrom: () => builder, destroy }
},
}))
function snapshot(clock: number, ids: string[]): RoomSnapshot {
return {
clock,
documentClock: clock,
documents: ids.map((id) => ({
state: { id, typeName: 'shape' } as UnknownRecord,
lastChangedClock: clock,
})),
tombstones: {},
tombstoneHistoryStartsAtClock: 0,
schema: { schemaVersion: 2, sequences: {} } as any,
}
}
async function seedChain(
chainBucket: R2Bucket,
legacyBucket: R2Bucket,
roomKey: string,
versions: RoomSnapshot[]
) {
let chain: ChainState | null = null
let pending: PendingDelta[] = []
for (let i = 0; i < versions.length; i++) {
const iso = `2026-09-01T00:00:0${i}.000Z`
const result = await writeVersionChainEntry({
bucket: chainBucket,
roomKey,
iso,
chain,
pending,
previous: i === 0 ? null : versions[i - 1],
next: versions[i],
now: i * 1000,
})
chain = result.chain
pending = result.pending
await legacyBucket.put(`${roomKey}/${iso}`, JSON.stringify(versions[i]))
}
}
function env(chainBucket: R2Bucket, legacyBucket: R2Bucket): Environment {
return {
ROOMS_HISTORY: chainBucket,
ROOMS_HISTORY_EPHEMERAL: legacyBucket,
MEASURE: undefined,
} as unknown as Environment
}
const versions = [snapshot(1, ['shape:a']), snapshot(2, ['shape:a', 'shape:b'])]
describe('sweepVersionChains', () => {
beforeEach(() => {
files.rows = []
destroy.mockClear()
verifier.stub = null
})
it('reports a healthy room as verified with no failures', async () => {
const chainBucket = createFakeR2()
const legacyBucket = createFakeR2()
await seedChain(chainBucket, legacyBucket, 'app_rooms/file1', versions)
files.rows = [{ id: 'file1', updatedAt: 100 }]
const result = await sweepVersionChains({
env: env(chainBucket, legacyBucket),
rooms: 10,
readsPerRoom: 50,
})
expect(result.swept).toBe(1)
expect(result.verified).toBe(1)
expect(result.failed).toBe(0)
expect(result.failures).toEqual([])
// A short page means the table ended, so there is nothing to resume from.
expect(result.nextCursor).toBeNull()
})
it('counts a room with no chain as swept but not verified', async () => {
const chainBucket = createFakeR2()
const legacyBucket = createFakeR2()
files.rows = [{ id: 'file1', updatedAt: 100 }]
const result = await sweepVersionChains({
env: env(chainBucket, legacyBucket),
rooms: 10,
readsPerRoom: 50,
})
// The distinction that stops a partial rollout's empty rooms reading as a clean fleet.
expect(result.swept).toBe(1)
expect(result.verified).toBe(0)
expect(result.failed).toBe(0)
})
it('reports a room whose chain disagrees with its legacy copy', async () => {
const chainBucket = createFakeR2()
const legacyBucket = createFakeR2()
await seedChain(chainBucket, legacyBucket, 'app_rooms/file1', versions)
// Rewrite one full copy so the chain replay and the legacy record disagree.
await legacyBucket.put(
'app_rooms/file1/2026-09-01T00:00:01.000Z',
JSON.stringify(snapshot(2, ['shape:a', 'shape:z']))
)
files.rows = [{ id: 'file1', updatedAt: 100 }]
const result = await sweepVersionChains({
env: env(chainBucket, legacyBucket),
rooms: 10,
readsPerRoom: 50,
})
expect(result.failed).toBe(1)
expect(result.failures).toEqual([
{ fileId: 'file1', reason: 'mismatch', detail: 'mismatch at 2026-09-01T00:00:01.000Z' },
])
})
it('does not fail a room whose legacy copy cannot be read', async () => {
const chainBucket = createFakeR2()
const legacyBucket = createFakeR2()
await seedChain(chainBucket, legacyBucket, 'app_rooms/file1', versions)
// Corrupt one full copy: the comparison for that version cannot run, but the chain itself
// replays and hashes clean.
await legacyBucket.put('app_rooms/file1/2026-09-01T00:00:01.000Z', 'not json')
files.rows = [{ id: 'file1', updatedAt: 100 }]
const measure = { writeDataPoint: vi.fn() }
const result = await sweepVersionChains({
env: { ...env(chainBucket, legacyBucket), MEASURE: measure } as unknown as Environment,
rooms: 10,
readsPerRoom: 50,
})
// A legacy object that will not read is not a broken chain, so it must not count as a failure
// or emit the `fail` datapoint the verify-failures alert pages on.
expect(result.failed).toBe(0)
expect(result.failures).toEqual([])
expect(result.legacyReadFailures).toBe(1)
const verifyPoints = measure.writeDataPoint.mock.calls
.map(([point]) => point.blobs)
.filter((blobs) => blobs[0] === 'version_chain_verify')
expect(verifyPoints).toEqual([])
})
it('skips test files without reading R2 and still advances the cursor past them', async () => {
const legacyBucket = createFakeR2()
const list = vi.fn()
files.rows = [{ id: 'test_file1', updatedAt: 100 }]
const result = await sweepVersionChains({
env: env({ list } as unknown as R2Bucket, legacyBucket),
rooms: 1,
readsPerRoom: 50,
})
expect(list).not.toHaveBeenCalled()
expect(result.swept).toBe(0)
// A full page of test files must not park the walk on the same offset forever.
expect(result.nextCursor).toBe('100_test_file1')
})
it('returns a cursor when the page is full and resumes from it', async () => {
const chainBucket = createFakeR2()
const legacyBucket = createFakeR2()
await seedChain(chainBucket, legacyBucket, 'app_rooms/file1', versions)
files.rows = [
{ id: 'file1', updatedAt: 200 },
{ id: 'file2', updatedAt: 100 },
]
const first = await sweepVersionChains({
env: env(chainBucket, legacyBucket),
rooms: 1,
readsPerRoom: 50,
})
expect(first.swept).toBe(1)
expect(first.nextCursor).toBe('200_file1')
})
it('resumes at the room the read budget stopped on rather than past it', async () => {
const chainBucket = createFakeR2()
const legacyBucket = createFakeR2()
files.rows = [
{ id: 'file1', updatedAt: 300 },
{ id: 'file2', updatedAt: 200 },
{ id: 'file3', updatedAt: 100 },
]
verifier.stub = async () => ({
checked: 1,
replayed: 1,
reads: 900,
complete: true,
mismatches: [],
errors: [],
legacyReadFailures: [],
})
const result = await sweepVersionChains({
env: env(chainBucket, legacyBucket),
rooms: 3,
readsPerRoom: 50,
})
expect(result.swept).toBe(1)
// file2 was never looked at, and continuation excludes the cursor: pointing it at file2 would
// drop that room from the sweep for good.
expect(result.nextCursor).toBe('300_file1')
})
it('keeps the cursor when the budget stops the walk on the last page', async () => {
const chainBucket = createFakeR2()
const legacyBucket = createFakeR2()
files.rows = [
{ id: 'file1', updatedAt: 300 },
{ id: 'file2', updatedAt: 200 },
]
verifier.stub = async () => ({
checked: 1,
replayed: 1,
reads: 900,
complete: true,
mismatches: [],
errors: [],
legacyReadFailures: [],
})
const result = await sweepVersionChains({
env: env(chainBucket, legacyBucket),
rooms: 10,
readsPerRoom: 50,
})
// A short page usually means the walk is done, but the budget left file2 unswept, so
// reporting completion here would retire the sweep with a room it never read.
expect(result.swept).toBe(1)
expect(result.nextCursor).toBe('300_file1')
})
})

View File

@@ -0,0 +1,209 @@
import { IRequest, json } from 'itty-router'
import { createPostgresConnectionPool } from '../postgres'
import { getR2KeyForRoom } from '../r2'
import { Environment } from '../types'
import { writeDataPoint } from '../utils/analytics'
import { isTestFile } from '../utils/tla/isTestFile'
import { verifyRoomVersions } from './verifyVersionChain'
/** One room the sweep could not verify clean. `detail` is the first mismatch or error message. */
export interface SweepFailure {
fileId: string
reason: 'mismatch' | 'chain-error'
detail: string
}
export interface SweepResult {
/** Rooms examined this batch. */
swept: number
/**
* Rooms that actually had a chain to replay. Counted apart from `swept` because during a
* partial rollout most files have no chain at all, and a batch of those would otherwise report
* as a clean sweep having verified nothing — the fleet-wide version of `VerifyResult.complete`.
*/
verified: number
/** Rooms whose per-room read budget ran out with entries unvisited. */
incomplete: number
failed: number
/** Versions whose legacy copy would not read. Not failures: the chain itself replayed clean. */
legacyReadFailures: number
reads: number
/** Pass back as `cursor` to continue; null when the batch reached the end of the table. */
nextCursor: string | null
failures: SweepFailure[]
}
// Deliberately small. A sweep runs in a single worker invocation against the ~1000 subrequest cap,
// and every R2 read a room spends counts against it, so the ceiling that matters is
// rooms x reads-per-room. Replaying deltas is also CPU-bound (a hash per version over the whole
// board), which is what keeps the room count low rather than the read budget alone.
const DEFAULT_ROOMS_PER_SWEEP = 10
const MAX_ROOMS_PER_SWEEP = 50
const DEFAULT_READS_PER_ROOM = 30
const MAX_READS_PER_ROOM = 200
// Leaves headroom under the subrequest cap for the Postgres connection and the response.
const SWEEP_READ_BUDGET = 800
function clampNumber(raw: unknown, fallback: number, max: number): number {
const value = Number(raw)
if (!Number.isFinite(value) || value <= 0) return fallback
return Math.min(Math.floor(value), max)
}
/**
* Keyset cursor over (updatedAt, id). Both halves are needed: files sharing an `updatedAt` would
* otherwise be skipped or repeated across batches depending on which side of the page they fell.
*/
function encodeCursor(updatedAt: number, id: string): string {
return `${updatedAt}_${id}`
}
function decodeCursor(raw: string | undefined): { updatedAt: number; id: string } | null {
if (!raw) return null
const separator = raw.indexOf('_')
if (separator <= 0) return null
const updatedAt = Number(raw.slice(0, separator))
const id = raw.slice(separator + 1)
if (!Number.isFinite(updatedAt) || !id) return null
return { updatedAt, id }
}
/**
* Verifies the version chains of a batch of files, newest-touched first.
*
* Exists because `verifyVersionChainRoute` answers for one room an operator already suspects, and
* nothing else looks: a delta bug that writes cleanly and only fails on read is invisible until a
* user opens history. Ordering by `updatedAt` puts the rooms the current write path just touched
* at the front, so a bug reaching production is caught in the first batch rather than after a walk
* through dormant history.
*
* Reads R2 directly and never wakes the file's durable object — the chain lives in the bucket, so
* sweeping cannot evict live rooms or contend with their persists.
*/
export async function sweepVersionChains({
env,
cursor,
rooms,
readsPerRoom,
}: {
env: Environment
cursor?: string
rooms: number
readsPerRoom: number
}): Promise<SweepResult> {
const db = createPostgresConnectionPool(env, '/app/admin/version-chain/sweep')
let candidates: Array<{ id: string; updatedAt: number }>
try {
const start = decodeCursor(cursor)
let query = db
.selectFrom('file')
.select(['id', 'updatedAt'])
.where('isDeleted', '=', false)
.orderBy('updatedAt', 'desc')
.orderBy('id', 'desc')
.limit(rooms)
if (start) {
query = query.where((eb) =>
eb.or([
eb('updatedAt', '<', start.updatedAt),
eb.and([eb('updatedAt', '=', start.updatedAt), eb('id', '<', start.id)]),
])
)
}
candidates = await query.execute()
} finally {
await db.destroy()
}
const result: SweepResult = {
swept: 0,
verified: 0,
incomplete: 0,
failed: 0,
legacyReadFailures: 0,
reads: 0,
nextCursor: null,
failures: [],
}
let stoppedOnBudget = false
for (const file of candidates) {
if (isTestFile(file.id)) {
// A declined room still advances the cursor, or a page of test files parks the walk on the
// same offset forever.
result.nextCursor = encodeCursor(file.updatedAt, file.id)
continue
}
if (result.reads >= SWEEP_READ_BUDGET) {
stoppedOnBudget = true
break
}
const verify = await verifyRoomVersions({
chainBucket: env.ROOMS_HISTORY,
legacyBucket: env.ROOMS_HISTORY_EPHEMERAL,
roomKey: getR2KeyForRoom({ slug: file.id, isApp: true }),
limit: Math.min(readsPerRoom, SWEEP_READ_BUDGET - result.reads),
})
result.swept++
result.reads += verify.reads
if (verify.replayed > 0) result.verified++
if (!verify.complete) result.incomplete++
if (verify.legacyReadFailures.length > 0) {
result.legacyReadFailures += verify.legacyReadFailures.length
console.warn(
`Version chain sweep could not read ${verify.legacyReadFailures.length} legacy copies. file=${file.id} first=${verify.legacyReadFailures[0].timestamp}: ${verify.legacyReadFailures[0].message}`
)
}
const reason =
verify.errors.length > 0 ? 'chain-error' : verify.mismatches.length > 0 ? 'mismatch' : null
if (reason) {
result.failed++
const detail =
reason === 'chain-error'
? `${verify.errors[0].timestamp}: ${verify.errors[0].message}`
: `mismatch at ${verify.mismatches[0]}`
result.failures.push({ fileId: file.id, reason, detail })
// Same event and blob shape the durable object's own verify writes, so one alert covers
// both sources. The file id goes to the log rather than the metric, matching the
// convention that analytics blobs carry no room identifiers.
writeDataPoint(undefined, env.MEASURE, env, 'version_chain_verify', {
blobs: ['fail', reason],
})
console.error(`Version chain sweep failed. file=${file.id} reason=${reason} ${detail}`)
}
// Only once the room has been verified. Continuation excludes the cursor, so advancing past
// the room the budget stopped on would drop it from the sweep for good — never looked at,
// while the run still reports clean.
result.nextCursor = encodeCursor(file.updatedAt, file.id)
}
// The run itself is a datapoint: without it a sweep that stopped running, or one that only ever
// reaches rooms with no chain, is indistinguishable from a fleet that is healthy.
writeDataPoint(undefined, env.MEASURE, env, 'version_chain_sweep', {
doubles: [result.swept, result.verified, result.failed, result.incomplete, result.reads],
})
// A short batch means the table ended, but only a batch that ran to completion is done: a budget
// break leaves rooms behind the cursor even on the last page.
if (candidates.length < rooms && !stoppedOnBudget) result.nextCursor = null
return result
}
export async function sweepVersionChainsRoute(
request: IRequest,
env: Environment
): Promise<Response> {
const rooms = clampNumber(request.query?.rooms, DEFAULT_ROOMS_PER_SWEEP, MAX_ROOMS_PER_SWEEP)
const readsPerRoom = clampNumber(
request.query?.readsPerRoom,
DEFAULT_READS_PER_ROOM,
MAX_READS_PER_ROOM
)
const cursor = typeof request.query?.cursor === 'string' ? request.query.cursor : undefined
return json(await sweepVersionChains({ env, cursor, rooms, readsPerRoom }))
}

View File

@@ -0,0 +1,140 @@
import { AcceptInviteResponseBody } from '@tldraw/dotcom-shared'
import { getIndexBelow, IndexKey } from '@tldraw/utils'
import { IRequest } from 'itty-router'
import { sql } from 'kysely'
import { createPostgresConnectionPool } from '../../postgres'
import { Environment } from '../../types'
import { getAuth } from '../../utils/tla/getAuth'
import { getJoinableWorkspaceFromInvite } from '../../utils/tla/getJoinableWorkspaceFromInvite'
export async function acceptInvite(request: IRequest, env: Environment): Promise<Response> {
const { token } = request.params
if (!token) {
return Response.json(
{ error: true, message: 'Invite token is required' } satisfies AcceptInviteResponseBody,
{ status: 400 }
)
}
// Return our own 401 body: the router's generic one has no `message`, which the client shows
// as the toast description.
const auth = await getAuth(request, env)
if (!auth) {
return Response.json(
{ error: true, message: 'Sign in to accept this invite' } satisfies AcceptInviteResponseBody,
{ status: 401 }
)
}
const db = createPostgresConnectionPool(env, 'acceptInvite')
try {
return await db.transaction().execute(async (tx) => {
// First, validate the invite token and get workspace info
const workspace = await getJoinableWorkspaceFromInvite(tx, token)
if (!workspace) {
return Response.json(
{
error: true,
message: 'Invalid or expired invite token',
} satisfies AcceptInviteResponseBody,
{ status: 404 }
)
}
const existingMember = await tx
.selectFrom('group_user')
.select('userId')
.where('groupId', '=', workspace.id)
.where('userId', '=', auth.userId)
.executeTakeFirst()
if (existingMember) {
return Response.json({
error: false,
message: 'You are already a member of this group',
workspaceId: workspace.id,
workspaceName: workspace.name,
alreadyMember: true,
} satisfies AcceptInviteResponseBody)
}
// Get the user's information for the group_user record
const user = await tx
.selectFrom('user')
.select(['name', 'color', 'flags'])
.where('id', '=', auth.userId)
.executeTakeFirst()
if (!user) {
return Response.json(
{
error: true,
message: 'User not found',
} satisfies AcceptInviteResponseBody,
{ status: 404 }
)
}
// Get the lowest index to place new group at the top
const lowestIndexGroup = await sql<{
index: string
// kysely doesn't support 'collate' in the query builder, so we have to use raw sql
// collate "C" makes it use straight up byte comparison instead of lexicographic comparison
}>`select index from group_user where "userId" = ${auth.userId} order by index collate "C" asc limit 1`.execute(
tx
)
// Use tldraw's fractional indexing to place new group at the top
const lowestIndex = lowestIndexGroup.rows[0]?.index as IndexKey | undefined
// Generate a new index below the current lowest (to place at top); first group gets 'a1'
const index = lowestIndex ? getIndexBelow(lowestIndex) : ('a1' as IndexKey)
// Add user to the group. Two accepts racing past the membership check above (two tabs,
// two devices) would otherwise turn the second into a unique violation and a 500 for a
// join that succeeded.
const inserted = await tx
.insertInto('group_user')
.values({
groupId: workspace.id,
userId: auth.userId,
userColor: user.color || '#000000',
userName: user.name,
role: 'member',
index,
createdAt: Date.now(),
updatedAt: Date.now(),
})
.onConflict((oc) => oc.columns(['userId', 'groupId']).doNothing())
.executeTakeFirst()
if (inserted.numInsertedOrUpdatedRows === 0n) {
return Response.json({
error: false,
message: 'You are already a member of this group',
workspaceId: workspace.id,
workspaceName: workspace.name,
alreadyMember: true,
} satisfies AcceptInviteResponseBody)
}
return Response.json({
error: false,
message: 'Successfully joined the group',
workspaceId: workspace.id,
workspaceName: workspace.name,
success: true,
} satisfies AcceptInviteResponseBody)
})
} catch (error) {
console.error('Error accepting invite:', error)
return Response.json(
{
error: true,
message: 'Internal server error',
} satisfies AcceptInviteResponseBody,
{ status: 500 }
)
} finally {
await db.destroy()
}
}

View File

@@ -0,0 +1,331 @@
import { describe, expect, it } from 'vitest'
import {
BOARD_SEARCH_MAX_TERMS,
BOARD_SEARCH_PAGE_SIZE,
BoardSearchRow,
compareBoardSearchOrder,
getBoardSearchResults,
isAfterBoardSearchCursor,
parseSearchBoardsInput,
} from './boardTools'
function parsedJson(result: ReturnType<typeof getBoardSearchResults>) {
const block = result.content[0]
if (block.type !== 'text') throw new Error('Expected a text block')
return JSON.parse(block.text)
}
function makeRow(overrides: Partial<BoardSearchRow> = {}): BoardSearchRow {
return {
id: 'board-1',
name: 'Roadmap',
// A board the caller made: arrival and creation are the same moment.
arrivedAt: 1_700_000_000_000,
createdAt: 1_700_000_000_000,
updatedAt: 1_700_000_500_000,
workspaceName: 'Design',
source: 'owned' as const,
...overrides,
}
}
function makePage(count: number): BoardSearchRow[] {
return Array.from({ length: count }, (_, index) =>
makeRow({
id: `board-${index}`,
arrivedAt: 1_700_000_000_000 - index,
createdAt: 1_700_000_000_000 - index,
})
)
}
describe('parseSearchBoardsInput', () => {
// Listing the newest boards is the tool's other job: it is the only way for a model to answer
// "what boards do I have?" without being handed an id first.
it('treats a missing query as no filter', () => {
expect(parseSearchBoardsInput({})).toEqual({ terms: [], cursor: null })
})
// The wire-legal call a model makes by following the tool's own description literally: this is
// the only tool with `required: []`, so omitted `arguments` is a legitimate "list my newest
// boards", not a malformed call that should hit `requireArgumentsObject(undefined)`.
it('treats wholly omitted arguments the same as an empty object', () => {
expect(parseSearchBoardsInput(undefined)).toEqual(parseSearchBoardsInput({}))
})
it('treats a whitespace-only query as no filter', () => {
expect(parseSearchBoardsInput({ query: ' ' }).terms).toEqual([])
})
// Terms are ANDed, so word order in the query need not match word order in the name.
it('splits a query into terms on whitespace', () => {
expect(parseSearchBoardsInput({ query: 'design system' }).terms).toEqual(['design', 'system'])
})
it('refuses a non-string query', () => {
expect(() => parseSearchBoardsInput({ query: 12 })).toThrow('query must be a string')
})
// A name is a name, not a document: a pasted paragraph is a model mistake, and every term
// becomes its own ILIKE.
it('refuses an over-long query', () => {
expect(() => parseSearchBoardsInput({ query: 'x'.repeat(201) })).toThrow('200 characters')
})
// A query length limit alone does not bound term count, since whitespace is cheap: each term
// becomes its own unindexable ILIKE over an already-unbounded scan.
it('refuses more than the maximum number of terms', () => {
const query = Array.from({ length: BOARD_SEARCH_MAX_TERMS + 1 }, (_, i) => `t${i}`).join(' ')
expect(() => parseSearchBoardsInput({ query })).toThrow(
`${BOARD_SEARCH_MAX_TERMS} words or fewer`
)
})
it('accepts exactly the maximum number of terms', () => {
const query = Array.from({ length: BOARD_SEARCH_MAX_TERMS }, (_, i) => `t${i}`).join(' ')
expect(parseSearchBoardsInput({ query }).terms).toHaveLength(BOARD_SEARCH_MAX_TERMS)
})
it('refuses arguments that are not an object', () => {
expect(() => parseSearchBoardsInput('boards')).toThrow('Tool arguments must be an object')
})
// The cursor is opaque so that its scheme can change; a model must only ever hand back one it
// was given.
it('round-trips a cursor from a previous result', () => {
const first = parsedJson(getBoardSearchResults(makePage(BOARD_SEARCH_PAGE_SIZE + 1), []))
expect(parseSearchBoardsInput({ cursor: first.nextCursor }).cursor).toEqual({
arrivedAt: 1_700_000_000_000 - (BOARD_SEARCH_PAGE_SIZE - 1),
id: `board-${BOARD_SEARCH_PAGE_SIZE - 1}`,
})
})
// A cursor is a position in one query's results. Carried onto a different query it would point
// at row 20 of a set the model never saw the start of, with nothing in the result to say so.
it('continues a cursor only under the query that minted it', () => {
const page = parsedJson(getBoardSearchResults(makePage(BOARD_SEARCH_PAGE_SIZE + 1), ['design']))
expect(
parseSearchBoardsInput({ query: 'design', cursor: page.nextCursor }).cursor
).not.toBeNull()
expect(() => parseSearchBoardsInput({ query: 'roadmap', cursor: page.nextCursor })).toThrow(
'cursor is from a different query'
)
expect(() => parseSearchBoardsInput({ cursor: page.nextCursor })).toThrow(
'cursor is from a different query'
)
})
// Matching is `ilike` and terms are split on whitespace before they are ever compared, so
// neither case nor spacing changed which boards the cursor was pointing into.
it('treats a requery that differs only in case or spacing as the same query', () => {
const page = parsedJson(
getBoardSearchResults(makePage(BOARD_SEARCH_PAGE_SIZE + 1), ['Design', 'System'])
)
expect(
parseSearchBoardsInput({ query: ' design system ', cursor: page.nextCursor }).cursor
).not.toBeNull()
})
// The other direction of the same rule: "list my newest boards" is a query too, and a cursor
// into it does not carry over to a search.
it('refuses a no-query cursor once a query is added', () => {
const page = parsedJson(getBoardSearchResults(makePage(BOARD_SEARCH_PAGE_SIZE + 1), []))
expect(() => parseSearchBoardsInput({ query: 'design', cursor: page.nextCursor })).toThrow(
'cursor is from a different query'
)
})
// Fixture board ids in the eval harness are arbitrary strings, and `btoa` throws outside Latin-1.
// That throw would happen inside the route's try block and reach a model as a database failure.
it('round-trips an id that btoa alone could not encode', () => {
// The cursor is minted from the last row *on* the page, so the odd id has to sit there.
const rows = makePage(BOARD_SEARCH_PAGE_SIZE + 1)
rows[BOARD_SEARCH_PAGE_SIZE - 1] = { ...rows[BOARD_SEARCH_PAGE_SIZE - 1], id: 'brädå:1' }
const result = parsedJson(getBoardSearchResults(rows, []))
expect(parseSearchBoardsInput({ cursor: result.nextCursor }).cursor).toEqual({
arrivedAt: 1_700_000_000_000 - (BOARD_SEARCH_PAGE_SIZE - 1),
id: 'brädå:1',
})
})
// Every fixture below carries the trailing `:` that makes it a three-field cursor. Without it the
// field-count check refuses them first, and the guard each case names is never reached — which is
// what happened when the cursor grew its query field and these were not revisited: deleting the
// timestamp validation outright left all of them green.
//
// Nothing downstream can act on a malformed cursor, and silently starting from the first page
// would look to a model like the last page repeating itself.
it('refuses a cursor it did not issue', () => {
expect(() => parseSearchBoardsInput({ cursor: 'not-a-cursor' })).toThrow('cursor is not valid')
expect(() => parseSearchBoardsInput({ cursor: btoa('nonsense') })).toThrow(
'cursor is not valid'
)
expect(() => parseSearchBoardsInput({ cursor: 12 })).toThrow('cursor must be a string')
expect(() => parseSearchBoardsInput({ cursor: btoa('1:%zz:') })).toThrow('cursor is not valid')
})
// `Number.isInteger` admits 1e300, which binds as an out-of-range int8 and makes Postgres throw:
// caller garbage would reach a model as "the board database could not be reached".
it('refuses a sort key no timestamp could be', () => {
expect(() => parseSearchBoardsInput({ cursor: btoa('1e300:board-1:') })).toThrow(
'cursor is not valid'
)
expect(() => parseSearchBoardsInput({ cursor: btoa('-1:board-1:') })).toThrow(
'cursor is not valid'
)
})
// `Number('')` is 0, which passes `Number.isSafeInteger(0)`: the forged cursor `btoa(":id:")`
// would otherwise decode to `{createdAt: 0, id: 'id'}` and seek strictly below epoch forever,
// with no signal to the model that its cursor was the problem.
it('refuses a cursor with an empty timestamp half', () => {
expect(() => parseSearchBoardsInput({ cursor: btoa(':board-1:') })).toThrow(
'cursor is not valid'
)
})
// The digit check admits any run of digits, including one past MAX_SAFE_INTEGER. That binds as an
// out-of-range int8 and makes Postgres throw, so caller garbage would reach a model as "the board
// database could not be reached" rather than as a bad cursor. This is the case
// `Number.isSafeInteger` is there for: `1e300` is refused a line earlier, for not being digits.
it('refuses an all-digit timestamp too large to be a safe integer', () => {
expect(() => parseSearchBoardsInput({ cursor: btoa('99999999999999999999:board-1:') })).toThrow(
'cursor is not valid'
)
})
// The id half is what makes "where the page ended" a single point rather than a range. Empty, the
// cursor would seek on createdAt alone, and every board sharing that timestamp would be re-served
// or skipped at the boundary.
it('refuses a cursor with an empty id half', () => {
expect(() => parseSearchBoardsInput({ cursor: btoa('1::') })).toThrow('cursor is not valid')
})
// Every other string form `Number()` coerces to a finite value but a plain-digit timestamp
// never is.
it.each(['1e3', ' 5', '+5', 'Infinity'])(
'refuses a timestamp half of %j, which Number() would otherwise accept',
(timestampPart) => {
expect(() => parseSearchBoardsInput({ cursor: btoa(`${timestampPart}:board-1:`) })).toThrow(
'cursor is not valid'
)
}
)
})
// The one statement of result order. The SQL in searchBoards.ts mirrors this, and the eval harness
// pages through fixtures with it — if the two disagree, an eval stops being evidence.
describe('compareBoardSearchOrder', () => {
it('puts the newest-created board first', () => {
expect(
compareBoardSearchOrder({ arrivedAt: 2, id: 'a' }, { arrivedAt: 1, id: 'a' })
).toBeLessThan(0)
})
// Boards created in one batch share a createdAt. Without a unique tiebreaker the cursor cannot
// say where a page ended inside that group.
it('breaks a tie on id, descending', () => {
expect(
compareBoardSearchOrder({ arrivedAt: 0, id: 'b' }, { arrivedAt: 0, id: 'a' })
).toBeLessThan(0)
expect(compareBoardSearchOrder({ arrivedAt: 0, id: 'a' }, { arrivedAt: 0, id: 'a' })).toBe(0)
})
it('sorts an older board after a newer one whatever their ids', () => {
expect(
compareBoardSearchOrder({ arrivedAt: 0, id: 'z' }, { arrivedAt: 1, id: 'a' })
).toBeGreaterThan(0)
})
})
describe('isAfterBoardSearchCursor', () => {
it('accepts only rows that sort after the cursor', () => {
const cursor = { arrivedAt: 10, id: 'm' }
expect(isAfterBoardSearchCursor({ arrivedAt: 9, id: 'z' }, cursor)).toBe(true)
expect(isAfterBoardSearchCursor({ arrivedAt: 11, id: 'a' }, cursor)).toBe(false)
})
// The point of ordering on arrival rather than creation: a board somebody shared with you this
// morning leads the list, however long ago its owner made it. Under a createdAt sort it would land
// below everything you have made since, which for a long-lived board is the bottom of the page.
it('puts a recently shared old board above a board made earlier today', () => {
const sharedThisMorning = { arrivedAt: 1_700_000_000_000, id: 'shared' }
const mineFromLastWeek = { arrivedAt: 1_699_000_000_000, id: 'mine' }
expect(compareBoardSearchOrder(sharedThisMorning, mineFromLastWeek)).toBeLessThan(0)
})
// Descending order means "after" is "less than". Reversed, a page returns itself forever.
it('excludes the cursor row itself and takes the tie on id', () => {
const cursor = { arrivedAt: 10, id: 'm' }
expect(isAfterBoardSearchCursor(cursor, cursor)).toBe(false)
expect(isAfterBoardSearchCursor({ arrivedAt: 10, id: 'l' }, cursor)).toBe(true)
expect(isAfterBoardSearchCursor({ arrivedAt: 10, id: 'n' }, cursor)).toBe(false)
})
})
describe('getBoardSearchResults', () => {
it('shapes a board for the model', () => {
const result = parsedJson(getBoardSearchResults([makeRow()], []))
expect(result).toEqual({
boardCount: 1,
boards: [
{
boardId: 'board-1',
name: 'Roadmap',
// Same instant for a board the caller made; they diverge only for a shared one.
addedAt: '2023-11-14T22:13:20.000Z',
createdAt: '2023-11-14T22:13:20.000Z',
updatedAt: '2023-11-14T22:21:40.000Z',
source: 'owned',
},
],
})
})
// "My workspace" on every row of the common case is noise; on a shared workspace's board it is
// the thing that identifies where the board lives.
it('names the workspace only on boards outside the caller’s own', () => {
const result = parsedJson(getBoardSearchResults([makeRow({ source: 'workspace' })], []))
expect(result.boards[0]).toMatchObject({ source: 'workspace', workspaceName: 'Design' })
})
// A link-shared board is owned by a workspace the caller is not in. Reporting its name would hand
// somebody who was given a link the name of an organisation they have no seat in.
it('withholds the workspace name from a link-shared board', () => {
const result = parsedJson(getBoardSearchResults([makeRow({ source: 'shared' })], []))
expect(result.boards[0].source).toBe('shared')
expect(result.boards[0]).not.toHaveProperty('workspaceName')
})
// An unnamed board is titled by its creation date on tldraw.com, in the viewer's locale and
// timezone. A stand-in title invented here would be one the caller never sees on their own
// screen, and one no query could match, since the column holds ''.
it('reports a blank name as blank rather than inventing one', () => {
const result = parsedJson(getBoardSearchResults([makeRow({ name: ' ' })], []))
expect(result.boards[0].name).toBe('')
})
// The query asks for one row more than a page so that "is there another page" needs no second
// count. The extra row must never be shown.
it('serves one page and a cursor from the surplus row', () => {
const result = parsedJson(getBoardSearchResults(makePage(BOARD_SEARCH_PAGE_SIZE + 1), []))
expect(result.boardCount).toBe(BOARD_SEARCH_PAGE_SIZE)
expect(result.boards).toHaveLength(BOARD_SEARCH_PAGE_SIZE)
expect(result.nextCursor).toEqual(expect.any(String))
expect(result.boards.at(-1).boardId).toBe(`board-${BOARD_SEARCH_PAGE_SIZE - 1}`)
})
// A cursor on the last page would have a model fetch an empty page to discover it had finished.
it('omits the cursor on the last page', () => {
const result = parsedJson(getBoardSearchResults(makePage(BOARD_SEARCH_PAGE_SIZE), []))
expect(result.boardCount).toBe(BOARD_SEARCH_PAGE_SIZE)
expect(result.nextCursor).toBeUndefined()
})
// An empty result is a normal result. Models treat isError as failure and retry it.
it('returns an empty result rather than an error', () => {
const result = getBoardSearchResults([], [])
expect(result.isError).toBeUndefined()
expect(parsedJson(result)).toEqual({ boardCount: 0, boards: [] })
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,51 @@
import { CreateFilesRequestBody } from '@tldraw/dotcom-shared'
import { RoomSnapshot } from '@tldraw/sync-core'
import { createTLSchema } from '@tldraw/tlschema'
import { uniqueId } from '@tldraw/utils'
import { IRequest } from 'itty-router'
import { getR2KeyForRoom } from '../../r2'
import { Environment } from '../../types'
import { getUserIdFromRequest } from '../../utils/tla/permissions'
import { validateSnapshot } from '../../utils/validateSnapshot'
// Create new files based on snapshots. This is used when dropping .tldr files onto the app.
export async function createFiles(request: IRequest, env: Environment): Promise<Response> {
// The data sent from the client will include the data for the new room
const userId = await getUserIdFromRequest(request, env)
if (!userId) {
return Response.json({ error: true, message: 'No user' }, { status: 401 })
}
const slugs: string[] = []
const data = (await request.json()) as CreateFilesRequestBody
for (const _snapshot of data.snapshots) {
// There's a chance the data will be invalid, so we check it first
// need to maybe migrate the snapshot
const snapshotResult = validateSnapshot(_snapshot)
if (!snapshotResult.ok) {
return Response.json({ error: true, message: snapshotResult.error }, { status: 400 })
}
// Create the new snapshot
const snapshot: RoomSnapshot = {
schema: createTLSchema().serialize(),
clock: 0,
documents: Object.values(snapshotResult.value).map((r) => ({
state: r,
lastChangedClock: 0,
})),
tombstones: {},
}
// Create a new slug for the room
const newSlug = uniqueId()
// Bang the snapshot into the database
await env.ROOMS.put(getR2KeyForRoom({ slug: newSlug, isApp: true }), JSON.stringify(snapshot))
slugs.push(newSlug)
}
return new Response(JSON.stringify({ error: false, slugs }))
}

View File

@@ -0,0 +1,160 @@
import { afterEach, describe, expect, it } from 'vitest'
import { testRoutes } from '../../testRoutes'
import { BOARD_SEARCH_PAGE_SIZE } from './boardTools'
import { getEvalsFixtureScreenshotPlan, resetEvalsFixtureSessionsForTests } from './evalsLocalMcp'
import { makeSnapshot } from './screenshotTestHelpers'
const localEnv = { IS_LOCAL: 'true', TLDRAW_ENV: 'development' } as never
afterEach(resetEvalsFixtureSessionsForTests)
function request(path: string, method: string, body?: unknown) {
return new Request(`http://localhost${path}`, {
method,
...(body === undefined
? null
: { headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }),
})
}
describe('local eval fixture MCP', () => {
it('is unavailable outside local development', async () => {
const response = await testRoutes.fetch(request('/app/__test__/evals/plan', 'POST', {}), {
IS_LOCAL: 'false',
TLDRAW_ENV: 'production',
} as never)
expect(response.status).toBe(404)
})
it('plans fixture screenshots with the real clustering logic', () => {
const snapshot = makeSnapshot([{ id: 'page:one', index: 'a1', shapes: 1 }])
const plan = getEvalsFixtureScreenshotPlan(snapshot, {
'shape:page:one-0': { minX: 0, minY: 0, maxX: 80, maxY: 80, text: 'Q4' },
})
expect(plan).toHaveLength(1)
expect(plan[0]).toMatchObject({
pageId: 'page:one',
shapeIds: ['shape:page:one-0'],
})
expect(plan[0].clusterIds).toHaveLength(1)
})
it('serves uploaded boards through the real board tools', async () => {
const snapshot = makeSnapshot([{ id: 'page:one', index: 'a1', name: 'One', shapes: 1 }])
const boardResponse = await testRoutes.fetch(
request('/app/__test__/evals/sessions/session/boards/roadmap', 'PUT', {
snapshot,
measurements: {
'shape:page:one-0': { minX: 0, minY: 0, maxX: 80, maxY: 80, text: 'Q4' },
},
shots: {},
}),
localEnv
)
expect(boardResponse.status).toBe(201)
const mcpResponse = await testRoutes.fetch(
request('/app/__test__/evals/sessions/session/mcp', 'POST', {
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name: 'get_board_info', arguments: { boardId: 'roadmap' } },
}),
localEnv
)
expect(mcpResponse.status).toBe(200)
const rpc = (await mcpResponse.json()) as any
expect(JSON.parse(rpc.result.content[0].text)).toMatchObject({
name: 'My Board',
pages: [{ id: 'page:one', name: 'One', hasContent: true }],
})
})
it('serves search_boards over the session’s fixture boards', async () => {
for (const [boardId, boardName] of [
['roadmap', 'Q4 roadmap'],
['sketches', 'Loose sketches'],
]) {
const stored = await testRoutes.fetch(
request(`/app/__test__/evals/sessions/search/boards/${boardId}`, 'PUT', {
snapshot: makeSnapshot(
[{ id: 'page:one', index: 'a1', name: 'One', shapes: 1 }],
boardName
),
measurements: {},
shots: {},
}),
localEnv
)
expect(stored.status).toBe(201)
}
const mcpResponse = await testRoutes.fetch(
request('/app/__test__/evals/sessions/search/mcp', 'POST', {
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name: 'search_boards', arguments: { query: 'roadmap' } },
}),
localEnv
)
expect(mcpResponse.status).toBe(200)
const rpc = (await mcpResponse.json()) as any
expect(JSON.parse(rpc.result.content[0].text)).toMatchObject({
boardCount: 1,
// A plausible timestamp, not the 1970 a bare index would produce: a model reasoning about
// how new a board is in an eval reads this field.
boards: [
{
boardId: 'roadmap',
name: 'Q4 roadmap',
source: 'owned',
createdAt: '2026-01-05T09:00:00.000Z',
},
],
})
})
// Fixtures have no `file` row and so no creation times; insertion order stands in for one, so the
// ordering and paging the deployed tool applies are both still exercised.
it('pages through fixture boards with a cursor', async () => {
for (let index = 0; index < BOARD_SEARCH_PAGE_SIZE + 1; index++) {
await testRoutes.fetch(
request(`/app/__test__/evals/sessions/paged/boards/board-${index}`, 'PUT', {
snapshot: makeSnapshot([{ id: 'page:one', index: 'a1', shapes: 1 }], `Board ${index}`),
measurements: {},
shots: {},
}),
localEnv
)
}
async function searchPage(args: object) {
const response = await testRoutes.fetch(
request('/app/__test__/evals/sessions/paged/mcp', 'POST', {
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name: 'search_boards', arguments: args },
}),
localEnv
)
return JSON.parse(((await response.json()) as any).result.content[0].text)
}
const first = await searchPage({})
expect(first.boardCount).toBe(BOARD_SEARCH_PAGE_SIZE)
expect(first.nextCursor).toEqual(expect.any(String))
const second = await searchPage({ cursor: first.nextCursor })
expect(second.boardCount).toBe(1)
expect(second.nextCursor).toBeUndefined()
// The whole point of a cursor: no board appears on two pages, and none is skipped.
const ids = [...first.boards, ...second.boards].map(
(board: { boardId: string }) => board.boardId
)
expect(new Set(ids).size).toBe(BOARD_SEARCH_PAGE_SIZE + 1)
})
})

View File

@@ -0,0 +1,249 @@
import { RoomSnapshot } from '@tldraw/sync-core'
import { IRequest } from 'itty-router'
import { Environment } from '../../types'
import { getDocumentNameFromSnapshot } from '../getDocumentNameFromSnapshot'
import {
BOARD_INFO_TOOL_NAME,
BOARD_NOT_FOUND_MESSAGE,
CLUSTER_INFO_TOOL_NAME,
CLUSTER_SCREENSHOT_TOOL_NAME,
PAGE_INFO_TOOL_NAME,
SEARCH_BOARDS_TOOL_NAME,
ShapeMeasurement,
ToolResult,
compareBoardSearchOrder,
getBoardInfo,
getBoardSearchResults,
clusterPage,
getClusterInfo,
getPageInfo,
handleMcpJsonRpc,
isAfterBoardSearchCursor,
parseBoardInfoInput,
parseClusterInfoInput,
parseClusterScreenshotInput,
parsePageInfoInput,
parseSearchBoardsInput,
pickClusterShapes,
resolvePage,
toolError,
toolPageResult,
} from './boardTools'
const HARNESS_GAP_MARKER = '[harness-gap]'
// Insertion order is the fixture stand-in for creation time, but a bare index would have every eval
// board report a 1970 timestamp — and a model reasoning about "my newest board" reads those. Spaced
// an hour apart from a fixed date rather than from `Date.now()`, so two runs over the same fixtures
// produce byte-identical results.
const FIXTURE_CREATED_AT_BASE_MS = Date.UTC(2026, 0, 5, 9, 0, 0)
const FIXTURE_CREATED_AT_STEP_MS = 60 * 60 * 1000
function fixtureTimestamp(index: number): number {
return FIXTURE_CREATED_AT_BASE_MS + index * FIXTURE_CREATED_AT_STEP_MS
}
interface FixtureBoard {
snapshot: RoomSnapshot
measurements: Record<string, ShapeMeasurement>
shots: Record<string, string>
}
interface ScreenshotPlanEntry {
pageId: string
clusterIds: string[]
shapeIds: string[]
}
const sessions = new Map<string, Map<string, FixtureBoard>>()
function notFoundUnlessLocal(env: Environment): Response | null {
return env.IS_LOCAL === 'true' ? null : new Response('Not Found', { status: 404 })
}
function screenshotFileName(clusterIds: string[], theme: 'light' | 'dark') {
const key = [...clusterIds]
.sort()
.join('+')
.replace(/[^a-zA-Z0-9+_-]/g, '_')
return `${key}.${theme}.png`
}
function textOf(result: ToolResult): string {
const block = result.content.find((part) => part.type === 'text')
if (!block || block.type !== 'text') throw new Error('Expected a text tool result')
return block.text
}
export function getEvalsFixtureScreenshotPlan(
snapshot: RoomSnapshot,
measurements: Record<string, ShapeMeasurement>
): ScreenshotPlanEntry[] {
const pages = JSON.parse(textOf(getBoardInfo(snapshot))) as {
pages: Array<{ id: string }>
}
const plan: ScreenshotPlanEntry[] = []
for (const page of pages.pages) {
const selector = { kind: 'id', id: page.id } as const
const resolved = resolvePage(snapshot, selector)
if (!resolved.ok) continue
const clusters = clusterPage(resolved, measurements)
const info = JSON.parse(textOf(getPageInfo(resolved, clusters))) as {
clusters: Array<{ id: string }>
}
const clusterIds = info.clusters.map((cluster) => cluster.id)
const sets = [...clusterIds.map((id) => [id]), ...(clusterIds.length > 1 ? [clusterIds] : [])]
for (const set of sets) {
const picked = pickClusterShapes(clusters, set, selector)
if (!picked.ok || picked.shapeIds.length === 0) continue
plan.push({ pageId: page.id, clusterIds: set, shapeIds: picked.shapeIds })
}
}
return plan
}
export async function planEvalsFixtureScreenshots(
request: IRequest,
env: Environment
): Promise<Response> {
const unavailable = notFoundUnlessLocal(env)
if (unavailable) return unavailable
const body = (await request.json()) as {
snapshot: RoomSnapshot
measurements: Record<string, ShapeMeasurement>
}
return Response.json({ plan: getEvalsFixtureScreenshotPlan(body.snapshot, body.measurements) })
}
export async function putEvalsFixtureBoard(request: IRequest, env: Environment): Promise<Response> {
const unavailable = notFoundUnlessLocal(env)
if (unavailable) return unavailable
const board = (await request.json()) as FixtureBoard
let session = sessions.get(request.params.sessionId)
if (!session) {
session = new Map()
sessions.set(request.params.sessionId, session)
}
session.set(request.params.boardId, board)
return Response.json({ stored: true }, { status: 201 })
}
export function deleteEvalsFixtureSession(request: IRequest, env: Environment): Response {
const unavailable = notFoundUnlessLocal(env)
if (unavailable) return unavailable
sessions.delete(request.params.sessionId)
return Response.json({ deleted: true })
}
async function callFixtureTool(
name: string,
args: unknown,
boards: Map<string, FixtureBoard>
): Promise<ToolResult> {
try {
switch (name) {
case SEARCH_BOARDS_TOOL_NAME: {
const { terms, cursor } = parseSearchBoardsInput(args)
// The session is the whole "account": every board in it is one the caller owns. Names
// come from the snapshot, since a fixture has no `file` row to carry one, and
// insertion order stands in for creation order — so ordering and paging behave the
// way they do in production without fixtures needing timestamps.
const rows = [...boards.entries()]
.map(([id, board], index) => ({
id,
name: getDocumentNameFromSnapshot(board.snapshot) ?? '',
// A fixture session is the caller's own account, so every board arrived by being
// made here — the two timestamps are the same value, exactly as `createFile` writes
// them in production.
arrivedAt: fixtureTimestamp(index),
createdAt: fixtureTimestamp(index),
updatedAt: fixtureTimestamp(index),
workspaceName: '',
source: 'owned' as const,
}))
.filter((row) =>
terms.every((term) => row.name.toLowerCase().includes(term.toLowerCase()))
)
.sort(compareBoardSearchOrder)
.filter((row) => !cursor || isAfterBoardSearchCursor(row, cursor))
return getBoardSearchResults(rows, terms)
}
case BOARD_INFO_TOOL_NAME: {
const { boardId } = parseBoardInfoInput(args)
const board = boards.get(boardId)
return board ? getBoardInfo(board.snapshot) : toolError(BOARD_NOT_FOUND_MESSAGE)
}
case PAGE_INFO_TOOL_NAME: {
const { boardId, page } = parsePageInfoInput(args)
const board = boards.get(boardId)
if (!board) return toolError(BOARD_NOT_FOUND_MESSAGE)
const resolved = resolvePage(board.snapshot, page)
return resolved.ok
? getPageInfo(resolved, clusterPage(resolved, board.measurements))
: resolved.result
}
case CLUSTER_INFO_TOOL_NAME: {
const { boardId, page, clusterId } = parseClusterInfoInput(args)
const board = boards.get(boardId)
if (!board) return toolError(BOARD_NOT_FOUND_MESSAGE)
const resolved = resolvePage(board.snapshot, page)
return resolved.ok
? getClusterInfo(resolved, clusterPage(resolved, board.measurements), clusterId, page)
: resolved.result
}
case CLUSTER_SCREENSHOT_TOOL_NAME: {
const { boardId, page, clusterIds, theme } = parseClusterScreenshotInput(args)
const board = boards.get(boardId)
if (!board) return toolError(BOARD_NOT_FOUND_MESSAGE)
const resolved = resolvePage(board.snapshot, page)
if (!resolved.ok) return resolved.result
const picked = pickClusterShapes(
clusterPage(resolved, board.measurements),
clusterIds,
page
)
if (!picked.ok) return picked.result
const png = board.shots[screenshotFileName(clusterIds, theme)]
return png
? toolPageResult(resolved.pageName, png)
: toolError(
`${HARNESS_GAP_MARKER} No screenshot was built for this cluster set. Run against staging or production to see the real render.`
)
}
default:
return toolError(`Unknown tool: ${name}`)
}
} catch (error) {
return toolError(error instanceof Error ? error.message : String(error))
}
}
export async function evalsFixtureMcp(request: IRequest, env: Environment): Promise<Response> {
const unavailable = notFoundUnlessLocal(env)
if (unavailable) return unavailable
const boards = sessions.get(request.params.sessionId)
if (!boards) return new Response('Eval fixture session not found', { status: 404 })
let rpcRequest: Parameters<typeof handleMcpJsonRpc>[0]
try {
rpcRequest = (await request.json()) as Parameters<typeof handleMcpJsonRpc>[0]
} catch {
return Response.json(
{ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } },
{ status: 200 }
)
}
const reply = await handleMcpJsonRpc(rpcRequest, (name, args) =>
callFixtureTool(name, args, boards)
)
if (reply.kind === 'accepted') return new Response(null, { status: 202 })
return Response.json(
reply.kind === 'result'
? { jsonrpc: '2.0', id: reply.id, result: reply.result }
: { jsonrpc: '2.0', id: reply.id, error: { code: reply.code, message: reply.message } }
)
}
export function resetEvalsFixtureSessionsForTests() {
sessions.clear()
}

View File

@@ -0,0 +1,17 @@
import { notFound } from '@tldraw/worker-shared'
import { IRequest } from 'itty-router'
import { Environment } from '../../types'
import { getRoomDurableObjectId } from '../../utils/durableObjects'
import { isRoomIdTooLong, roomIdIsTooLong } from '../../utils/roomIdIsTooLong'
// Forwards a room request to the durable object associated with that room
export async function forwardRoomRequest(request: IRequest, env: Environment): Promise<Response> {
const roomId = request.params.roomId
if (!roomId) return notFound()
if (isRoomIdTooLong(roomId)) return roomIdIsTooLong()
// Set up the durable object for this room
const id = getRoomDurableObjectId(env, roomId)
return env.TLDR_DOC.get(id).fetch(request)
}

View File

@@ -0,0 +1,37 @@
import { RoomSnapshot } from '@tldraw/sync-core'
import { StatusError } from 'itty-router'
import { getR2KeyForRoom } from '../../r2'
import { Environment } from '../../types'
export async function returnFileSnapshot(env: Environment, fileSlug: string, isApp: boolean) {
const snapshot = await getFileSnapshot(env, fileSlug, isApp)
if (!snapshot) {
throw new StatusError(404, 'File not found')
}
const tldrFile = {
tldrawFileFormatVersion: 1,
schema: snapshot.schema,
records: snapshot.documents.map((doc) => doc.state),
}
return new Response(JSON.stringify(tldrFile, null, 2), {
headers: {
'Content-Type': 'application/json',
'Content-Disposition': `attachment; filename="${fileSlug}.tldr"`,
},
})
}
export async function getFileSnapshot(
env: Environment,
fileSlug: string,
isApp: boolean
): Promise<RoomSnapshot | null> {
const snapshot = await env.ROOMS.get(getR2KeyForRoom({ slug: fileSlug, isApp }))
if (!snapshot) {
return null
}
return snapshot.json() as Promise<RoomSnapshot>
}

View File

@@ -0,0 +1,47 @@
import { GetInviteInfoResponseBody } from '@tldraw/dotcom-shared'
import { IRequest } from 'itty-router'
import { createPostgresConnectionPool } from '../../postgres'
import { Environment } from '../../types'
import { getJoinableWorkspaceFromInvite } from '../../utils/tla/getJoinableWorkspaceFromInvite'
export async function getInviteInfo(request: IRequest, env: Environment): Promise<Response> {
const { token } = request.params
if (!token) {
return Response.json({ error: true, message: 'Invite token is required' }, { status: 400 })
}
const db = createPostgresConnectionPool(env, 'getInviteInfo')
try {
const workspace = await getJoinableWorkspaceFromInvite(db, token)
if (!workspace) {
return Response.json(
{
error: true,
message: 'Invalid or expired invite token',
} satisfies GetInviteInfoResponseBody,
{ status: 404 }
)
}
return Response.json({
error: false,
workspaceId: workspace.id,
workspaceName: workspace.name,
isValid: true,
inviteSecret: token,
} satisfies GetInviteInfoResponseBody)
} catch (error) {
console.error('Error getting invite info:', error)
return Response.json(
{
error: true,
message: 'Internal server error',
} satisfies GetInviteInfoResponseBody,
{ status: 500 }
)
} finally {
await db.destroy()
}
}

View File

@@ -0,0 +1,468 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { OG_REPAIR_COOLDOWN_MS } from '../../config'
import { getOgImage } from './getOgImage'
import { getPublishedFileInfo } from './getPublishedFile'
import { getSharedFileInfo } from './getSharedFile'
import { getOgImageCacheKey } from './ogImageQueue'
import {
blobsWithPrefix,
failureBlobsOf,
indexesOf,
makeFakeQueue,
makeFakeRoomsBucket,
makeFakeThumbnailsBucket,
makeScreenshotTestEnv as makeEnv,
} from './screenshotTestHelpers'
vi.mock('./getPublishedFile', () => ({
getPublishedFileInfo: vi.fn(),
}))
vi.mock('./getSharedFile', async (importOriginal) => ({
...(await importOriginal<typeof import('./getSharedFile')>()),
getSharedFileInfo: vi.fn(),
}))
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
function makeRequest(prefix: string, slug: string, method = 'GET', headers?: HeadersInit) {
return Object.assign(
new Request(`https://sync.tldraw.xyz/app/social-preview/${prefix}/${slug}/image`, {
method,
headers,
}),
{ params: { prefix, slug } }
) as any
}
describe('getOgImage', () => {
// A board with no image of its own is sent to the site-wide default rather than having the worker
// proxy those bytes: it is a static asset on the client origin, already cached at the edge.
it('redirects to the default image on a cold cache', async () => {
vi.mocked(getPublishedFileInfo).mockResolvedValue({
id: 'file-1',
published: true,
lastPublished: 1751234567890,
})
const bucket = makeFakeThumbnailsBucket()
const queue = makeFakeQueue()
const env = makeEnv({ THUMBNAILS: bucket, QUEUE: queue })
const response = await getOgImage(makeRequest('p', 'published-board'), env)
expect(response.status).toBe(302)
expect(response.headers.get('location')).toBe('https://www.tldraw.com/social-og.png')
expect(failureBlobsOf(env)).toEqual(['failure:not_rendered_yet'])
// This surface has no follow-up concept, so it records `none` rather than `false` — otherwise
// a query for triggered renders would sweep up every og datapoint too.
expect(blobsWithPrefix(env, 'followup:')).toEqual(['followup:none'])
})
// The asymmetry this repair exists for: a published snapshot is frozen and its publish effect is
// the only thing that ever asks for a render, so an ask lost to a queue failure or a stale pending
// marker would leave a generic card until somebody republished.
it('asks for a render when a published board has no image at all', async () => {
vi.mocked(getPublishedFileInfo).mockResolvedValue({
id: 'file-1',
published: true,
lastPublished: 1,
})
const queue = makeFakeQueue()
const env = makeEnv({ THUMBNAILS: makeFakeThumbnailsBucket(), QUEUE: queue })
await getOgImage(makeRequest('p', 'published-board'), env)
expect(queue.send).toHaveBeenCalledTimes(1)
expect(queue.send).toHaveBeenCalledWith({
type: 'og-image-render',
kind: 'published',
slug: 'published-board',
reason: 'crawler',
})
})
// The repair is the one render ask an unauthenticated request can cause, so once a repair job has
// failed its whole retry budget, crawler traffic must not be able to re-arm another chain until the
// cooldown lapses — otherwise a board that cannot render is a Browser Run spend lever for whoever
// fetches its URL.
it('does not re-ask while a failed repair has the board on cooldown', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
vi.mocked(getPublishedFileInfo).mockResolvedValue({
id: 'file-1',
published: true,
lastPublished: 1,
})
const bucket = makeFakeThumbnailsBucket()
await bucket.put(
getOgImageCacheKey({ kind: 'published', slug: 'failing-board' }).replace(
/\.png$/,
'.repair-cooldown'
),
new Uint8Array().buffer,
{ customMetadata: { expiresAt: String(Date.now() + OG_REPAIR_COOLDOWN_MS) } }
)
const queue = makeFakeQueue()
const env = makeEnv({ THUMBNAILS: bucket, QUEUE: queue })
await getOgImage(makeRequest('p', 'failing-board'), env)
expect(queue.send).not.toHaveBeenCalled()
// Once the cooldown lapses, the repair asks again.
vi.setSystemTime(Date.parse('2026-01-01T00:00:00Z') + OG_REPAIR_COOLDOWN_MS + 1000)
await getOgImage(makeRequest('p', 'failing-board'), env)
expect(queue.send).toHaveBeenCalledTimes(1)
})
// A shared file gets no such repair, and the reason is that it does not need one: every persist
// that advances its document clock re-asks, so a lost ask is made good by the next edit. Rendering
// from here would be work whose result nobody comes back for — unfurl platforms resolve a card once
// and reuse it for every repost.
it('does not ask for a render when a shared file has no image yet', async () => {
vi.mocked(getSharedFileInfo).mockResolvedValue({
id: 'file-1',
shared: true,
sharedLinkType: 'view',
isDeleted: false,
} as any)
const queue = makeFakeQueue()
const env = makeEnv({
THUMBNAILS: makeFakeThumbnailsBucket(),
ROOMS: makeFakeRoomsBucket('room-etag-1'),
QUEUE: queue,
})
await getOgImage(makeRequest('f', 'file-1'), env)
expect(queue.send).not.toHaveBeenCalled()
})
// The redirect sits on a board's own permanent OG image URL, so nothing between here and the
// crawler may pin it: a shared cache holding this redirect would outlive the render it stands in
// for, and keep sending crawlers to the default long after the board has a picture.
it('redirects with a short client-only TTL', async () => {
vi.mocked(getPublishedFileInfo).mockResolvedValue({
id: 'file-1',
published: true,
lastPublished: 1,
})
const env = makeEnv({ THUMBNAILS: makeFakeThumbnailsBucket(), QUEUE: makeFakeQueue() })
const response = await getOgImage(makeRequest('p', 'board'), env)
expect(response.headers.get('cache-control')).toBe('public, max-age=60')
})
it('serves an image whose version matches as a fresh hit', async () => {
vi.mocked(getPublishedFileInfo).mockResolvedValue({
id: 'file-1',
published: true,
lastPublished: 1,
})
const bucket = makeFakeThumbnailsBucket()
await bucket.put(
getOgImageCacheKey({ kind: 'published', slug: 'cached-board' }),
new Uint8Array([1, 2, 3]).buffer,
{ customMetadata: { version: '1', createdAt: String(Date.now()) } }
)
const queue = makeFakeQueue()
const env = makeEnv({ THUMBNAILS: bucket, QUEUE: queue })
const response = await getOgImage(makeRequest('p', 'cached-board'), env)
expect(response.status).toBe(200)
expect(response.headers.get('content-type')).toBe('image/png')
expect(response.headers.get('x-tldraw-og-cache')).toBe('hit')
expect(await response.arrayBuffer()).toEqual(new Uint8Array([1, 2, 3]).buffer)
expect(queue.send).not.toHaveBeenCalled()
})
// The lifetime is short and revalidation is cheap on purpose. Nothing deletes a board's image when
// it stops being public, so this route re-checking the share gate is the only thing keeping an
// unshared board's thumbnail off the internet — and a cache serving without asking is that check not
// happening. `stale-while-revalidate` is absent for the same reason: it would extend serving a day
// past expiry.
it('serves a fresh hit with a short lifetime and an etag, and no stale-while-revalidate', async () => {
vi.mocked(getPublishedFileInfo).mockResolvedValue({
id: 'file-1',
published: true,
lastPublished: 1,
})
const bucket = makeFakeThumbnailsBucket()
await bucket.put(
getOgImageCacheKey({ kind: 'published', slug: 'cached-board' }),
new Uint8Array([1, 2, 3]).buffer,
{ customMetadata: { version: '1', createdAt: String(Date.now()) } }
)
const env = makeEnv({ THUMBNAILS: bucket, QUEUE: makeFakeQueue() })
const response = await getOgImage(makeRequest('p', 'cached-board'), env)
expect(response.headers.get('cache-control')).toBe('public, max-age=300')
expect(response.headers.get('etag')).toBe('"etag-1"')
})
it('answers a conditional request whose etag still matches with a 304 and no body', async () => {
vi.mocked(getPublishedFileInfo).mockResolvedValue({
id: 'file-1',
published: true,
lastPublished: 1,
})
const bucket = makeFakeThumbnailsBucket()
await bucket.put(
getOgImageCacheKey({ kind: 'published', slug: 'cached-board' }),
new Uint8Array([1, 2, 3]).buffer,
{ customMetadata: { version: '1', createdAt: String(Date.now()) } }
)
const env = makeEnv({ THUMBNAILS: bucket, QUEUE: makeFakeQueue() })
const response = await getOgImage(
makeRequest('p', 'cached-board', 'GET', { 'if-none-match': '"etag-1"' }),
env
)
expect(response.status).toBe(304)
expect(await response.text()).toBe('')
// The headers still refresh what the caller holds, so the next revalidation is a whole lifetime
// away rather than immediate.
expect(response.headers.get('cache-control')).toBe('public, max-age=300')
expect(response.headers.get('etag')).toBe('"etag-1"')
expect(response.headers.get('x-tldraw-og-cache')).toBe('hit')
})
// The bytes are read lazily on a conditional request, so a render landing since the caller cached
// has to be noticed and served rather than answered with a 304 for content that has moved.
it('serves the new bytes when a conditional request holds a superseded etag', async () => {
vi.mocked(getPublishedFileInfo).mockResolvedValue({
id: 'file-1',
published: true,
lastPublished: 2,
})
const bucket = makeFakeThumbnailsBucket()
const key = getOgImageCacheKey({ kind: 'published', slug: 'cached-board' })
await bucket.put(key, new Uint8Array([1, 2, 3]).buffer, {
customMetadata: { version: '1', createdAt: String(Date.now()) },
})
await bucket.put(key, new Uint8Array([4, 5, 6]).buffer, {
customMetadata: { version: '2', createdAt: String(Date.now()) },
})
const env = makeEnv({ THUMBNAILS: bucket, QUEUE: makeFakeQueue() })
const response = await getOgImage(
makeRequest('p', 'cached-board', 'GET', { 'if-none-match': '"etag-1"' }),
env
)
expect(response.status).toBe(200)
expect(await response.arrayBuffer()).toEqual(new Uint8Array([4, 5, 6]).buffer)
expect(response.headers.get('etag')).toBe('"etag-2"')
expect(response.headers.get('x-tldraw-og-cache')).toBe('hit')
})
// The share gate runs before the etag is ever looked at, so holding a valid etag for a board that
// has since been unshared buys nothing. This is what the short lifetime is protecting.
it('sends a conditional request for a board that is no longer shared to the default image', async () => {
vi.mocked(getSharedFileInfo).mockResolvedValue({
id: 'file-1',
shared: false,
sharedLinkType: 'view',
isDeleted: false,
} as any)
const bucket = makeFakeThumbnailsBucket()
await bucket.put(
getOgImageCacheKey({ kind: 'shared_file', slug: 'file-1' }),
new Uint8Array([1, 2, 3]).buffer,
{ customMetadata: { version: 'room-etag-1', createdAt: String(Date.now()) } }
)
const env = makeEnv({
THUMBNAILS: bucket,
ROOMS: makeFakeRoomsBucket('room-etag-1'),
QUEUE: makeFakeQueue(),
})
const response = await getOgImage(
makeRequest('f', 'file-1', 'GET', { 'if-none-match': '"etag-1"' }),
env
)
expect(response.status).toBe(302)
expect(response.headers.get('location')).toBe('https://www.tldraw.com/social-og.png')
})
// There is no "too stale to serve". An old picture of this board beats the generic tldraw logo, so a
// version mismatch only shortens the cache lifetime — it never withholds the image or asks for a
// render, and the image's age is not consulted at all.
it('serves a version-mismatched image as stale with a short TTL, however old it is', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
vi.mocked(getPublishedFileInfo).mockResolvedValue({
id: 'file-1',
published: true,
lastPublished: 2,
})
const bucket = makeFakeThumbnailsBucket()
await bucket.put(
getOgImageCacheKey({ kind: 'published', slug: 'cached-board' }),
new Uint8Array([1, 2, 3]).buffer,
{ customMetadata: { version: '1', createdAt: String(Date.now()) } }
)
const queue = makeFakeQueue()
const env = makeEnv({ THUMBNAILS: bucket, QUEUE: queue })
// Minutes old and a year old behave identically: both serve the bytes as stale.
for (const now of ['2026-01-01T00:30:00Z', '2027-01-01T00:00:00Z']) {
vi.setSystemTime(new Date(now))
const stale = await getOgImage(makeRequest('p', 'cached-board'), env)
expect(stale.status).toBe(200)
expect(stale.headers.get('x-tldraw-og-cache')).toBe('stale')
expect(stale.headers.get('cache-control')).toBe('public, max-age=300')
expect(await stale.arrayBuffer()).toEqual(new Uint8Array([1, 2, 3]).buffer)
}
expect(queue.send).not.toHaveBeenCalled()
})
// The route's whole gate: viewable (published, or shared via link) and an image exists. A shared
// file with no image yet passes the first and fails the second, so it gets the default.
it('serves a shared file that passes its gate but has no image yet', async () => {
vi.mocked(getSharedFileInfo).mockResolvedValue({
id: 'shared-file',
shared: true,
isDeleted: false,
})
const queue = makeFakeQueue()
const env = makeEnv({
ROOMS: makeFakeRoomsBucket('etag-1'),
THUMBNAILS: makeFakeThumbnailsBucket(),
QUEUE: queue,
})
const response = await getOgImage(makeRequest('f', 'shared-file'), env)
expect(response.status).toBe(302)
expect(queue.send).not.toHaveBeenCalled()
})
// An unshared board's thumbnail stays in R2 for owner-facing surfaces behind authz, so the gate is
// the only thing keeping it off the public internet. It has to be checked on every request rather
// than relied on having deleted the object at unshare time.
it('refuses a board that has an image but no longer passes its gate', async () => {
vi.mocked(getSharedFileInfo).mockResolvedValue({
id: 'unshared-file',
shared: false,
isDeleted: false,
})
const bucket = makeFakeThumbnailsBucket()
await bucket.put(
getOgImageCacheKey({ kind: 'shared_file', slug: 'unshared-file' }),
new Uint8Array([1, 2, 3]).buffer,
{ customMetadata: { version: 'etag-1', createdAt: String(Date.now()) } }
)
const env = makeEnv({ ROOMS: makeFakeRoomsBucket('etag-1'), THUMBNAILS: bucket })
const response = await getOgImage(makeRequest('f', 'unshared-file'), env)
expect(response.status).toBe(302)
expect(response.headers.get('location')).toBe('https://www.tldraw.com/social-og.png')
// Still there, just unreachable from here.
expect(
bucket.store.has(getOgImageCacheKey({ kind: 'shared_file', slug: 'unshared-file' }))
).toBe(true)
})
it('answers HEAD probes with the same cache headers as a GET, but no body', async () => {
vi.mocked(getPublishedFileInfo).mockResolvedValue({
id: 'file-1',
published: true,
lastPublished: 1,
})
const bucket = makeFakeThumbnailsBucket()
await bucket.put(
getOgImageCacheKey({ kind: 'published', slug: 'cached-board' }),
new Uint8Array([1, 2, 3]).buffer,
{ customMetadata: { version: '1', createdAt: String(Date.now()) } }
)
const queue = makeFakeQueue()
const env = makeEnv({ THUMBNAILS: bucket, QUEUE: queue })
const response = await getOgImage(makeRequest('p', 'cached-board', 'HEAD'), env)
// Same headers a GET would return, so crawlers see the cache status...
expect(response.status).toBe(200)
expect(response.headers.get('content-type')).toBe('image/png')
expect(response.headers.get('x-tldraw-og-cache')).toBe('hit')
// ...but the R2 body is never read.
expect((await response.arrayBuffer()).byteLength).toBe(0)
expect(queue.send).not.toHaveBeenCalled()
})
it('answers a HEAD probe on a cold cache with the same redirect a GET would get', async () => {
vi.mocked(getPublishedFileInfo).mockResolvedValue({
id: 'file-1',
published: true,
lastPublished: 1751234567890,
})
const queue = makeFakeQueue()
const env = makeEnv({ THUMBNAILS: makeFakeThumbnailsBucket(), QUEUE: queue })
const response = await getOgImage(makeRequest('p', 'published-board', 'HEAD'), env)
expect(response.status).toBe(302)
expect(response.headers.get('location')).toBe('https://www.tldraw.com/social-og.png')
// Including the repair ask: some crawlers only ever probe with HEAD, and a published board with
// no image has nothing else that will ask for one.
expect(queue.send).toHaveBeenCalledTimes(1)
})
it('sends private or unknown boards to the default tldraw OG image', async () => {
vi.mocked(getSharedFileInfo).mockResolvedValue({
id: 'private-file',
shared: false,
isDeleted: false,
})
const queue = makeFakeQueue()
const response = await getOgImage(
makeRequest('f', 'private-file'),
makeEnv({ ROOMS: makeFakeRoomsBucket(), QUEUE: queue })
)
expect(response.status).toBe(302)
expect(response.headers.get('location')).toBe('https://www.tldraw.com/social-og.png')
expect(queue.send).not.toHaveBeenCalled()
})
// These datapoints carry no board identity at all — no index, no slug, no hash, no derived id.
// The dataset answers aggregate spend and failure questions, and a board that resolves must be
// indistinguishable from one that doesn't, or the dimension is back by another name.
it('writes no board identity on any datapoint, resolved or not', async () => {
vi.mocked(getPublishedFileInfo).mockResolvedValue({
id: 'file-1',
published: true,
lastPublished: 1,
})
vi.mocked(getSharedFileInfo).mockResolvedValue({
id: 'private-file',
shared: false,
isDeleted: false,
})
const env = makeEnv({
ROOMS: makeFakeRoomsBucket('etag-1'),
THUMBNAILS: makeFakeThumbnailsBucket(),
QUEUE: makeFakeQueue(),
})
// A board that resolves, and one that fails its gate (which returns before writing anything).
await getOgImage(makeRequest('p', 'published-slug'), env)
await getOgImage(makeRequest('f', 'private-file'), env)
expect(indexesOf(env)).toEqual([undefined])
// Nothing board-shaped anywhere in the blobs either.
const written = JSON.stringify((env.MEASURE as any).writeDataPoint.mock.calls)
expect(written).not.toContain('published-slug')
expect(written).not.toContain('file-1')
expect(written).not.toContain('private-file')
})
})

View File

@@ -0,0 +1,236 @@
import { IRequest } from 'itty-router'
import {
OG_FALLBACK_MAX_AGE_SECONDS,
OG_FRESH_IMAGE_MAX_AGE_SECONDS,
OG_STALE_IMAGE_MAX_AGE_SECONDS,
} from '../../config'
import { Environment, ThumbnailBoardKind } from '../../types'
import { getPublicOrigin } from '../../utils/getPublicOrigin'
import { enqueueOgImageRender, getOgImageCacheKey, isOgImageRepairOnCooldown } from './ogImageQueue'
import {
ResolvedThumbnailBoard,
resolveThumbnailBoard,
writeScreenshotTelemetry,
} from './thumbnailRender'
import { reportThumbnailError } from './thumbnailShared'
// Almost a pure read. Two questions and nothing else: is this board publicly viewable (published, or
// shared via link), and does a thumbnail for it exist? Both yes, serve it. Anything else, redirect to
// the site-wide default. No rendering inline, no rate limiting, no waiting.
//
// Making the thumbnail exist before the board is ever shared is the job of the publish and edit
// triggers (the outbox publish effect, TLFileDurableObject), not of the request that finds it missing:
// unfurl platforms cache a URL's card on first resolve, so a render triggered from here lands after
// the crawler has already taken the default away. Rendering inline is worse still — captures run
// 4-17s, past any crawler's patience. See browser-run-thumbnails.md.
//
// The one exception is `repairMissingPublishedImage` below, which asks for a render when a *published*
// board has no image at all — the single case where no other trigger will ever ask again.
const DEFAULT_OG_IMAGE_PATH = '/social-og.png'
export async function getOgImage(
request: IRequest,
env: Environment,
ctx?: ExecutionContext
): Promise<Response> {
// Crawlers probe this URL with HEAD before (or instead of) GET, so the route is registered with
// .all and HEAD must still return the same cache headers a GET would. Only a real GET reads the
// R2 body; any other method is treated as a probe and answers headers only.
const wantsBody = request.method === 'GET'
const imageUrl = `${getPublicOrigin(request, env)}${DEFAULT_OG_IMAGE_PATH}`
const board = await resolveOgBoard(request, env).catch((error) => {
// Resolution reads Postgres and R2, so a throw here is infrastructure failing, not a board
// that isn't public — but both produce the same default-image redirect. Report it, or an
// outage is indistinguishable from a quiet day of unpublished boards.
reportThumbnailError(error, {
ctx,
env,
request,
surface: 'og_route',
// The route prefix only. No board identifier reaches an error report: for a link-shared file
// the slug is the file id, which is the capability to view the board.
extras: { prefix: request.params.prefix },
})
return null
})
if (!board) return redirectToDefaultOgImage(imageUrl)
const cacheKey = getOgImageCacheKey(board)
// A conditional request needs metadata only: if the etag still matches, the answer is a 304 and the
// bytes are never read. A HEAD probe needs metadata only for the same reason.
const ifNoneMatch = request.headers.get('if-none-match')
let cached =
wantsBody && !ifNoneMatch
? await env.THUMBNAILS?.get(cacheKey)
: await env.THUMBNAILS?.head(cacheKey)
if (cached) {
// The caller already holds these bytes. This is the path the short max-age is designed to make
// common: a cache revalidates for the price of a 304, and every revalidation re-runs the share
// gate above, so an unshared board stops being served within minutes rather than within a day.
if (ifNoneMatch && etagMatches(ifNoneMatch, cached.etag)) {
writeScreenshotTelemetry(env, { source: 'og', cacheStatus: cacheStatusOf(cached, board) })
return notModifiedResponse(cacheParamsOf(cached, board))
}
// A conditional GET whose etag no longer matches: the board has re-rendered since it was cached,
// so the bytes are needed after all. Every header below is derived from this second read, so a
// render landing between the two cannot make them describe bytes we did not send.
if (wantsBody && !('body' in cached)) {
cached = await env.THUMBNAILS?.get(cacheKey)
if (!cached) return redirectToDefaultOgImage(imageUrl)
}
writeScreenshotTelemetry(env, { source: 'og', cacheStatus: cacheStatusOf(cached, board) })
return imageResponse(
wantsBody ? await (cached as R2ObjectBody).arrayBuffer() : null,
cacheParamsOf(cached, board)
)
}
// Never rendered, so the board has nothing of its own to show and the request is sent to the
// site-wide default instead. The short max-age is what lets the real image take over as soon as a
// publish or an edit lands it.
writeScreenshotTelemetry(env, {
source: 'og',
cacheStatus: 'miss',
failureReason: 'not_rendered_yet',
})
await repairMissingPublishedImage(board, env, ctx)
return redirectToDefaultOgImage(imageUrl)
}
async function resolveOgBoard(
request: IRequest,
env: Environment
): Promise<ResolvedThumbnailBoard | null> {
const kind = parseOgKind(request.params.prefix)
const slug = parseSlug(request.params.slug)
if (!kind || !slug) return null
// 'public' is load-bearing here: since a thumbnail exists for every board and is never deleted, this
// gate is the only thing keeping a private board's off the public internet.
const resolved = await resolveThumbnailBoard(env, kind, slug, { access: 'public' })
return resolved.ok ? resolved.board : null
}
function parseOgKind(value: unknown): ThumbnailBoardKind | null {
if (value === 'p' || value === 'published') return 'published'
if (value === 'f' || value === 'shared_file') return 'shared_file'
return null
}
function parseSlug(value: unknown) {
return typeof value === 'string' && value.length > 0 && !value.includes('/') ? value : null
}
/**
* The one case where this route asks for a render, and it is a repair rather than a refresh.
*
* The two kinds are not symmetric in how many triggers they have. A shared file re-asks on every
* persist that advances its document clock, so an ask lost to a queue failure or a stale pending
* marker is made good by the next edit. A published board has exactly one trigger — the publish
* effect in `publishSnapshots.ts` — and its snapshot is frozen, so nothing ever edits it into
* asking again. One lost ask there means a generic card until somebody republishes.
*
* So this fires only for `published`, and only on a total miss: no image at all, not a stale one.
* The pending marker dedupes it while a job is alive, and the repair cooldown bounds it after one
* dies: this is the only render ask an unauthenticated request can cause, so once a crawler-triggered
* job has burnt its whole retry budget, a board that cannot render costs one retry chain per
* OG_REPAIR_COOLDOWN_MS rather than one per marker-clear — traffic an outside caller controls must
* not be able to re-arm it faster than that.
*
* This does not undo the reasoning that removed the general on-miss enqueue. That enqueue was
* pointless because the crawler which triggered it has already cached the default by the time the
* render lands, and unfurl platforms resolve a card once — both still true. The point here is not to
* serve *this* request; it is that without it, nothing else will ever ask.
*/
async function repairMissingPublishedImage(
board: ResolvedThumbnailBoard,
env: Environment,
ctx: ExecutionContext | undefined
) {
if (board.kind !== 'published') return
if (await isOgImageRepairOnCooldown(env, board)) return
const enqueued = enqueueOgImageRender(env, board, { reason: 'crawler' }).catch((error) => {
reportThumbnailError(error, {
ctx,
env,
surface: 'og_route',
extras: { kind: board.kind, repair: true },
})
})
// Off the response path in production; awaited only when there is no execution context to hand it
// to, which is tests.
if (ctx) ctx.waitUntil(enqueued)
else await enqueued
}
// Whether the image still depicts the board's current content decides the cache lifetime and nothing
// else — both are served. There is no "too stale to serve": an old picture of this board beats the
// generic tldraw logo, so a mismatch only asks callers back sooner.
function cacheStatusOf(cached: R2Object, board: ResolvedThumbnailBoard) {
return cached.customMetadata?.version === String(board.version) ? 'hit' : 'stale'
}
function cacheParamsOf(cached: R2Object, board: ResolvedThumbnailBoard): CacheParams {
const cacheStatus = cacheStatusOf(cached, board)
return {
cacheStatus,
maxAgeSeconds:
cacheStatus === 'hit' ? OG_FRESH_IMAGE_MAX_AGE_SECONDS : OG_STALE_IMAGE_MAX_AGE_SECONDS,
etag: cached.httpEtag,
version: cached.customMetadata?.version,
}
}
interface CacheParams {
cacheStatus: 'hit' | 'stale'
maxAgeSeconds: number
etag?: string
version?: string
}
// `if-none-match` is a list, each entry optionally weak-prefixed and quoted; R2's `etag` is the bare
// value, so both sides are normalised before comparing.
function etagMatches(ifNoneMatch: string, etag: string) {
return ifNoneMatch
.split(',')
.map((candidate) => candidate.trim().replace(/^W\//, '').replace(/^"|"$/g, ''))
.some((candidate) => candidate === '*' || candidate === etag)
}
function cacheHeaders({ cacheStatus, maxAgeSeconds, etag, version }: CacheParams) {
return {
// No `stale-while-revalidate`: it would let a cache keep serving for a day past expiry, which is
// a day of serving without the share gate — the same reason the default-image redirect carries
// none. See OG_FRESH_IMAGE_MAX_AGE_SECONDS in config.ts.
'cache-control': `public, max-age=${maxAgeSeconds}`,
'x-tldraw-og-cache': cacheStatus,
...(etag ? { etag } : null),
...(version ? { 'x-tldraw-og-version': version } : null),
}
}
function imageResponse(body: ArrayBuffer | null, params: CacheParams) {
return new Response(body, {
headers: { 'content-type': 'image/png', ...cacheHeaders(params) },
})
}
// A 304 carries no body, and its headers refresh what the caller already holds.
function notModifiedResponse(params: CacheParams) {
return new Response(null, { status: 304, headers: cacheHeaders(params) })
}
// Sends a request with no board image of its own to the site-wide default. The worker never serves
// those bytes itself: the default is a static asset on the client origin, already cached at the edge,
// and proxying it would put worker egress in front of every unfurl of an unrendered board.
function redirectToDefaultOgImage(imageUrl: string) {
return new Response(null, {
status: 302,
headers: {
location: imageUrl,
'cache-control': `public, max-age=${OG_FALLBACK_MAX_AGE_SECONDS}`,
},
})
}

View File

@@ -0,0 +1,78 @@
import { RoomSnapshot } from '@tldraw/sync-core'
import { IRequest } from 'itty-router'
import { createPostgresConnectionPool } from '../../postgres'
import { getR2KeyForRoom } from '../../r2'
import { Environment } from '../../types'
export interface PublishedFileInfo {
id: string
published: boolean
lastPublished: number
}
// Look up the file behind a published slug without loading the room snapshot itself. Returns null
// when the slug is unknown; callers decide how to treat unpublished files.
export async function getPublishedFileInfo(
env: Environment,
publishedSlug: string
): Promise<PublishedFileInfo | null> {
const parentSlug = await env.SNAPSHOT_SLUG_TO_PARENT_SLUG.get(publishedSlug)
if (!parentSlug) return null
// createPostgresConnectionPool news up a pg.Pool; destroy it so idle pools don't pile up in the
// isolate across MCP resolves, OG image requests, and queue re-resolves.
const db = createPostgresConnectionPool(env, 'getPublishedFileInfo')
try {
const file = await db
.selectFrom('file')
.select(['id', 'published', 'lastPublished'])
.where('id', '=', parentSlug)
.executeTakeFirst()
return file ?? null
} finally {
await db.destroy()
}
}
export async function getPublishedRoomSnapshot(
env: Environment,
roomId: string
): Promise<RoomSnapshot | undefined> {
// Re-resolve the published slug on every read so the published gate holds at serve time, not
// just when the board was first resolved. A board un-published between resolution and this
// read must stop resolving even though its R2 snapshot lingers until the outbox's unpublish
// effect deletes it (the DB `published` flag flips immediately; R2 cleanup lags behind it).
// Undefined (not a throw) so unknown/unpublished slugs surface as a 404, not a 500.
const file = await getPublishedFileInfo(env, roomId)
if (!file) return undefined
if (!file.published) return undefined
return (await env.ROOM_SNAPSHOTS.get(
getR2KeyForRoom({ slug: `${file.id}/${roomId}`, isApp: true })
).then((r) => r?.json())) as RoomSnapshot | undefined
}
// Get a published file from a file's publishedSlug, if there is one.
export async function getPublishedFile(request: IRequest, env: Environment): Promise<Response> {
const { roomId } = request.params
if (!roomId) {
return Response.json({ error: true, message: 'Room ID is required' }, { status: 400 })
}
const publishedRoomSnapshot = await getPublishedRoomSnapshot(env, roomId)
if (!publishedRoomSnapshot)
return Response.json({ error: true, message: 'Room not found' }, { status: 404 })
const { documents, schema } = publishedRoomSnapshot
return new Response(
JSON.stringify({
records: documents.map((d) => d.state),
schema: schema,
error: false,
}),
{
headers: { 'content-type': 'application/json' },
}
)
}

View File

@@ -0,0 +1,140 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
SharedFileInfo,
getSharedFileRoomSnapshot,
isFileAnonymouslyViewable,
isFileRenderable,
} from './getSharedFile'
function makeFile(overrides: Partial<SharedFileInfo> = {}): SharedFileInfo {
return { id: 'file-abc', shared: true, isDeleted: false, ...overrides }
}
// The two gates, and the one difference between them: sharing. Rendering happens for every board so
// an owner-facing surface has a thumbnail; serving to an anonymous caller does not.
describe('isFileRenderable', () => {
it('allows a private file — privacy gates serving, not rendering', () => {
expect(isFileRenderable(makeFile({ shared: false }))).toBe(true)
expect(isFileAnonymouslyViewable(makeFile({ shared: false }))).toBe(false)
})
it('refuses a missing file', () => {
expect(isFileRenderable(null)).toBe(false)
})
// A deleted board has nothing worth depicting, shared or not.
it('refuses a deleted file', () => {
expect(isFileRenderable(makeFile({ isDeleted: true }))).toBe(false)
expect(isFileRenderable(makeFile({ isDeleted: true, shared: false }))).toBe(false)
})
// Reading a test file needs admin auth, so it has no business being pulled through the render page
// even though nothing would serve the result anonymously.
it('refuses a test file', () => {
expect(isFileRenderable(makeFile({ id: 'test_abc' }))).toBe(false)
})
})
describe('isFileAnonymouslyViewable', () => {
it('allows a shared, non-deleted, non-test file', () => {
expect(isFileAnonymouslyViewable(makeFile())).toBe(true)
})
it('refuses a missing file', () => {
expect(isFileAnonymouslyViewable(null)).toBe(false)
})
it('refuses a private (unshared) file', () => {
expect(isFileAnonymouslyViewable(makeFile({ shared: false }))).toBe(false)
})
it('refuses a deleted file even if still shared', () => {
expect(isFileAnonymouslyViewable(makeFile({ isDeleted: true }))).toBe(false)
})
it('refuses a test file, which needs admin auth the anonymous tool never has', () => {
expect(isFileAnonymouslyViewable(makeFile({ id: 'test_abc' }))).toBe(false)
})
})
// The `file` option on getSharedFileRoomSnapshot. It decides whether the gate runs against a freshly
// read row or one the caller already holds — never whether the gate runs at all. The Postgres pool is
// faked rather than the reader mocked, so these exercise the real query path and can tell a skipped
// round trip from a skipped check.
const pg = vi.hoisted(() => {
const executeTakeFirst = vi.fn()
const db: any = {
selectFrom: () => db,
select: () => db,
where: () => db,
executeTakeFirst,
destroy: vi.fn(),
}
return { db, executeTakeFirst, createPostgresConnectionPool: vi.fn(() => db) }
})
vi.mock('../../postgres', () => ({
createPostgresConnectionPool: pg.createPostgresConnectionPool,
}))
describe('getSharedFileRoomSnapshot', () => {
const snapshot = { documents: [], schema: { schemaVersion: 2, sequences: {} } }
function makeEnv() {
return { ROOMS: { get: vi.fn().mockResolvedValue({ json: async () => snapshot }) } } as any
}
afterEach(() => {
vi.clearAllMocks()
})
it('reads the file row when none is supplied', async () => {
pg.executeTakeFirst.mockResolvedValue(makeFile())
await expect(
getSharedFileRoomSnapshot(makeEnv(), 'file-abc', { access: 'public' })
).resolves.toEqual(snapshot)
expect(pg.createPostgresConnectionPool).toHaveBeenCalledTimes(1)
})
it('reuses a supplied row instead of asking Postgres again', async () => {
await expect(
getSharedFileRoomSnapshot(makeEnv(), 'file-abc', { access: 'public', file: makeFile() })
).resolves.toEqual(snapshot)
expect(pg.createPostgresConnectionPool).not.toHaveBeenCalled()
})
// The point of the option is to skip the round trip, not the check. A row that fails the gate is
// refused exactly as a freshly-read one would be, and the snapshot is never read.
it('still applies the gate to a supplied row', async () => {
const env = makeEnv()
await expect(
getSharedFileRoomSnapshot(env, 'file-abc', {
access: 'public',
file: makeFile({ shared: false }),
})
).rejects.toThrow('not shared')
await expect(
getSharedFileRoomSnapshot(env, 'file-abc', {
access: 'render',
file: makeFile({ isDeleted: true }),
})
).rejects.toThrow('not renderable')
expect(pg.createPostgresConnectionPool).not.toHaveBeenCalled()
expect(env.ROOMS.get).not.toHaveBeenCalled()
})
// A supplied row is gated under the *caller's* access level, not whichever one it was resolved
// under, so handing a render-gated row to a public read cannot widen what that read may see.
it('refuses a private row supplied to a public read', async () => {
await expect(
getSharedFileRoomSnapshot(makeEnv(), 'file-abc', {
access: 'public',
file: makeFile({ shared: false }),
})
).rejects.toThrow('not shared')
})
})

View File

@@ -0,0 +1,96 @@
import { RoomSnapshot } from '@tldraw/sync-core'
import { createPostgresConnectionPool } from '../../postgres'
import { getR2KeyForRoom } from '../../r2'
import { Environment, ThumbnailBoardAccess } from '../../types'
import { isTestFile } from '../../utils/tla/isTestFile'
export interface SharedFileInfo {
id: string
shared: boolean
isDeleted: boolean
}
// Look up an app file directly by its id (the `:slug` in tldraw.com/f/:slug) without loading the
// room snapshot. Returns null when the file is unknown.
export async function getSharedFileInfo(
env: Environment,
slug: string
): Promise<SharedFileInfo | null> {
// createPostgresConnectionPool news up a pg.Pool; destroy it so idle pools don't pile up in the
// isolate across MCP resolves, OG image requests, and queue re-resolves.
const db = createPostgresConnectionPool(env, 'getSharedFileInfo')
try {
const file = await db
.selectFrom('file')
.select(['id', 'shared', 'isDeleted'])
.where('id', '=', slug)
.executeTakeFirst()
return file ?? null
} finally {
await db.destroy()
}
}
/**
* Whether a file is worth rendering a thumbnail of at all: it exists, is not deleted, and is not a test
* file (reading one requires admin auth, so it has no business going through the render page). Says
* nothing about who may *see* that thumbnail — every board gets one, private ones included, and serving
* is gated separately.
*/
export function isFileRenderable(file: SharedFileInfo | null): file is SharedFileInfo {
return !!file && !file.isDeleted && !isTestFile(file.id)
}
/**
* Whether a file may be served to an anonymous caller — the MCP tool, the OG image route, the
* crawler HTML. Renderable, plus actually shared via link. This mirrors the anonymous read gate
* enforced by the file room itself (`TLFileDurableObject.onRequest`). `sharedLinkType` (`view` vs
* `edit`) is irrelevant to viewing.
*/
export function isFileAnonymouslyViewable(file: SharedFileInfo | null): file is SharedFileInfo {
return isFileRenderable(file) && file.shared === true
}
/**
* Applies the gate an access level asks for. The mapping lives here, once, so that resolving a board
* and reading its snapshot cannot end up applying different gates to the same access level — which
* would mean a board resolving publicly and then being read under the weaker one.
*/
export function isFileViewableFor(
file: SharedFileInfo | null,
access: ThumbnailBoardAccess
): file is SharedFileInfo {
return access === 'public' ? isFileAnonymouslyViewable(file) : isFileRenderable(file)
}
/**
* Read the live room snapshot for an app file from R2.
*
* The gate always runs here — never trust that whatever resolved this board already applied it. What
* `file` decides is only whether the row it runs against is re-read or reused:
*
* - **Omitted (the default): re-read.** The row is fetched fresh, so a board un-shared since the
* caller last looked stops resolving. This is what a read separated from its resolve by a network
* hop needs — `getThumbnailSnapshot` serves a *private* board's document to the render page inside
* a token's window, and the un-share must land inside that window.
* - **Supplied: reuse.** For a caller that resolved this board microseconds ago in the same function,
* a second query returns the same row and buys nothing but a Postgres connection. The gate is still
* applied to the row that was passed, so the check is not skipped — only the round trip is.
*
* Pass it only where the resolve is genuinely adjacent to the read. Anywhere else, the freshness is
* the point.
*/
export async function getSharedFileRoomSnapshot(
env: Environment,
slug: string,
{ access, file: resolvedFile }: { access: ThumbnailBoardAccess; file?: SharedFileInfo }
): Promise<RoomSnapshot | undefined> {
const file = resolvedFile ?? (await getSharedFileInfo(env, slug))
if (!isFileViewableFor(file, access)) {
throw Error(access === 'public' ? 'not shared' : 'not renderable')
}
return (await env.ROOMS.get(getR2KeyForRoom({ slug, isApp: true })).then((r) => r?.json())) as
| RoomSnapshot
| undefined
}

View File

@@ -0,0 +1,332 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { THUMBNAIL_RENDER_TOKEN_TTL_MS } from '../../config'
import { Environment } from '../../types'
import {
ThumbnailRenderJob,
mintThumbnailRenderToken,
recordMintedRenderToken,
isMintedRenderToken,
wasRenderTokenServedSince,
} from '../../utils/renderTokens'
import { getPublishedRoomSnapshot } from './getPublishedFile'
import { getSharedFileRoomSnapshot } from './getSharedFile'
import { getThumbnailSnapshot } from './getThumbnailSnapshot'
import { makeFakeThumbnailsBucket } from './screenshotTestHelpers'
vi.mock('./getPublishedFile', () => ({
getPublishedRoomSnapshot: vi.fn(),
}))
vi.mock('./getSharedFile', () => ({
getSharedFileRoomSnapshot: vi.fn(),
}))
afterEach(() => {
vi.clearAllMocks()
})
const env = { MCP_SCREENSHOT_TOKEN_SECRET: 'test-secret' } as Environment
function makeJob(overrides: Partial<ThumbnailRenderJob> = {}): ThumbnailRenderJob {
return {
v: 1,
kind: 'published',
slug: 'my-board',
version: 1751234567890,
access: 'render',
camera: 'content',
x: 0,
y: 0,
z: 1,
width: 1200,
height: 630,
theme: 'dark',
exp: Date.now() + THUMBNAIL_RENDER_TOKEN_TTL_MS,
...overrides,
}
}
function makeRequest(token: string | null) {
const url = new URL('https://sync.tldraw.xyz/app/thumbnail-render/snapshot')
if (token !== null) url.searchParams.set('token', token)
return new Request(url) as any
}
describe('getThumbnailSnapshot', () => {
it('returns snapshot data and render params for a valid token', async () => {
vi.mocked(getPublishedRoomSnapshot).mockResolvedValue({
documents: [{ state: { id: 'shape:1', typeName: 'shape' }, lastChangedClock: 0 }],
schema: { schemaVersion: 2, sequences: {} },
clock: 0,
} as any)
const response = await getThumbnailSnapshot(makeRequest(await mintToken()), env)
expect(response.status).toBe(200)
const body = (await response.json()) as any
expect(body).toEqual({
error: false,
records: [{ id: 'shape:1', typeName: 'shape' }],
schema: { schemaVersion: 2, sequences: {} },
renderParams: {
camera: 'content',
// Echoed even though `content` makes the render page ignore them, so a job that omits
// `camera` has a viewport to fall back on without a second shape of response.
x: 0,
y: 0,
z: 1,
width: 1200,
height: 630,
theme: 'dark',
},
})
expect(vi.mocked(getPublishedRoomSnapshot)).toHaveBeenCalledWith(env, 'my-board')
})
it('passes the target pageId through to the render params', async () => {
vi.mocked(getPublishedRoomSnapshot).mockResolvedValue({
documents: [
{
state: { id: 'page:abc', typeName: 'page', name: 'A', index: 'a1' },
lastChangedClock: 0,
},
{ state: { id: 'shape:1', typeName: 'shape', parentId: 'page:abc' }, lastChangedClock: 0 },
],
schema: { schemaVersion: 2, sequences: {} },
clock: 0,
} as any)
const response = await getThumbnailSnapshot(
makeRequest(await mintToken({ camera: 'content', pageId: 'page:abc' })),
env
)
expect(response.status).toBe(200)
const body = (await response.json()) as any
expect(body.renderParams).toMatchObject({ camera: 'content', pageId: 'page:abc' })
})
it('returns 404 when the token targets a page that no longer exists in the snapshot', async () => {
// A shared file's live snapshot can lose the targeted page to a concurrent edit between the
// token being minted and the render reloading the snapshot. Rendering a different page would
// return a PNG mislabeled with the original page name, so the endpoint fails instead.
vi.mocked(getSharedFileRoomSnapshot).mockResolvedValue({
documents: [
{
state: { id: 'page:still-here', typeName: 'page', name: 'A', index: 'a1' },
lastChangedClock: 0,
},
],
schema: { schemaVersion: 2, sequences: {} },
clock: 0,
} as any)
const response = await getThumbnailSnapshot(
makeRequest(
await mintToken({ kind: 'shared_file', slug: 'file-abc', pageId: 'page:deleted' })
),
env
)
expect(response.status).toBe(404)
})
it('rejects requests without a token', async () => {
const response = await getThumbnailSnapshot(makeRequest(null), env)
expect(response.status).toBe(400)
})
it('rejects invalid and expired tokens', async () => {
const invalid = await getThumbnailSnapshot(makeRequest('bogus.token'), env)
expect(invalid.status).toBe(403)
const expired = await getThumbnailSnapshot(
makeRequest(await mintToken({ exp: Date.now() - 1 })),
env
)
expect(expired.status).toBe(403)
expect(vi.mocked(getPublishedRoomSnapshot)).not.toHaveBeenCalled()
})
it('returns 404 when the board is no longer published', async () => {
vi.mocked(getPublishedRoomSnapshot).mockRejectedValue(Error('not published'))
const response = await getThumbnailSnapshot(makeRequest(await mintToken()), env)
expect(response.status).toBe(404)
})
// Every failure answers the same 404 (the render page turns it into an error state, and the
// capture surfaces it as a generic render failure), so the report is the only thing that says
// what actually broke.
it('reports the underlying cause behind the 404', async () => {
// No ctx is passed, so reportThumbnailError logs instead of reaching Sentry.
const logged = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(getPublishedRoomSnapshot).mockRejectedValue(new Error('connection terminated'))
expect((await getThumbnailSnapshot(makeRequest(await mintToken()), env)).status).toBe(404)
expect(logged).toHaveBeenCalledTimes(1)
logged.mockRestore()
})
it('returns 404 rather than throwing on a partial snapshot with no documents', async () => {
// A corrupt R2 payload can have schema metadata but no documents array.
vi.mocked(getPublishedRoomSnapshot).mockResolvedValue({
schema: { schemaVersion: 2, sequences: {} },
clock: 0,
} as any)
const response = await getThumbnailSnapshot(makeRequest(await mintToken()), env)
expect(response.status).toBe(404)
})
it('resolves shared-file tokens through the shared-file snapshot source', async () => {
vi.mocked(getSharedFileRoomSnapshot).mockResolvedValue({
documents: [{ state: { id: 'shape:1', typeName: 'shape' }, lastChangedClock: 0 }],
schema: { schemaVersion: 2, sequences: {} },
clock: 0,
} as any)
const response = await getThumbnailSnapshot(
makeRequest(await mintToken({ kind: 'shared_file', slug: 'file-abc', version: 'etag-1' })),
env
)
expect(response.status).toBe(200)
expect(vi.mocked(getSharedFileRoomSnapshot)).toHaveBeenCalledWith(env, 'file-abc', {
access: 'render',
})
expect(vi.mocked(getPublishedRoomSnapshot)).not.toHaveBeenCalled()
})
// The gate comes from the signed job, not from this route. An MCP token is minted `public`, so it
// reads under the anonymous gate and stays confined to what the MCP tool could resolve — a board
// that went private after minting is refused, where a thumbnail render's `render` token is not.
it('reads under the access level the token was minted with', async () => {
vi.mocked(getSharedFileRoomSnapshot).mockResolvedValue({
documents: [{ state: { id: 'shape:1', typeName: 'shape' }, lastChangedClock: 0 }],
schema: { schemaVersion: 2, sequences: {} },
clock: 0,
} as any)
await getThumbnailSnapshot(
makeRequest(
await mintToken({
kind: 'shared_file',
slug: 'file-abc',
version: 'etag-1',
access: 'public',
})
),
env
)
expect(vi.mocked(getSharedFileRoomSnapshot)).toHaveBeenCalledWith(env, 'file-abc', {
access: 'public',
})
})
// Deleted, not un-shared: this route reads with `access: 'render'`, so a private board resolves and
// its content is served to the render page. What refuses is a board that is deleted or unknown,
// which `isFileRenderable` rejects.
it('returns 404 when the board is deleted during the token window', async () => {
vi.mocked(getSharedFileRoomSnapshot).mockRejectedValue(Error('not renderable'))
const response = await getThumbnailSnapshot(
makeRequest(await mintToken({ kind: 'shared_file', slug: 'file-abc', version: 'etag-1' })),
env
)
expect(response.status).toBe(404)
})
})
// The route is the place this actually protects something: it serves any board's full document now
// that thumbnails render for every board, so a valid signature must not be sufficient on its own.
describe('getThumbnailSnapshot render token records', () => {
function makeEnvWithBucket() {
return {
MCP_SCREENSHOT_TOKEN_SECRET: 'test-secret',
THUMBNAILS: makeFakeThumbnailsBucket(),
} as unknown as Environment
}
function snapshotOfOneShape() {
return {
documents: [{ state: { id: 'shape:1', typeName: 'shape' }, lastChangedClock: 0 }],
schema: { schemaVersion: 2, sequences: {} },
clock: 0,
} as any
}
it('serves a token that was recorded at mint time', async () => {
vi.mocked(getPublishedRoomSnapshot).mockResolvedValue(snapshotOfOneShape())
const envWithBucket = makeEnvWithBucket()
const job = makeJob()
const token = await mintThumbnailRenderToken(envWithBucket, job)
await recordMintedRenderToken(envWithBucket, job, token)
const response = await getThumbnailSnapshot(makeRequest(token), envWithBucket)
expect(response.status).toBe(200)
})
// What a dead session reads back to tell a page that ran from one that never did.
it('stamps the token record as served once the page fetches its snapshot', async () => {
vi.mocked(getPublishedRoomSnapshot).mockResolvedValue(snapshotOfOneShape())
const envWithBucket = makeEnvWithBucket()
const job = makeJob()
const token = await mintThumbnailRenderToken(envWithBucket, job)
await recordMintedRenderToken(envWithBucket, job, token)
const sessionStart = Date.now()
expect(await wasRenderTokenServedSince(envWithBucket, job, token, sessionStart)).toBe(
'unreached'
)
const response = await getThumbnailSnapshot(makeRequest(token), envWithBucket)
expect(response.status).toBe(200)
expect(await wasRenderTokenServedSince(envWithBucket, job, token, sessionStart)).toBe('reached')
// Stamping rewrites the record; the mint check must survive it.
expect(await isMintedRenderToken(envWithBucket, job, token)).toBe(true)
})
// The case a leaked MCP_SCREENSHOT_TOKEN_SECRET produces: signatures that verify, for any board,
// minted by someone who cannot write to our bucket. Refused, and with the same 403 a bad signature
// gets — which check failed is not the caller's business.
it('refuses a validly signed token with no record, without reading the board', async () => {
vi.mocked(getPublishedRoomSnapshot).mockResolvedValue(snapshotOfOneShape())
const envWithBucket = makeEnvWithBucket()
const forged = await mintThumbnailRenderToken(envWithBucket, makeJob())
const response = await getThumbnailSnapshot(makeRequest(forged), envWithBucket)
expect(response.status).toBe(403)
expect(await response.json()).toEqual({
error: true,
message: 'Invalid or expired render token',
})
// Refused before the board is touched, so a forged token cannot even cause a snapshot read.
expect(vi.mocked(getPublishedRoomSnapshot)).not.toHaveBeenCalled()
})
// A capture that crosses a rolling deploy: minted by the previous worker version, which had no
// `access` field and wrote no records, then presented to this one. It reads as `public`, so it is
// served rather than 403ing on a record that was never going to exist.
it('serves a pre-deploy token that has no access field and no record', async () => {
vi.mocked(getSharedFileRoomSnapshot).mockResolvedValue(snapshotOfOneShape())
const envWithBucket = makeEnvWithBucket()
const token = await mintThumbnailRenderToken(
envWithBucket,
makeJob({ kind: 'shared_file', slug: 'file-abc', version: 'etag-1', access: undefined })
)
const response = await getThumbnailSnapshot(makeRequest(token), envWithBucket)
expect(response.status).toBe(200)
// And under the gate that worker version applied, not the wider one.
expect(vi.mocked(getSharedFileRoomSnapshot)).toHaveBeenCalledWith(envWithBucket, 'file-abc', {
access: 'public',
})
})
})
async function mintToken(overrides: Partial<ThumbnailRenderJob> = {}) {
return mintThumbnailRenderToken(env, makeJob(overrides))
}

View File

@@ -0,0 +1,108 @@
import { ThumbnailSnapshotResponseBody } from '@tldraw/dotcom-shared'
import { TLRecord } from '@tldraw/tlschema'
import { IRequest } from 'itty-router'
import { Environment } from '../../types'
import {
isMintedRenderToken,
markRenderTokenServed,
renderJobAccess,
renderParamsForJob,
verifyThumbnailRenderToken,
} from '../../utils/renderTokens'
import { getPublishedRoomSnapshot } from './getPublishedFile'
import { getSharedFileRoomSnapshot } from './getSharedFile'
import { reportThumbnailError } from './thumbnailShared'
// Serves snapshot data to the thumbnail render page. Only accepts short-lived render tokens
// minted by this worker, so the render page cannot be pointed at arbitrary boards even though
// published snapshot data is itself public.
export async function getThumbnailSnapshot(
request: IRequest,
env: Environment,
ctx?: ExecutionContext
): Promise<Response> {
const token = new URL(request.url).searchParams.get('token')
if (!token) {
return json({ error: true, message: 'token is required' }, 400)
}
const job = await verifyThumbnailRenderToken(env, token)
if (!job) {
return json({ error: true, message: 'Invalid or expired render token' }, 403)
}
// A valid signature only proves the holder of the secret made this, and a `render` token reads a
// private board's full document. So one must also be recorded as ours (see isMintedRenderToken).
// Answers the same 403 as a bad signature: which check failed is not the caller's business.
if (!(await isMintedRenderToken(env, job, token))) {
return json({ error: true, message: 'Invalid or expired render token' }, 403)
}
// The page has called home. Stamped here, before the read, so a session that later dies can be told
// apart from one whose page never ran at all (see wasRenderTokenServedSince). Awaited rather than
// deferred: a page that errors the instant it has its snapshot ends the session, whose cleanup
// deletes the stamp — a deferred write could land after that and stay behind.
await markRenderTokenServed(env, job, token)
// Read under the gate the job was signed with, not a fixed one, so an MCP token stays confined to
// what the MCP tool could resolve — including a board that has gone private since it was minted.
// Shared files re-check here rather than trusting the mint, so a board deleted inside the token's
// window (THUMBNAIL_RENDER_TOKEN_TTL_MS) stops resolving either way.
const snapshot = await (
job.kind === 'published'
? getPublishedRoomSnapshot(env, job.slug)
: getSharedFileRoomSnapshot(env, job.slug, { access: renderJobAccess(job) })
).catch((error) => {
// A load failure and a genuinely missing board both answer 404 here, which the render page
// turns into an error state and the capture surfaces as a generic render failure. Report the
// real cause so a broken snapshot read doesn't hide behind that.
reportThumbnailError(error, {
ctx,
env,
request,
surface: 'thumbnail_snapshot',
extras: { kind: job.kind },
})
return undefined
})
// A corrupt or partial R2 payload can carry schema metadata without a documents array; guard it
// so it returns a controlled 404 rather than throwing on the .map below and 500ing the render.
if (!snapshot?.schema || !snapshot.documents) {
return json({ error: true, message: 'Board not found' }, 404)
}
// The token can target a specific page (the MCP tool labels its result with that page's name,
// resolved when the token was minted). Shared files reload a live snapshot that may have changed
// since then, so confirm the page still exists: if it was deleted meanwhile, the render page would
// silently fall back to whichever page the snapshot opens to and the tool would return a PNG
// mislabeled with the original page's name. Fail instead, so the capture surfaces as a retryable
// render error rather than a wrong image.
if (
job.pageId &&
!snapshot.documents.some((d) => (d.state as TLRecord | undefined)?.id === job.pageId)
) {
return json({ error: true, message: 'Page not found' }, 404)
}
// Same reasoning as the page check above, and it matters more here: with every requested shape
// gone the render page would have nothing to fit the camera to and would export a blank frame,
// which the tool would return as if it were a picture of those shapes. Requiring every id to
// still be present turns a stale request into a retryable render error instead.
if (job.shapeIds) {
const presentIds = new Set(snapshot.documents.map((d) => (d.state as TLRecord | undefined)?.id))
if (!job.shapeIds.every((id) => presentIds.has(id as TLRecord['id']))) {
return json({ error: true, message: 'Shape not found' }, 404)
}
}
return json({
error: false,
records: snapshot.documents.map((d) => d.state) as TLRecord[],
schema: snapshot.schema,
renderParams: renderParamsForJob(job),
})
}
function json(body: ThumbnailSnapshotResponseBody, status = 200) {
return Response.json(body, { status })
}

View File

@@ -0,0 +1,120 @@
import { IndexKey } from '@tldraw/utils'
import { IRequest } from 'itty-router'
import { createPostgresConnectionPool } from '../../postgres'
import { Environment } from '../../types'
import { writeDataPoint } from '../../utils/analytics'
import { LOAD_ID_HEADER, parseLoadId } from '../../utils/loadId'
import { isRateLimited } from '../../utils/rateLimit'
import { getClerkClient } from '../../utils/tla/getAuth'
type InitOutcome = 'existing' | 'created' | 'rate_limited' | 'no_clerk_user' | 'no_email' | 'error'
// Ensures the user row + home workspace exist before Zero can query. Idempotent: concurrent
// first-sign-ins race safely because all three inserts no-op on conflict, so the loser of the
// race falls through to the same 200 as the winner instead of hitting a unique violation.
export async function initUser(req: IRequest, env: Environment): Promise<Response> {
const start = Date.now()
const loadId = parseLoadId(req.headers.get(LOAD_ID_HEADER))
// Timed into Analytics Engine (joinable on the load id). Every response built here also carries
// it as a Server-Timing header for the client's first_load report; a thrown error bubbles to
// the shared handler and loses the header, which is fine since the report is not the place to
// learn about a 500.
const record = (outcome: InitOutcome) => {
const ms = Date.now() - start
writeDataPoint(undefined, env.MEASURE, env, 'init_user', {
blobs: [outcome, ...(loadId ? [loadId] : [])],
doubles: [ms],
})
return ms
}
const respond = (body: string, status: number, outcome: InitOutcome) => {
const ms = record(outcome)
return new Response(body, {
status,
headers: { 'Server-Timing': `init;dur=${ms};desc=${outcome}` },
})
}
const id = req.params.userId
const db = createPostgresConnectionPool(env, '/app/init')
try {
const existing = await db
.selectFrom('user')
.where('id', '=', id)
.select('id')
.executeTakeFirst()
if (existing) return respond('ok', 200, 'existing')
// Only the creation path is rate-limited: existing users hit the cheap SELECT above on
// every sign-in and shouldn't burn rate-limit budget or risk a 429 boot-hang.
if (await isRateLimited(env, id)) {
return respond('Rate limited', 429, 'rate_limited')
}
// auth is checked in the main worker, so the clerk user definitely exists
const clerk = getClerkClient(env)
const clerkUser = await clerk.users.getUser(id)
if (!clerkUser) return respond('Clerk user not found', 404, 'no_clerk_user')
// A Clerk user can lack an email (e.g. some SSO/social flows); reading [0].emailAddress
// on such a user throws and permanently 500s user boot. Fail cleanly with a 400 instead.
const email =
clerkUser.primaryEmailAddress?.emailAddress ?? clerkUser.emailAddresses[0]?.emailAddress
if (!email) return respond('Clerk user has no email address', 400, 'no_email')
await db.transaction().execute(async (tx) => {
const now = Date.now()
await tx
.insertInto('user')
.values({
id,
name: clerkUser.fullName ?? '',
email,
avatar: clerkUser.imageUrl,
color: '___INIT___',
exportFormat: 'png',
exportTheme: 'light',
exportBackground: true,
exportPadding: true,
createdAt: now,
updatedAt: now,
// No feature flags on new users; the column is retained for future flags.
flags: '',
})
.onConflict((oc) => oc.doNothing())
.execute()
await tx
.insertInto('group')
.values({
id,
// The home/private workspace defaults to "My workspace" and is renameable.
name: 'My workspace',
createdAt: now,
updatedAt: now,
isDeleted: false,
inviteSecret: null,
})
.onConflict((oc) => oc.doNothing())
.execute()
await tx
.insertInto('group_user')
.values({
userId: id,
groupId: id,
createdAt: now,
updatedAt: now,
role: 'owner',
index: 'a1' as IndexKey,
userName: clerkUser.fullName ?? '',
userColor: '',
})
.onConflict((oc) => oc.doNothing())
.execute()
})
return respond('ok', 200, 'created')
} catch (e) {
record('error')
throw e
} finally {
await db.destroy()
}
}

View File

@@ -0,0 +1,450 @@
import { signJwt } from '@clerk/backend/jwt'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { Environment } from '../../types'
import { isFeatureFlagEnabledForUser } from '../../utils/featureFlags'
import {
MCP_PROTECTED_RESOURCE_METADATA_FALLBACK_PATH,
MCP_PROTECTED_RESOURCE_METADATA_PATH,
McpAuthResult,
authenticateMcpRequest,
getMcpAuthorizationServer,
getMcpProtectedResourceMetadata,
getMcpResourceUrl,
mcpCorsPreflight,
withMcpCors,
} from './mcpAuth'
// Nothing in @clerk/backend is mocked. The one thing stubbed is global `fetch`, and only because
// there is no Clerk instance to fetch signing keys from: the SDK's JWKS request is answered with a key
// set built from a generated pair. Everything the SDK then does with those keys — signature, `typ`,
// `exp`, `sub` — runs for real.
//
// That matters because "`typ: at+jwt` is the only thing separating an access token from a session
// token" is the load-bearing claim of the file under test, and a mocked verifier cannot demonstrate
// it at all: an earlier version of this file mocked the whole verifier and its two "verification"
// tests were assertions about what the mock had been *called with*.
vi.mock('../../utils/featureFlags', () => ({ isFeatureFlagEnabledForUser: vi.fn() }))
const RESOURCE = 'https://www.tldraw.com/api/app/mcp'
// pk_test_<base64 of "clerk.tldraw.com$">, which is the shape Clerk publishable keys take.
const PUBLISHABLE_KEY = `pk_test_${btoa('clerk.tldraw.com$')}`
// The Clerk instance the publishable key above names, and so the `iss` a real token carries.
const ISSUER = 'https://clerk.tldraw.com'
const KEY_ID = 'test-signing-key'
// The instance's signing key, a second pair that stands for anybody else's, and the JWKS document the
// stubbed fetch hands the SDK.
let signingKey: JsonWebKey
let foreignKey: JsonWebKey
let jwks: { keys: (JsonWebKey & { kid: string })[] }
async function generateRsaPair() {
const pair = await crypto.subtle.generateKey(
{
name: 'RSASSA-PKCS1-v1_5',
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
hash: 'SHA-256',
},
true,
['sign', 'verify']
)
return {
privateJwk: await crypto.subtle.exportKey('jwk', pair.privateKey),
publicJwk: await crypto.subtle.exportKey('jwk', pair.publicKey),
}
}
beforeAll(async () => {
const instance = await generateRsaPair()
signingKey = instance.privateJwk
foreignKey = (await generateRsaPair()).privateJwk
jwks = { keys: [{ ...instance.publicJwk, kid: KEY_ID, alg: 'RS256', use: 'sig' }] }
})
/**
* A real, signed token. Every default is what Clerk actually issues — RFC 9068's `at+jwt`, the
* instance as `iss`, a subject, a live `exp` — so each test overrides exactly the one thing it is
* about, and no test can pass because two things were wrong at once.
*
* No `aud`: Clerk stamps none, so its absence here is the shape of a real token rather than an
* omission. See authenticateMcpRequest for what stands in for that binding.
*/
async function signToken({
key,
typ = 'at+jwt',
// `null` omits the claim entirely, which `undefined` could not: it would fall through to the
// default here and quietly sign a perfectly good token.
sub = 'user_123' as string | null,
exp = (Math.floor(Date.now() / 1000) + 300) as number | null,
claims = {} as Record<string, unknown>,
}: {
key?: JsonWebKey
typ?: string
sub?: string | null
exp?: number | null
claims?: Record<string, unknown>
} = {}) {
const payload: Record<string, unknown> = { iss: ISSUER, ...claims }
if (sub !== null) payload.sub = sub
if (exp !== null) payload.exp = exp
return await signJwt(payload, key ?? signingKey, {
algorithm: 'RS256',
header: { typ, kid: KEY_ID },
})
}
// The SDK caches a fetched key set per secret key for five minutes at module scope, which outlives
// `clearAllMocks`. A test that counts JWKS fetches needs a secret key nothing else has warmed.
let secretKeyCounter = 0
function makeEnv(overrides: Partial<Record<string, unknown>> = {}) {
return {
CLERK_SECRET_KEY: 'sk_test_secret',
CLERK_PUBLISHABLE_KEY: PUBLISHABLE_KEY,
MCP_SERVER_URL: RESOURCE,
...overrides,
} as unknown as Environment
}
function makeRequest(headers: Record<string, string> = {}) {
return new Request('https://sync.tldraw.xyz/app/mcp', { method: 'POST', headers }) as any
}
const bearer = (token: string) => makeRequest({ authorization: `Bearer ${token}` })
// Typed through the failure arm rather than a structural cast, so renaming `response` there breaks
// here at compile time instead of as an undefined read in every assertion.
function responseOf(result: McpAuthResult) {
return (result as Extract<McpAuthResult, { ok: false }>).response
}
const fetchMock = vi.fn(async (input: string | URL | Request, _init?: RequestInit) => {
const url = input instanceof Request ? input.url : String(input)
if (url.endsWith('/jwks')) return Response.json(jwks)
throw new Error(`unexpected fetch during MCP auth: ${url}`)
})
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('fetch', fetchMock)
vi.mocked(isFeatureFlagEnabledForUser).mockResolvedValue(true)
// The refusal cases below are the expected way to see these logged, and a test run that prints them
// reads like a failure. Tests that care which branch refused a token assert on the spy.
vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
describe('getMcpResourceUrl', () => {
it('uses the configured URL', () => {
expect(getMcpResourceUrl(makeRequest(), makeEnv())).toBe(RESOURCE)
})
// The fallback reads the request's own origin, and therefore the Host header. Deployments configure
// the var so that a forged Host cannot move the advertised metadata or the challenge pointer to a
// host of the caller's choosing.
it('falls back to the request origin when unset', () => {
expect(getMcpResourceUrl(makeRequest(), makeEnv({ MCP_SERVER_URL: undefined }))).toBe(
'https://sync.tldraw.xyz/api/app/mcp'
)
})
})
describe('getMcpAuthorizationServer', () => {
// Derived from the publishable key rather than configured separately, so the authorization server
// clients are sent to cannot drift from the instance whose tokens we accept — a mismatch would have
// every client sign in successfully and then be refused.
it('derives the Clerk frontend API origin from the publishable key', () => {
expect(getMcpAuthorizationServer(makeEnv())).toBe('https://clerk.tldraw.com')
})
it('prefers an explicit override', () => {
expect(
getMcpAuthorizationServer(makeEnv({ MCP_OAUTH_AUTHORIZATION_SERVER: 'https://auth.example' }))
).toBe('https://auth.example')
})
it('returns null for a missing or unparseable key', () => {
expect(getMcpAuthorizationServer(makeEnv({ CLERK_PUBLISHABLE_KEY: undefined }))).toBe(null)
expect(getMcpAuthorizationServer(makeEnv({ CLERK_PUBLISHABLE_KEY: 'not-a-key' }))).toBe(null)
})
})
describe('getMcpProtectedResourceMetadata', () => {
it('names the resource and its authorization server', async () => {
const response = getMcpProtectedResourceMetadata(makeRequest(), makeEnv())
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({
resource: RESOURCE,
authorization_servers: ['https://clerk.tldraw.com'],
scopes_supported: ['openid', 'profile', 'email', 'offline_access'],
bearer_methods_supported: ['header'],
})
})
// The fallback URL clients try when the path-derived one 404s. Both paths answer with the same
// document on purpose: the resource stays the MCP endpoint rather than becoming the origin, which
// is not protected and which no token is ever minted for.
it('names the MCP endpoint, not the origin, on the path-less fallback URL', async () => {
expect(MCP_PROTECTED_RESOURCE_METADATA_FALLBACK_PATH).toBe(
'/.well-known/oauth-protected-resource'
)
expect(
MCP_PROTECTED_RESOURCE_METADATA_PATH.startsWith(MCP_PROTECTED_RESOURCE_METADATA_FALLBACK_PATH)
).toBe(true)
const response = getMcpProtectedResourceMetadata(makeRequest(), makeEnv())
expect(await response.json()).toMatchObject({ resource: RESOURCE })
})
// Advertising a resource with no authorization server would push the failure further along, into
// the client, where it reads as a broken server rather than an unconfigured one.
it('refuses to advertise a resource it has no authorization server for', () => {
expect(
getMcpProtectedResourceMetadata(makeRequest(), makeEnv({ CLERK_PUBLISHABLE_KEY: undefined }))
.status
).toBe(503)
})
})
// The endpoint and its metadata are deliberately outside the worker's origin allowlist, which exists
// to protect *cookie*-authenticated routes. A browser-context MCP client — the Inspector on
// localhost:6274, a web connector — is on no allowlist and never will be.
describe('MCP CORS', () => {
it('allows any origin and exposes the challenge header', () => {
const response = withMcpCors(new Response('ok'))
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*')
// Without this a browser client can see that it was refused but not the resource_metadata
// pointer telling it where to sign in — so it cannot start the flow the 401 is inviting.
expect(response.headers.get('Access-Control-Expose-Headers')).toContain('WWW-Authenticate')
})
it('keeps the body and status of the response it wraps', async () => {
const response = withMcpCors(Response.json({ error: 'nope' }, { status: 401 }))
expect(response.status).toBe(401)
expect(await response.json()).toEqual({ error: 'nope' })
})
it('answers a preflight with the same headers', () => {
const response = mcpCorsPreflight()
expect(response.status).toBe(204)
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*')
expect(response.headers.get('Access-Control-Allow-Headers')).toContain('authorization')
})
})
describe('authenticateMcpRequest', () => {
// The pointer is the whole point of the 401: without it a client learns only that it was refused,
// not where to send the user to sign in.
it('answers 401 with a pointer to the metadata when there is no token', async () => {
const result = await authenticateMcpRequest(makeRequest(), makeEnv())
expect(result.ok).toBe(false)
const response = responseOf(result)
expect(response.status).toBe(401)
expect(response.headers.get('WWW-Authenticate')).toBe(
`Bearer resource_metadata="https://www.tldraw.com${MCP_PROTECTED_RESOURCE_METADATA_PATH}", ` +
`scope="openid profile email offline_access"`
)
})
// The scope list is what stops a client guessing. Claude's hosted connectors request every scope
// the authorization server advertises when the challenge names none, and Clerk advertises scopes
// it grants each client only a subset of — so an unnamed list is refused with `invalid_scope` at
// the authorization server, before consent and before any request reaches this worker.
it('names the scopes a client should ask for', async () => {
const result = await authenticateMcpRequest(makeRequest(), makeEnv())
expect(responseOf(result).headers.get('WWW-Authenticate')).toContain(
'scope="openid profile email offline_access"'
)
})
// Two places state the scopes and a client may read either, so they cannot be allowed to drift:
// a client that asks for what the metadata advertises and a client that asks for what the
// challenge names have to end up asking for the same thing.
it('names the same scopes in the challenge and the metadata', async () => {
const result = await authenticateMcpRequest(makeRequest(), makeEnv())
const challenge = responseOf(result).headers.get('WWW-Authenticate') ?? ''
const metadata = (await getMcpProtectedResourceMetadata(makeRequest(), makeEnv()).json()) as {
scopes_supported: string[]
}
expect(challenge).toContain(`scope="${metadata.scopes_supported.join(' ')}"`)
})
it('accepts a valid token and returns its subject', async () => {
const result = await authenticateMcpRequest(bearer(await signToken()), makeEnv())
expect(result).toEqual({ ok: true, userId: 'user_123' })
})
// The signing keys are the accepting instance's own, fetched from Clerk's Backend API with the
// secret key — which is what binds a token to that instance without an `iss` check, and what keeps
// the advertised authorization server (derived from the same instance's publishable key) and the
// accepted issuer from drifting apart. Fetched once and reused: rebuilt per request it would put a
// round trip to Clerk in front of every MCP call, so the second request here must not fetch again.
it('fetches the signing keys from the Backend API once and reuses them', async () => {
const secretKey = `sk_test_jwks_${secretKeyCounter++}`
const env = makeEnv({ CLERK_SECRET_KEY: secretKey })
await authenticateMcpRequest(bearer(await signToken()), env)
await authenticateMcpRequest(bearer(await signToken()), env)
expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, init] = fetchMock.mock.calls[0]
expect(String(url)).toBe('https://api.clerk.com/v1/jwks')
expect(init?.headers).toMatchObject({ Authorization: `Bearer ${secretKey}` })
})
// Without a secret key there is nothing to verify against, and without a derivable authorization
// server no client could have been sent anywhere to get a token. Either is our misconfiguration
// rather than a bad token, so it is logged as one — but answered like every other refusal, since
// naming the difference would describe the deployment to someone guessing at it.
it.each([
['no secret key', { CLERK_SECRET_KEY: undefined }],
['no derivable authorization server', { CLERK_PUBLISHABLE_KEY: undefined }],
])('refuses as unconfigured with %s', async (_name, overrides) => {
const result = await authenticateMcpRequest(bearer(await signToken()), makeEnv(overrides))
expect(result).toMatchObject({ ok: false, reason: 'unconfigured' })
expect(responseOf(result).status).toBe(401)
expect(fetchMock).not.toHaveBeenCalled()
expect(console.error).toHaveBeenCalledWith(
'MCP token verification is unconfigured: no Clerk instance to verify against'
)
})
// The ways a token can be wrong, each signed for real and each refused by the SDK's verifier rather
// than by a mock returning what the test wanted. `typ` is the one that matters most: Clerk stamps
// no `aud` on either kind of token, so the token type is the *only* thing separating an OAuth
// access token from an ordinary tldraw.com session JWT. Accepting a session token would make a
// website credential enough to drive this server, and the consent step an agent walks a user
// through decoration.
//
// "Another issuer" is not a case of its own any more: with no `iss` check, what refuses a token
// from another Clerk instance is that it is signed with a key that is not in our instance's key
// set, which is the foreign-key case.
describe.each([
['a Clerk session JWT rather than an access token', () => signToken({ typ: 'JWT' })],
[
"a token signed with a key that is not in the instance's key set",
() => signToken({ key: foreignKey }),
],
['an expired token', () => signToken({ exp: Math.floor(Date.now() / 1000) - 3600 })],
// A token minted without `exp` would otherwise never expire; the SDK requires the claim.
['a token with no expiry at all', () => signToken({ exp: null })],
['a token with no subject', () => signToken({ sub: null })],
])('refuses %s', (_name, makeToken) => {
it('with a 401 that says invalid_token and no detail', async () => {
const result = await authenticateMcpRequest(bearer(await makeToken()), makeEnv())
expect(result).toMatchObject({ ok: false, reason: 'invalid_token' })
const response = responseOf(result)
expect(response.status).toBe(401)
expect(response.headers.get('WWW-Authenticate')).toContain('error="invalid_token"')
// Why it failed is not the caller's business — an expired token and one minted for another
// resource answer the same — but a client does need to know to re-authenticate rather than
// stop. One message covers all of them.
expect(await response.json()).toEqual({
error: 'invalid_token',
error_description: 'The access token is expired, revoked, or issued for another resource',
})
})
})
it('accepts a token whose expiry is inside the clock tolerance', async () => {
// Two seconds past, which a worker whose clock runs slightly fast would produce. Inside the 5s
// skew @clerk/backend allows by default.
const result = await authenticateMcpRequest(
bearer(await signToken({ exp: Math.floor(Date.now() / 1000) - 2 })),
makeEnv()
)
expect(result).toEqual({ ok: true, userId: 'user_123' })
})
// Only the SDK's reason and message are logged. The token itself, or a decoded payload, would put
// `sub`, `client_id`, `scope` and `jti` — every one of the things the response refuses to disclose —
// into a log with a wider audience than the caller.
it('logs why verification failed without logging the token or its claims', async () => {
const token = await signToken({
typ: 'JWT',
claims: { client_id: 'client_secretive', scope: 'profile email', jti: 'jti_secretive' },
})
await authenticateMcpRequest(bearer(token), makeEnv())
const logged = vi.mocked(console.error).mock.calls.flat().join(' ')
expect(logged).toContain('MCP token verification failed:')
expect(logged).toContain('token-type-mismatch')
expect(logged).not.toContain('client_secretive')
expect(logged).not.toContain('jti_secretive')
expect(logged).not.toContain(token)
})
// 403, not 401: the caller did authenticate and still may not in, which retrying the flow cannot
// fix. A 401 here would have clients loop through sign-in forever.
it('answers 403 for an authenticated user the flag does not cover', async () => {
vi.mocked(isFeatureFlagEnabledForUser).mockResolvedValue(false)
const result = await authenticateMcpRequest(bearer(await signToken()), makeEnv())
expect(result).toMatchObject({ ok: false, reason: 'not_allowlisted' })
const response = responseOf(result)
expect(response.status).toBe(403)
expect(response.headers.get('WWW-Authenticate')).toBe(null)
expect(isFeatureFlagEnabledForUser).toHaveBeenCalledWith(
expect.anything(),
'mcp_server_access',
'user_123'
)
})
// The reason rides on the refusal so the route can put it on a datapoint: during a flag-gated
// rollout, "not signed in" and "signed in and not on the list" are the two numbers worth watching
// and they call for entirely different responses.
it('names no_token distinctly from the other refusals', async () => {
const noToken = await authenticateMcpRequest(makeRequest(), makeEnv())
expect(noToken).toMatchObject({ ok: false, reason: 'no_token' })
})
// The SDK only strips an exact `Bearer ` prefix, so the token is parsed here first and handed to it
// re-wrapped; a client sending a lowercase or padded scheme must not be told its credential is bad.
it('accepts the bearer scheme case-insensitively and ignores surrounding space', async () => {
const token = await signToken()
expect(
await authenticateMcpRequest(
makeRequest({ authorization: ` bearer ${token} ` }),
makeEnv()
)
).toEqual({ ok: true, userId: 'user_123' })
})
// Anything that is not a bearer token is treated as no token at all, so the client is told where to
// authenticate rather than that its credential was bad.
it('treats a non-bearer authorization header as unauthenticated', async () => {
const result = await authenticateMcpRequest(
makeRequest({ authorization: 'Basic dXNlcjpwYXNz' }),
makeEnv()
)
const response = responseOf(result)
expect(response.status).toBe(401)
expect(response.headers.get('WWW-Authenticate')).not.toContain('error=')
expect(fetchMock).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,373 @@
import { IRequest } from 'itty-router'
import { Environment } from '../../types'
import { isFeatureFlagEnabledForUser } from '../../utils/featureFlags'
import { getClerkClient } from '../../utils/tla/getAuth'
// The OAuth 2.1 resource-server half of the board screenshot MCP server: discovery metadata, bearer
// token verification, and the feature flag gate that decides which authenticated users are let in.
// The authorization server itself is Clerk — this worker never issues a token, only checks one.
//
// Why a resource server at all, rather than the session cookie the rest of the worker uses: the
// callers here are Claude, ChatGPT and Cursor, which cannot hold a tldraw.com session. MCP's
// authorization flow is how they sign a user in on their own, and it is built on OAuth 2.1.
/**
* The MCP endpoint's public path, including the `/api` prefix that is applied upstream and stripped
* before the router sees it (see the worker's `fetch`). The public URL is the resource identifier
* clients authenticate against, so this is the form that appears in discovery metadata — not the
* `/app/mcp` the router matches.
*/
export const MCP_RESOURCE_PATH = '/api/app/mcp'
/**
* Where protected resource metadata lives, per RFC 9728: the resource's path, appended to
* `/.well-known/oauth-protected-resource` at the resource's own origin.
*
* At the origin, which is why this needs its own route in `wrangler.toml` — the worker is otherwise
* only routed `www.tldraw.com/api/*`, and a client that cannot fetch this simply never discovers
* where to authenticate. That failure is silent on our side: it produces no request to log.
*/
export const MCP_PROTECTED_RESOURCE_METADATA_PATH = `/.well-known/oauth-protected-resource${MCP_RESOURCE_PATH}`
/**
* The path-less form of the above, which the MCP authorization spec tells clients to fall back to
* when the path-derived URL 404s.
*
* Served because clients actually do this, not because the origin is itself a protected resource:
* `www.tldraw.com` is a web app, and the only OAuth-protected thing on it is the MCP server. Both
* URLs therefore answer with the same document, whose `resource` stays `MCP_RESOURCE_PATH` — a
* client that fell back to this path was asking about that resource anyway, and pointing it at the
* origin instead would advertise a resource no token is ever minted for.
*/
export const MCP_PROTECTED_RESOURCE_METADATA_FALLBACK_PATH = '/.well-known/oauth-protected-resource'
/**
* The scopes a client should ask for, named in both the `WWW-Authenticate` challenge and the RFC
* 9728 metadata.
*
* Stated rather than left to the client, because a client that is told nothing guesses, and Claude's
* hosted connectors guess by requesting every scope the authorization server advertises. Clerk
* advertises six — `public_metadata` and `private_metadata` among them — and grants each client a
* subset, so an unstated scope list had Claude asking for two it was never granted and Clerk
* refusing the whole request with `invalid_scope`, before the consent screen and before any request
* reached this worker. Claude Code was unaffected only because it sends no `scope` at all and gets
* Clerk's configured defaults.
*
* What that cost, and the reason this is worth stating even though nothing here reads a scope: the
* only fix available from the dashboard was granting Claude the two metadata scopes, widening what
* its tokens can reach in Clerk to make an over-request legal. Naming the four here is what makes
* revoking them possible.
*
* The corollary to keep in view: a client asks for what it is offered, so anything added to this
* list — a custom tldraw scope, say — is requested by every client immediately and refused for every
* one not granted it on the authorization server first.
*
* These four are what the Clerk CIMD clients are granted. Narrower would serve: nothing here reads a
* scope, and `sub` — all this server takes from a token — rides on `openid` alone, with
* `offline_access` for the refresh token. `profile` and `email` are kept because they are what
* Clerk's defaults already hand Claude Code, and matching them keeps one behaviour across clients
* rather than two.
*/
export const MCP_SCOPES = ['openid', 'profile', 'email', 'offline_access'] as const
/**
* This server's own identifier: what RFC 9728 metadata advertises as the resource, and what the
* `WWW-Authenticate` challenge points a client at.
*
* Not a test applied to incoming tokens, despite RFC 8707 intending exactly that — see
* `authenticateMcpRequest` for why there is no audience check and what stands in for one.
*
* Deployments set `MCP_SERVER_URL` explicitly rather than letting this be derived from the request,
* because the derivation reads the `Host` header: a request carrying a forged one would otherwise
* move the advertised metadata and the challenge pointer to a host of the caller's choosing, which
* is how a client gets aimed at an authorization server that is not ours. The fallback exists for
* local dev and tests, where there is no configured origin and no attacker to speak of.
*/
export function getMcpResourceUrl(request: Request, env: Environment): string {
if (env.MCP_SERVER_URL) return env.MCP_SERVER_URL
return new URL(MCP_RESOURCE_PATH, new URL(request.url).origin).toString()
}
/**
* The Clerk instance acting as our authorization server, derived from the publishable key rather
* than configured separately.
*
* Derived deliberately: the instance it names is the one whose secret key verifies tokens, so the
* authorization server we point clients at and the one whose tokens we accept cannot drift apart
* without the two Clerk keys themselves disagreeing. Two vars could drift on their own, and the symptom
* would be every client completing a sign-in and then being refused — with nothing in our logs
* distinguishing it from a bad token.
*
* A Clerk publishable key is `pk_(test|live)_<base64 of "frontend-api-host$">`.
*/
export function getMcpAuthorizationServer(env: Environment): string | null {
if (env.MCP_OAUTH_AUTHORIZATION_SERVER) return env.MCP_OAUTH_AUTHORIZATION_SERVER
const key = env.CLERK_PUBLISHABLE_KEY
if (!key) return null
const encoded = key.replace(/^pk_(test|live)_/, '')
if (encoded === key) return null
try {
const host = atob(encoded).replace(/\$$/, '')
if (!host) return null
return `https://${host}`
} catch {
return null
}
}
/**
* RFC 9728 protected resource metadata. Names the authorization server so a client that has only our
* URL can find where to sign the user in.
*
* Cached for an hour: it changes only when the Clerk instance does, and clients fetch it on every
* connect.
*/
export function getMcpProtectedResourceMetadata(request: IRequest, env: Environment): Response {
const authorizationServer = getMcpAuthorizationServer(env)
if (!authorizationServer) {
// Nothing useful to advertise, and advertising a resource with no authorization server would
// have clients fail further along with a less legible error than "not configured".
return Response.json({ error: 'MCP authorization is not configured' }, { status: 503 })
}
return Response.json(
{
resource: getMcpResourceUrl(request, env),
authorization_servers: [authorizationServer],
scopes_supported: MCP_SCOPES,
bearer_methods_supported: ['header'],
resource_documentation: 'https://tldraw.dev',
},
{ headers: { 'cache-control': 'public, max-age=3600' } }
)
}
/**
* CORS for the MCP endpoint and its discovery metadata, which are deliberately not held to the
* worker's origin allowlist.
*
* That allowlist exists to stop a page on somebody else's site from riding a visitor's tldraw.com
* *cookie*. This endpoint has no cookie to ride — it authenticates a bearer token and nothing else —
* so an origin check buys nothing here and costs real clients. A browser-context MCP client (the
* Inspector on `localhost:6274`, a web connector fetching directly) is on no allowlist and never will
* be, and what it gets instead of a legible refusal is a bare `403 Not allowed` carrying no CORS
* headers at all: indistinguishable from "there is no MCP server here". That is precisely the silent
* discovery failure the extra `wrangler.toml` route exists to prevent, moved one layer further in.
*
* Clients holding no `Origin` at all — Claude Desktop, `mcp-remote`, Cursor — were always fine, which
* is why this is easy to miss: testing with one of them proves nothing about the others.
*/
export const MCP_CORS_HEADERS: Record<string, string> = {
// Safe as `*` precisely because there are no credentials in play: nothing here reads a cookie, and
// a bearer token is something the client already holds rather than something the browser would
// attach on its behalf. `*` and credentials are mutually exclusive, and we want the former.
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers':
'authorization, content-type, mcp-protocol-version, mcp-method, mcp-name, mcp-session-id',
// The whole content of a 401 is its `WWW-Authenticate` challenge — without it a browser client can
// see that it was refused but not the `resource_metadata` pointer telling it where to sign in, so
// it cannot start the flow the refusal is inviting.
'Access-Control-Expose-Headers': 'WWW-Authenticate',
'Access-Control-Max-Age': '86400',
}
/**
* Stamps the MCP CORS headers onto a response. Set rather than appended, and set before the worker's
* own `corsify` runs — which returns a response untouched once it already carries an
* `Access-Control-Allow-Origin`, so this wins without having to be special-cased there.
*/
export function withMcpCors(response: Response): Response {
const corsified = new Response(response.body, response)
for (const [header, value] of Object.entries(MCP_CORS_HEADERS)) {
corsified.headers.set(header, value)
}
return corsified
}
/**
* The preflight answer for the MCP routes. Registered ahead of the router's shared `preflight`, which
* answers from the origin allowlist and so would hand a browser client a 204 with no
* `Access-Control-Allow-Origin` — a refusal it can't read either.
*/
export function mcpCorsPreflight(): Response {
return withMcpCors(new Response(null, { status: 204 }))
}
/**
* The `401` that starts an MCP client's sign-in. The `resource_metadata` parameter is the whole
* point of it: without that pointer a client knows only that it was refused, not where to go.
*/
export function mcpUnauthorized(
request: Request,
env: Environment,
{ error, description }: { error?: string; description?: string } = {}
): Response {
const metadataUrl = new URL(
MCP_PROTECTED_RESOURCE_METADATA_PATH,
getMcpResourceUrl(request, env)
).toString()
// `scope` before the error parameters because it is the one a first-contact client acts on: it is
// what Claude's hosted connectors read to decide what to ask Clerk for, and without it they ask
// for everything Clerk advertises. See MCP_SCOPES.
const params = [`resource_metadata="${metadataUrl}"`, `scope="${MCP_SCOPES.join(' ')}"`]
if (error) params.push(`error="${error}"`)
if (description) params.push(`error_description="${description}"`)
return Response.json(
{ error: error ?? 'unauthorized', error_description: description },
{ status: 401, headers: { 'WWW-Authenticate': `Bearer ${params.join(', ')}` } }
)
}
/**
* Why a request was turned away, as a closed vocabulary. Carried on the refusal so the route can put
* it on a datapoint: during a flag-gated rollout the number that matters most is how many callers are
* being refused and *which* kind of no they got — "not signed in" and "signed in, not on the list"
* call for entirely different responses — and neither was visible anywhere, since the per-call event
* is written by the dispatcher, which a refused request never reaches.
*
* Written by the route rather than here, which is where every other MCP datapoint is written and
* which keeps this module free of a dependency on the one that imports it.
*/
export type McpAuthRefusal = 'no_token' | 'invalid_token' | 'unconfigured' | 'not_allowlisted'
export type McpAuthResult =
| { ok: true; userId: string }
| { ok: false; response: Response; reason: McpAuthRefusal }
/**
* Authenticates and authorizes one MCP request: a valid bearer token minted for this resource, for a
* user the `mcp_server_access` flag names.
*
* There is no anonymous path. This endpoint used to serve any caller that named a public board, and
* requiring a token retires that deliberately — per-IP rate limits and unattributable Browser Run
* spend were the cost of it.
*
* The two refusals are kept distinct because they mean different things to a client: `401` says
* "authenticate, here is where", which a client can act on; `403` says "you did authenticate and you
* still may not", which it cannot, and retrying the flow would loop.
*/
export async function authenticateMcpRequest(
request: IRequest,
env: Environment
): Promise<McpAuthResult> {
// One 401 for every way a token can be refused, built once. The reason rides on the result for
// telemetry; the *response* says the same thing whichever it was, deliberately — see
// INVALID_TOKEN_DESCRIPTION.
const invalidToken = (reason: McpAuthRefusal): McpAuthResult => ({
ok: false,
reason,
response: mcpUnauthorized(request, env, {
error: 'invalid_token',
description: INVALID_TOKEN_DESCRIPTION,
}),
})
const token = getBearerToken(request)
if (!token) {
// No `error` parameter: nothing was presented, so there is nothing to call invalid, and a bare
// challenge is what tells a first-contact client to go and authenticate.
return { ok: false, reason: 'no_token', response: mcpUnauthorized(request, env) }
}
// Verified by @clerk/backend against the Clerk instance the secret key names: signature (against a
// JWKS the SDK fetches from the Backend API once and caches), `sub`, lifetime and token type. Not
// `verifyToken`, which is for *session* tokens and refuses an access token on its header alone;
// `acceptsToken: 'oauth_token'` is the path that expects RFC 9068's `at+jwt`.
//
// `typ` is load-bearing rather than pedantry, and is the only thing separating an OAuth access token
// from a Clerk *session* JWT. Clerk stamps no `aud` on either, so nothing here can tell them apart
// by audience, and a session token — `typ: JWT` — would otherwise be a valid bearer token. That
// would make an ordinary tldraw.com website credential enough to drive this server, and the consent
// step an agent walks the user through decoration. The SDK answers one with `token-type-mismatch`.
//
// No `iss` check, where the jose verifier this replaced pinned one: the key set is the accepting
// instance's own, so a token any other issuer signed fails on signature, and the authorization
// server clients are sent to is derived from the same instance's publishable key.
if (!env.CLERK_SECRET_KEY || !getMcpAuthorizationServer(env)) {
// Nothing to verify against, or nothing a client could have been sent to. This is our
// misconfiguration rather than a bad token, so it is logged as one — but the caller is told only
// what every other refusal tells it, since naming the difference would describe our deployment
// to someone guessing at it.
console.error('MCP token verification is unconfigured: no Clerk instance to verify against')
return invalidToken('unconfigured')
}
const state = await getClerkClient(env).authenticateRequest(
// The SDK parses `Authorization` itself and only strips an exact `Bearer ` prefix; handing it the
// token getBearerToken already accepted keeps a lowercase or padded scheme working, and keeps the
// body and every other header out of its hands.
new Request(request.url, { headers: { authorization: `Bearer ${token}` } }),
{ acceptsToken: 'oauth_token' }
)
if (!state.isAuthenticated) {
// The reason a token failed is not the caller's business — an expired token and one minted for
// somebody else's resource answer the same thing — but a client does need to know it should
// re-authenticate rather than give up, which is what `invalid_token` says.
//
// The SDK's reason and message only, never the token or its decoded payload: `sub`, `client_id`,
// `scope` and `jti` are every one of the things the response above is careful not to disclose,
// written to a log with a wider audience than the caller.
console.error('MCP token verification failed:', state.reason, state.message)
return invalidToken('invalid_token')
}
// There is deliberately no check here that this token was issued for *this* resource, and its
// absence is the part of this file most likely to look like an oversight.
//
// RFC 8707 would bind a token to the resource it was minted for, via `aud`, so a token the user
// granted to somebody else's MCP server could not be replayed against ours. Clerk does not
// implement it: it stamps no `aud` on an access token whether or not the client sends a `resource`
// parameter, so there is nothing here to compare. An earlier version of this file checked anyway
// and, because production enforced unconditionally, would have refused every token ever issued.
//
// What closes the hole instead lives on the authorization server, where the client registry is:
// Clerk's `client_id_metadata_documents_only_allow_pre_registered_clients` refuses to issue tokens
// to CIMD clients nobody approved, so a client we have never heard of cannot obtain a token for our
// users in the first place. Approving one is a Clerk dashboard action, not a deploy.
//
// The consequence to keep in mind: that setting is the whole of the protection, and it is invisible
// from this repository. If it is ever turned off, every self-registered client in the world can
// call this endpoint with a token its user consented to for something else entirely. A `client_id`
// allowlist here would be the belt to that setting's braces if we ever want one — the claim is on
// every token.
const userId = state.toAuth().userId
if (!(await isFeatureFlagEnabledForUser(env, 'mcp_server_access', userId))) {
// Deliberately not a 404. The endpoint's existence is already public — it is in the discovery
// metadata this same server serves — so hiding it here would cost a legible error and conceal
// nothing.
return {
ok: false,
reason: 'not_allowlisted',
response: Response.json(
{
error: 'forbidden',
error_description: 'This account does not have access to the tldraw MCP server yet.',
},
{ status: 403 }
),
}
}
return { ok: true, userId }
}
/**
* One message for every way a token can be refused. An expired token, one signed by another Clerk
* instance, a session token wearing the wrong `typ` and a subjectless one all answer the same thing:
* the caller cannot act on the difference, and spelling it out would tell an attacker which of their
* guesses was closest.
*/
const INVALID_TOKEN_DESCRIPTION =
'The access token is expired, revoked, or issued for another resource'
function getBearerToken(request: Request): string | null {
const header = request.headers.get('authorization')
if (!header) return null
const match = /^Bearer\s+(.+)$/i.exec(header.trim())
return match ? match[1].trim() : null
}

View File

@@ -0,0 +1,211 @@
import { TLShape } from '@tldraw/tlschema'
import { describe, expect, it } from 'vitest'
import { Environment } from '../../types'
import {
ResolvedPageOk,
ShapeMeasurement,
buildClusterIndex,
clusterPage,
clustersFromIndex,
getClusterInfo,
parseClusterIndex,
} from './boardTools'
import {
MAX_CLUSTER_INDEX_LENGTH,
readPageClusters,
writePageClusterIndex,
} from './mcpClusterIndex'
import { makeFakeFileDurableObjectNamespace, makeScreenshotTestEnv } from './screenshotTestHelpers'
import { ResolvedThumbnailBoard } from './thumbnailRender'
// The cluster index in isolation: what survives the round trip through storage, what is refused on
// the way back, and the two limits that stop a cache from becoming a liability. The tool-level
// behaviour it produces — which calls measure and which don't — is in mcpServer.test.
function makeShape(id: string, x: number): TLShape {
return {
id,
typeName: 'shape',
parentId: 'page:a',
type: 'geo',
x,
y: 0,
rotation: 0,
index: 'a1',
opacity: 1,
isLocked: false,
meta: {},
props: { w: 80, h: 80 },
} as unknown as TLShape
}
function makePage(
shapes = [makeShape('shape:one', 0), makeShape('shape:two', 5000)]
): ResolvedPageOk {
return { ok: true, pageId: 'page:a', pageName: 'Cover', shapes, pageIds: ['page:a'] }
}
const MEASUREMENTS: Record<string, ShapeMeasurement> = {
'shape:one': { minX: 0, minY: 0, maxX: 80, maxY: 80, text: 'Checkout total' },
'shape:two': { minX: 5000, minY: 0, maxX: 5080, maxY: 80 },
}
const BOARD: ResolvedThumbnailBoard = {
kind: 'published',
slug: 'abc',
version: 'v1',
access: 'public',
fileId: 'file-1',
}
const SNAPSHOT_VERSION = 'snapshot-v1'
function makeEnv() {
const namespace = makeFakeFileDurableObjectNamespace()
return { env: makeScreenshotTestEnv({ TLDR_DOC: namespace }), namespace }
}
describe('the stored index', () => {
it('answers exactly what the clusters it was built from would', () => {
const page = makePage()
const clusters = clusterPage(page, MEASUREMENTS)
const selector = { kind: 'ordinal', ordinal: 0 } as const
const stored = parseClusterIndex(JSON.stringify(buildClusterIndex(clusters)))!
const rehydrated = clustersFromIndex(page, stored)!
expect(getClusterInfo(page, rehydrated, clusters[0].id, selector)).toEqual(
getClusterInfo(page, clusters, clusters[0].id, selector)
)
})
it('keeps the measured text and drops the measured bounds', () => {
const index = buildClusterIndex(clusterPage(makePage(), MEASUREMENTS))
// Text is not derivable from the record, so it is stored. Bounds are: they decide which atoms
// merge, and that decision is already in `clusters`.
expect(index.text).toEqual({ 'shape:one': 'Checkout total' })
expect(JSON.stringify(index)).not.toContain('minX')
})
it('refuses a row this build did not write', () => {
const index = buildClusterIndex(clusterPage(makePage(), MEASUREMENTS))
expect(parseClusterIndex('not json')).toBeNull()
expect(parseClusterIndex('null')).toBeNull()
// A format from another build. The cache key rotates on edits, not on deploys, so this is the
// only thing standing between a changed index shape and a tool reading it as current.
expect(parseClusterIndex(JSON.stringify({ ...index, v: index.v + 1 }))).toBeNull()
expect(parseClusterIndex(JSON.stringify({ ...index, clusters: undefined }))).toBeNull()
})
it('refuses to rebuild for a page that has gained shapes', () => {
const index = buildClusterIndex(clusterPage(makePage(), MEASUREMENTS))
// The direction a stored index cannot otherwise notice. Nothing about the index is wrong — it
// rebuilds cleanly — it is just short, and answering short is a wrong answer no uncached call
// could give. An index built when the page was empty is the worst case: a full page would be
// reported as having no clusters at all.
const grown = makePage([...makePage().shapes, makeShape('shape:three', 200)])
expect(clustersFromIndex(grown, index)).toBeNull()
expect(clustersFromIndex(grown, buildClusterIndex([]))).toBeNull()
})
it('refuses to rebuild for a page whose shapes it does not name', () => {
const index = buildClusterIndex(clusterPage(makePage(), MEASUREMENTS))
expect(clustersFromIndex(makePage(), index)).not.toBeNull()
// One shape gone: the board moved under the index in a way its version did not catch, so it is
// refused rather than served with a cluster that is quietly a shape short.
expect(clustersFromIndex(makePage([makeShape('shape:one', 0)]), index)).toBeNull()
})
})
describe('reading and writing', () => {
it('stores a page index and reads its clusters back for the same content version', async () => {
const { env, namespace } = makeEnv()
const page = makePage()
const clusters = clusterPage(page, MEASUREMENTS)
await writePageClusterIndex({ env }, BOARD, page, buildClusterIndex(clusters), SNAPSHOT_VERSION)
expect(await readPageClusters({ env }, BOARD, page, SNAPSHOT_VERSION)).toEqual(clusters)
expect(namespace.calls.put).toBe(1)
// The resolved board version can move before R2 does, so it is deliberately not the cache key.
const moved = { ...BOARD, version: 'v2' }
expect(await readPageClusters({ env }, moved, page, SNAPSHOT_VERSION)).toEqual(clusters)
// The digest of the snapshot actually read is the key. This catches a publish-gap read where
// lastPublished already moved but R2 still held the previous snapshot, even when both snapshots
// contain the same shape ids.
expect(await readPageClusters({ env }, moved, page, 'snapshot-v2')).toBeNull()
// And writing under the new snapshot replaces the row rather than adding one, so a file's cache
// is bounded by its page count no matter how often it is edited.
await writePageClusterIndex({ env }, moved, page, buildClusterIndex(clusters), 'snapshot-v2')
expect([...namespace.objects.values()].flatMap((store) => [...store.keys()])).toHaveLength(1)
})
it('does not store a page whose index is over the size cap', async () => {
const { env, namespace } = makeEnv()
const shapes = Array.from({ length: 12000 }, (_, i) => makeShape(`shape:${i}`, i * 200))
const page = makePage(shapes)
const index = buildClusterIndex(clusterPage(page, {}))
expect(JSON.stringify(index).length).toBeGreaterThan(MAX_CLUSTER_INDEX_LENGTH)
await writePageClusterIndex({ env }, BOARD, page, index, SNAPSHOT_VERSION)
// Nothing written, and nothing thrown: a page this size keeps measuring, which is what it did
// before the cache existed.
expect([...namespace.objects.values()].flatMap((store) => [...store.keys()])).toHaveLength(0)
expect(await readPageClusters({ env }, BOARD, page, SNAPSHOT_VERSION)).toBeNull()
})
it('reports a broken durable object as a miss rather than raising', async () => {
const broken = {
idFromName: (name: string) => ({ toString: () => `do(${name})` }),
get: () => ({
async getMcpClusterIndex() {
throw new Error('nope')
},
async putMcpClusterIndex() {
throw new Error('nope')
},
}),
}
const env = makeScreenshotTestEnv({ TLDR_DOC: broken }) as Environment
const page = makePage()
expect(await readPageClusters({ env }, BOARD, page, SNAPSHOT_VERSION)).toBeNull()
await expect(
writePageClusterIndex(
{ env },
BOARD,
page,
buildClusterIndex(clusterPage(page, MEASUREMENTS)),
SNAPSHOT_VERSION
)
).resolves.toBeUndefined()
})
it('treats a malformed stored row as a miss', async () => {
const { env, namespace } = makeEnv()
const page = makePage()
await writePageClusterIndex(
{ env },
BOARD,
page,
buildClusterIndex(clusterPage(page, MEASUREMENTS)),
SNAPSHOT_VERSION
)
// Not a shape parseClusterIndex checks field by field — reading it throws inside the rebuild,
// which the cache read catches and reports. Either way the caller measures.
for (const store of namespace.objects.values()) {
for (const [key, row] of store) {
store.set(key, { ...row, payload: '{"v":1,"clusters":[{}],"text":{}}' })
}
}
expect(await readPageClusters({ env }, BOARD, page, SNAPSHOT_VERSION)).toBeNull()
})
})

View File

@@ -0,0 +1,126 @@
import { ShapeCluster } from '@tldraw/dotcom-shared'
import { Environment, McpClusterIndexKey } from '../../types'
import { getRoomDurableObject } from '../../utils/durableObjects'
import {
PageClusterIndex,
ResolvedPageOk,
clustersFromIndex,
parseClusterIndex,
} from './boardTools'
import { ResolvedThumbnailBoard } from './thumbnailRender'
import { reportThumbnailError } from './thumbnailShared'
// The cache that keeps the clustering tools off Browser Run. What it holds and why is documented
// where it is built, in boardTools.ts; this is where it is stored and read.
//
// A cache in the strict sense: every path through here falls back to measuring, nothing depends on a
// read succeeding, and a tool never fails because a write did not.
//
// The store is the file's own Durable Object, addressed by `ResolvedThumbnailBoard.fileId`. That is
// the only Durable Object hop in the MCP request path — the tools otherwise read the room's
// persisted snapshot straight out of R2 and never talk to the object that wrote it.
//
// Fresh relative to the snapshot, not to the board. An index is keyed to the content version it was
// measured from, so it can never be staler than the snapshot the call is already reading — and that
// snapshot is the last persisted one, which for a shared file lags the live room by the persist
// interval. A tool carries that same lag whether it hit this cache or measured from scratch, so no
// expiry here would shorten it (see "Open questions" in browser-run-thumbnails.md).
interface CacheContext {
env: Environment
request?: Request
ctx?: ExecutionContext
}
/**
* The ceiling on a stored index, above which a page is never cached and keeps measuring.
*
* A page of a few thousand shapes serializes to tens of kilobytes. The cap is for the outlier, and it
* is deliberately generous — the biggest pages are the most expensive to measure, so refusing to
* cache them is refusing the cache's best case. What it bounds is the worst case a file can hold:
* this times its page count times the two board kinds, paid for as long as the file lives, since rows
* are replaced and pruned but never expired. Measured in UTF-16 units, so a page of
* non-ASCII text stores more bytes than the number suggests — well inside any storage limit either way.
*/
export const MAX_CLUSTER_INDEX_LENGTH = 256 * 1024
function keyFor(
board: ResolvedThumbnailBoard,
pageId: string,
snapshotVersion: string
): McpClusterIndexKey {
return { kind: board.kind, pageId, version: snapshotVersion }
}
/**
* A page's clusters as stored, or null if there isn't a usable index for it.
*
* "Usable" is decided here in full — the row exists, it is for this content version, it parses as
* this build's format, and it names the shapes that are on the page — so a caller gets clusters it
* can answer from or nothing at all, and never a third case to handle.
*/
export async function readPageClusters(
{ env, request, ctx }: CacheContext,
board: ResolvedThumbnailBoard,
page: ResolvedPageOk,
snapshotVersion: string
): Promise<ShapeCluster[] | null> {
try {
const stored = await getRoomDurableObject(env, board.fileId).getMcpClusterIndex(
keyFor(board, page.pageId, snapshotVersion)
)
if (!stored) return null
const index = parseClusterIndex(stored)
// A row written by a build with a different format, or one that disagrees with the snapshot,
// is a miss rather than an error: the next measure overwrites it.
return index && clustersFromIndex(page, index)
} catch (error) {
// A cache that cannot be read costs a render, and the caller is about to pay it anyway. Worth
// reporting all the same: silently falling back means every call re-measures at full price.
reportThumbnailError(error, {
ctx,
env,
request,
surface: 'mcp_cluster_index_read',
extras: { kind: board.kind, pageId: page.pageId },
})
return null
}
}
/**
* Stores a page's index for the content it was measured from.
*
* Awaited rather than fired off in the background: a background write would leave the very next tool
* call — usually get_cluster_info on the clusters just listed — racing the write it depends on.
*/
export async function writePageClusterIndex(
{ env, request, ctx }: CacheContext,
board: ResolvedThumbnailBoard,
page: ResolvedPageOk,
index: PageClusterIndex,
snapshotVersion: string
): Promise<void> {
try {
const payload = JSON.stringify(index)
if (payload.length > MAX_CLUSTER_INDEX_LENGTH) return
await getRoomDurableObject(env, board.fileId).putMcpClusterIndex(
keyFor(board, page.pageId, snapshotVersion),
payload,
page.pageIds
)
} catch (error) {
// Reported, never raised. The measurements are in hand and the tool's answer is correct; a
// failed write costs the next caller a render, which is exactly what they would have paid
// without a cache at all.
reportThumbnailError(error, {
ctx,
env,
request,
surface: 'mcp_cluster_index_write',
extras: { kind: board.kind, pageId: page.pageId },
})
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest'
import { Environment } from '../../types'
import { MCP_SERVER_INFO } from './boardTools'
import { SUPPORTED_PROTOCOL_VERSIONS } from './mcpServer'
import {
MCP_SERVER_CARD_PATH,
MCP_SERVER_CARD_WELL_KNOWN_PATH,
getMcpServerCard,
} from './mcpServerCard'
const RESOURCE = 'https://www.tldraw.com/api/app/mcp'
function makeEnv(overrides: Partial<Record<string, unknown>> = {}) {
return { MCP_SERVER_URL: RESOURCE, ...overrides } as unknown as Environment
}
function makeRequest() {
return new Request('https://sync.tldraw.xyz/app/mcp/server-card') as any
}
describe('getMcpServerCard', () => {
it('points at the MCP endpoint clients actually connect to', async () => {
const card = (await getMcpServerCard(makeRequest(), makeEnv()).json()) as any
expect(card.remotes).toEqual([
{
type: 'streamable-http',
url: RESOURCE,
supportedProtocolVersions: SUPPORTED_PROTOCOL_VERSIONS,
},
])
})
// The card is read before a client connects and never checked against anything afterwards, so
// nothing but this stops it drifting away from what the live server answers with.
it('reports the same version and title the server hands back on connect', async () => {
const card = (await getMcpServerCard(makeRequest(), makeEnv()).json()) as any
expect(card.version).toBe(MCP_SERVER_INFO.version)
expect(card.title).toBe(MCP_SERVER_INFO.title)
})
// `Response.json` would have said `application/json`, and did until a preview deploy showed the
// AI Catalog entry pointing here declaring a type the URL did not return. A client that checks
// the two against each other refuses on that.
it('is served as a Server Card rather than as plain JSON', () => {
expect(getMcpServerCard(makeRequest(), makeEnv()).headers.get('content-type')).toBe(
'application/mcp-server-card+json'
)
})
// Both are schema constraints, and both are the kind of thing that only fails once the document is
// published and somebody else's validator rejects it.
it('satisfies the schema constraints on name and description', async () => {
const card = (await getMcpServerCard(makeRequest(), makeEnv()).json()) as any
expect(card.name).toMatch(/^[a-zA-Z0-9.-]+\/[a-zA-Z0-9._-]+$/)
expect(card.description.length).toBeLessThanOrEqual(100)
expect(card.$schema).toBe(
'https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json'
)
})
// Falls back to the request's own origin when nothing is configured, which is what local dev and
// preview deployments run on.
it('derives the endpoint from the request when no server URL is configured', async () => {
const card = (await getMcpServerCard(
makeRequest(),
makeEnv({ MCP_SERVER_URL: undefined })
).json()) as any
expect(card.remotes[0].url).toBe('https://sync.tldraw.xyz/api/app/mcp')
})
})
describe('server card paths', () => {
// The `.well-known` alias sits outside the /api/* route pattern, so it only reaches this worker
// because wrangler.toml carries a route for the prefix. Getting the path wrong here is invisible
// until a request 404s in production.
it('places the canonical card under the MCP endpoint and the alias at the origin', () => {
expect(MCP_SERVER_CARD_PATH).toBe('/app/mcp/server-card')
expect(MCP_SERVER_CARD_WELL_KNOWN_PATH).toBe('/.well-known/mcp/server-card.json')
})
})

View File

@@ -0,0 +1,88 @@
import { IRequest } from 'itty-router'
import { Environment } from '../../types'
import { MCP_SERVER_INFO } from './boardTools'
import { getMcpResourceUrl } from './mcpAuth'
import { SUPPORTED_PROTOCOL_VERSIONS } from './mcpServer'
// A Server Card is the pre-connection half of MCP discovery: what a client reads to decide whether
// it wants this server and how to reach it, without a token. That matters here, where every call to
// the server itself is refused without one — an agent that cannot authenticate can still learn where
// to go.
//
// Nothing checks the card against the running server, so the values below are sourced from the same
// constants it answers with rather than restated.
/**
* The path the MCP spec reserves for a card: the server's own URL with `/server-card` appended. As
* registered on the router — the public form is `/api/app/mcp/server-card`, with the `/api` prefix
* stripped upstream.
*/
export const MCP_SERVER_CARD_PATH = '/app/mcp/server-card'
/**
* A second path for the same document, under `.well-known`.
*
* The extension's own discovery notes argue against this: a card is not site-wide the way OAuth
* metadata is, and the catalog at `/.well-known/ai-catalog.json` is the mechanism that is supposed
* to point at cards. It is served anyway because agent-readiness scanners probe it directly, and one
* more route pointing at the same handler costs less than being invisible to them. Prefer
* {@link MCP_SERVER_CARD_PATH} when linking.
*
* At the origin rather than under `/api`, so — like the protected resource metadata — it needs its
* own `wrangler.toml` route to reach this worker at all.
*/
export const MCP_SERVER_CARD_WELL_KNOWN_PATH = '/.well-known/mcp/server-card.json'
/** Not a version to bump: a breaking revision of the card shape publishes a new `vN` URL. */
const SERVER_CARD_SCHEMA_URL =
'https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json'
/**
* Reverse-DNS with exactly one slash, which the card schema requires and the runtime
* `serverInfo.name` (`tldraw-boards`) does not satisfy — the two identify the same server in two
* namespaces that cannot be made textually equal. The human-readable identity travels in `title`
* and `description`, which do match.
*/
const SERVER_CARD_NAME = 'com.tldraw/boards'
/**
* The media type a Server Card is served with, which is not `application/json`.
*
* Clients ask for this type by `Accept`, and the AI Catalog entry pointing here declares it — a
* catalog whose `type` disagrees with what the URL actually returns is the mismatch a strict client
* refuses on. `Response.json` would quietly make it `application/json`, so the card is serialised by
* hand.
*/
const SERVER_CARD_MEDIA_TYPE = 'application/mcp-server-card+json'
export function getMcpServerCard(request: IRequest, env: Environment): Response {
return new Response(
JSON.stringify({
$schema: SERVER_CARD_SCHEMA_URL,
name: SERVER_CARD_NAME,
version: MCP_SERVER_INFO.version,
title: MCP_SERVER_INFO.title,
// The schema caps this at 100 characters.
description: 'Search, inspect and screenshot the tldraw.com boards you have access to.',
websiteUrl: 'https://tldraw.dev',
repository: {
url: 'https://github.com/tldraw/tldraw',
source: 'github',
subfolder: 'apps/dotcom/sync-worker',
},
remotes: [
{
type: 'streamable-http',
url: getMcpResourceUrl(request, env),
supportedProtocolVersions: SUPPORTED_PROTOCOL_VERSIONS,
},
],
}),
{
headers: {
'content-type': SERVER_CARD_MEDIA_TYPE,
'cache-control': 'public, max-age=3600',
},
}
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,500 @@
import { DEFAULT_THUMBNAIL_HEIGHT, DEFAULT_THUMBNAIL_WIDTH } from '@tldraw/dotcom-shared'
import { RoomSnapshot } from '@tldraw/sync-core'
import {
OG_MAX_RENDER_ATTEMPTS,
OG_PENDING_MARKER_TTL_MS,
OG_REPAIR_COOLDOWN_MS,
OG_RETRY_DELAY_SECONDS,
} from '../../config'
import {
Environment,
OgImageRenderQueueMessage,
OgImageRenderReason,
ThumbnailBoardRef,
} from '../../types'
import { deleteRenderTokenRecord } from '../../utils/renderTokens'
import { enumerateBoardPages } from './boardTools'
import {
ResolvedThumbnailBoard,
captureThumbnailScreenshot,
loadBoardSnapshot,
putThumbnailPng,
resolveThumbnailBoard,
writeScreenshotTelemetry,
summarizeSnapshotContent,
} from './thumbnailRender'
import { classifyScreenshotFailure, reportThumbnailError } from './thumbnailShared'
// Queue-backed async board thumbnail generation. Renders are asked for by the things that change a
// board's content — publishing (the outbox publish effect) and editing (TLFileDurableObject) — and this
// consumer performs the capture out of band, refreshing the R2 cache the GET og-image route reads.
// That route only ever reads; the MCP tool must return its image in-band, so it captures inline into
// its own bucket. Neither goes through here.
//
// This path has no cap of any kind, by design. What bounds it is the render debounce upstream in
// TLFileDurableObject, which is per-board, so total spend scales with how many boards are edited at
// once. See "Request limits" in browser-run-thumbnails.md for why, and why the only rate limiting in
// the pipeline lives on the MCP endpoint instead (mcpServer.ts).
// OG images render a single page as the unfurl preview. Pick the first page (in board order) that
// has content, so a board whose first page is empty still gets a meaningful image; fall back to the
// first page when none have content, which renders as a blank.
function pickOgImagePageId(snapshot: RoomSnapshot): string | undefined {
const pages = enumerateBoardPages(snapshot)
if (pages.length === 0) return undefined
return (pages.find((page) => page.hasContent) ?? pages[0]).id
}
/**
* A board's one OG image. The key carries only what can address two objects at once: the board, and
* the theme, which a dark-mode card would need.
*
* Keep it that way, and keep the output dimensions out in particular. This key is the image's sole
* address, in a bucket with no expiration rule, so any segment that can change re-addresses every
* board's image at once and strands the old objects permanently — unreachable, un-overwritable, one per
* board. A size change is a replacement rather than a second object, so it belongs in the object's
* metadata, which overwrites in place.
*
* The trade: a size change serves old-sized images as fresh hits until each board next renders, since
* the stored `version` tracks board content rather than render parameters.
*/
export function getOgImageCacheKey(board: ThumbnailBoardRef) {
return `og/${board.kind}/${board.slug}/light.png`
}
export type EnqueueOgImageResult = 'enqueued' | 'already_pending' | 'unavailable'
// The single-flight marker. Its TTL and the retry constants live together in config.ts, where the
// comment on OG_PENDING_MARKER_TTL_MS explains the inequality that binds them.
function getOgImagePendingKey(board: ThumbnailBoardRef) {
return getOgImageCacheKey(board).replace(/\.png$/, '.pending')
}
// Armed when a crawler-triggered job gives up, consulted only by the OG route's published-board
// repair (see OG_REPAIR_COOLDOWN_MS in config.ts). Deliberately not consulted by enqueueOgImageRender
// itself: a republish is a genuine new snapshot and must render regardless of how the last attempt
// went, so only the one ask an outside caller can cause is metered. For the same reason the publish
// trigger *clears* it: the cooldown is evidence about the snapshot that failed, and a republish
// replaces that snapshot, so the new one gets its own repair backstop — and re-arms the cooldown
// itself if it turns out to fail too.
function getOgImageRepairCooldownKey(board: ThumbnailBoardRef) {
return getOgImageCacheKey(board).replace(/\.png$/, '.repair-cooldown')
}
export async function isOgImageRepairOnCooldown(
env: Environment,
board: ThumbnailBoardRef
): Promise<boolean> {
if (!env.THUMBNAILS) return false
// Fail open on a read error: the enqueue this gates needs the same bucket for its pending marker,
// so if R2 is genuinely down the ask fails and reports there rather than being silently skipped.
const existing = await env.THUMBNAILS.head(getOgImageRepairCooldownKey(board)).catch(() => null)
return isMarkerAlive(existing)
}
// The pending and repair-cooldown markers are empty objects whose lifetime lives in `expiresAt`
// metadata, since the bucket has no expiration rule of its own.
function isMarkerAlive(marker: R2Object | null): boolean {
if (!marker) return false
const expiresAt = Number(marker.customMetadata?.expiresAt)
return Number.isFinite(expiresAt) && expiresAt > Date.now()
}
function putMarker(bucket: R2Bucket, key: string, ttlMs: number, now = Date.now()) {
return bucket.put(key, new Uint8Array(), {
customMetadata: { expiresAt: String(now + ttlMs) },
})
}
export async function enqueueOgImageRender(
env: Environment,
board: ThumbnailBoardRef,
{
reason,
followUp,
firedAt,
}: {
// Required rather than defaulted: every trigger knows why it is asking, and a default would put
// whichever one forgot to say into some other trigger's telemetry bucket.
reason: OgImageRenderReason
followUp?: boolean
/**
* When the ask fired, for asks made by the file DO's debounce alarm. The marker's expiry is
* stamped from it rather than from the moment the R2 write below lands: the alarm resets the
* debouncer's window *before* this function's R2 round trip runs, so a persist can land in
* between and start a new max-wait window earlier than the marker's write. Counting the TTL
* from the fire keeps that window ending at or past the marker's expiry, which is what lets
* OG_RENDER_MAX_WAIT_MS >= OG_PENDING_MARKER_TTL_MS hold by exact equality (both pinned in
* ogImageQueue.test.ts). Callers that are not debounced fires omit it.
*/
firedAt?: number
}
): Promise<EnqueueOgImageResult> {
if (!env.THUMBNAILS || !env.QUEUE) return 'unavailable'
const pendingKey = getOgImagePendingKey(board)
if (isMarkerAlive(await env.THUMBNAILS.head(pendingKey))) {
return 'already_pending'
}
await putMarker(env.THUMBNAILS, pendingKey, OG_PENDING_MARKER_TTL_MS, firedAt)
const message: OgImageRenderQueueMessage = {
type: 'og-image-render',
kind: board.kind,
slug: board.slug,
reason,
...(followUp ? { followUp } : null),
}
await env.QUEUE.send(message)
return 'enqueued'
}
// The two keys are not symmetric when a board stops being publicly viewable, and the difference is
// what the next two functions are for. `og/shared_file/{fileId}/…` is keyed on the file id, so it
// stays useful for as long as the board exists and unsharing keeps it: an unshared board's image is
// already unreachable, since the only route that serves one re-checks the gate per request.
// `og/published/{publishedSlug}/…` depicts a published snapshot, so unpublishing destroys what it was
// a picture of — and its key is the published slug, so leaving it would strand an object that a
// regenerated publish link could make permanently unreadable. See "Nothing deletes a rendered image"
// in browser-run-thumbnails.md.
// Clears only the pending render marker, keeping the image. Called when a render job is dropped: a
// marker left behind would suppress the next legitimate enqueue until it expired.
export async function clearOgImagePendingMarker(
env: Environment,
board: ThumbnailBoardRef
): Promise<void> {
if (!env.THUMBNAILS) return
await env.THUMBNAILS.delete(getOgImagePendingKey(board)).catch(() => {})
}
// Deletes the image as well as the marker and the repair cooldown. Only for `published` boards
// losing their publication. Scoped by the `board` passed in, so calling it with `kind: 'published'`
// cannot touch the file-keyed image of the same board.
export async function deleteOgImage(env: Environment, board: ThumbnailBoardRef): Promise<void> {
if (!env.THUMBNAILS) return
await Promise.all([
env.THUMBNAILS.delete(getOgImageCacheKey(board)).catch(() => {}),
env.THUMBNAILS.delete(getOgImagePendingKey(board)).catch(() => {}),
env.THUMBNAILS.delete(getOgImageRepairCooldownKey(board)).catch(() => {}),
])
}
/**
* Everything this pipeline stores for one board, removed when the file is hard deleted
* (`TLFileDurableObject.appFileRecordDidDelete`, alongside the room snapshot and the histories).
*
* Both kinds go, because each is normally kept for a reason about a board that still exists: the
* file-keyed image survives *unsharing* deliberately, and the published-slug image only goes on
* *unpublish*. A hard delete leaves nothing to reshare and no snapshot to depict.
*
* The stakes are orphans rather than tidiness. `og/…` keys carry no version, so a board owns exactly
* one object, and `THUMBNAILS` has no lifecycle rule and must never be given one — so anything left
* behind here is an object nothing will ever read, overwrite or sweep. MCP screenshots need no
* equivalent: their keys carry a content version and their bucket expires them.
*
* Best effort throughout, because it runs inside a teardown that must complete regardless.
*/
export async function deleteBoardThumbnails(
env: Environment,
{ fileId, publishedSlug }: { fileId: string; publishedSlug?: string | null }
): Promise<void> {
const boards: ThumbnailBoardRef[] = [{ kind: 'shared_file', slug: fileId }]
// A file with no published slug never had a published key to delete, and deriving one from an empty
// slug would address `og/published//light.png` — some other board's neighbourhood, not this one's.
if (publishedSlug) boards.push({ kind: 'published', slug: publishedSlug })
await Promise.all(
boards.flatMap((board) => [deleteOgImage(env, board), deleteRenderTokenRecord(env, board)])
)
}
/**
* The publish effect's ask, and the reporting that goes with it.
*
* This is the *only* trigger a published board has. Its snapshot is frozen, so nothing edits it into
* asking again, where a shared file re-asks on every persist that advances its document clock. So an
* ask lost here — thrown, or turned away as `already_pending` by a marker an earlier failure left
* behind, or `unavailable` — leaves that board's card generic until it is republished, and none of
* those are exceptional enough to notice by themselves. `getOgImage` repairs the outcome on the next
* fetch; `reportProblem` is how the cause becomes visible.
*/
export async function enqueuePublishThumbnailRender(
env: Environment,
publishedSlug: string,
reportProblem: (error: unknown) => void
): Promise<void> {
const board: ThumbnailBoardRef = { kind: 'published', slug: publishedSlug }
// An in-place republish reuses the slug, so a cooldown armed against the previous snapshot's
// failure would otherwise outlive the snapshot it was evidence about and block the new one's
// repair. Cleared unconditionally and first: even if the enqueue below fails, the next crawl's
// repair is exactly the backstop that failure needs.
await env.THUMBNAILS?.delete(getOgImageRepairCooldownKey(board)).catch(() => {})
try {
const result = await enqueueOgImageRender(env, board, { reason: 'publish' })
if (result !== 'enqueued') {
reportProblem(new Error(`Publish thumbnail enqueue did not take effect: ${result}`))
}
} catch (error) {
reportProblem(error)
}
}
// Queue consumer. Re-resolves the board at render time rather than trusting the enqueued state: a
// board deleted or unpublished while queued is dropped without rendering, and the version is re-read
// so the render captures the newest content, coalescing a burst of enqueues into one capture.
export async function handleOgImageRenderMessage(
env: Environment,
message: Message<OgImageRenderQueueMessage>,
ctx?: ExecutionContext
): Promise<void> {
const { kind, slug } = message.body
const boardRef: ThumbnailBoardRef = { kind, slug }
// A message already in the queue may carry no reason; see OgImageRenderQueueMessage.
const reason = message.body.reason ?? 'crawler'
// Normalised once, for the same reason `reason` is: the flag is optional on the wire (only follow-up
// messages set it), and every delivery of one job must record the same value on every path.
const followUp = message.body.followUp ?? false
const cacheKey = getOgImageCacheKey(boardRef)
// The board was deleted, was unpublished, or has no persisted content. Terminal, not transient: ack
// rather than retry, since no number of retries brings the board back. Applies the same delete/keep
// asymmetry as the effects above.
const dropNoLongerViewable = async () => {
await (kind === 'published'
? deleteOgImage(env, boardRef)
: clearOgImagePendingMarker(env, boardRef))
writeScreenshotTelemetry(env, {
source: 'queue',
reason,
followUp,
cacheStatus: 'miss',
failureReason: 'board_not_viewable',
})
message.ack()
}
try {
// 'render' rather than 'public': every board gets a thumbnail, private ones included. The OG
// route re-applies the public gate when it serves.
const resolved = await resolveThumbnailBoard(env, kind, slug, { access: 'render' })
if (!resolved.ok) {
await dropNoLongerViewable()
return
}
const board = resolved.board
// Another consumer (or an earlier retry) may already have rendered this version.
const cached = await env.THUMBNAILS?.head(cacheKey)
if (cached?.customMetadata?.version === String(board.version)) {
await clearOgImagePendingMarker(env, boardRef)
writeScreenshotTelemetry(env, { source: 'queue', reason, followUp, cacheStatus: 'hit' })
message.ack()
return
}
if (!env.THUMBNAILS) {
throw new Error('THUMBNAILS bucket is not configured')
}
// No capacity check, by design (see the top of this file). The version check above is what stops
// redundant work: everything past this point is a board whose cached thumbnail genuinely no
// longer matches its content.
// Loaded to target the first page that has content, so a board whose first page is empty still
// gets a meaningful unfurl image.
//
// `file` is the row the resolve above already gated on, handed back so this read re-applies the
// gate without asking Postgres the same question a second time. Safe precisely here: the two are
// microseconds apart in one function, where a re-read would return the row we already hold. The
// render page's own read (getThumbnailSnapshot) deliberately does not do this — it is a separate
// request, and its re-read is what makes an un-share land inside the token's window.
const snapshot = await loadBoardSnapshot(env, board, { access: 'render', file: board.file })
if (!snapshot) {
// No persisted content. The render page reads the snapshot through the same functions, so it
// would 404 and come back as a render failure — after spending a Browser Run slot to learn
// what we already know. Retry from here instead, in case content lands shortly after the
// enqueue. A read that *fails* throws rather than landing here, and the catch below retries it
// the same way, so neither path spends Browser Run.
await retryOrDrop(env, message, {
reason,
followUp,
failureReason: 'board_empty',
board: boardRef,
})
return
}
// The render page exports the chosen page; the worker screenshots it through the BROWSER
// binding and writes the PNG to the cache key the OG route reads.
const pageId = pickOgImagePageId(snapshot)
const render = await captureThumbnailScreenshot(env, board, {
surface: 'og',
pageId,
theme: 'light',
width: DEFAULT_THUMBNAIL_WIDTH,
height: DEFAULT_THUMBNAIL_HEIGHT,
// `source` is the telemetry surface, not the render pipeline: these sessions belong to the
// queue's ledger even though the job is signed for the og pipeline.
telemetry: { source: 'queue', reason },
content: summarizeSnapshotContent(snapshot, pageId),
})
await putThumbnailPng(env.THUMBNAILS, cacheKey, render.base64, board.version)
await clearOgImagePendingMarker(env, boardRef)
await enqueueFollowUpIfBoardMoved(env, message, board, reason, ctx)
writeScreenshotTelemetry(env, { source: 'queue', reason, followUp, cacheStatus: 'miss' })
message.ack()
} catch (error) {
// Reported once per job, on the delivery that gives up, rather than once per delivery: a board
// that fails deterministically fails all OG_MAX_RENDER_ATTEMPTS times, and one problem should not
// file three events. A failure that recovers on retry reports nothing, which is correct — the
// render landed. Sentry gets the unbounded original; telemetry below gets a bounded reason code,
// since raw error.message would blow up that dimension's cardinality.
if (message.attempts >= OG_MAX_RENDER_ATTEMPTS) {
reportThumbnailError(error, {
ctx,
env,
surface: 'og_queue',
extras: { kind, attempts: message.attempts },
})
}
// A board deleted between the resolve above and the snapshot read is retried rather than dropped,
// because from here it looks like any other read failure. That costs one extra delivery, not one
// extra render: the retry re-resolves at the top and drops before spending any Browser Run.
await retryOrDrop(env, message, {
reason,
followUp,
failureReason: classifyScreenshotFailure(error),
board: boardRef,
})
}
}
/**
* A capture takes seconds, and the board can change during one. A *publish* landing in that window
* asks for a render, finds the pending marker this job set, and is turned away — the ask is
* *dropped*, not deferred, and nothing ever re-asks for a published board: its snapshot is frozen,
* so the render we just wrote would be the last word, showing the previous publication. This check
* is what closes that, and it is `published`-only because published boards are the only kind whose
* dropped ask stays dropped.
*
* A shared file's dropped ask is deferred by construction, in two halves pinned in
* ogImageQueue.test.ts. A *debounced* fire's ask is only turned away while this job's marker is
* alive, which places its persist a full OG_RENDER_DEBOUNCE_MS before the marker's clear — while
* the image whose write performs that clear read its snapshot at most THUMBNAIL_RENDER_TIMEOUT_MS
* plus the post-capture tail before it, retries included. The debounce being the longer of the two
* means the dropped ask's content is already in the image. A *max-wait* fire escapes that bound but
* cannot be turned away at all: the fire that enqueued this job reset the debouncer's window, so a
* clamped fire lands at or past the marker's TTL. Following up here as well bought nothing worth
* its cost — follow-ups were roughly a fifth of shared-file
* queue captures in production (measured 2026-08-11 via the `followup` telemetry blob): on a board
* that settled, the follow-up merely relocated the render the debounced ask was about to do; on a
* board still moving, it rendered a mid-edit state the next debounced render superseded.
*
* Both halves price the job ending in an image write, which a give-up never does — the asks its
* marker turned away deferred into nothing. A known residue, not a regression; see "the deferral
* stops at that give-up" in browser-run-thumbnails.md.
*
* Deliberately never chained. A published board republished without pause would otherwise find
* itself stale on every follow-up and render continuously. One extra render per triggered render is
* the ceiling.
*
* Best effort: the image is already written and the marker already cleared, so a failure here loses a
* refresh, not the render. It must not turn a completed job into a retry.
*/
async function enqueueFollowUpIfBoardMoved(
env: Environment,
message: Message<OgImageRenderQueueMessage>,
rendered: ResolvedThumbnailBoard,
reason: OgImageRenderReason,
ctx?: ExecutionContext
) {
if (rendered.kind !== 'published') return
if (message.body.followUp) return
try {
const current = await readCurrentPublishedVersion(env, rendered)
if (current === null) return
if (String(current) === String(rendered.version)) return
await enqueueOgImageRender(env, rendered, { reason, followUp: true })
} catch (error) {
reportThumbnailError(error, {
ctx,
env,
surface: 'og_queue',
extras: { kind: rendered.kind, followUpCheck: true },
})
}
}
/**
* The published board's current `lastPublished`, for the "did it move while we were capturing?"
* check above and nothing else. `null` means there is nothing to compare against, which is treated
* as "don't follow up". A full resolve rather than something lighter, because a published version is
* a Postgres column with no R2 shortcut — and publishing is not a hot path.
*/
async function readCurrentPublishedVersion(
env: Environment,
board: ResolvedThumbnailBoard
): Promise<string | number | null> {
const resolved = await resolveThumbnailBoard(env, board.kind, board.slug, { access: 'render' })
return resolved.ok ? resolved.board.version : null
}
async function retryOrDrop(
env: Environment,
message: Message<OgImageRenderQueueMessage>,
{
reason,
followUp,
failureReason,
board,
}: {
/**
* Passed in already resolved rather than read off the message here, so a delivery that fails is
* attributed to the same trigger as one that succeeds. Reading `message.body.reason` directly
* would bucket a legacy message with no reason as `none` on this path and `crawler` on the
* others, splitting one job's deliveries across two values.
*/
reason: OgImageRenderReason
/** Resolved by the caller for the same reason as `reason` above. */
followUp: boolean
failureReason: string
board: ThumbnailBoardRef
}
) {
// One datapoint per delivery, the opposite of the Sentry report above: three deliveries are three
// failures on this ledger, one problem on Sentry's. The Browser Run spend of a delivery that
// reached the capture is on the browser_run_session event, written inside the render itself.
writeScreenshotTelemetry(env, {
source: 'queue',
reason,
followUp,
cacheStatus: 'miss',
failureReason,
})
// attempts counts this delivery, so attempts >= MAX means this was the final try.
if (message.attempts < OG_MAX_RENDER_ATTEMPTS) {
// Marker kept: a retry is still this job in flight, and the next delivery re-resolves anyway, so
// an ask turned away meanwhile costs nothing — it would have rendered the same content.
message.retry({ delaySeconds: OG_RETRY_DELAY_SECONDS * message.attempts })
return
}
// Given up, so nothing is in flight and the marker has nothing left to single-flight. Clearing it
// rather than letting it lapse means the next ask is acted on immediately instead of being turned
// away for the rest of the TTL — which matters most here, since this board has no image at all.
await clearOgImagePendingMarker(env, board)
// But when the ask that failed was itself the OG route's crawler-triggered repair, "immediately"
// is the problem rather than the point: with the marker gone, the next unauthenticated request
// would re-arm the whole retry chain, letting traffic an outside caller controls spend Browser Run
// on a board that just proved it cannot render. Arm the repair cooldown instead — publish- and
// edit-triggered asks don't consult it, so a genuine republish still renders straight away. Best
// effort: a cooldown that fails to write costs extra renders, not the ack.
if (board.kind === 'published' && reason === 'crawler' && env.THUMBNAILS) {
await putMarker(
env.THUMBNAILS,
getOgImageRepairCooldownKey(board),
OG_REPAIR_COOLDOWN_MS
).catch(() => {})
}
message.ack()
}

View File

@@ -0,0 +1,163 @@
import { IRequest } from 'itty-router'
import { describe, expect, it } from 'vitest'
import { Environment } from '../../types'
import {
ThumbnailRenderJob,
mintThumbnailRenderToken,
recordMintedRenderToken,
} from '../../utils/renderTokens'
import { putThumbnailRenderResult } from './putThumbnailRenderResult'
import { makeFakeThumbnailsBucket, makeScreenshotTestEnv } from './screenshotTestHelpers'
function makeRequest(body: unknown): IRequest {
return { json: async () => body } as unknown as IRequest
}
const JOB: ThumbnailRenderJob = {
v: 1,
kind: 'published',
slug: 'board',
version: 1,
access: 'public',
surface: 'mcp',
camera: 'content',
x: 0,
y: 0,
z: 1,
width: 1200,
height: 630,
theme: 'light',
exp: Date.now() + 60_000,
}
const TIMINGS = {
bootAt: 100,
dataAt: 150,
mountAt: 900,
settledAt: 1400,
exportedAt: 2100,
}
describe('the timing beacon', () => {
it('writes one datapoint per beacon, carrying the surface', async () => {
const env = makeScreenshotTestEnv() as unknown as Environment
const token = await mintThumbnailRenderToken(env, JOB)
const response = await putThumbnailRenderResult(makeRequest({ token, timings: TIMINGS }), env)
expect(response.status).toBe(200)
const calls = (env.MEASURE as any).writeDataPoint.mock.calls
expect(calls).toHaveLength(1)
const point = calls[0][0]
// writeDataPoint prefixes [eventName, workerName]; the beacon's own blobs follow.
expect(point.blobs[0]).toBe('render_page_timings')
expect(point.blobs.slice(2)).toEqual(['surface:mcp'])
expect(point.doubles).toEqual([100, 150, 900, 1400, 2100])
})
it('refuses a beacon whose token was not signed by us', async () => {
const env = makeScreenshotTestEnv() as unknown as Environment
const other = makeScreenshotTestEnv({
MCP_SCREENSHOT_TOKEN_SECRET: 'someone-elses-secret',
}) as unknown as Environment
const forged = await mintThumbnailRenderToken(other, JOB)
const response = await putThumbnailRenderResult(
makeRequest({ token: forged, timings: TIMINGS }),
env
)
expect(response.status).toBe(403)
expect((env.MEASURE as any).writeDataPoint).not.toHaveBeenCalled()
})
// Unlike the snapshot route, the beacon does not require the minted-token record. An MCP record
// is keyed per capture and deleted as soon as the session returns, and the page sends this beacon
// immediately before signalling ready — so a live capture's beacon regularly lands after its own
// record is gone. Requiring it would drop the timings for the fastest renders, which is the wrong
// end of the distribution to lose. Both a recorded and an unrecorded token are accepted.
it('accepts a signed token whose minted record has already been cleaned up', async () => {
const env = makeScreenshotTestEnv({
THUMBNAILS: makeFakeThumbnailsBucket(),
}) as unknown as Environment
const job: ThumbnailRenderJob = { ...JOB, access: 'render' }
const unrecorded = await mintThumbnailRenderToken(env, job)
const accepted = await putThumbnailRenderResult(
makeRequest({ token: unrecorded, timings: TIMINGS }),
env
)
expect(accepted.status).toBe(200)
const recordedJob = { ...job, exp: job.exp + 1 }
const recorded = await mintThumbnailRenderToken(env, recordedJob)
await recordMintedRenderToken(env, recordedJob, recorded)
const stillAccepted = await putThumbnailRenderResult(
makeRequest({ token: recorded, timings: TIMINGS }),
env
)
expect(stillAccepted.status).toBe(200)
expect((env.MEASURE as any).writeDataPoint).toHaveBeenCalledTimes(2)
})
// The signature stays the whole gate, so an expired token is still refused: nothing else bounds
// how long a leaked one could keep writing datapoints.
it('refuses an expired token', async () => {
const env = makeScreenshotTestEnv() as unknown as Environment
const expired = await mintThumbnailRenderToken(env, { ...JOB, exp: Date.now() - 1 })
const response = await putThumbnailRenderResult(
makeRequest({ token: expired, timings: TIMINGS }),
env
)
expect(response.status).toBe(403)
expect((env.MEASURE as any).writeDataPoint).not.toHaveBeenCalled()
})
it('refuses non-finite stamps rather than polluting the dataset', async () => {
const env = makeScreenshotTestEnv() as unknown as Environment
const token = await mintThumbnailRenderToken(env, JOB)
const response = await putThumbnailRenderResult(
makeRequest({ token, timings: { ...TIMINGS, settledAt: 'NaN' } }),
env
)
expect(response.status).toBe(400)
expect((env.MEASURE as any).writeDataPoint).not.toHaveBeenCalled()
})
it.each([null, 42, 'a string'])(
'refuses the non-object JSON body %j with a 400',
async (body) => {
const env = makeScreenshotTestEnv() as unknown as Environment
const response = await putThumbnailRenderResult(makeRequest(body), env)
expect(response.status).toBe(400)
}
)
it('refuses timings: null rather than destructuring it', async () => {
const env = makeScreenshotTestEnv() as unknown as Environment
const token = await mintThumbnailRenderToken(env, JOB)
const response = await putThumbnailRenderResult(makeRequest({ token, timings: null }), env)
expect(response.status).toBe(400)
expect((env.MEASURE as any).writeDataPoint).not.toHaveBeenCalled()
})
it('leaves the measure-result branch alone', async () => {
const env = makeScreenshotTestEnv() as unknown as Environment
const token = await mintThumbnailRenderToken(env, { ...JOB, mode: 'measure' })
const response = await putThumbnailRenderResult(
makeRequest({ token, bounds: { 'shape:a': { x: 0, y: 0, w: 10, h: 10 } } }),
env
)
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ error: false, stored: 1 })
})
})

View File

@@ -0,0 +1,111 @@
import {
ThumbnailRenderResultRequestBody,
ThumbnailRenderTimingsRequestBody,
} from '@tldraw/dotcom-shared'
import { IRequest } from 'itty-router'
import { Environment } from '../../types'
import { writeDataPoint } from '../../utils/analytics'
import { verifyThumbnailRenderToken } from '../../utils/renderTokens'
import { ShapeMeasurement } from './boardTools'
import { putRenderResult } from './thumbnailRender'
// How measurements get out of the browser.
//
// Browser Rendering hands the Worker pixels, not JSON, so a render page asked to measure a page has
// to push its answer back. It POSTs here before signalling ready, and the Worker — still blocked on
// its own screenshot call — picks the result up as soon as that returns.
//
// Scoped to the signed job: the storage key is the token, so a result can only ever reach the render
// that asked for it.
export async function putThumbnailRenderResult(
request: IRequest,
env: Environment
): Promise<Response> {
let body: ThumbnailRenderResultRequestBody | ThumbnailRenderTimingsRequestBody
try {
body = (await request.json()) as
| ThumbnailRenderResultRequestBody
| ThumbnailRenderTimingsRequestBody
} catch {
return Response.json({ error: true, message: 'Invalid JSON body' }, { status: 400 })
}
// `null`, numbers and strings are valid JSON too; refused as the caller's mistake here, where
// the `in` and property checks below would throw on them and escape as a worker 500.
if (!body || typeof body !== 'object') {
return Response.json({ error: true, message: 'Invalid JSON body' }, { status: 400 })
}
// The render page's phase-timing beacon, sharing this route because it shares the route's
// auth story: a signed token proves the POST comes from a render we asked for. Telemetry only —
// nothing downstream waits on it, so it is accepted for any valid token, screenshot or measure.
if ('timings' in body) {
// `typeof null` is 'object', and these timings get destructured below.
if (!body.token || !body.timings || typeof body.timings !== 'object') {
return Response.json(
{ error: true, message: 'token and timings are required' },
{ status: 400 }
)
}
// Signed and unexpired, but deliberately *not* minted-checked, unlike the snapshot route. An
// MCP record is keyed per capture and deleted the moment the session returns (see
// deleteMintedRenderToken), while the page sends this beacon and signals ready in the next
// statement — so requiring the record would race the worker's own cleanup and lose the
// timings for precisely the fastest renders, which is the wrong end to go blind at. The
// signature is enough here because this branch hands back no board data and nothing waits on
// it: a forged token could only add noise to `render_page_timings`, which is already true of
// every `public` job, where the minted check passes vacuously anyway.
const job = await verifyThumbnailRenderToken(env, body.token)
if (!job) {
return Response.json({ error: true, message: 'Invalid render token' }, { status: 403 })
}
const { bootAt, dataAt, mountAt, settledAt, exportedAt } = body.timings
const stamps = [bootAt, dataAt, mountAt, settledAt, exportedAt]
if (!stamps.every(Number.isFinite)) {
return Response.json({ error: true, message: 'timings must be finite' }, { status: 400 })
}
// One datapoint per completed export: where the in-browser time went. browser_run_session
// prices the whole session; this decomposes it into boot / acquire / mount / settle / export,
// which is what ranks the render page's optimisations against each other.
writeDataPoint(undefined, env.MEASURE, env, 'render_page_timings', {
blobs: [`surface:${job.surface ?? 'og'}`],
doubles: stamps,
})
return Response.json({ error: false })
}
if (!body?.token || !body.bounds || typeof body.bounds !== 'object') {
return Response.json({ error: true, message: 'token and bounds are required' }, { status: 400 })
}
const job = await verifyThumbnailRenderToken(env, body.token)
// The mode is inside the signed payload, so a screenshot token can't be replayed to post a result.
if (!job || job.mode !== 'measure') {
return Response.json({ error: true, message: 'Invalid render token' }, { status: 403 })
}
const bounds: Record<string, ShapeMeasurement> = {}
for (const [shapeId, box] of Object.entries(body.bounds)) {
// A non-finite value would produce NaN distances and silently detach that shape from the
// minimum spanning tree, so anything malformed is dropped rather than stored.
if (
!Number.isFinite(box?.x) ||
!Number.isFinite(box?.y) ||
!Number.isFinite(box?.w) ||
!Number.isFinite(box?.h)
) {
continue
}
bounds[shapeId] = {
minX: box.x,
minY: box.y,
maxX: box.x + box.w,
maxY: box.y + box.h,
// Only an editor can answer getText, so whatever it said is kept verbatim.
...(typeof box.text === 'string' && box.text ? { text: box.text } : null),
}
}
await putRenderResult(env, body.token, bounds)
return Response.json({ error: false, stored: Object.keys(bounds).length })
}

View File

@@ -0,0 +1,315 @@
import { vi } from 'vitest'
import { Environment } from '../../types'
import { base64UrlDecode } from '../../utils/base64'
// Shared fakes for the Browser Run thumbnail / OG image tests (thumbnailRender,
// mcpServer, ogImageQueue, getOgImage). The R2/browser/queue fakes, snapshot builder
// and token helpers belong here rather than in any one test file, so that all four exercise the same
// stand-ins.
// Builds a room snapshot with the given pages and per-page shape counts. Shapes are parented
// directly to their page, which is what enumerateBoardPages checks for "has content".
export function makeSnapshot(
pages: Array<{ id: string; index: string; name?: string; shapes: number }>,
boardName: string | null = 'My Board'
) {
const documents: Array<{ state: any }> = [
{ state: { typeName: 'document', id: 'document:document', name: boardName ?? '' } },
]
for (const page of pages) {
documents.push({
state: { typeName: 'page', id: page.id, name: page.name, index: page.index },
})
for (let i = 0; i < page.shapes; i++) {
documents.push({
state: {
typeName: 'shape',
id: `shape:${page.id}-${i}`,
parentId: page.id,
type: 'geo',
x: i * 100,
y: 0,
rotation: 0,
props: { w: 80, h: 80 },
},
})
}
}
return { documents, schema: { schemaVersion: 2, sequences: {} } } as any
}
// In-memory stand-in for an R2 bucket (THUMBNAILS or MCP_DATA_BUCKET, which have the same shape).
// Exposes `store` so tests can inspect or seed
// entries directly. Covers get/head/put/delete; entries carry the customMetadata and upload time
// the routes read.
export function makeFakeThumbnailsBucket() {
const store = new Map<
string,
{ body: ArrayBuffer; customMetadata?: Record<string, string>; uploaded: Date; etag: string }
>()
// R2 exposes an object's etag twice: `etag` bare and `httpEtag` quoted for the header. The OG route
// compares against the first and sends the second, so the fake has to carry both. The value only
// has to change when the bytes do, which a counter gives without hashing anything.
let version = 0
const etagOf = (value: { etag: string }) => ({ etag: value.etag, httpEtag: `"${value.etag}"` })
return {
store,
async get(key: string) {
const value = store.get(key)
if (!value) return null
return {
customMetadata: value.customMetadata,
uploaded: value.uploaded,
...etagOf(value),
body: value.body,
arrayBuffer: async () => value.body,
// R2 objects expose both; the render-result read uses text, the PNG cache uses bytes.
text: async () => new TextDecoder().decode(value.body),
}
},
async head(key: string) {
const value = store.get(key)
if (!value) return null
return { customMetadata: value.customMetadata, uploaded: value.uploaded, ...etagOf(value) }
},
async put(
key: string,
body: ArrayBuffer | string,
options?: { customMetadata?: Record<string, string> }
) {
store.set(key, {
// Real R2 accepts a string body; normalize so arrayBuffer() and text() both work
// whichever form the caller used.
body: typeof body === 'string' ? new TextEncoder().encode(body).buffer : body,
customMetadata: options?.customMetadata,
uploaded: new Date(Date.now()),
etag: `etag-${++version}`,
})
},
async delete(key: string) {
store.delete(key)
},
// Prefix listing, which the render-token cleanup uses now that a board's records are spread
// across one key per surface (and, for MCP, per page and theme). `truncated` is always false:
// the real bucket paginates at 1000 and a board never approaches that.
async list({ prefix = '' }: { prefix?: string } = {}) {
return {
truncated: false as const,
objects: [...store.keys()]
.filter((key) => key.startsWith(prefix))
.map((key) => ({ key, ...etagOf(store.get(key)!) })),
}
},
}
}
// In-memory stand-in for the ROOMS bucket. Only `head` is used (to read the persisted snapshot's
// etag); pass `null` to simulate a room with no persisted snapshot.
export function makeFakeRoomsBucket(etag: string | null = 'room-etag-1') {
return {
async head(_key: string) {
return etag === null ? null : { etag }
},
}
}
// The BROWSER binding's `.quickAction('screenshot', body)` returns a Response whose body is the PNG
// bytes. [1,2,3] base64-encodes to AQID. Pass a custom impl to simulate failures.
export function makeBrowserBinding(
screenshot: (body: any) => Promise<Response> = async () =>
new Response(new Uint8Array([1, 2, 3]), { status: 200 })
) {
return { quickAction: vi.fn((_action: string, body: any) => screenshot(body)) }
}
export function makeFakeQueue() {
return { send: vi.fn(async (_message: unknown) => undefined) }
}
// Stands in for the render page on a `measure` job: a real one POSTs its measurements to
// /app/thumbnail-render/result before signalling ready, so the fake writes them to the same place
// the worker reads from. Without this every clustering tool would fail on a missing result, since
// they all measure the page before clustering it.
export function makeMeasuringBrowserBinding(
env: () => Environment,
bounds: Record<
string,
{ minX: number; minY: number; maxX: number; maxY: number; text?: string }
> = {}
) {
return makeBrowserBinding(async (body: any) => {
const token = new URL(body.url).searchParams.get('token')
const job = token
? JSON.parse(new TextDecoder().decode(base64UrlDecode(token.split('.')[0])))
: null
if (job?.mode === 'measure') {
await (env().THUMBNAILS as R2Bucket).put(
`render-result/${encodeURIComponent(token!)}.json`,
JSON.stringify(bounds)
)
}
return new Response(new Uint8Array([1, 2, 3]), { status: 200 })
})
}
// In-memory stand-in for the TLDR_DOC namespace, with the file object's MCP cluster index storage.
//
// One store per object id, because that is the property the design rests on: the index key carries no
// slug precisely because each file has its own object, so a fake that pooled every file into one
// store could not tell a right `fileId` from a wrong one.
//
// Within a store, rows are keyed the way the real table is — (kind, pageId), with the version held as
// a value rather than part of the key — so a write for new content replaces the old row exactly as
// `INSERT OR REPLACE` does, a read for a stale version misses, and a write prunes the pages the board
// no longer has. mcpClusterIndexStorage.test.ts is what holds this fake to the real statements.
export function makeFakeFileDurableObjectNamespace() {
const objects = new Map<string, Map<string, { version: string; payload: string }>>()
const calls = { get: 0, put: 0 }
const keyOf = (key: { kind: string; pageId: string }) => `${key.kind}/${key.pageId}`
const storeFor = (id: string) => {
const existing = objects.get(id)
if (existing) return existing
const created = new Map<string, { version: string; payload: string }>()
objects.set(id, created)
return created
}
return {
objects,
calls,
// A legible id rather than an opaque hash, so an assertion that reaches one reads as `do(<name>)`.
idFromName: (name: string) => ({ toString: () => `do(${name})` }),
get: (id: { toString(): string }) => {
const store = storeFor(String(id))
return {
async getMcpClusterIndex(key: any) {
calls.get++
const row = store.get(keyOf(key))
return row && row.version === key.version ? row.payload : null
},
async putMcpClusterIndex(key: any, payload: string, livePageIds: string[]) {
calls.put++
for (const existing of [...store.keys()]) {
const [kind, pageId] = existing.split('/')
if (kind === key.kind && !livePageIds.includes(pageId)) store.delete(existing)
}
store.set(keyOf(key), { version: key.version, payload })
},
}
},
}
}
export function makeScreenshotTestEnv(overrides: Partial<Record<string, unknown>> = {}) {
return {
BROWSER: makeBrowserBinding(),
MCP_SCREENSHOT_RENDER_ORIGIN: 'https://www.tldraw.com',
MCP_SCREENSHOT_TOKEN_SECRET: 'test-secret',
MEASURE: { writeDataPoint: vi.fn() },
QUEUE: makeFakeQueue(),
// The MCP cluster index cache lives on the file's durable object, so the default env has a
// working one: a test that measures twice for the same board and page is asserting that the
// cache did not hold, which is worth failing on rather than passing by accident.
TLDR_DOC: makeFakeFileDurableObjectNamespace(),
...overrides,
} as unknown as Environment
}
/** The fake TLDR_DOC namespace an env was built with, for tests that assert on cache traffic. */
export function clusterIndexStoreOf(env: Environment) {
return env.TLDR_DOC as unknown as ReturnType<typeof makeFakeFileDurableObjectNamespace>
}
/** Every cluster index row an env holds, as `<object>|<kind>/<pageId>`, for whole-store assertions. */
export function clusterIndexKeysOf(env: Environment) {
return [...clusterIndexStoreOf(env).objects].flatMap(([id, store]) =>
[...store.keys()].map((key) => `${id}|${key}`)
)
}
export function screenshotOf(env: Environment) {
return (env.BROWSER as any).quickAction as ReturnType<typeof vi.fn>
}
// The datapoints for one event name (blob1). The MCP route writes two events per screenshot tool
// call — the render ledger (`mcp_shared_board_screenshot`, request-level) and the spend ledger
// (`browser_run_session`, one per browser session) — and they share prefixes (`reason:`), so a test
// asserting on one has to say which.
export function datapointsNamed(
env: Environment,
name: string
): Array<{ blobs: string[]; doubles: number[] }> {
const points: Array<{ blobs: string[]; doubles: number[] }> = (
env.MEASURE as any
).writeDataPoint.mock.calls.map((call: any[]) => call[0])
return points.filter((point) => point.blobs[0] === name)
}
// Pulls the `<prefix>:…` telemetry blob out of every request-level datapoint, so tests can assert on
// the low-cardinality dimensions (failure reason codes, and the caller recorded only on failures)
// without depending on the order of the blobs array.
export function blobsWithPrefix(env: Environment, prefix: string): string[] {
return datapointsNamed(env, 'mcp_shared_board_screenshot')
.map((point) => point.blobs.find((blob) => blob.startsWith(prefix)))
.filter(Boolean) as string[]
}
// The `<prefix>:…` blobs of one event's datapoints, with the prefix stripped, in write order.
export function blobValuesOf(env: Environment, name: string, prefix: string): string[] {
return datapointsNamed(env, name)
.map((point) => point.blobs.find((blob) => blob.startsWith(`${prefix}:`)))
.filter((blob): blob is string => Boolean(blob))
.map((blob) => blob.slice(prefix.length + 1))
}
export function failureBlobsOf(env: Environment) {
return blobsWithPrefix(env, 'failure:')
}
export function callerBlobsOf(env: Environment) {
return blobsWithPrefix(env, 'caller:')
}
// Every browser_run_session datapoint, parsed for whole-object assertions: one entry per browser
// session actually created, in creation order. `durationMs` is that session's own wall-clock —
// request rows carry no durations at all under the split ledger.
export function sessionsOf(env: Environment): Array<{
source: string
mode: string
outcome: string
reason: string
page: string
durationMs: number
browserMsUsed: number
/** records, page shapes, media assets, bbox width, bbox height */
content: number[]
}> {
return datapointsNamed(env, 'browser_run_session').map((point) => {
const value = (prefix: string) =>
point.blobs.find((blob) => blob.startsWith(prefix))!.slice(prefix.length)
return {
source: value('source:'),
mode: value('mode:'),
outcome: value('outcome:'),
reason: value('reason:'),
page: value('page:'),
durationMs: point.doubles[2],
browserMsUsed: point.doubles[3],
content: point.doubles.slice(4, 9),
}
})
}
// The index (index1) of every datapoint written. Always `undefined`, since the dataset carries no
// board identity — this exists to pin that, not to read a value out.
export function indexesOf(env: Environment): (string | undefined)[] {
return (env.MEASURE as any).writeDataPoint.mock.calls.map(
(call: any[]) => (call[0].indexes as [string] | undefined)?.[0]
)
}
// quickAction is called as quickAction('screenshot', body); the render URL rides in body (arg 1).
export function tokenFromScreenshot(env: Environment): string {
const body = screenshotOf(env).mock.calls[0]![1] as { url: string }
return new URL(body.url).searchParams.get('token')!
}

View File

@@ -0,0 +1,391 @@
import { DB } from '@tldraw/dotcom-shared'
import {
CompiledQuery,
DatabaseConnection,
Kysely,
PostgresAdapter,
PostgresIntrospector,
PostgresQueryCompiler,
QueryResult,
} from 'kysely'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Environment } from '../../types'
import { BOARD_SEARCH_PAGE_SIZE } from './boardTools'
vi.mock('../../postgres', () => ({ createPostgresConnectionPool: vi.fn() }))
const { createPostgresConnectionPool } = await import('../../postgres')
const { escapeLikePattern, searchAccessibleBoards, selectAccessibleGroupIds } =
await import('./searchBoards')
const env = {} as Environment
/**
* A real Kysely on a driver that records the SQL instead of sending it, and hands back the queued
* result sets in order.
*
* Real rather than a stub builder because the thing under test *is* the SQL: whether the query
* joins `file_state` decides whether the ordering can ever be index-served, and whether `id`
* carries `COLLATE "C"` decides whether the database agrees with the JS comparator. Neither is
* visible in a recording of which builder methods were called.
*/
function mockPool(resultSets: unknown[][]) {
const queries: CompiledQuery[] = []
const queued = [...resultSets]
const connection: DatabaseConnection = {
async executeQuery<R>(compiled: CompiledQuery<unknown>): Promise<QueryResult<R>> {
queries.push(compiled)
return { rows: (queued.shift() ?? []) as R[] }
},
async *streamQuery() {},
}
const db = new Kysely<DB>({
dialect: {
createAdapter: () => new PostgresAdapter(),
createIntrospector: (instance) => new PostgresIntrospector(instance),
createQueryCompiler: () => new PostgresQueryCompiler(),
createDriver: () => ({
async init() {},
async acquireConnection() {
return connection
},
async beginTransaction() {},
async commitTransaction() {},
async rollbackTransaction() {},
async releaseConnection() {},
async destroy() {},
}),
},
})
vi.mocked(createPostgresConnectionPool).mockReturnValue(db)
return { queries }
}
/** The workspace read is the second query; the first is the workspace-membership lookup. */
function fileQuery(queries: CompiledQuery[]) {
return queries[1]
}
/** The link-shared read, issued after the workspace one and merged with it. */
function sharedQuery(queries: CompiledQuery[]) {
return queries[2]
}
/** The caller's home group: its id is their user id, which is what makes a board theirs. */
const HOME_MEMBERSHIP = [{ groupId: 'user-1', role: 'owner' }]
const FILE_ROW = {
id: 'board-1',
name: 'Roadmap',
createdAt: '1699999000000',
updatedAt: '1700000000000',
ownerName: 'My workspace',
owningGroupId: 'user-1',
}
afterEach(() => vi.clearAllMocks())
// `%` and `_` are LIKE wildcards. A search for `%` that reached Postgres unescaped would match
// every board the caller has, which is the opposite of a search.
describe('escapeLikePattern', () => {
it('escapes both wildcards and the escape character itself', () => {
expect(escapeLikePattern('100%')).toBe('100\\%')
expect(escapeLikePattern('a_b')).toBe('a\\_b')
expect(escapeLikePattern('c:\\temp')).toBe('c:\\\\temp')
})
it('leaves an ordinary term alone', () => {
expect(escapeLikePattern('roadmap')).toBe('roadmap')
})
})
// Capability, never role name: `can` is the one place a role's meaning lives, and an unknown role
// string out of the database answers false rather than throwing.
describe('selectAccessibleGroupIds', () => {
it('keeps roles that can access files', () => {
expect(
selectAccessibleGroupIds([
{ groupId: 'g1', role: 'owner' },
{ groupId: 'g2', role: 'member' },
])
).toEqual(['g1', 'g2'])
})
it('drops an unknown role rather than trusting it', () => {
expect(selectAccessibleGroupIds([{ groupId: 'g3', role: 'spectator' }])).toEqual([])
})
})
describe('searchAccessibleBoards', () => {
// Since the groups model this is the only arm that returns a caller's own boards — their home
// group's id is their user id. A home group flagged deleted therefore empties search entirely
// while every other tool on this server keeps working, so the predicate has to be pinned.
it('excludes deleted workspaces from the membership lookup', async () => {
const { queries } = mockPool([HOME_MEMBERSHIP, []])
await searchAccessibleBoards(env, 'user-1', { terms: [], cursor: null })
expect(queries[0].sql).toContain('"group"."isDeleted" = $2')
expect(queries[0].parameters).toEqual(['user-1', false])
})
it('asks for one row more than a page, newest created first', async () => {
const { queries } = mockPool([HOME_MEMBERSHIP, [FILE_ROW]])
await searchAccessibleBoards(env, 'user-1', { terms: [], cursor: null })
expect(fileQuery(queries).sql).toContain(
'order by "file"."createdAt" desc, file.id collate "C" desc limit'
)
expect(fileQuery(queries).parameters.at(-1)).toBe(21)
})
// The sort key has to come from `file` alone. A key read through a join to another table can
// never be index-ordered, so such a join is what forced Postgres to materialise and sort the
// caller's whole candidate set on every page — and `file_state` is also the table that would
// quietly widen the scope to link-shared boards. The one join left is the lateral over the
// caller's own workspace ids, which reads no table at all.
it('reads every row from the file table, joining no other table', async () => {
const { queries } = mockPool([HOME_MEMBERSHIP, []])
await searchAccessibleBoards(env, 'user-1', { terms: [], cursor: null })
expect(fileQuery(queries).sql).not.toContain('file_state')
expect(fileQuery(queries).sql).not.toContain('group_file')
expect(fileQuery(queries).sql.match(/join/g)).toEqual(['join'])
})
// The merge is what keeps the ordering index-served for a caller in more than one workspace:
// `"owningGroupId" = ANY(:groups)` is not the single equality the index needs, so it seq-scans
// every board in scope. Each arm has to take a whole page — take fewer and a workspace that owns
// the whole page cannot fill it.
it('takes a page from each workspace and merges them', async () => {
const { queries } = mockPool([[{ groupId: 'g1', role: 'member' }, HOME_MEMBERSHIP[0]], []])
await searchAccessibleBoards(env, 'user-1', { terms: [], cursor: null })
const { sql, parameters } = fileQuery(queries)
expect(sql).toContain('cross join lateral')
expect(sql).not.toContain('owningGroupId" in')
expect(parameters[0]).toEqual(['g1', 'user-1'])
// One limit, on the arm. The outer limit the SQL used to carry is now the JS merge's slice.
expect(parameters.filter((value) => value === 21)).toHaveLength(1)
})
it('matches every term as an escaped, case-insensitive substring', async () => {
const { queries } = mockPool([HOME_MEMBERSHIP, []])
await searchAccessibleBoards(env, 'user-1', { terms: ['design', '50%'], cursor: null })
expect(fileQuery(queries).sql).toContain('"file"."name" ilike $4 and "file"."name" ilike $5')
expect(fileQuery(queries).parameters).toContain('%design%')
expect(fileQuery(queries).parameters).toContain('%50\\%%')
})
// Test files need admin auth to read, so they are not the caller's to find. Filtered in SQL,
// not after: filtering afterwards would let one consume a slot on the page.
it('excludes deleted and test files in the query', async () => {
const { queries } = mockPool([HOME_MEMBERSHIP, []])
await searchAccessibleBoards(env, 'user-1', { terms: [], cursor: null })
expect(fileQuery(queries).sql).toContain('"file"."isDeleted" = $2')
expect(fileQuery(queries).sql).toContain('"file"."id" not like $3')
expect(fileQuery(queries).parameters).toContain('test\\_%')
})
it('scopes to the workspaces the caller can access files in', async () => {
const { queries } = mockPool([[{ groupId: 'g1', role: 'member' }], []])
await searchAccessibleBoards(env, 'user-1', { terms: [], cursor: null })
expect(fileQuery(queries).sql).toContain('"file"."owningGroupId" = "g"."groupId"')
expect(fileQuery(queries).parameters[0]).toEqual(['g1'])
})
// `hasReadAccessToFile` has no `ownerId` branch since #10391, and a legacy row carrying one would
// be found here and refused by every tool the model reached for next. It is also what kept the
// ordering unindexable: a top-level `OR` makes Postgres answer with an unordered `BitmapOr`.
it('mentions ownerId nowhere in the query', async () => {
const { queries } = mockPool([HOME_MEMBERSHIP, []])
await searchAccessibleBoards(env, 'user-1', { terms: [], cursor: null })
expect(fileQuery(queries).sql).not.toContain('ownerId')
expect(fileQuery(queries).sql).not.toContain(' or ')
})
// An `in ()` against an empty list is a SQL error, and a caller in no workspace can reach no
// boards anyway, so there is nothing to ask Postgres.
it('runs no file query when the caller is in no workspace', async () => {
const { queries } = mockPool([[]])
expect(await searchAccessibleBoards(env, 'user-1', { terms: [], cursor: null })).toEqual([])
expect(queries).toHaveLength(1)
})
// Descending order means the next page is strictly *below* the cursor, and the id half is what
// stops boards created in one batch, which share a createdAt, from being skipped or repeated
// across a page boundary. It has to stay a row comparison: Postgres cannot match the equivalent
// `createdAt < :c or (createdAt = :c and id < :i)` to the index, so a late page walks every row
// above the cursor and discards it.
it('seeks past the cursor with a row comparison rather than counting an offset', async () => {
const { queries } = mockPool([HOME_MEMBERSHIP, []])
await searchAccessibleBoards(env, 'user-1', {
terms: [],
cursor: { arrivedAt: 1_700_000_000_000, id: 'board-9' },
})
expect(fileQuery(queries).sql).toContain('("file"."createdAt", file.id collate "C") < ($4, $5)')
expect(fileQuery(queries).parameters).toContain('board-9')
})
it('adds no cursor predicate on the first page', async () => {
const { queries } = mockPool([HOME_MEMBERSHIP, []])
await searchAccessibleBoards(env, 'user-1', { terms: [], cursor: null })
expect(fileQuery(queries).sql).not.toContain('collate "C") < ')
})
// `compareBoardSearchOrder` compares ids by UTF-16 code unit, and a tldraw id is drawn from
// `A-Za-z0-9_-` — exactly where an ICU or glibc collation parts company with code-unit order.
// Without the declared collation the eval harness pages differently from production.
it('orders and seeks ids under the same collation the JS comparator uses', async () => {
const { queries } = mockPool([HOME_MEMBERSHIP, []])
await searchAccessibleBoards(env, 'user-1', {
terms: [],
cursor: { arrivedAt: 1, id: 'board-9' },
})
const sql = fileQuery(queries).sql
expect(sql.match(/file\.id collate "C"/g)).toHaveLength(2)
})
// Both timestamps arrive from pg as strings, because they are int8 columns. Left as strings the
// sort key would compare lexicographically and the cursor would encode a quoted number.
it('returns the sort key and the edit time as numbers', async () => {
mockPool([HOME_MEMBERSHIP, [FILE_ROW]])
const rows = await searchAccessibleBoards(env, 'user-1', { terms: [], cursor: null })
expect(rows).toEqual([
{
id: 'board-1',
name: 'Roadmap',
// A workspace board arrives when it is made, so the two are the same instant here.
arrivedAt: 1_699_999_000_000,
createdAt: 1_699_999_000_000,
updatedAt: 1_700_000_000_000,
workspaceName: 'My workspace',
source: 'owned' as const,
},
])
})
// A board is the caller's own when its owning group is their home group, whose id is their user
// id. `getBoardSearchResults` reports the workspace name only for the boards that are not.
it('marks a board personal when its owning group is the caller', async () => {
// Distinct timestamps rather than a tie: the rows are merged through compareBoardSearchOrder
// now, which breaks a tie on id *descending* — so two rows sharing one would come back
// board-2 first, which is what the database would return too.
mockPool([
HOME_MEMBERSHIP,
[
FILE_ROW,
{ ...FILE_ROW, id: 'board-2', createdAt: '1699998000000', owningGroupId: 'group-9' },
],
])
const rows = await searchAccessibleBoards(env, 'user-1', { terms: [], cursor: null })
expect(rows.map((row) => [row.id, row.source])).toEqual([
['board-1', 'owned'],
['board-2', 'workspace'],
])
})
// Opening a link-shared board writes a group_file row into the opener's *home* group while the
// file stays owned elsewhere, so the caller's own id is the whole of this read's access predicate.
it("reads link-shared boards through the caller's home group", async () => {
const { queries } = mockPool([HOME_MEMBERSHIP, [], []])
await searchAccessibleBoards(env, 'user-1', { terms: [], cursor: null })
const { sql, parameters } = sharedQuery(queries)
expect(sql).toContain('"group_file"')
expect(sql).toContain('"group_file"."groupId" = ')
expect(sql).toContain('"file"."shared" = ')
expect(parameters).toContain('user-1')
})
// Without this the read returns every board the caller owns, not just a rare mislinked one:
// `createFile` writes a group_file row into the owning workspace — the home group, for a personal
// board — and sets shared: true, so both of this read's predicates match the caller's own boards.
// The caller's own id has to be in the exclusion list, or each one comes back here as well as
// from the workspace read.
it('excludes boards owned by workspaces the caller is in, their own included', async () => {
const { queries } = mockPool([[...HOME_MEMBERSHIP, { groupId: 'g1', role: 'member' }], [], []])
await searchAccessibleBoards(env, 'user-1', { terms: [], cursor: null })
const { sql, parameters } = sharedQuery(queries)
expect(sql).toContain('"file"."owningGroupId" not in')
// 'user-1' twice: once as the home group this read reads, once as a workspace it excludes.
expect(parameters.filter((value) => value === 'user-1')).toHaveLength(2)
})
// The columns `053_group_file_created_at_index.sql` indexes. Sorting or seeking on file.createdAt
// here would have no ordered access path at all, since the access key is on the other table.
it('sorts and seeks the shared read on the link time, not the file time', async () => {
const { queries } = mockPool([HOME_MEMBERSHIP, [], []])
await searchAccessibleBoards(env, 'user-1', {
terms: [],
cursor: { arrivedAt: 1_700_000_000_000, id: 'board-9' },
})
const { sql } = sharedQuery(queries)
expect(sql).toContain('("group_file"."createdAt", group_file."fileId" collate "C") < ')
expect(sql).toContain('order by "group_file"."createdAt" desc')
expect(sql).not.toContain('order by "file"."createdAt"')
})
// The same filters have to reach both reads, or one side's page is a different search from the
// other's and the merged page means nothing.
it('applies the same terms to both reads', async () => {
const { queries } = mockPool([HOME_MEMBERSHIP, [], []])
await searchAccessibleBoards(env, 'user-1', { terms: ['design'], cursor: null })
for (const query of [fileQuery(queries), sharedQuery(queries)]) {
expect(query.sql).toContain('"file"."name" ilike')
expect(query.parameters).toContain('%design%')
}
})
// The merge is what makes arrival ordering visible: an old board shared with the caller this
// morning has to outrank a newer board of their own.
it('merges both reads by arrival, newest arrival first', async () => {
const oldButJustShared = {
...FILE_ROW,
id: 'shared-1',
createdAt: '1600000000000',
arrivedAt: '1700000900000',
owningGroupId: 'group-9',
}
const { queries } = mockPool([HOME_MEMBERSHIP, [FILE_ROW], [oldButJustShared]])
const rows = await searchAccessibleBoards(env, 'user-1', { terms: [], cursor: null })
expect(queries).toHaveLength(3)
expect(rows.map((row) => [row.id, row.arrivedAt, row.createdAt])).toEqual([
['shared-1', 1_700_000_900_000, 1_600_000_000_000],
['board-1', 1_699_999_000_000, 1_699_999_000_000],
])
// Not `workspace`: the caller is not a member of the workspace that owns a link-shared board,
// so calling it one would claim standing there that they do not have.
expect(rows.map((row) => row.source)).toEqual(['shared', 'owned'])
})
it('takes no more than one page plus the surplus row across both reads', async () => {
const page = (prefix: string, from: number) =>
Array.from({ length: BOARD_SEARCH_PAGE_SIZE + 1 }, (_, i) => ({
...FILE_ROW,
id: `${prefix}-${i}`,
createdAt: String(from - i),
arrivedAt: String(from - i),
}))
const { queries } = mockPool([
HOME_MEMBERSHIP,
page('own', 1_700_000_000_000),
page('shared', 1_700_000_000_500),
])
const rows = await searchAccessibleBoards(env, 'user-1', { terms: [], cursor: null })
expect(queries).toHaveLength(3)
expect(rows).toHaveLength(BOARD_SEARCH_PAGE_SIZE + 1)
// Both reads offered a full page; the more recently arrived set wins the merge outright.
expect(rows.every((row) => row.id.startsWith('shared-'))).toBe(true)
})
// The pool is per call, so a query that throws must still return its connection or pools pile
// up in the isolate.
it('destroys the pool even when the query throws', async () => {
const destroy = vi.fn(async () => {})
vi.mocked(createPostgresConnectionPool).mockReturnValue({
selectFrom: () => {
throw new Error('connection refused')
},
destroy,
} as any)
await expect(
searchAccessibleBoards(env, 'user-1', { terms: [], cursor: null })
).rejects.toThrow('connection refused')
expect(destroy).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,330 @@
import { DB, can } from '@tldraw/dotcom-shared'
import { Kysely, RawBuilder, SqlBool, sql } from 'kysely'
import { createPostgresConnectionPool } from '../../postgres'
import { Environment } from '../../types'
import {
BOARD_SEARCH_PAGE_SIZE,
BoardSearchCursor,
BoardSearchRow,
compareBoardSearchOrder,
} from './boardTools'
// The database half of search_boards. The model-facing half — what the tool is called, how its
// arguments and cursors are parsed, how results are shaped — lives in boardTools.ts, which stays
// free of Postgres so the eval harness can serve the same tool from fixtures.
// The sort key is `file.createdAt`, and it has to be a column nothing rewrites. A keyset cursor
// stops duplicates but cannot save a *mutating* key: a row whose key changes can move from below the
// cursor (not yet served) to above it (already passed) and then appear on no page at all. That rules
// out the obvious-looking `file.updatedAt`, which `005_update_file_trigger.sql` bumps on any column
// change — under it a board edited mid-paging silently vanishes from an agent's results. `createdAt`
// is written once by the `createFile` mutator and is in `immutableColumns.file`, and no trigger
// touches it, so only inserts and deletes move the set, which a keyset handles by construction.
// `COLLATE "C"` because `compareBoardSearchOrder` in boardTools.ts compares ids by UTF-16 code unit
// and the eval harness pages with it. A tldraw id is drawn from `A-Za-z0-9_-`, which is exactly
// where an ICU or glibc collation diverges from code-unit order, so without this the database and
// the harness would order the same rows differently. A future index on this ordering has to declare
// the same collation or it will not be usable.
const FILE_ID = sql<string>`file.id collate "C"`
// The same expression over the guest read, where the id comes off the link rather than the file.
const LINK_FILE_ID = sql<string>`group_file."fileId" collate "C"`
/**
* One page of the boards a caller may search: the ones their workspaces own, and the ones shared
* with them by link.
*
* This is the scope `hasReadAccessToFile` admits, and the relationship only runs one way: everything
* returned here must be a board the other tools will open. A model that finds a board and is then
* refused it has no way to interpret that.
*
* Two reads, because that gate has two arms with nothing in common but their answer, and they are
* merged here rather than unioned in SQL so both can use `compareBoardSearchOrder` — the single
* statement of this ordering, which the eval harness already pages fixtures with. A `UNION`'s own
* `ORDER BY` would be a third copy of it to drift.
*
* Sequential rather than `Promise.all`: `createPostgresConnectionPool` defaults to one connection,
* so issuing them together queues the second behind the first for the same wall-clock.
*/
export async function searchAccessibleBoards(
env: Environment,
userId: string,
{ terms, cursor }: { terms: string[]; cursor: BoardSearchCursor | null }
): Promise<BoardSearchRow[]> {
// createPostgresConnectionPool news up a pg.Pool; destroy it so idle pools don't pile up in the
// isolate across tool calls.
const db = createPostgresConnectionPool(env, 'sync-worker/searchAccessibleBoards')
try {
const groupIds = await getAccessibleGroupIds(db, userId)
// Nothing is in scope, so there are no pages to merge and nothing to ask Postgres.
if (!groupIds.length) return []
const owned = await searchWorkspaceBoards(db, userId, groupIds, terms, cursor)
const shared = await searchBoardsSharedWithCaller(db, userId, groupIds, terms, cursor)
// Lossless because each read took a whole page: the global top page cannot hold a row that is
// in neither side's own top page. The surplus row survives the merge too, which is what lets
// the caller answer "is there another page" without a second count.
return [...owned, ...shared].sort(compareBoardSearchOrder).slice(0, BOARD_SEARCH_PAGE_SIZE + 1)
} finally {
await db.destroy()
}
}
// What both reads select from `file`, and what those columns mean once read back. `ownerName` is
// misleadingly named: `set_file_owner_details_trigger` (`023_groups.sql`) writes the owning *user's*
// name there for a user-owned row and the owning group's name for a group-owned one. Every row these
// reads return is group-owned, so on all of them it is a workspace name.
const BOARD_COLUMNS = [
'file.id',
'file.name',
'file.createdAt',
'file.updatedAt',
'file.ownerName',
'file.owningGroupId',
] as const
// Both timestamps arrive from pg as strings, because they are int8 columns. Left as strings the sort
// key would compare lexicographically and the cursor would encode a quoted number.
function toBoardSearchRow(
row: {
id: string
name: string
createdAt: string | number
updatedAt: string | number
ownerName: string
owningGroupId: string | null
},
arrivedAt: string | number,
source: BoardSearchRow['source']
): BoardSearchRow {
return {
id: row.id,
name: row.name,
arrivedAt: Number(arrivedAt),
createdAt: Number(row.createdAt),
updatedAt: Number(row.updatedAt),
workspaceName: row.ownerName,
source,
}
}
/**
* The filters both reads share, applied through one helper because a filter that reached one and not
* the other would make that read's page a different search from the rest of the merge — and the
* surplus row only means "there is another page" if both were searching the same thing.
*/
function narrowToTerms<T extends { where: any }>(query: T, terms: string[]): T {
let narrowed = query
.where('file.isDeleted', '=', false)
// Mirrors isTestFile: reading a test file needs admin auth, so it is not the caller's to find.
// In SQL rather than after the fact, or one could take a slot on the page.
.where('file.id', 'not like', 'test\\_%')
for (const term of terms) {
narrowed = narrowed.where('file.name', 'ilike', `%${escapeLikePattern(term)}%`)
}
return narrowed
}
/**
* The boards owned by a workspace the caller can access files in — including their own, since a home
* group's id is the user's own id.
*
* One page per workspace, merged, rather than one query over every workspace at once. The index can
* only be read in order under a single-equality access predicate, and `"owningGroupId" = ANY(:groups)`
* is not one: an array-driven btree scan is unordered before PG 17 and we run 16, so a caller in three
* workspaces seq-scanned 30,000 rows to return the first 21. Asking each workspace for its own top page
* and merging reads 63.
*
* `file."createdAt"` is this side's arrival time, not merely a proxy for it: `createFile` writes one
* timestamp into the file row and its `group_file` row alike, so for a board made here the two are the
* same value. A board *moved* in is the one case they diverge — it keeps its original creation time
* rather than sorting by the move — which is a smaller discrepancy than reading the sort key from a
* table the index cannot reach.
*/
async function searchWorkspaceBoards(
db: Kysely<DB>,
userId: string,
groupIds: string[],
terms: string[],
cursor: BoardSearchCursor | null
): Promise<BoardSearchRow[]> {
const rows = await db
.selectFrom(
// Wrapped in a sub-select purely to name the column: `unnest(...) as "g"` gives the one column
// the table's own alias, so the arm below would have to read `g.g`.
sql<{ groupId: string }>`(select unnest(${sql.val(groupIds)}::text[]) as "groupId")`.as('g')
)
.crossJoinLateral((eb) => {
let arm = narrowToTerms(
eb
.selectFrom('file')
.select(BOARD_COLUMNS)
// `file_owning_group_created_at_idx` (`052_file_search_index.sql`) serves this equality
// and the ordering together, so each arm reads its page and stops instead of top-N
// sorting a whole workspace. What no index helps is the `ilike '%term%'` above: a query
// matching nothing still reads every board in scope. The index bounds paging, not
// matching.
.where('file.owningGroupId', '=', sql.ref<string>('g.groupId')),
terms
)
if (cursor) {
arm = arm.where(seekBelow(FILE_ID, sql.ref('file.createdAt'), cursor))
}
return (
arm
.orderBy('file.createdAt', 'desc')
.orderBy(FILE_ID, 'desc')
// One more than a page, so the caller can answer "there is another page" without a second
// count query over the same set.
.limit(BOARD_SEARCH_PAGE_SIZE + 1)
.as('s')
)
})
.select(['s.id', 's.name', 's.createdAt', 's.updatedAt', 's.ownerName', 's.owningGroupId'])
.execute()
// A home group carries its user's id, which is what makes a board the caller's own rather than a
// shared workspace's.
return rows.map((row) =>
toBoardSearchRow(row, row.createdAt, row.owningGroupId === userId ? 'owned' : 'workspace')
)
}
/**
* The boards shared with the caller by link.
*
* Opening one writes a `group_file` row into the opener's *home* group while the file stays owned
* elsewhere, and `039_tighten_group_file_association.sql` permits exactly that row and no other
* cross-workspace link — so the caller's home group is the whole of this access predicate. The scope
* invariant holds: `hasReadAccessToFile` grants on `shared` alone, so every row here is one the other
* tools will open, and unsharing deletes these rows (`034_fix_unshare_group_file_cleanup.sql`), so
* the set cannot outlive the access that justifies it.
*
* Files whose owning workspace the caller already belongs to are excluded, and that exclusion is not
* an edge case — without it this read returns every board the caller owns. `createFile` writes a
* `group_file` row into the owning workspace, which for a personal board *is* the home group, and it
* sets `shared: true`; so both predicates below match a caller's own boards exactly as well as their
* guest links, and every one of them would come back here as well as from `searchWorkspaceBoards`,
* twice on one page. The mislinked case it also covers — a guest file whose workspace the caller
* later joined — is the rarer half of its job. `getWorkspaceFilesSorted` in the client guards the
* same rows the same way.
*
* Sorted and sought on `group_file."createdAt"`, which is when the caller opened the link. That is
* the same thing the other read's `file."createdAt"` means for a board made in a workspace, so the
* two merge — and it is the only sort key here an index could reach, since `group_file` carries the
* access key.
*
* No index serves it yet, so this read fetches every guest link the caller has and sorts them: 879
* buffers for the heaviest account in production (233 links), against 89 with one. An index on
* `group_file("groupId", "createdAt" DESC)` is the fix and is deliberately a separate deploy — a
* migration run that locks both `file` and `group_file` can deadlock against a concurrent file move,
* which takes those two in the opposite order, and one that locks only one of them cannot. The
* ordering here is already what that index will want, so adding it is a migration and nothing else.
*/
async function searchBoardsSharedWithCaller(
db: Kysely<DB>,
userId: string,
groupIds: string[],
terms: string[],
cursor: BoardSearchCursor | null
): Promise<BoardSearchRow[]> {
let query = narrowToTerms(
db
.selectFrom('group_file')
.innerJoin('file', 'file.id', 'group_file.fileId')
.select([...BOARD_COLUMNS, 'group_file.createdAt as arrivedAt'])
.where('group_file.groupId', '=', userId)
.where('file.shared', '=', true)
// Load-bearing rather than tidy-up: the caller's home group id is in `groupIds`, so this is
// what keeps their own boards' owning rows out of a read whose predicates above match them.
// Safe as a plain `not in` only because `050_drop_legacy_owner_columns.sql` made
// `owningGroupId` NOT NULL: while legacy files carried NULL there, this would have dropped
// every one of them silently, since `NULL not in (...)` is NULL rather than true.
.where('file.owningGroupId', 'not in', groupIds),
terms
)
if (cursor) {
query = query.where(seekBelow(LINK_FILE_ID, sql.ref('group_file.createdAt'), cursor))
}
const rows = await query
.orderBy('group_file.createdAt', 'desc')
.orderBy(LINK_FILE_ID, 'desc')
.limit(BOARD_SEARCH_PAGE_SIZE + 1)
.execute()
// Shared by construction: this read only ever returns boards owned outside the caller's own
// workspaces, reached through the guest link opening one leaves behind.
return rows.map((row) => toBoardSearchRow(row, row.arrivedAt, 'shared'))
}
/**
* The keyset seek, over whichever pair of columns this read sorts by.
*
* Seek, not offset: an insert anywhere above the cursor shifts every later row by one, so an offset
* would re-serve one board and skip another at the boundary. This asks for rows strictly below where
* the last page ended, over the same two expressions the ORDER BY sorts on — sort by one thing and
* seek by another and a page boundary lands somewhere the sort did not put it.
*
* A row comparison, not the equivalent `arrivedAt < :a or (arrivedAt = :a and id < :i)`. Postgres
* keeps that OR as a filter, so a late page walks every row above the cursor and throws it away: page
* 500 of a 10k-board workspace read 9,927 rows to return 21. A row comparison is matchable against
* the index — PG16 truncates it to the leading columns the index actually has, seeks on the
* timestamp, and rechecks the whole comparison as a filter. Same rows, 26 read.
*
* The `id` half is the tiebreaker: boards created in one batch share a timestamp, so without
* comparing ids among rows that share a key, a page boundary landing inside such a group would drop
* the rest of it. It mirrors `compareBoardSearchOrder` in boardTools.ts — change one and the eval
* harness starts paging differently from production.
*/
function seekBelow(
id: RawBuilder<string>,
arrivedAt: RawBuilder<unknown>,
cursor: BoardSearchCursor
) {
return sql<SqlBool>`(${arrivedAt}, ${id}) < (${cursor.arrivedAt}, ${cursor.id})`
}
// The workspaces whose files this caller may reach. Deleted groups are excluded here rather than
// left to the file rows, so a deleted workspace cannot widen the id list at all.
//
// Since the groups model this is also the arm that returns a caller's *own* boards — a user's home
// group has `group.id === user.id`. So a home group flagged deleted empties search completely while
// every other tool on this server keeps working.
async function getAccessibleGroupIds(db: Kysely<DB>, userId: string): Promise<string[]> {
const memberships = await db
.selectFrom('group_user')
.innerJoin('group', 'group.id', 'group_user.groupId')
.select(['group_user.groupId', 'group_user.role'])
.where('group_user.userId', '=', userId)
.where('group.isDeleted', '=', false)
.execute()
return selectAccessibleGroupIds(memberships)
}
/**
* Filters memberships down to the workspaces whose files the caller may read.
*
* Asks `can(role, 'accessFiles')` rather than comparing role names, so the meaning of a role stays
* in the roles table — and an unknown role string out of the database answers false rather than
* throwing.
*/
export function selectAccessibleGroupIds(
memberships: Array<{ groupId: string; role: string }>
): string[] {
return memberships
.filter((membership) => can(membership.role, 'accessFiles'))
.map((membership) => membership.groupId)
}
/**
* Escapes a search term for use inside a LIKE/ILIKE pattern.
*
* `%` and `_` are wildcards, so a term carrying one must not be read as a pattern: a search for `%`
* would otherwise match every board the caller has. The backslash is in the class because it is the
* escape character itself; `String.replace` replaces each match independently in a single pass, so
* the escapes this adds are never rescanned.
*/
export function escapeLikePattern(value: string): string {
return value.replace(/[\\%_]/g, (char) => `\\${char}`)
}

View File

@@ -0,0 +1,154 @@
import { getShapeClusters } from '@tldraw/dotcom-shared'
import { describe, expect, it } from 'vitest'
import { enumerateBoardPages } from './boardTools'
import { makeSnapshot } from './screenshotTestHelpers'
import { buildThumbnailRenderUrl, summarizeSnapshotContent } from './thumbnailRender'
describe('enumerateBoardPages', () => {
it('lists pages in fractional-index order with names and content flags', () => {
const pages = enumerateBoardPages(
makeSnapshot([
{ id: 'page:b', name: 'Ideas', index: 'a2', shapes: 1 },
{ id: 'page:a', name: 'Cover', index: 'a1', shapes: 2 },
{ id: 'page:c', name: '', index: 'a3', shapes: 0 },
])
)
expect(pages).toEqual([
{ index: 0, id: 'page:a', name: 'Cover', hasContent: true },
{ index: 1, id: 'page:b', name: 'Ideas', hasContent: true },
{ index: 2, id: 'page:c', name: 'Page 3', hasContent: false },
])
})
})
describe('buildThumbnailRenderUrl', () => {
it('builds the render page URL with the token', () => {
const url = new URL(buildThumbnailRenderUrl('https://render.example', 'the-token'))
expect(url.pathname).toBe('/__thumbnail-render')
expect(url.searchParams.get('token')).toBe('the-token')
})
})
describe('getShapeClusters', () => {
const shape = (id: string, parentId = 'page:a') =>
({
typeName: 'shape',
id,
parentId,
type: 'geo',
x: 0,
y: 0,
rotation: 0,
props: { w: 10, h: 10 },
}) as any
it('creates one stable cluster per top-level shape', () => {
const shapes = [shape('shape:a'), shape('shape:b')]
const first = getShapeClusters(shapes, 'page:a')
const second = getShapeClusters([...shapes].reverse(), 'page:a')
expect(first).toHaveLength(2)
expect(new Set(first.map((cluster) => cluster.id)).size).toBe(2)
expect(
Object.fromEntries(first.flatMap((cluster) => cluster.shapes.map((s) => [s.id, cluster.id])))
).toEqual(
Object.fromEntries(second.flatMap((cluster) => cluster.shapes.map((s) => [s.id, cluster.id])))
)
})
it('keeps frames and groups with their descendants', () => {
const frame = shape('shape:frame')
const child = shape('shape:child', 'shape:frame')
const grandchild = shape('shape:grandchild', 'shape:child')
const cluster = getShapeClusters([frame, child, grandchild], 'page:a')[0]
expect(cluster.numberOfShapes).toBe(3)
expect(cluster.shapes.map((s) => s.id)).toEqual([
'shape:frame',
'shape:child',
'shape:grandchild',
])
})
it('changes a cluster id when its membership changes', () => {
const frame = shape('shape:frame')
const before = getShapeClusters([frame], 'page:a')[0]
const after = getShapeClusters([frame, shape('shape:child', frame.id)], 'page:a')[0]
expect(after.id).not.toBe(before.id)
})
})
describe('summarizeSnapshotContent', () => {
const doc = (state: Record<string, unknown>) => ({ state, lastChangedClock: 0 })
const snapshot = {
clock: 0,
schema: { schemaVersion: 2, sequences: {} },
documents: [
doc({ id: 'document:document', typeName: 'document' }),
doc({ id: 'page:a', typeName: 'page' }),
doc({ id: 'page:b', typeName: 'page' }),
doc({
id: 'shape:1',
typeName: 'shape',
type: 'geo',
parentId: 'page:a',
x: 100,
y: 50,
props: { w: 200, h: 100 },
}),
doc({
id: 'shape:2',
typeName: 'shape',
type: 'draw',
parentId: 'page:a',
x: 1000,
y: 700,
props: {},
}),
doc({
id: 'shape:3',
typeName: 'shape',
type: 'geo',
parentId: 'shape:1',
x: 0,
y: 0,
props: { w: 10, h: 10 },
}),
doc({
id: 'shape:4',
typeName: 'shape',
type: 'geo',
parentId: 'page:b',
x: -5000,
y: 0,
props: { w: 10, h: 10 },
}),
doc({ id: 'asset:img', typeName: 'asset', type: 'image' }),
doc({ id: 'asset:vid', typeName: 'asset', type: 'video' }),
doc({ id: 'asset:bm', typeName: 'asset', type: 'bookmark' }),
],
} as any
it('counts the rendered page and sizes its top-level shapes', () => {
// The child inside shape:1 and the shape on page:b are not top-level on page:a; the draw
// stroke has no stored size and counts as a point.
expect(summarizeSnapshotContent(snapshot, 'page:a')).toEqual({
records: 10,
pageShapes: 2,
mediaAssets: 2,
bboxWidth: 900,
bboxHeight: 650,
})
})
it('reports zero size for a page with nothing on it', () => {
expect(summarizeSnapshotContent(snapshot, 'page:none')).toEqual({
records: 10,
pageShapes: 0,
mediaAssets: 2,
bboxWidth: 0,
bboxHeight: 0,
})
})
})

View File

@@ -0,0 +1,928 @@
import {
DEFAULT_THUMBNAIL_HEIGHT,
DEFAULT_THUMBNAIL_WIDTH,
THUMBNAIL_RENDER_PATH,
THUMBNAIL_RENDER_TIMEOUT_MS,
getThumbnailScreenshotRequestBody,
} from '@tldraw/dotcom-shared'
import { RoomSnapshot } from '@tldraw/sync-core'
import { THUMBNAIL_RENDER_TOKEN_TTL_MS } from '../../config'
import { getR2KeyForRoom } from '../../r2'
import {
Environment,
OgImageRenderReason,
ThumbnailBoardAccess,
ThumbnailBoardKind,
ThumbnailBoardRef,
ThumbnailRenderSurface,
} from '../../types'
import { writeDataPoint } from '../../utils/analytics'
import { arrayBufferToBase64, base64ToArrayBuffer } from '../../utils/base64'
import {
RenderPageReach,
ThumbnailRenderJob,
deleteMintedRenderToken,
mintThumbnailRenderToken,
recordMintedRenderToken,
wasRenderTokenServedSince,
} from '../../utils/renderTokens'
import { ShapeMeasurement } from './boardTools'
import { getPublishedFileInfo, getPublishedRoomSnapshot } from './getPublishedFile'
import {
SharedFileInfo,
getSharedFileInfo,
getSharedFileRoomSnapshot,
isFileViewableFor,
} from './getSharedFile'
import {
BoardSnapshotReadError,
BrowserRenderError,
classifyScreenshotFailure,
} from './thumbnailShared'
// The render-and-cache core shared by every Browser Run screenshot surface: the MCP screenshot
// tool (mcpServer.ts), the OG image route (getOgImage.ts), and the OG render queue
// consumer (ogImageQueue.ts). Owns board resolution, snapshot loading, page enumeration, the
// Browser Rendering invocation, and the shared telemetry writer. The surfaces own their own protocol
// handling, cache keys, and retry/backoff policies.
//
// Owns no rate limiting: the pipeline's only limiters live in mcpServer.ts, so a new
// surface built on these helpers cannot pick one up by accident.
// A board a screenshot surface has resolved. Whether it is *publicly viewable* depends on the access
// the caller asked for — see ThumbnailBoardAccess. The version rotates when the rendered content
// changes (lastPublished for published boards, the persisted room snapshot's R2 etag for shared
// files), so it can key the thumbnail caches.
export interface ResolvedThumbnailBoard extends ThumbnailBoardRef {
version: string | number
/**
* The file this board's content belongs to: the slug itself for a shared file, and the parent file
* a published slug resolves to for a published board.
*
* Carried because the file's Durable Object is addressed by it, and that object is where the MCP
* server caches a page's cluster index (see mcpClusterIndex.ts). Kept off `ThumbnailBoardRef`,
* which is the *identity* of a board and keys its caches: two kinds can share a file id, so a key
* built from this would collide a published board's index with its file's.
*/
fileId: string
/**
* The gate this board was resolved under, carried so a render cannot be minted under a weaker one
* than the resolution used. `captureThumbnailScreenshot` signs it into the job, and the snapshot
* route reads the board back under it.
*/
access: ThumbnailBoardAccess
/**
* The `file` row this resolution gated on, for `shared_file` boards only. Carried so a caller that
* reads the snapshot in the same breath can hand it back to `loadBoardSnapshot` instead of asking
* Postgres the same question twice — see the `file` option on `getSharedFileRoomSnapshot` for when
* that is and is not appropriate. Never signed into a render job: the job carries the gate, and a
* row that travelled inside a token could not be re-checked on the way back.
*/
file?: SharedFileInfo
}
export type ResolveThumbnailBoardResult =
| { ok: true; board: ResolvedThumbnailBoard }
| { ok: false; reason: 'not_found' | 'board_empty' }
/**
* Resolves a board slug of a known kind, applying the gate the caller asked for (see
* `ThumbnailBoardAccess`). `access: 'public'` is the only thing standing between a private board and
* the public internet, and it is re-applied per request rather than inferred from what is in R2 —
* necessarily, since nothing deletes a thumbnail when a board stops being public.
*
* `board_empty` means the board passed its gate but has no persisted room content; `not_found` covers
* everything the gate refused.
*/
export async function resolveThumbnailBoard(
env: Environment,
kind: ThumbnailBoardKind,
slug: string,
{
access,
file: knownFile,
}: {
access: ThumbnailBoardAccess
/**
* The row a caller has *just* read for this same file, to be gated again rather than fetched a
* second time. The MCP path's access check reads `file` and this then selects a strict subset of
* the same columns microseconds later, so without this every tool call opens two Postgres pools
* for one row — three when the published fallback runs. Opt in per call site, like the same
* option on `loadBoardSnapshot`: the gate below is applied either way, so what a caller takes on
* is the staleness of the row, not a weaker check.
*/
file?: SharedFileInfo
}
): Promise<ResolveThumbnailBoardResult> {
if (kind === 'published') {
// A published board's whole identity is its published slug, so there is no weaker gate to
// apply: an unpublished board has no published snapshot to render in the first place.
const publishedFile = await getPublishedFileInfo(env, slug)
if (!publishedFile?.published) return { ok: false, reason: 'not_found' }
return {
ok: true,
board: {
kind,
slug,
version: publishedFile.lastPublished,
access,
// The row this published slug resolved through, which is the file that owns the snapshot.
fileId: publishedFile.id,
},
}
}
const file = knownFile ?? (await getSharedFileInfo(env, slug))
if (!isFileViewableFor(file, access)) return { ok: false, reason: 'not_found' }
// The persisted room's R2 etag rotates when the board content changes, so it keys the
// thumbnail cache without a separate content-version field.
const persisted = await env.ROOMS.head(getR2KeyForRoom({ slug, isApp: true }))
if (!persisted) return { ok: false, reason: 'board_empty' }
return { ok: true, board: { kind, slug, version: persisted.etag, access, file, fileId: file.id } }
}
// Reads a resolved board's snapshot, keeping the two outcomes callers must tell apart distinct.
// `null` means one thing only: an empty board, with no persisted room content. Anything the readers
// throw — Postgres, R2, a malformed payload, or the gate they re-check as they read — is wrapped as a
// BoardSnapshotReadError, so a database outage reaches telemetry under its own reason code instead of
// as an empty board.
export async function loadBoardSnapshot(
env: Environment,
board: ThumbnailBoardRef,
{
access,
file,
}: {
access: ThumbnailBoardAccess
/**
* The row a caller has *just* resolved this board against, to be gated again rather than
* re-fetched. Opt in per call site rather than reading it off `board`, so a surface takes the
* staleness that comes with it deliberately. See `getSharedFileRoomSnapshot`.
*/
file?: SharedFileInfo
}
): Promise<RoomSnapshot | null> {
try {
const snapshot =
board.kind === 'published'
? await getPublishedRoomSnapshot(env, board.slug)
: await getSharedFileRoomSnapshot(env, board.slug, { access, file })
return snapshot ?? null
} catch (error) {
// Keep the original message in the wrapper's own text as well as its `cause`, so the Sentry
// event title still names the real failure rather than reading as a generic read error.
throw new BoardSnapshotReadError(
`Could not read board snapshot: ${error instanceof Error ? error.message : String(error)}`,
{ cause: error }
)
}
}
/**
* Mints a render job's token and records it as ours, in one step because the two halves must not be
* separable.
*
* A token minted without a record fails its own check at the snapshot route and surfaces as a generic
* render failure — and the way that arrived the first time was exactly this: a second mint site that
* did the first half only, leaving every measure token unrecorded. Both mint sites go through here so
* a third cannot repeat it.
*
* Recorded before the browser can present the token, so the snapshot route can tell one we minted from
* one merely signed with our secret. Awaited, not fired off: the render is about to depend on it
* having landed. A no-op for `public` jobs — see recordMintedRenderToken.
*/
async function mintRecordedRenderToken(env: Environment, job: ThumbnailRenderJob) {
const token = await mintThumbnailRenderToken(env, job)
await recordMintedRenderToken(env, job, token)
return token
}
export function buildThumbnailRenderUrl(renderOrigin: string, token: string) {
const url = new URL(THUMBNAIL_RENDER_PATH, renderOrigin)
url.searchParams.set('token', token)
return url.toString()
}
function getRenderOrigin(env: Environment) {
// Staging and production set this in wrangler.toml to their own client origin. Local dev sets it
// to the local client; previews configure it explicitly when they need to exercise this path.
if (!env.MCP_SCREENSHOT_RENDER_ORIGIN) {
throw new Error(
`MCP_SCREENSHOT_RENDER_ORIGIN is not configured. It must point at an origin that serves the ${THUMBNAIL_RENDER_PATH} render page.`
)
}
return env.MCP_SCREENSHOT_RENDER_ORIGIN
}
// Renders one content-fit page of a resolved board through the render page and returns the PNG.
// Mints the short-lived signed render job, so the browser session only ever visits the
// tldraw-owned render page; callers decide where (and whether) to cache the result.
export async function captureThumbnailScreenshot(
env: Environment,
board: ResolvedThumbnailBoard,
{
surface,
pageId,
shapeIds,
theme,
width,
height,
telemetry,
capture,
content,
}: {
/** Which pipeline is asking. Signed into the job; namespaces the minted-token record. */
surface: ThumbnailRenderSurface
/**
* The single page to render. When omitted, the render page exports whichever page the
* snapshot opens to (used by OG images).
*/
pageId?: string
/**
* Restricts the export to these shapes rather than the whole page: the render page fits the
* camera to their common bounds and draws only them.
*/
shapeIds?: string[]
theme: 'light' | 'dark'
width: number
height: number
/**
* Who this session is for on the spend ledger. Required so a new caller cannot create
* browser sessions that never appear on it; `mode` is stamped here, since this path is by
* definition a screenshot render.
*/
telemetry: { source: BrowserRunSessionContext['source']; reason?: OgImageRenderReason }
/**
* `live` skips the in-page export and lets the screenshot rasterize the live canvas, after the
* page has pruned it to the requested shapes. Signed into the job so the render page reads it
* with the rest of its parameters. See ThumbnailRenderParams.
*/
capture?: 'live'
/** What the board holds, for the ledger (see summarizeSnapshotContent). Never identifies it. */
content?: RenderContentSummary
}
): Promise<{ base64: string; durationMs: number }> {
const job: ThumbnailRenderJob = {
v: 1,
kind: board.kind,
slug: board.slug,
version: board.version,
// Taken from the resolution rather than the caller, so a surface cannot ask for a board under
// one gate and render it under another.
access: board.access,
surface,
camera: 'content',
...(pageId ? { pageId } : null),
...(shapeIds?.length ? { shapeIds } : null),
...(capture ? { capture } : null),
// Ignored while `camera` is 'content', which is what every surface mints; carried because the
// job type keeps the explicit-viewport path available (see ThumbnailRenderJob).
x: 0,
y: 0,
z: 1,
width,
height,
theme,
exp: Date.now() + THUMBNAIL_RENDER_TOKEN_TTL_MS,
}
const token = await mintRecordedRenderToken(env, job)
try {
return await runRenderSession(env, buildThumbnailRenderUrl(getRenderOrigin(env), token), {
width,
height,
session: { source: telemetry.source, mode: 'screenshot', reason: telemetry.reason, capture },
content,
pageReached: (since) => wasRenderTokenServedSince(env, job, token, since),
})
} finally {
// The MCP surface keys its records by token, so nothing later overwrites this one and it has to
// be dropped here — the same cleanup a measure gets, for the same reason. In `finally` because a
// failed render leaves exactly the orphan a successful one would, and the snapshot fetch the
// record guards is over either way by the time the session ends. A no-op for OG, whose per-board
// key must outlive the capture; see deleteMintedRenderToken.
await deleteMintedRenderToken(env, job, token)
}
}
/** What a browser session was created for, stamped on its `browser_run_session` datapoint. */
export interface BrowserRunSessionContext {
source: 'mcp' | 'og' | 'queue'
mode: 'measure' | 'screenshot'
/** The queue trigger, on sessions the queue runs. Request-path sessions have none. */
reason?: OgImageRenderReason
/** `live` when the render was asked to rasterize the live canvas; absent means the page exports. */
capture?: 'live'
}
/**
* What a board holds, as numbers, for the session ledger. Numbers only: doubles carry no cardinality,
* and none of these can identify a board (see "No board identifier leaves this pipeline" in
* browser-run-thumbnails.md). Sizes come straight off the records, so the box is the union of top-level
* shapes' positions and stored sizes — rotation ignored, sizeless shapes (draw strokes) counted as
* points. A proxy, not geometry; the question it answers is whether render time and failure track
* what is on the board, which a proxy resolves.
*/
export interface RenderContentSummary {
records: number
/** Shapes parented directly to the rendered page. */
pageShapes: number
/** Image and video asset records in the document, whichever page uses them. */
mediaAssets: number
bboxWidth: number
bboxHeight: number
}
const EMPTY_CONTENT_SUMMARY: RenderContentSummary = {
records: 0,
pageShapes: 0,
mediaAssets: 0,
bboxWidth: 0,
bboxHeight: 0,
}
export function summarizeSnapshotContent(
snapshot: RoomSnapshot,
pageId: string | undefined
): RenderContentSummary {
let pageShapes = 0
let mediaAssets = 0
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const { state } of snapshot.documents) {
const record = state as {
typeName?: string
type?: string
parentId?: string
x?: number
y?: number
props?: { w?: unknown; h?: unknown }
}
if (record.typeName === 'asset') {
if (record.type === 'image' || record.type === 'video') mediaAssets++
continue
}
if (record.typeName !== 'shape' || (pageId && record.parentId !== pageId)) continue
pageShapes++
const x = record.x ?? 0
const y = record.y ?? 0
const w = typeof record.props?.w === 'number' ? record.props.w : 0
const h = typeof record.props?.h === 'number' ? record.props.h : 0
minX = Math.min(minX, x)
minY = Math.min(minY, y)
maxX = Math.max(maxX, x + w)
maxY = Math.max(maxY, y + h)
}
return {
records: snapshot.documents.length,
pageShapes,
mediaAssets,
bboxWidth: pageShapes ? maxX - minX : 0,
bboxHeight: pageShapes ? maxY - minY : 0,
}
}
// The Browser Run spend ledger: one datapoint per browser session actually created, written here at
// the choke point every session flows through rather than by each surface's own bookkeeping. This is
// deliberately a separate event from `mcp_shared_board_screenshot`, which stays request-level: that
// event answers cache and refusal questions, this one answers spend. Analytics Engine cannot join,
// so each event carries the dimensions its own questions need — `mode` is what separates a measure
// render from a screenshot render, which the request event cannot express.
function writeBrowserRunSessionTelemetry(
env: Environment,
session: BrowserRunSessionContext,
{
outcome,
durationMs,
width,
height,
page,
browserMsUsed,
content = EMPTY_CONTENT_SUMMARY,
}: {
/** `ok`, or the bounded browser failure code for a session that died. */
outcome: string
durationMs: number
width: number
height: number
/** `reached` on every session that produced a capture; see wasRenderTokenServedSince for the rest. */
page: RenderPageReach
/** See TimedCapture.browserMsUsed; 0 for sessions that died before a response. */
browserMsUsed: number
content?: RenderContentSummary
}
) {
writeDataPoint(undefined, env.MEASURE, env, 'browser_run_session', {
blobs: [
`source:${session.source}`,
`mode:${session.mode}`,
`outcome:${outcome}`,
`reason:${session.reason ?? 'none'}`,
// Appended: existing blob positions must not shift.
`page:${page}`,
// Appended after `page:` for the same reason. `none` on measures, which draw nothing; a
// screenshot render exports unless it asked for live.
`capture:${session.mode === 'measure' ? 'none' : (session.capture ?? 'export')}`,
],
doubles: [
width,
height,
durationMs,
browserMsUsed,
content.records,
content.pageShapes,
content.mediaAssets,
content.bboxWidth,
content.bboxHeight,
],
})
}
// One Browser Run session against the render page, returning whatever the Quick Action captured.
// Only the capture path wants those pixels: they are editor.toImage's output, which the page
// displays as a full-viewport image to be caught. A measure runs no export and discards the image —
// the session is only how the page gets to run, and its answer comes back through the result
// endpoint.
//
// A failed render marks an error state rather than the ready one, so it returns as a failure
// immediately instead of burning the timeout (see THUMBNAIL_SETTLED_SELECTOR /
// THUMBNAIL_CAPTURE_SELECTOR in @tldraw/dotcom-shared).
async function runRenderSession(
env: Environment,
renderUrl: string,
{
width,
height,
session,
content,
pageReached,
}: {
width: number
height: number
session: BrowserRunSessionContext
content?: RenderContentSummary
/** Asked only for a session that died, with the session's start; see RenderPageReach. */
pageReached?(since: number): Promise<RenderPageReach>
}
): Promise<{ base64: string; durationMs: number }> {
// Built once and handed to whichever transport runs, so the wait strategy, capture target and
// timeout cannot drift between Browser Run and its development stand-in.
const requestBody = getThumbnailScreenshotRequestBody({
renderUrl,
width,
height,
timeoutMs: THUMBNAIL_RENDER_TIMEOUT_MS,
})
// Local dev has no route to Browser Run, so it points this at a screenshot service instead (the
// client's dev server, which can drive Playwright). Selected on the var being set rather than on
// an environment name, so only an environment that configures one can take this path.
let timed: TimedCapture
const startedAt = Date.now()
try {
const capture = env.LOCAL_SCREENSHOT_SERVICE_URL
? callLocalScreenshotService(env.LOCAL_SCREENSHOT_SERVICE_URL, requestBody)
: callBrowserRun(env, requestBody)
// Measures are exempt from the worker-side deadline, and it is the *result* that makes them
// so: the render page POSTs its measurements before signalling ready, and the result route
// accepts any unexpired signed token, so abandoning the wait early would let that POST land
// after measurePageShapes' cleanup has already deleted the result key — stranding an object
// in a bucket that must never get a lifecycle rule. The deadline exists for the OG pipeline's
// invariants, which never price a measure; a measure stays bounded by the quick action's own
// per-phase timers.
timed = await (session.mode === 'measure'
? capture
: abandonAtRenderTimeout(capture, startedAt))
} catch (error) {
// A BrowserRenderError is a session that existed and died, so it lands on the ledger with the
// time it held its browser. Anything else never created a session and records nothing.
if (error instanceof BrowserRenderError) {
writeBrowserRunSessionTelemetry(env, session, {
outcome: classifyScreenshotFailure(error),
durationMs: error.durationMs,
width,
height,
page: (await pageReached?.(startedAt)) ?? 'unknown',
browserMsUsed: 0,
content,
})
}
throw error
}
const buffer = timed.buffer
if (buffer.byteLength === 0) {
// The session ran to completion — the spend is real — it just produced nothing usable.
writeBrowserRunSessionTelemetry(env, session, {
outcome: 'empty_render',
durationMs: timed.durationMs,
width,
height,
page: 'reached',
browserMsUsed: timed.browserMsUsed,
content,
})
throw new Error('Render produced an empty screenshot')
}
writeBrowserRunSessionTelemetry(env, session, {
outcome: 'ok',
durationMs: timed.durationMs,
width,
height,
page: 'reached',
browserMsUsed: timed.browserMsUsed,
content,
})
return { base64: arrayBufferToBase64(buffer), durationMs: timed.durationMs }
}
type ThumbnailScreenshotRequestBody = ReturnType<typeof getThumbnailScreenshotRequestBody>
// A capture that came back OK, fully read, and how long the whole thing took. The transports read
// the body themselves rather than handing back a Response: the read has to happen inside
// abandonAtRenderTimeout's deadline, or a body stream that stalls after a 200's headers would hold
// the delivery unbounded — past every bound the pipeline prices a capture at. Each transport throws
// its own kind of failure; only Browser Run's carries the status, body detail and timeout budget
// that BrowserRenderError exists to hold.
interface TimedCapture {
buffer: ArrayBuffer
durationMs: number
/** Browser wall clock billed for the session, or 0 where the transport has none (local dev). */
browserMsUsed: number
}
// Both transports read the billing header through this, so the ledger column means one thing.
function browserMsUsedOf(response: Response) {
return Number(response.headers.get('X-Browser-Ms-Used')) || 0
}
async function callBrowserRun(
env: Environment,
requestBody: ThumbnailScreenshotRequestBody
): Promise<TimedCapture> {
if (!env.BROWSER) {
throw new Error(
'Browser Rendering is not configured. Set the BROWSER binding (local dev needs Cloudflare credentials).'
)
}
const startedAt = Date.now()
// Browser Rendering `/screenshot` Quick Action, invoked straight through the binding (no
// puppeteer, no API token). Requires compatibility_date >= 2026-03-24 for `quickAction`.
const response = await env.BROWSER.quickAction('screenshot', requestBody)
const durationMs = Date.now() - startedAt
if (!response.ok) {
throw new BrowserRenderError({
status: response.status,
detail: await readBrowserErrorDetail(response),
durationMs,
timeoutMs: THUMBNAIL_RENDER_TIMEOUT_MS,
})
}
// A body stream that fails is still a session that existed and spent, so it is wrapped into the
// error type the ledger recognises rather than escaping as a raw stream error and vanishing from
// the spend record.
const buffer = await response.arrayBuffer().catch((error) => {
throw new BrowserRenderError({
status: response.status,
detail: `Reading the capture body failed: ${error instanceof Error ? error.message : String(error)}`,
durationMs: Date.now() - startedAt,
timeoutMs: THUMBNAIL_RENDER_TIMEOUT_MS,
})
})
return { buffer, durationMs: Date.now() - startedAt, browserMsUsed: browserMsUsedOf(response) }
}
// The request cannot cap its own total: `gotoOptions.timeout` and `waitForSelector.timeout` are
// sequential phases that each get the full render budget (see getThumbnailScreenshotRequestBody),
// so a page that stalls through both holds the call for roughly twice THUMBNAIL_RENDER_TIMEOUT_MS.
// Everything sized against "a capture takes at most the render timeout" — the pending-marker TTL
// against the retry chain, the edit debounce outlasting a capture, both pinned in
// ogImageQueue.test.ts — needs the single budget to be a real ceiling, so it is enforced here on
// the whole call. The abandoned browser session is Cloudflare's to reap and still spends its
// remainder — this bounds the delivery, not the spend.
async function abandonAtRenderTimeout(
capture: Promise<TimedCapture>,
startedAt: number
): Promise<TimedCapture> {
let timer: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([
capture,
new Promise<never>((_, reject) => {
timer = setTimeout(() => {
reject(
new BrowserRenderError({
// No response was received, so there is no status; the detail is what classifies
// this as a browser_timeout (see classifyBrowserRenderFailure).
status: 0,
detail: `Capture abandoned by the worker at the ${THUMBNAIL_RENDER_TIMEOUT_MS} ms render timeout`,
durationMs: Date.now() - startedAt,
timeoutMs: THUMBNAIL_RENDER_TIMEOUT_MS,
})
)
}, THUMBNAIL_RENDER_TIMEOUT_MS)
}),
])
} finally {
// Only the timer needs cleanup: an abandoned capture's eventual rejection is already handled,
// since Promise.race subscribes to every input.
clearTimeout(timer)
}
}
// How much of Cloudflare's error body to keep. It is a short JSON object in practice; the cap is
// there so a proxy's HTML error page can't put a document into a Sentry event.
const MAX_BROWSER_ERROR_DETAIL_LENGTH = 500
// Reads why Browser Run refused, which the status cannot say on its own (see BrowserRenderError).
// Failure-proof by construction: this runs on a path that is already failing, so a body that won't
// read or won't parse must degrade to "no detail" rather than replace the status we do have with a
// second error.
async function readBrowserErrorDetail(response: Response): Promise<string | undefined> {
try {
const text = (await response.text()).trim()
if (!text) return undefined
return truncate(extractBrowserErrorMessages(text) ?? text)
} catch {
return undefined
}
}
// Cloudflare's error body is `{"success":false,"errors":[{"code":…,"message":"…"}]}`, and the
// messages are the whole readable part. Anything else — a proxy's HTML, a plain string, a shape we
// don't recognise — falls back to the raw text.
function extractBrowserErrorMessages(text: string): string | undefined {
try {
const body = JSON.parse(text)
if (!Array.isArray(body?.errors)) return undefined
const messages = body.errors
.map((error: unknown) => (error as { message?: unknown } | null)?.message)
.filter((message: unknown): message is string => typeof message === 'string' && !!message)
return messages.length > 0 ? messages.join('; ') : undefined
} catch {
return undefined
}
}
function truncate(text: string) {
return text.length > MAX_BROWSER_ERROR_DETAIL_LENGTH
? `${text.slice(0, MAX_BROWSER_ERROR_DETAIL_LENGTH)}…`
: text
}
// Development stand-in for the Browser Rendering call above. Sent the same request body, and its
// response decoded by the same code, so everything either side of the call is the production path.
// The browser is a local Playwright one though, not Browser Run, so a render that works here is not
// evidence that it works in production.
async function callLocalScreenshotService(
serviceUrl: string,
requestBody: ThumbnailScreenshotRequestBody
): Promise<TimedCapture> {
const startedAt = Date.now()
const response = await fetch(serviceUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
})
if (!response.ok) {
throw new Error(
`Local screenshot service failed (${response.status}): ${await response.text()}`
)
}
// A dev server that has not loaded the screenshot plugin answers this path with the client's
// index.html — a 200 full of bytes that would otherwise be cached as if it were a thumbnail.
const contentType = response.headers.get('content-type') ?? ''
if (!contentType.includes('image/png')) {
throw new Error(
`Local screenshot service returned ${contentType || 'no content type'}, expected image/png. Is the client dev server running with the thumbnail screenshot plugin?`
)
}
const buffer = await response.arrayBuffer()
return { buffer, durationMs: Date.now() - startedAt, browserMsUsed: browserMsUsedOf(response) }
}
// Writes one rendered PNG to a thumbnail cache, stamping the content version (so a stale version
// can be detected) alongside any surface-specific metadata.
// --- Measuring a page in a real editor ----------------------------------------------------------
//
// A Worker cannot size a shape: autosizing text needs font metrics, and several shapes store no size
// at all. So when clustering needs geometry, the render page is driven in `measure` mode — it loads
// the same snapshot, waits for fonts, reads editor.getShapePageBounds for every shape, and POSTs the
// result back. Stashed under the job's own token and read once, so it is a rendezvous for a single
// in-flight render rather than a cache with a lifetime to manage.
function getRenderResultKey(token: string) {
return `render-result/${encodeURIComponent(token)}.json`
}
export async function putRenderResult(
env: Environment,
token: string,
bounds: Record<string, ShapeMeasurement>
) {
if (!env.THUMBNAILS) return
await env.THUMBNAILS.put(getRenderResultKey(token), JSON.stringify(bounds), {
httpMetadata: { contentType: 'application/json' },
})
}
/**
* Measures every shape on a page through a real editor, returning `shapeId -> bounds and text`.
*
* Costs one Browser Rendering session, the same as a screenshot — there is no cheaper way to get
* geometry the Worker cannot compute. Callers own the rate limiting that implies; the session
* itself lands on the `browser_run_session` spend ledger with `mode:measure`, written at the render
* choke point rather than by callers.
*/
export async function measurePageShapes(
env: Environment,
board: ResolvedThumbnailBoard,
pageId: string,
{ surface }: { surface: ThumbnailRenderSurface }
): Promise<Record<string, ShapeMeasurement>> {
const job: ThumbnailRenderJob = {
v: 1,
kind: board.kind,
slug: board.slug,
version: board.version,
// Taken from the resolution rather than the caller, same as captureThumbnailScreenshot: a
// measure token for a private board reads the whole document through the snapshot route, so it
// carries the gate the board resolved under rather than defaulting to `public`.
access: board.access,
surface,
mode: 'measure',
pageId,
x: 0,
y: 0,
z: 1,
// Nothing is exported, but the viewport still has to be a sane size: shapes are measured
// against a laid-out document, not a zero-sized one.
width: DEFAULT_THUMBNAIL_WIDTH,
height: DEFAULT_THUMBNAIL_HEIGHT,
theme: 'light',
exp: Date.now() + THUMBNAIL_RENDER_TOKEN_TTL_MS,
}
// Checked before the render rather than after it: the read below cannot work without the bucket, and
// discovering that once a full browser session has already been spent makes the failure cost a
// capture it was never going to use.
if (!env.THUMBNAILS) throw new Error('THUMBNAILS bucket is not configured')
const token = await mintRecordedRenderToken(env, job)
const key = getRenderResultKey(token)
try {
// Awaited for the ready signal, not for the image it returns; see runRenderSession.
await runRenderSession(env, buildThumbnailRenderUrl(getRenderOrigin(env), token), {
width: DEFAULT_THUMBNAIL_WIDTH,
height: DEFAULT_THUMBNAIL_HEIGHT,
// The measure runs only on the MCP surface today; `surface` names the render pipeline for the
// signed job, not the telemetry source, so this is stated rather than derived.
session: { source: 'mcp', mode: 'measure' },
pageReached: (since) => wasRenderTokenServedSince(env, job, token, since),
})
const stored = await env.THUMBNAILS.get(key)
if (!stored) throw new Error('The render page did not report any measurements')
return JSON.parse(await stored.text())
} finally {
// Both of this capture's leftovers, dropped on the failure path as well as the success one.
//
// The record is keyed by its own token, so concurrent measures of one page cannot invalidate each
// other — which also means nothing later overwrites it. The result object is keyed the same way,
// and the render page POSTs it *before* signalling ready: a Quick Action that then times out —
// chronically a few percent of renders — leaves an object nothing will ever read or overwrite, in
// a bucket that deliberately carries no lifecycle rule.
//
// Best effort on the result key, and not for the sake of a tidy bucket: throwing here would
// replace a measure's real answer, or its real error, with a cleanup failure.
await deleteMintedRenderToken(env, job, token)
try {
await env.THUMBNAILS.delete(key)
} catch {
// Ignored — see above.
}
}
}
// Writes one rendered PNG to a thumbnail cache, stamping the content version (so a stale version
// can be detected) alongside any surface-specific metadata.
export async function putThumbnailPng(
bucket: R2Bucket,
key: string,
base64: string,
version: string | number,
extraMetadata?: Record<string, string>
) {
await bucket.put(key, base64ToArrayBuffer(base64), {
httpMetadata: { contentType: 'image/png' },
customMetadata: {
version: String(version),
createdAt: String(Date.now()),
...extraMetadata,
},
})
}
// The request-level datapoint writer for every screenshot surface, so they share a dataset and blob
// layout and one dashboard covers them all; the source blob distinguishes mcp (the tool), og (the
// GET route) and queue (the render consumer). The dataset name covers all three despite its mcp_
// prefix, and must keep doing so: renaming it would split the dashboard's history.
//
// This event answers cache and refusal questions — hit rates by source, who was refused and why,
// what was rate-limited. What requests *spend* lives on the separate `browser_run_session` event
// (see writeBrowserRunSessionTelemetry): one request can hold zero, one, or two browser sessions,
// so durations on request rows could only ever be sums that answer neither ledger cleanly.
//
// Carries **no board identity**: no index, no slug, no hash, no derived id. Not an omission — these
// datapoints answer aggregate cache and failure-rate questions, and the cost of that choice is a
// dataset that cannot say which board is failing. See "No board identifier leaves this pipeline" in
// browser-run-thumbnails.md.
export function writeScreenshotTelemetry(
env: Environment,
data: {
source: 'mcp' | 'og' | 'queue'
/**
* Which trigger asked for this render — what attributes a render to publishing or editing.
* Only meaningful on queue datapoints; the request paths have no trigger and record `none`.
*/
reason?: OgImageRenderReason
/**
* Whether this delivery is the follow-up a completed render enqueues when a `published` board —
* the only kind that follows up — moved during its capture (see `enqueueFollowUpIfBoardMoved`),
* rather than the ask a trigger made. Separates
* the two halves of render spend, which `reason` cannot: a follow-up inherits the reason of the
* job it follows, so both land in the same bucket. Only meaningful on queue datapoints; the
* request paths have no follow-up and record `none`.
*/
followUp?: boolean
/**
* What the PNG cache did for this request. `none` when the request never consulted it: the
* info tools have no cache at all, and a screenshot request refused before the cache read
* (bad input, rate-limited caller, unresolvable board) says nothing about cache health.
* Excluding `none` is what keeps a hit-rate-by-source panel honest.
*/
cacheStatus: 'hit' | 'stale' | 'miss' | 'none'
/**
* What the MCP cluster index cache did, which decides whether the call ran a measure render.
* Its own dimension rather than part of `cacheStatus`, which is the PNG cache: the two save
* different things, and one blob mixing them would make either hit rate unreadable. `none` on
* every OG row and on any MCP row that never reached the clustering step.
*/
clusterCacheStatus?: 'hit' | 'miss' | 'none'
/**
* Hashed identity of whoever asked, for surfaces that have one. Recorded only on rate-limited
* rows — see below.
*
* This is a hashed user id on the MCP surface, which now requires authentication; it replaced
* a hashed client IP, and holds the same blob position so the dashboard panels reading it did
* not have to move. The OG surfaces have no caller at all and leave it unset.
*/
callerHash?: string
failureReason?: string
rateLimitAllowed?: boolean
}
) {
const rateLimitAllowed = data.rateLimitAllowed ?? true
// Only on rate-limited rows, which is the whole question a per-caller blob answers: who to look at
// when spend spikes or a limit keeps firing.
//
// Deliberately narrower than "any failure". Cardinality is about distinct *values*, not row count,
// and the failure set is mostly routine model mistakes — a wrong board id, a stale cluster id, a
// page that moved. Nearly every caller produces one of those eventually, so gating on failure
// would leave the number of distinct callers converging on the number of users, which is the cost
// the gate exists to avoid. Rate limits fire rarely and only for callers worth naming.
writeDataPoint(undefined, env.MEASURE, env, 'mcp_shared_board_screenshot', {
blobs: [
`source:${data.source}`,
`cache:${data.cacheStatus}`,
`failure:${data.failureReason ?? 'none'}`,
`rate_limit:${rateLimitAllowed ? 'allowed' : 'blocked'}`,
`caller:${!rateLimitAllowed && data.callerHash ? data.callerHash : 'none'}`,
// Appended rather than slotted in beside `source`, so the existing blob positions (and the
// dashboard panels reading them) don't shift.
`reason:${data.reason ?? 'none'}`,
// Appended for the same reason. `none` rather than `false` for the surfaces that have no
// follow-up concept at all, so a query for triggered renders can say `followup:false` and mean
// it, instead of sweeping up every og and mcp datapoint too.
`followup:${data.followUp ?? 'none'}`,
// Appended, like the two above, so nothing shifts. Only the MCP clustering tools ever set it.
`clusters:${data.clusterCacheStatus ?? 'none'}`,
],
doubles: [
DEFAULT_THUMBNAIL_WIDTH,
DEFAULT_THUMBNAIL_HEIGHT,
// Durations moved to the browser_run_session event; the sentinel holds this position (and
// the ones after it) so historical rows and the panels reading them keep their meaning.
-1,
-1,
rateLimitAllowed ? 1 : 0,
],
})
}

View File

@@ -0,0 +1,204 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Environment } from '../../types'
import {
BoardSnapshotReadError,
BrowserRenderError,
classifyScreenshotFailure,
RateLimiterUnavailableError,
describeThumbnailFailure,
reportThumbnailError,
} from './thumbnailShared'
afterEach(() => {
vi.restoreAllMocks()
})
function makeBrowserRenderError(
overrides: Partial<ConstructorParameters<typeof BrowserRenderError>[0]> = {}
) {
return new BrowserRenderError({
status: 422,
detail: undefined,
durationMs: 1_000,
timeoutMs: 45_000,
...overrides,
})
}
describe('BrowserRenderError', () => {
// Sentry groups on the message, so the detail has to stay off it: a body that names the board or
// the failing asset would turn one recurring issue into a new one per distinct string.
it('keeps the status-only message that Sentry groups on', () => {
const error = makeBrowserRenderError({ detail: 'Navigation timeout of 45000 ms exceeded' })
expect(error.message).toBe('Browser Rendering screenshot failed (422)')
})
})
describe('classifyScreenshotFailure', () => {
// Matched by type and first: a binding outage means the work was never attempted, so every other
// reason here would name a subsystem that is fine.
it('names a rate limiter outage as itself', () => {
expect(classifyScreenshotFailure(new RateLimiterUnavailableError(new Error('down')))).toBe(
'rate_limiter_unavailable'
)
expect(describeThumbnailFailure('rate_limiter_unavailable')).toBe(
'a rate limit could not be checked'
)
})
// 422 is Cloudflare's answer to a crashed page, an out-of-memory render, and every one of its
// timers expiring alike, so the status can't split them and the response body has to.
it('reads the timer out of the response body', () => {
const error = makeBrowserRenderError({
detail: 'Navigation timeout of 45000 ms exceeded',
durationMs: 900,
})
expect(classifyScreenshotFailure(error)).toBe('browser_timeout')
})
// The other half of the same 422: our own render page marked data-thumbnail-error, the capture
// selector (success-only) was therefore absent, and the call came back long before the budget.
it('classifies an early failure with no timer named as a render failure', () => {
const error = makeBrowserRenderError({
detail: 'Element with selector "body[data-thumbnail-ready=\\"true\\"]" not found',
durationMs: 3_000,
})
expect(classifyScreenshotFailure(error)).toBe('browser_failed')
})
// Fallback for a body that says nothing useful: a call that spent essentially the whole budget
// waited a timer out, whatever it was called.
it('falls back to the elapsed budget when the body names no timer', () => {
expect(classifyScreenshotFailure(makeBrowserRenderError({ durationMs: 44_000 }))).toBe(
'browser_timeout'
)
expect(classifyScreenshotFailure(makeBrowserRenderError({ durationMs: 5_000 }))).toBe(
'browser_failed'
)
})
it('still distinguishes a snapshot read failure from a render failure', () => {
expect(classifyScreenshotFailure(new BoardSnapshotReadError('postgres is down'))).toBe(
'snapshot_read_error'
)
})
it('keeps classifying the untyped errors the surfaces still throw', () => {
expect(classifyScreenshotFailure(new Error('Browser Rendering is not configured.'))).toBe(
'not_configured'
)
expect(classifyScreenshotFailure(new Error('Render produced an empty screenshot'))).toBe(
'empty_render'
)
expect(classifyScreenshotFailure(new Error('something else entirely'))).toBe('render_error')
})
})
describe('describeThumbnailFailure', () => {
// 'the render failed' is nonsense for a tool that starts no render — search_boards and
// get_board_info both fail this way when Postgres does, and both prefix it with their own
// summary ("Could not search boards: …").
it('describes a board lookup failure without mentioning rendering', () => {
expect(describeThumbnailFailure('board_lookup_error')).toBe(
'the board database could not be reached'
)
})
})
describe('reportThumbnailError', () => {
// Without this the report says only "Browser Rendering screenshot failed (422)", which is equally
// true of a crashed page, an OOM render and a timeout — the whole point of reporting is to know
// which. Tests get no ExecutionContext, so reporting takes its console fallback.
it('reports the cause Cloudflare gave alongside the caller-supplied extras', () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const error = makeBrowserRenderError({
detail: 'Navigation timeout of 45000 ms exceeded',
durationMs: 45_100,
})
reportThumbnailError(error, {
ctx: undefined,
env: {} as Environment,
surface: 'og_queue',
extras: { kind: 'published' },
})
expect(consoleError).toHaveBeenCalledExactlyOnceWith(
'[thumbnails:og_queue]',
{
kind: 'published',
browser_render_status: 422,
browser_render_detail: 'Navigation timeout of 45000 ms exceeded',
browser_render_duration_ms: 45_100,
browser_render_timeout_ms: 45_000,
browser_render_reason: 'browser_timeout',
},
error
)
})
// An empty body is itself a fact worth seeing, so it is recorded rather than omitted.
it('records the absence of a response body', () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
reportThumbnailError(makeBrowserRenderError(), {
ctx: undefined,
env: {} as Environment,
surface: 'mcp_screenshot',
})
expect(consoleError.mock.calls[0]![1]).toMatchObject({
browser_render_detail: '(no response body)',
})
})
// The request is used for its method and user agent and nothing else. It is deliberately never
// handed to createSentry, which passes one to Toucan with `allowedSearchParams: /(.*)/` — that
// would record the full URL and every query parameter, and on these routes the URL is the
// sensitive part: a link-shared file's id sits in the OG route's path, and a signed render token
// (a live capability to read the board's whole snapshot) sits in the snapshot route's query.
it('takes no URL or query parameter from the request', () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const request = new Request(
'https://sync.tldraw.xyz/api/app/thumbnail-render/snapshot?token=super-secret-render-token',
{ headers: { 'user-agent': 'Twitterbot/1.0' } }
)
reportThumbnailError(new Error('nope'), {
ctx: undefined,
env: {} as Environment,
request,
surface: 'thumbnail_snapshot',
extras: { kind: 'shared_file' },
})
const context = consoleError.mock.calls[0]![1]
expect(context).toEqual({
kind: 'shared_file',
request_method: 'GET',
request_user_agent: 'Twitterbot/1.0',
})
const serialised = JSON.stringify(context)
expect(serialised).not.toContain('super-secret-render-token')
expect(serialised).not.toContain('thumbnail-render')
expect(serialised).not.toContain('sync.tldraw.xyz')
})
it('leaves errors that are not render failures alone', () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const error = new BoardSnapshotReadError('postgres is down')
reportThumbnailError(error, {
ctx: undefined,
env: {} as Environment,
surface: 'og_queue',
extras: { kind: 'shared_file' },
})
expect(consoleError).toHaveBeenCalledExactlyOnceWith(
'[thumbnails:og_queue]',
{ kind: 'shared_file' },
error
)
})
})

View File

@@ -0,0 +1,219 @@
import { createSentry } from '@tldraw/worker-shared'
import { Environment } from '../../types'
// Leaf helpers shared by the board-reading and thumbnail/OG-image surfaces
// (get{Published,SharedFile}, the render core in thumbnailRender.ts, mcpServer.ts,
// the OG route, and the OG queue consumer). This module imports nothing from those files so it can
// be depended on from any of them without creating an import cycle.
// A rate limit binding could not be consulted. Distinct from every other failure on these routes
// because nothing the caller did caused it and nothing they can do fixes it: the work was never
// attempted, so telling them the database or the renderer failed sends them, and whoever reads the
// dashboard, at a subsystem that is fine.
export class RateLimiterUnavailableError extends Error {
constructor(cause: unknown) {
super('Rate limiter unavailable', { cause })
this.name = 'RateLimiterUnavailableError'
}
}
// A board's snapshot could not be read: Postgres, R2, or a malformed payload. Distinct from a render
// failure so telemetry can tell "the database is down" apart from "Chrome fell over".
export class BoardSnapshotReadError extends Error {
constructor(message: string, options?: { cause?: unknown }) {
super(message, options)
this.name = 'BoardSnapshotReadError'
}
}
// A Browser Run `/screenshot` call that came back non-OK. The status alone says almost nothing:
// Cloudflare answers 422 for every "the page did not cooperate" outcome — a crashed page, an
// out-of-memory render, any Quick Action timer expiring — and our own render page marking
// `data-thumbnail-error` lands there too, because the capture selector only exists on the success
// path. What separates them is the response body and how much of the timeout budget the call spent,
// so both are carried here rather than in the message.
//
// `message` stays exactly `Browser Rendering screenshot failed (<status>)`. Sentry groups on it, so a
// varying detail string there would shatter one recurring issue into a stream of new ones;
// `reportThumbnailError` lifts these fields into the event's context instead.
export class BrowserRenderError extends Error {
readonly status: number
readonly detail: string | undefined
readonly durationMs: number
readonly timeoutMs: number
constructor(options: {
status: number
detail: string | undefined
durationMs: number
timeoutMs: number
}) {
super(`Browser Rendering screenshot failed (${options.status})`)
this.name = 'BrowserRenderError'
this.status = options.status
this.detail = options.detail
this.durationMs = options.durationMs
this.timeoutMs = options.timeoutMs
}
}
// Maps an arbitrary render error to a bounded set of reason codes for the `failure` telemetry blob.
// Raw `error.message` strings are unbounded and must never reach that dimension, so keep this a
// small, stable vocabulary. The typed cases are matched first and by type: a failed snapshot read is
// not a render that failed, and filing it as one sends a dashboard reader to the wrong subsystem.
//
// A board deleted or unpublished between a surface resolving it and reading its snapshot lands in
// `snapshot_read_error` with everything else the readers throw. That race is a few milliseconds wide,
// so separating it out isn't worth a dedicated error type threaded through every reporting path.
export function classifyScreenshotFailure(error: unknown): string {
if (error instanceof RateLimiterUnavailableError) return 'rate_limiter_unavailable'
if (error instanceof BoardSnapshotReadError) return 'snapshot_read_error'
if (error instanceof BrowserRenderError) return classifyBrowserRenderFailure(error)
const message = error instanceof Error ? error.message : String(error)
if (/not configured/i.test(message)) return 'not_configured'
if (/timeout|timed out/i.test(message)) return 'browser_timeout'
if (/empty screenshot/i.test(message)) return 'empty_render'
if (/Browser Rendering screenshot failed/i.test(message)) return 'browser_failed'
return 'render_error'
}
// Splits a Browser Run failure into `browser_timeout` and `browser_failed`, which the status cannot
// do (see BrowserRenderError). Reads the response body, which usually names the timer, and falls back
// to how much of the budget the call spent: one that used essentially the whole timeout waited a timer
// out, one that returned early failed for another reason. Note the message is no use here — a Browser
// Run failure's never contains the word "timeout", so matching on it reports a timeout rate of zero.
const BROWSER_TIMEOUT_DURATION_FRACTION = 0.9
function classifyBrowserRenderFailure(error: BrowserRenderError): string {
if (/timeout|timed out|timed-out/i.test(error.detail ?? '')) return 'browser_timeout'
if (error.durationMs >= error.timeoutMs * BROWSER_TIMEOUT_DURATION_FRACTION) {
return 'browser_timeout'
}
return 'browser_failed'
}
// Caller-facing explanation for a classified failure, as a clause to follow a tool's own prefix.
// Derived from the bounded reason code and never from `error.message`: these tools answer anonymous,
// unauthenticated callers, and Postgres and R2 errors carry internal hostnames, ports, and database
// usernames (the pool is built from BOTCOM_POSTGRES_POOLED_CONNECTION_STRING). The unbounded
// original still reaches Sentry through reportThumbnailError, which is where it is useful.
export function describeThumbnailFailure(reason: string): string {
switch (reason) {
case 'snapshot_read_error':
return "the board's saved content could not be read"
case 'not_configured':
return 'rendering is not configured'
case 'browser_timeout':
return 'rendering timed out'
case 'board_lookup_error':
// The tools that fail this way start no render, so the default 'the render failed' would
// be a plainly wrong thing to tell a caller.
return 'the board database could not be reached'
case 'rate_limiter_unavailable':
// Deliberately not phrased as being rate limited: the caller is inside their budget, and a
// message saying otherwise would have them back off for a minute that would not help.
return 'a rate limit could not be checked'
default:
return 'the render failed'
}
}
// Which swallowing surface an error came from. Set as a Sentry tag so they can be filtered apart,
// and kept a closed union so the tag's values stay a small, stable set.
export type ThumbnailErrorSurface =
| 'og_route'
| 'og_queue'
| 'thumbnail_snapshot'
| 'mcp_board_info'
| 'mcp_board_search'
| 'mcp_screenshot'
// Kept apart from 'mcp_screenshot': the render succeeded and the caller still got their PNG, so
// this never means "screenshots are broken" — it means the cache isn't absorbing them and every
// call is re-spending Browser Run.
| 'mcp_screenshot_cache_write'
// The cluster index cache in the file's Durable Object. Neither of these is ever a caller-visible
// failure — both fall back to measuring the page — but both mean the clustering tools are paying
// for a browser session per call again, which is the thing that cache exists to stop.
| 'mcp_cluster_index_read'
| 'mcp_cluster_index_write'
// Every thumbnail/OG surface swallows its own errors — the OG route falls back to the default image,
// the snapshot route 404s, the MCP tools return a tool error, the queue retries or drops. Right for
// callers, but it leaves a bounded telemetry reason code as the only trace, which says a board stopped
// rendering and nothing about why. Reporting here is what keeps these paths diagnosable.
//
// `ctx` supplies the waitUntil that lets the report outlive the response — route handlers get one from
// the router, the queue consumer from the worker entrypoint. Without one (unit tests) we log instead,
// since createSentry throws when SENTRY_DSN and friends are unset.
export function reportThumbnailError(
error: unknown,
{
ctx,
env,
request,
surface,
extras,
}: {
ctx: ExecutionContext | undefined
env: Environment
request?: Request
surface: ThumbnailErrorSurface
extras?: Record<string, unknown>
}
) {
try {
const context = {
...extras,
...safeRequestContext(request),
...browserRenderContextOf(error),
}
// `request` is deliberately NOT handed to createSentry, which passes one straight to Toucan with
// `allowedSearchParams: /(.*)/` — recording the full URL and every query parameter. On these
// routes the URL is the sensitive part: `/app/social-preview/f/<id>/image` carries a link-shared
// file's id, and the render-snapshot route carries a signed token that can read the board's whole
// contents until it expires. safeRequestContext takes what is useful instead.
const sentry = ctx ? createSentry(ctx, env) : null
if (!sentry) {
console.error(`[thumbnails:${surface}]`, context, error)
return
}
// eslint-disable-next-line @typescript-eslint/no-deprecated
sentry.withScope((scope) => {
scope.setTag('thumbnail_surface', surface)
scope.setExtras(context)
// eslint-disable-next-line @typescript-eslint/no-deprecated
sentry.captureException(error)
})
} catch (_e) {
// Reporting runs inside handlers whose whole point is to swallow failure, so it must never be
// the thing that throws: a missing Sentry env var would otherwise turn a degraded-but-fine
// response into a 500.
}
}
// The parts of a request worth attaching that carry no board identity. The URL is excluded (see the
// call site); the user agent is kept because on the OG route it names the crawler that tripped over
// the board, and the method because a HEAD probe and a GET behave differently there. Not the client
// IP — telemetry records a hash of that, on failures only, which is where it earns its keep.
function safeRequestContext(request: Request | undefined): Record<string, unknown> | null {
if (!request) return null
return {
request_method: request.method,
request_user_agent: request.headers.get('user-agent') ?? 'none',
}
}
// The fields BrowserRenderError keeps out of its message, lifted into the event context so they don't
// file a new Sentry issue per distinct detail string. Without them an event says only "Browser
// Rendering screenshot failed (422)", which is true of a crash, an OOM render and a timeout alike.
function browserRenderContextOf(error: unknown): Record<string, unknown> | null {
if (!(error instanceof BrowserRenderError)) return null
return {
browser_render_status: error.status,
// Cloudflare's own explanation of the failure, verbatim (truncated at the throw site). Absent
// when the response carried no body, which is itself worth being able to see.
browser_render_detail: error.detail ?? '(no response body)',
browser_render_duration_ms: error.durationMs,
browser_render_timeout_ms: error.timeoutMs,
browser_render_reason: classifyScreenshotFailure(error),
}
}

View File

@@ -0,0 +1,40 @@
import { handleUserAssetUpload } from '@tldraw/worker-shared'
import { IRequest } from 'itty-router'
import { createPostgresConnectionPool } from '../../postgres'
import { Environment } from '../../types'
import { getAuth } from '../../utils/tla/getAuth'
import { hasWriteAccessToFile } from '../../utils/tla/hasWriteAccessToFile'
export async function upload(request: IRequest, env: Environment): Promise<Response> {
const { body, url, headers } = request
const auth = await getAuth(request, env)
const userId = auth?.userId || null
const searchParams = new URL(url).searchParams
const fileId = searchParams.get('fileId')
if (!fileId) return Response.json({ error: 'File id is required' }, { status: 400 })
const objectName = request.params.objectName
if (!objectName) return Response.json({ error: 'Object name is required' }, { status: 400 })
const db = createPostgresConnectionPool(env, 'sync-worker')
let canWrite = false
try {
canWrite = await hasWriteAccessToFile(db, fileId, userId)
} finally {
await db.destroy()
}
if (!canWrite) {
return Response.json({ error: 'Could not upload the file' }, { status: 403 })
}
const res = await handleUserAssetUpload({
body,
headers,
bucket: env.UPLOADS,
objectName,
})
if (res.status === 200) {
await env.QUEUE.send({ type: 'asset-upload', objectName, fileId, userId })
}
return res
}

View File

@@ -0,0 +1,493 @@
import { UnknownRecord } from '@tldraw/store'
import { RoomSnapshot } from '@tldraw/sync-core'
import { IRequest } from 'itty-router'
import { describe, expect, it, vi } from 'vitest'
import { createFakeR2 } from '../test/fakeR2'
import { Environment } from '../types'
import { ChainState, PendingDelta, segmentCustomMetadata, versionKey } from '../versionChain'
import { encodeVersionBody } from '../versionChainCodec'
import { writeVersionChainEntry } from '../versionChainWrite'
import { buildSnapshotDelta } from '../versionDelta'
import { verifyRoomVersions, verifyVersionChainRoute } from './verifyVersionChain'
vi.mock('../utils/tla/getAuth', () => ({ requireAdminAccessToRequest: vi.fn() }))
const roomKey = 'app_rooms/slug'
function snapshot(clock: number, ids: string[]): RoomSnapshot {
return {
clock,
documentClock: clock,
documents: ids.map((id) => ({
state: { id, typeName: 'shape' } as UnknownRecord,
lastChangedClock: clock,
})),
tombstones: {},
tombstoneHistoryStartsAtClock: 0,
schema: { schemaVersion: 2, sequences: {} } as any,
}
}
/** Mirrors what dual-write produces: a chain in one bucket, full copies in the other. */
async function seedDualWrite(
chainBucket: R2Bucket,
legacyBucket: R2Bucket,
versions: RoomSnapshot[]
) {
let chain: ChainState | null = null
let pending: PendingDelta[] = []
for (let i = 0; i < versions.length; i++) {
const iso = `2026-09-01T00:00:0${i}.000Z`
const result = await writeVersionChainEntry({
bucket: chainBucket,
roomKey,
iso,
chain,
pending,
previous: i === 0 ? null : versions[i - 1],
next: versions[i],
now: i * 1000,
})
chain = result.chain
pending = result.pending
await legacyBucket.put(`${roomKey}/${iso}`, JSON.stringify(versions[i]))
}
}
describe('verifyRoomVersions under clock skew', () => {
it('verifies a chain whose later segment has an earlier key', async () => {
const chainBucket = createFakeR2()
const legacyBucket = createFakeR2()
const versions = [
snapshot(1, ['shape:a']),
snapshot(2, ['shape:a', 'shape:b']),
snapshot(3, ['shape:a', 'shape:b', 'shape:c']),
]
const iso = (s: number) => `2026-09-01T00:00:${String(s).padStart(2, '0')}.000Z`
const keyframeKey = versionKey(roomKey, iso(10), 'keyframe')
const kf = await encodeVersionBody(versions[0])
await chainBucket.put(keyframeKey, kf.body, { customMetadata: kf.metadata })
await legacyBucket.put(`${roomKey}/${iso(10)}`, JSON.stringify(versions[0]))
const put = async (at: number, firstSeq: number, prev: RoomSnapshot, next: RoomSnapshot) => {
const deltas = [{ t: iso(at), delta: buildSnapshotDelta(prev, next) }]
const encoded = await encodeVersionBody({ v: 1, deltas })
await chainBucket.put(versionKey(roomKey, iso(at), 'segment'), encoded.body, {
customMetadata: {
...encoded.metadata,
...segmentCustomMetadata({ keyframeKey, firstSeq, timestamps: [iso(at)] }),
},
})
await legacyBucket.put(`${roomKey}/${iso(at)}`, JSON.stringify(next))
}
// Sequence 1 at :20, then the clock steps back: sequence 2 lands under :15.
await put(20, 1, versions[0], versions[1])
await put(15, 2, versions[1], versions[2])
expect(await verifyRoomVersions({ chainBucket, legacyBucket, roomKey, limit: 20 })).toEqual({
checked: 3,
replayed: 3,
// One listing, then a get and a legacy get for the keyframe and for each segment.
reads: 7,
complete: true,
mismatches: [],
errors: [],
legacyReadFailures: [],
})
})
})
describe('verifyRoomVersions after cut-over', () => {
it('stops on the read budget when no legacy copy answers', async () => {
const chainBucket = createFakeR2()
const legacyBucket = createFakeR2()
// Chain only, as the bucket looks once dual-write is off: nothing increments a comparison
// counter, so a budget keyed off one would walk all twenty chains.
for (let i = 0; i < 20; i++) {
const iso = `2026-09-01T00:00:${String(i).padStart(2, '0')}.000Z`
const kf = await encodeVersionBody(snapshot(i + 1, [`shape:${i}`]))
await chainBucket.put(versionKey(roomKey, iso, 'keyframe'), kf.body, {
customMetadata: kf.metadata,
})
}
// One listing, then two reads per keyframe: three keyframes fit, the fourth never starts.
expect(await verifyRoomVersions({ chainBucket, legacyBucket, roomKey, limit: 7 })).toEqual({
checked: 0,
replayed: 3,
reads: 7,
complete: false,
mismatches: [],
errors: [],
legacyReadFailures: [],
})
})
})
describe('verifyRoomVersions with a limit', () => {
it('spends the budget on the newest chain first', async () => {
const chainBucket = createFakeR2()
const legacyBucket = createFakeR2()
// Two chains: an old one whose legacy copy is wrong, and a new one that is fine.
const old = snapshot(1, ['shape:old'])
const recent = snapshot(2, ['shape:new'])
for (const [iso, version, legacy] of [
['2026-08-01T00:00:00.000Z', old, snapshot(9, ['shape:tampered'])],
['2026-09-01T00:00:00.000Z', recent, recent],
] as const) {
const kf = await encodeVersionBody(version)
await chainBucket.put(versionKey(roomKey, iso, 'keyframe'), kf.body, {
customMetadata: kf.metadata,
})
await legacyBucket.put(`${roomKey}/${iso}`, JSON.stringify(legacy))
}
// One listing plus the two reads the first keyframe costs, so the second never starts.
const limited = await verifyRoomVersions({ chainBucket, legacyBucket, roomKey, limit: 3 })
const full = await verifyRoomVersions({ chainBucket, legacyBucket, roomKey, limit: 20 })
// With budget for one version, the newest chain is the one that gets checked.
expect(limited).toEqual({
checked: 1,
replayed: 1,
reads: 3,
complete: false,
mismatches: [],
errors: [],
legacyReadFailures: [],
})
expect(full.mismatches).toEqual(['2026-08-01T00:00:00.000Z'])
})
it('marks a run the listing alone exhausted as incomplete', async () => {
const chainBucket = createFakeR2()
const legacyBucket = createFakeR2()
await seedDualWrite(chainBucket, legacyBucket, [snapshot(1, ['shape:a'])])
// The listing spends the whole budget before the keyframe is read: nothing is verified, and
// `complete: false` is the only thing separating this result from a clean room.
expect(await verifyRoomVersions({ chainBucket, legacyBucket, roomKey, limit: 1 })).toEqual({
checked: 0,
replayed: 0,
reads: 1,
complete: false,
mismatches: [],
errors: [],
legacyReadFailures: [],
})
})
})
describe('verifyRoomVersions on a segment the read path rejects', () => {
const versions = [
snapshot(1, ['shape:a']),
snapshot(2, ['shape:a', 'shape:b']),
snapshot(3, ['shape:a', 'shape:b', 'shape:c']),
]
async function seedAndFindSegment() {
const chainBucket = createFakeR2()
const legacyBucket = createFakeR2()
await seedDualWrite(chainBucket, legacyBucket, versions)
const listing = await chainBucket.list({ prefix: `${roomKey}/` })
const segment = listing.objects.find((o) => o.key.endsWith('.s'))!
const object = (await chainBucket.get(segment.key))!
return { chainBucket, legacyBucket, segment, metadata: object.customMetadata! }
}
it('reports a body that does not begin with what its metadata lists', async () => {
const { chainBucket, legacyBucket, segment, metadata } = await seedAndFindSegment()
// Same first delta, then a version the listing never promised in place of the second.
const torn = await encodeVersionBody({
v: 1,
deltas: [
{ t: '2026-09-01T00:00:01.000Z', delta: buildSnapshotDelta(versions[0], versions[1]) },
{ t: '2026-09-01T00:00:09.000Z', delta: buildSnapshotDelta(versions[1], versions[2]) },
],
})
await chainBucket.put(segment.key, torn.body, { customMetadata: metadata })
const result = await verifyRoomVersions({ chainBucket, legacyBucket, roomKey, limit: 20 })
expect(result.errors.map((e) => e.message)).toEqual([
expect.stringMatching(/does not match its metadata/),
])
})
it('reports a segment written in an unknown format', async () => {
const { chainBucket, legacyBucket, segment, metadata } = await seedAndFindSegment()
const future = await encodeVersionBody({ v: 2, deltas: [] })
await chainBucket.put(segment.key, future.body, { customMetadata: metadata })
const result = await verifyRoomVersions({ chainBucket, legacyBucket, roomKey, limit: 20 })
expect(result.errors.map((e) => e.message)).toEqual([
expect.stringMatching(/unknown version segment format/),
])
})
})
describe('verifyRoomVersions on an orphaned segment', () => {
it('reports a segment whose keyframe is not in the index', async () => {
const chainBucket = createFakeR2()
const legacyBucket = createFakeR2()
const versions = [snapshot(1, ['shape:a']), snapshot(2, ['shape:a', 'shape:b'])]
await seedDualWrite(chainBucket, legacyBucket, versions)
// A segment left behind by a chain whose keyframe object is gone.
const orphanIso = '2026-08-01T00:00:01.000Z'
const missingKeyframeKey = versionKey(roomKey, '2026-08-01T00:00:00.000Z', 'keyframe')
const encoded = await encodeVersionBody({
v: 1,
deltas: [{ t: orphanIso, delta: buildSnapshotDelta(versions[0], versions[1]) }],
})
await chainBucket.put(versionKey(roomKey, orphanIso, 'segment'), encoded.body, {
customMetadata: {
...encoded.metadata,
...segmentCustomMetadata({
keyframeKey: missingKeyframeKey,
firstSeq: 1,
timestamps: [orphanIso],
}),
},
})
const result = await verifyRoomVersions({ chainBucket, legacyBucket, roomKey, limit: 20 })
expect(result.errors).toEqual([
{
timestamp: orphanIso,
message: expect.stringMatching(/references missing keyframe/),
},
])
// The intact chain still verifies clean around the orphan.
expect(result.mismatches).toEqual([])
expect(result.checked).toBe(2)
})
it('reports the orphan even when the budget stops the walk', async () => {
const chainBucket = createFakeR2()
const legacyBucket = createFakeR2()
const orphanIso = '2026-08-01T00:00:01.000Z'
const encoded = await encodeVersionBody({
v: 1,
deltas: [
{
t: orphanIso,
delta: buildSnapshotDelta(snapshot(1, ['shape:a']), snapshot(2, ['shape:a', 'shape:b'])),
},
],
})
await chainBucket.put(versionKey(roomKey, orphanIso, 'segment'), encoded.body, {
customMetadata: {
...encoded.metadata,
...segmentCustomMetadata({
keyframeKey: versionKey(roomKey, '2026-08-01T00:00:00.000Z', 'keyframe'),
firstSeq: 1,
timestamps: [orphanIso],
}),
},
})
// The listing alone exhausts the budget; the orphan check costs no reads and still runs.
const result = await verifyRoomVersions({ chainBucket, legacyBucket, roomKey, limit: 1 })
expect(result.errors.map((e) => e.message)).toEqual([
expect.stringMatching(/references missing keyframe/),
])
expect(result.replayed).toBe(0)
})
})
describe('verifyRoomVersions on an unreadable segment', () => {
const iso = (s: number) => `2026-09-01T00:00:0${s}.000Z`
const versions = [
snapshot(1, ['shape:a']),
snapshot(2, ['shape:a', 'shape:b']),
snapshot(3, ['shape:a', 'shape:b', 'shape:c']),
]
/** A keyframe and two segments, where the one at `brokenSeq` has lost its chain reference. */
async function seedWithBrokenSegment(
chainBucket: R2Bucket,
legacyBucket: R2Bucket,
brokenSeq: 1 | 2
) {
const keyframeKey = versionKey(roomKey, iso(0), 'keyframe')
const kf = await encodeVersionBody(versions[0])
await chainBucket.put(keyframeKey, kf.body, { customMetadata: kf.metadata })
await legacyBucket.put(`${roomKey}/${iso(0)}`, JSON.stringify(versions[0]))
for (const firstSeq of [1, 2] as const) {
const encoded = await encodeVersionBody({
v: 1,
deltas: [
{
t: iso(firstSeq),
delta: buildSnapshotDelta(versions[firstSeq - 1], versions[firstSeq]),
},
],
})
// The broken one keeps its body metadata and loses the chain reference, the way a
// half-written custom metadata set would.
await chainBucket.put(versionKey(roomKey, iso(firstSeq), 'segment'), encoded.body, {
customMetadata:
firstSeq === brokenSeq
? encoded.metadata
: {
...encoded.metadata,
...segmentCustomMetadata({
keyframeKey,
firstSeq,
timestamps: [iso(firstSeq)],
}),
},
})
await legacyBucket.put(`${roomKey}/${iso(firstSeq)}`, JSON.stringify(versions[firstSeq]))
}
}
it('reports a trailing segment that leaves no sequence gap behind it', async () => {
const chainBucket = createFakeR2()
const legacyBucket = createFakeR2()
await seedWithBrokenSegment(chainBucket, legacyBucket, 2)
// Without the rejection report this is a clean room: the walk replays the keyframe and
// sequence 1, then simply ends, and nothing is left to trip the sequence check.
expect(await verifyRoomVersions({ chainBucket, legacyBucket, roomKey, limit: 20 })).toEqual({
checked: 2,
replayed: 2,
reads: 5,
complete: true,
mismatches: [],
errors: [
{
timestamp: iso(2),
message: expect.stringMatching(/has no readable chain reference/),
},
],
legacyReadFailures: [],
})
})
it('reports a mid-chain segment directly, not only as the gap it leaves', async () => {
const chainBucket = createFakeR2()
const legacyBucket = createFakeR2()
await seedWithBrokenSegment(chainBucket, legacyBucket, 1)
const result = await verifyRoomVersions({ chainBucket, legacyBucket, roomKey, limit: 20 })
expect(result.errors).toEqual([
{ timestamp: iso(1), message: expect.stringMatching(/has no readable chain reference/) },
{ timestamp: iso(2), message: expect.stringMatching(/expected at sequence 1, found 2/) },
])
expect(result.mismatches).toEqual([])
})
it('reports the segment even when the budget stops the walk', async () => {
const chainBucket = createFakeR2()
const legacyBucket = createFakeR2()
const encoded = await encodeVersionBody({
v: 1,
deltas: [{ t: iso(1), delta: buildSnapshotDelta(versions[0], versions[1]) }],
})
await chainBucket.put(versionKey(roomKey, iso(1), 'segment'), encoded.body, {
customMetadata: encoded.metadata,
})
// The listing alone exhausts the budget; reading the index is what found this, so it is
// reported whether or not the walk gets to run.
const result = await verifyRoomVersions({ chainBucket, legacyBucket, roomKey, limit: 1 })
expect(result.errors.map((e) => e.message)).toEqual([
expect.stringMatching(/has no readable chain reference/),
])
expect(result.replayed).toBe(0)
})
})
describe('verifyRoomVersions', () => {
it('reports no mismatches when reconstruction matches the full copies', async () => {
const chainBucket = createFakeR2()
const legacyBucket = createFakeR2()
await seedDualWrite(chainBucket, legacyBucket, [
snapshot(1, ['shape:a']),
snapshot(2, ['shape:a', 'shape:b']),
])
expect(await verifyRoomVersions({ chainBucket, legacyBucket, roomKey, limit: 20 })).toEqual({
checked: 2,
replayed: 2,
reads: 5,
complete: true,
mismatches: [],
errors: [],
legacyReadFailures: [],
})
})
it('keeps replaying the chain when a legacy read fails', async () => {
const chainBucket = createFakeR2()
const legacyBucket = createFakeR2()
await seedDualWrite(chainBucket, legacyBucket, [
snapshot(1, ['shape:a']),
snapshot(2, ['shape:a', 'shape:b']),
])
const flakyIso = '2026-09-01T00:00:00.000Z'
const flakyLegacy = {
...legacyBucket,
get: async (key: string) => {
if (key === `${roomKey}/${flakyIso}`) throw new Error('network connection lost')
return legacyBucket.get(key)
},
} as unknown as R2Bucket
// The failed read is an error naming the legacy side, not a broken chain: the replay
// carries on and the next version still gets compared.
expect(
await verifyRoomVersions({ chainBucket, legacyBucket: flakyLegacy, roomKey, limit: 20 })
).toEqual({
checked: 1,
replayed: 2,
reads: 5,
complete: true,
mismatches: [],
errors: [],
legacyReadFailures: [
{
timestamp: flakyIso,
message: expect.stringMatching(/^legacy copy read failed: network connection lost/),
},
],
})
})
it('names the timestamp when a reconstruction disagrees', async () => {
const chainBucket = createFakeR2()
const legacyBucket = createFakeR2()
await seedDualWrite(chainBucket, legacyBucket, [
snapshot(1, ['shape:a']),
snapshot(2, ['shape:a', 'shape:b']),
])
await legacyBucket.put(
`${roomKey}/2026-09-01T00:00:01.000Z`,
JSON.stringify(snapshot(2, ['shape:wrong']))
)
const result = await verifyRoomVersions({ chainBucket, legacyBucket, roomKey, limit: 20 })
expect(result.mismatches).toEqual(['2026-09-01T00:00:01.000Z'])
})
})
describe('verifyVersionChainRoute', () => {
it('skips test rooms without touching R2', async () => {
const list = vi.fn()
const response = await verifyVersionChainRoute(
{ params: { roomId: 'test_board' }, query: {} } as unknown as IRequest,
{ ROOMS_HISTORY: { list }, ROOMS_HISTORY_EPHEMERAL: { list } } as unknown as Environment,
true
)
expect(response.status).toBe(404)
expect(list).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,258 @@
import { RoomSnapshot } from '@tldraw/sync-core'
import { notFound } from '@tldraw/worker-shared'
import { IRequest } from 'itty-router'
import { getR2KeyForRoom } from '../r2'
import { canonicalJson } from '../snapshotUtils'
import { Environment } from '../types'
import { isRoomIdTooLong, roomIdIsTooLong } from '../utils/roomIdIsTooLong'
import { requireAdminAccessToRequest } from '../utils/tla/getAuth'
import { isTestFile } from '../utils/tla/isTestFile'
import { decodeVersionBody } from '../versionChainCodec'
import { loadChainIndex, readSegmentDeltas, SegmentIndexEntry } from '../versionChainRead'
import { applySnapshotDelta, versionEnvelopeHash } from '../versionDelta'
export interface VerifyResult {
/** Versions compared against a legacy full copy. Zero once dual-write is off. */
checked: number
/** Versions replayed, whether or not a legacy copy existed to compare them against. */
replayed: number
/** R2 reads spent, listing included. */
reads: number
/**
* False when the read budget stopped the walk with index entries still unvisited. On a room
* whose listing alone exhausts the budget, `mismatches` and `errors` come back empty having
* verified nothing — this is what says that result is not a clean room.
*/
complete: boolean
mismatches: string[]
/** Chain faults only: a version that cannot be reconstructed from what is in the bucket. */
errors: Array<{ timestamp: string; message: string }>
/**
* Versions the legacy comparison could not check. Not chain faults: a legacy copy that will not
* read says nothing about the chain, and counting it as one would page on R2 flakes.
*/
legacyReadFailures: Array<{ timestamp: string; message: string }>
}
/**
* Compares every reconstructable version against the full copy dual-write left in the legacy
* bucket. This is the gate on turning the full copies off — until it runs clean on live traffic,
* the only evidence the encoding is exact comes from a 16-room sample.
*
* It keeps running after cut-over on the recorded hash alone, which is why `limit` counts R2 reads
* and not comparisons: with no legacy copies left to compare against, a comparison counter never
* moves and the walk would never stop.
*/
export async function verifyRoomVersions({
chainBucket,
legacyBucket,
roomKey,
limit,
}: {
chainBucket: R2Bucket
legacyBucket: R2Bucket
roomKey: string
limit: number
}): Promise<VerifyResult> {
const { entries, ops, rejected } = await loadChainIndex(chainBucket, roomKey)
// A set: a version can fail both the hash check and the legacy comparison, and it is one
// mismatch, not two.
const mismatches = new Set<string>()
const errors: Array<{ timestamp: string; message: string }> = []
const legacyReadFailures: Array<{ timestamp: string; message: string }> = []
let checked = 0
let replayed = 0
// Flagged at the break sites, not derived from `reads >= limit` at the end: the budget may
// overshoot on the version in flight, and a run that replayed everything is complete even so.
let complete = true
// Seeded with the listing: those pages are subrequests too, and on a room with a long history
// they are a real share of the budget.
let reads = ops
// Checked between versions rather than in front of each read, so a run can overshoot by the two
// reads one version costs. That is noise against the subrequest cap; an unbounded walk is not.
const withinBudget = () => reads < limit
const compareToLegacy = async (snapshot: RoomSnapshot, timestamp: string) => {
let expected: RoomSnapshot
try {
reads++
const legacyObject = await legacyBucket.get(`${roomKey}/${timestamp}`)
// Nothing to compare against for versions written before dual-write started, or for any
// version at all once it stops.
if (!legacyObject) return
expected = (await decodeVersionBody(legacyObject)) as RoomSnapshot
} catch (e: any) {
// Caught here, not in the callers: their catches mark the chain broken and stop the
// replay, and a flaky legacy read is neither.
legacyReadFailures.push({
timestamp,
message: `legacy copy read failed: ${String(e?.message ?? e)}`,
})
return
}
checked++
if (canonical(snapshot) !== canonical(expected)) mismatches.add(timestamp)
}
// Each chain replays once, front to back, comparing every intermediate state against the
// legacy full copy at the same timestamp. Reconstructing per version would refetch the same
// keyframe and segments once per version — quadratic over a chain for no extra coverage,
// since this fold is exactly the fold reconstruction performs.
//
// Segments are grouped under their keyframe and ordered by sequence, exactly as reconstruction
// orders them — never by key. Keys are wall-clock timestamps, and a durable object re-created
// on a host whose clock runs behind opens a later segment under an earlier key; walking the
// index in key order would fail the rollout gate on a chain that history reads serve fine.
// Newest chain first: the budget bounds the work, and the versions a rollout gate must see are
// the ones the current write path just produced. An active room writes ~2,000 versions a day,
// so oldest-first would spend the whole budget on history and never reach today's code.
const keyframes = entries
.filter((entry) => entry.kind === 'keyframe')
.sort((a, b) => b.key.localeCompare(a.key))
// A segment whose keyframe is not in the index belongs to no walk below, so without this a
// clean result would hide versions that 500 once the legacy copies are off. Judged against the
// index rather than what the walk claimed, and before the budget can stop anything: it costs no
// reads, and an early break must not misreport unwalked segments as orphans.
const keyframeKeys = new Set(keyframes.map((keyframe) => keyframe.key))
for (const entry of entries) {
if (entry.kind === 'segment' && !keyframeKeys.has(entry.keyframeKey)) {
errors.push({
timestamp: entry.timestamps[0],
message: `segment ${entry.key} references missing keyframe ${entry.keyframeKey}`,
})
}
}
// The same class as the orphans above, one step earlier: these never reached the index, so the
// check above cannot see them and the walk below never misses them. A rejected segment at the
// end of a chain leaves no sequence gap behind it, which is exactly how an unreadable tail
// passes as a clean room.
for (const object of rejected) {
errors.push({
timestamp: object.timestamp,
message: `segment ${object.key} has no readable chain reference`,
})
}
for (const keyframe of keyframes) {
if (!withinBudget()) {
complete = false
break
}
const segments = entries
.filter(
(entry): entry is SegmentIndexEntry =>
entry.kind === 'segment' && entry.keyframeKey === keyframe.key
)
.sort((a, b) => a.firstSeq - b.firstSeq)
let state: RoomSnapshot
try {
reads++
const object = await chainBucket.get(keyframe.key)
if (!object) throw new Error(`keyframe ${keyframe.key} is missing`)
state = (await decodeVersionBody(object)) as RoomSnapshot
replayed++
await compareToLegacy(state, keyframe.timestamps[0])
} catch (e: any) {
errors.push({ timestamp: keyframe.timestamps[0], message: String(e?.message ?? e) })
continue
}
let expectedSeq = 1
for (const segment of segments) {
if (!withinBudget()) {
complete = false
break
}
try {
if (segment.firstSeq !== expectedSeq) {
throw new Error(
`segment ${segment.key} expected at sequence ${expectedSeq}, found ${segment.firstSeq}`
)
}
reads++
// The same read as reconstruction, so a segment that verifies here also reads.
const deltas = await readSegmentDeltas(chainBucket, segment)
for (const { t, delta } of deltas) {
if (!withinBudget()) {
complete = false
break
}
state = applySnapshotDelta(state, delta)
replayed++
// Intra-chain check, independent of the legacy copies — after cut-over the
// recorded hash is the only witness (see versionEnvelopeHash).
if (delta.hash !== versionEnvelopeHash(state)) mismatches.add(t)
await compareToLegacy(state, t)
}
expectedSeq += segment.timestamps.length
} catch (e: any) {
errors.push({ timestamp: segment.timestamps[0], message: String(e?.message ?? e) })
// A broken link invalidates every later state in this chain; the next keyframe
// starts a fresh replay.
break
}
}
}
return {
checked,
replayed,
reads,
complete,
mismatches: [...mismatches],
errors,
legacyReadFailures,
}
}
/** Record order is unstable between persists, so compare content and not serialization order. */
function canonical(snapshot: RoomSnapshot): string {
return JSON.stringify({
clock: snapshot.clock,
documentClock: snapshot.documentClock,
// The schema decides how a restore migrates, so a reconstruction that dropped or swapped
// it must read as a mismatch, not a pass.
schema: canonicalJson(snapshot.schema ?? null),
tombstoneHistoryStartsAtClock: snapshot.tombstoneHistoryStartsAtClock,
tombstones: Object.fromEntries(Object.entries(snapshot.tombstones ?? {}).sort()),
documents: [...snapshot.documents]
.sort((a, b) => String(a.state.id).localeCompare(String(b.state.id)))
.map((d) => [d.state.id, d.lastChangedClock, canonicalJson(d.state)]),
})
}
export async function verifyVersionChainRoute(
request: IRequest,
env: Environment,
isApp: boolean
): Promise<Response> {
const roomId = request.params.roomId
if (!roomId) return notFound()
if (isRoomIdTooLong(roomId)) return roomIdIsTooLong()
await requireAdminAccessToRequest(request, env)
if (isTestFile(roomId)) {
return new Response('Not found', { status: 404 })
}
// R2 reads, not versions: an unbounded run walks into the per-invocation subrequest cap mid-way
// and reports nothing. Capped below that limit rather than at it, to leave room for the reads
// the last version in the budget is already spending.
const requested = Number(request.query.limit ?? 200)
const limit = Math.min(Number.isFinite(requested) && requested > 0 ? requested : 200, 900)
const result = await verifyRoomVersions({
chainBucket: env.ROOMS_HISTORY,
legacyBucket: env.ROOMS_HISTORY_EPHEMERAL,
roomKey: getR2KeyForRoom({ slug: roomId, isApp }),
limit,
})
return new Response(JSON.stringify(result), {
headers: { 'content-type': 'application/json' },
})
}

View File

@@ -0,0 +1,361 @@
import { SerializedSchema } from '@tldraw/store'
import { RoomSnapshot } from '@tldraw/sync-core'
import { createTLSchema } from '@tldraw/tlschema'
import {
canonicalJson,
generateSnapshotChunks,
getLastDocumentChangeClock,
getSnapshotFingerprint,
getSnapshotMetadata,
isSameFingerprint,
readPersistedFingerprint,
resolvePersistedFingerprint,
} from './snapshotUtils'
describe('generateSnapshotChunks', () => {
const decoder = new TextDecoder()
function chunksToString(chunks: Generator<Uint8Array>): string {
let result = ''
for (const chunk of chunks) {
result += decoder.decode(chunk)
}
return result
}
test('generates valid JSON for empty snapshot', () => {
const schema = createTLSchema()
const snapshot: RoomSnapshot = {
schema: schema.serialize(),
clock: 0,
documents: [],
tombstones: {},
}
const result = chunksToString(generateSnapshotChunks(snapshot))
const parsed = JSON.parse(result)
expect(parsed).toEqual(snapshot)
})
test('generates valid JSON that can be parsed', () => {
const schema = createTLSchema()
const doc1 = schema.types.document.create({ name: 'Test Doc' })
const doc2 = schema.types.page.create({ name: 'Page 1', index: 'a1' })
const snapshot: RoomSnapshot = {
schema: schema.serialize(),
clock: 10,
documents: [
{ state: doc1, lastChangedClock: 5 },
{ state: doc2, lastChangedClock: 8 },
],
tombstones: {
[doc1.id]: 3,
},
}
const result = chunksToString(generateSnapshotChunks(snapshot))
const parsed = JSON.parse(result)
expect(parsed).toEqual(snapshot)
})
test('generates chunks correctly', () => {
const schema = createTLSchema()
const documentRecord = schema.types.document.create({ name: 'Test' })
const snapshot: RoomSnapshot = {
schema: schema.serialize(),
clock: 1,
documents: [{ state: documentRecord, lastChangedClock: 1 }],
tombstones: {},
}
const chunks = generateSnapshotChunks(snapshot)
const chunksArray = Array.from(chunks)
// Verify we get multiple chunks
expect(chunksArray.length).toBeGreaterThan(1)
chunksArray.forEach((chunk) => {
expect(chunk).toBeInstanceOf(Uint8Array)
})
// Verify the combined result is valid JSON
const result = chunksToString(generateSnapshotChunks(snapshot))
expect(() => JSON.parse(result)).not.toThrow()
})
})
function makeSnapshot(partial: Partial<RoomSnapshot>): RoomSnapshot {
return {
schema: createTLSchema().serialize(),
documents: [],
tombstones: {},
...partial,
}
}
function doc(id: string, lastChangedClock: number): RoomSnapshot['documents'][number] {
return { state: { id: id as any, typeName: 'shape' }, lastChangedClock }
}
describe('getLastDocumentChangeClock', () => {
test('is 0 for an empty snapshot', () => {
expect(getLastDocumentChangeClock(makeSnapshot({}))).toBe(0)
})
test('is the max lastChangedClock across documents', () => {
const snapshot = makeSnapshot({
documents: [doc('a', 3), doc('b', 11), doc('c', 7)],
})
expect(getLastDocumentChangeClock(snapshot)).toBe(11)
})
test('includes tombstone clocks, so a delete moves the clock', () => {
const snapshot = makeSnapshot({
documents: [doc('a', 3)],
tombstones: { b: 12 },
})
expect(getLastDocumentChangeClock(snapshot)).toBe(12)
})
test('includes tombstoneHistoryStartsAtClock, so pruning moves the clock', () => {
const snapshot = makeSnapshot({
documents: [doc('a', 3)],
tombstones: {},
tombstoneHistoryStartsAtClock: 15,
})
expect(getLastDocumentChangeClock(snapshot)).toBe(15)
})
test('ignores the shared clock counter that object-lane (comment) writes bump', () => {
// A comment write advances documentClock without touching any document, so two
// snapshots that differ only in documentClock must report the same clock.
const before = makeSnapshot({ documents: [doc('a', 10)], documentClock: 10 })
const after = makeSnapshot({ documents: [doc('a', 10)], documentClock: 21, clock: 21 })
expect(getLastDocumentChangeClock(after)).toBe(getLastDocumentChangeClock(before))
expect(getLastDocumentChangeClock(after)).toBe(10)
})
})
function schemaWithSequences(sequences: Record<string, number>): SerializedSchema {
return { schemaVersion: 2, sequences }
}
describe('getSnapshotFingerprint', () => {
test('changes when a migration advances the schema but touches no record', () => {
// migrateStorage always writes the migrated schema, but only calls storage.set for records
// whose content actually changed — so a migration that applies to no record on this board
// leaves every clock untouched. The clock alone reads that as a no-op, which
// would leave the migrated schema unpersisted forever.
const documents = [doc('a', 10)]
const before = makeSnapshot({ documents, schema: schemaWithSequences({ store: 4 }) })
const after = makeSnapshot({ documents, schema: schemaWithSequences({ store: 5 }) })
expect(getLastDocumentChangeClock(after)).toBe(getLastDocumentChangeClock(before))
expect(isSameFingerprint(getSnapshotFingerprint(after), getSnapshotFingerprint(before))).toBe(
false
)
})
test('is unchanged for a snapshot that differs only in the shared clock counter', () => {
const documents = [doc('a', 10)]
const before = makeSnapshot({ documents, documentClock: 10 })
const after = makeSnapshot({ documents, documentClock: 21, clock: 21 })
expect(isSameFingerprint(getSnapshotFingerprint(after), getSnapshotFingerprint(before))).toBe(
true
)
})
test('ignores key order in the serialized schema', () => {
// Key order carries no meaning, and treating it as a change would re-upload every board.
const a = makeSnapshot({ schema: schemaWithSequences({ x: 1, y: 2 }) })
const b = makeSnapshot({ schema: { sequences: { y: 2, x: 1 }, schemaVersion: 2 } })
expect(getSnapshotFingerprint(a).schemaHash).toBe(getSnapshotFingerprint(b).schemaHash)
})
test('distinguishes a missing schema from any present one', () => {
const missing = getSnapshotFingerprint(makeSnapshot({ schema: undefined }))
const present = getSnapshotFingerprint(makeSnapshot({}))
expect(missing.schemaHash).not.toBe(present.schemaHash)
})
})
describe('isSameFingerprint', () => {
test('never matches an unknown (null) fingerprint on either side', () => {
const fingerprint = getSnapshotFingerprint(makeSnapshot({}))
expect(isSameFingerprint(null, fingerprint)).toBe(false)
expect(isSameFingerprint(fingerprint, null)).toBe(false)
expect(isSameFingerprint(null, null)).toBe(false)
})
test('does not match when only the document clock differs', () => {
const a = getSnapshotFingerprint(makeSnapshot({ documents: [doc('a', 1)] }))
const b = getSnapshotFingerprint(makeSnapshot({ documents: [doc('a', 2)] }))
expect(isSameFingerprint(a, b)).toBe(false)
})
})
describe('getSnapshotMetadata', () => {
test('stamps documentClock but keeps it out of the fingerprint', () => {
// documentClock is the counter shared with the object lane, so a comment write moves it
// without changing a byte of the document. It is recorded for diagnostics only — comparing
// on it would force an upload for every comment and defeat the dedupe entirely.
const documents = [doc('shape:a', 10)]
const beforeComment = makeSnapshot({ documents, documentClock: 10 })
const afterComment = makeSnapshot({ documents, documentClock: 21, clock: 21 })
expect(getSnapshotMetadata(beforeComment).documentClock).toBe('10')
expect(getSnapshotMetadata(afterComment).documentClock).toBe('21')
expect(
isSameFingerprint(getSnapshotFingerprint(afterComment), getSnapshotFingerprint(beforeComment))
).toBe(true)
})
test('a stamp still matches after comments have moved the clock past it', async () => {
// The rooms object keeps the clock of the last document write, so its stamp goes stale as
// comments accumulate. Reading it back must still dedupe against the live snapshot.
const documents = [doc('shape:a', 10)]
const atUpload = makeSnapshot({ documents, documentClock: 10 })
const afterComments = makeSnapshot({ documents, documentClock: 99, clock: 99 })
const read = await readPersistedFingerprint(
{ head: async () => ({ customMetadata: getSnapshotMetadata(atUpload) }) } as any,
'key'
)
expect(isSameFingerprint(read, getSnapshotFingerprint(afterComments))).toBe(true)
})
test('omits documentClock when the snapshot has no clock at all', () => {
expect(getSnapshotMetadata(makeSnapshot({})).documentClock).toBeUndefined()
})
})
describe('resolvePersistedFingerprint', () => {
// Each head() call returns the next entry, then repeats the last one.
function countingBucket(results: Array<{ customMetadata?: Record<string, string> } | null>) {
const state = { calls: 0 }
const bucket = {
head: async (_key: string) => results[Math.min(state.calls++, results.length - 1)],
} as any
return { bucket, state }
}
test('does not re-read a stamp the failing persist itself just wrote', async () => {
// The reported failure: a persist finds no stamp, writes the rooms object (stamping it),
// then fails on the history upload. Its retry re-resolves. If that re-read R2 it would get
// back its own fresh stamp, match the current fingerprint, take the skip path, and drop the
// history entry it still owes.
const snapshot = makeSnapshot({ documents: [doc('shape:a', 7)] })
const stampedByTheFailedAttempt = getSnapshotFingerprint(snapshot)
const { bucket, state } = countingBucket([
null,
{ customMetadata: getSnapshotMetadata(snapshot) },
])
const firstAttempt = await resolvePersistedFingerprint(undefined, bucket, 'key')
expect(firstAttempt).toBe(null)
const retry = await resolvePersistedFingerprint(firstAttempt, bucket, 'key')
expect(retry).toBe(null)
expect(isSameFingerprint(retry, stampedByTheFailedAttempt)).toBe(false)
expect(state.calls).toBe(1)
})
test('reads R2 when nothing has been looked up yet', async () => {
const snapshot = makeSnapshot({ documents: [doc('shape:a', 9)] })
const fingerprint = getSnapshotFingerprint(snapshot)
const { bucket, state } = countingBucket([{ customMetadata: getSnapshotMetadata(snapshot) }])
expect(await resolvePersistedFingerprint(undefined, bucket, 'key')).toEqual(fingerprint)
expect(state.calls).toBe(1)
})
test('returns an already-known fingerprint without reading R2', async () => {
const fingerprint = getSnapshotFingerprint(makeSnapshot({ documents: [doc('shape:a', 3)] }))
const { bucket, state } = countingBucket([null])
expect(await resolvePersistedFingerprint(fingerprint, bucket, 'key')).toEqual(fingerprint)
expect(state.calls).toBe(0)
})
})
describe('readPersistedFingerprint', () => {
function bucketWithHead(result: { customMetadata?: Record<string, string> } | null) {
return { head: async (_key: string) => result } as any
}
test('round-trips the fingerprint written by getSnapshotMetadata', async () => {
const snapshot = makeSnapshot({ documents: [doc('a', 42)] })
const written = getSnapshotFingerprint(snapshot)
const customMetadata = getSnapshotMetadata(snapshot)
const read = await readPersistedFingerprint(bucketWithHead({ customMetadata }), 'key')
expect(read).toEqual(written)
expect(read?.lastDocumentChangeClock).toBe(42)
})
test('is null when the object does not exist', async () => {
expect(await readPersistedFingerprint(bucketWithHead(null), 'key')).toBe(null)
})
test('is null for a legacy object with no metadata', async () => {
expect(await readPersistedFingerprint(bucketWithHead({}), 'key')).toBe(null)
})
test('is null for malformed metadata', async () => {
expect(
await readPersistedFingerprint(
bucketWithHead({
customMetadata: { lastDocumentChangeClock: 'not-a-number', schemaHash: 'a' },
}),
'key'
)
).toBe(null)
})
test('is null when the schema hash is absent, so the object is re-uploaded', async () => {
expect(
await readPersistedFingerprint(
bucketWithHead({ customMetadata: { lastDocumentChangeClock: '42' } }),
'key'
)
).toBe(null)
})
})
describe('canonicalJson', () => {
it('produces the same string for a live value and for its JSON round-trip', () => {
const live = {
b: undefined,
a: 1,
// Index 2 is a hole, which JSON writes as null just like the explicit undefined.
nested: {
z: undefined,
y: Object.assign([1, undefined], { 3: 3 }),
x: { fn: () => 1, ok: 'yes' },
},
when: new Date(0),
nan: NaN,
list: [{ q: undefined, p: 2 }],
}
const roundTripped = JSON.parse(JSON.stringify(live))
expect(canonicalJson(live)).toBe(canonicalJson(roundTripped))
expect(canonicalJson(live)).toBe(
'{"a":1,"list":[{"p":2}],"nan":null,"nested":{"x":{"ok":"yes"},"y":[1,null,null,3]},"when":"1970-01-01T00:00:00.000Z"}'
)
})
it('sorts keys at every depth and leaves clean JSON otherwise untouched', () => {
const value = { b: { d: 1, c: [3, 2] }, a: null }
expect(canonicalJson(value)).toBe('{"a":null,"b":{"c":[3,2],"d":1}}')
expect(canonicalJson(value)).toBe(JSON.stringify({ a: null, b: { c: [3, 2], d: 1 } }))
})
})

View File

@@ -0,0 +1,225 @@
import { RoomSnapshot } from '@tldraw/sync-core'
// R2 custom metadata keys describing the snapshot in the object body. Let a `head()` answer
// "what is persisted?" without downloading the object.
const LAST_DOCUMENT_CHANGE_CLOCK_METADATA_KEY = 'lastDocumentChangeClock'
const SCHEMA_HASH_METADATA_KEY = 'schemaHash'
// Diagnostic only — deliberately absent from SnapshotFingerprint, see getSnapshotMetadata.
const DOCUMENT_CLOCK_METADATA_KEY = 'documentClock'
/**
* Identifies the content of a snapshot. Two snapshots with equal fingerprints serialize to the
* same document bytes, so re-uploading one over the other is pure waste.
*
* Not called a version: in this worker a "version" is an entry in the version cache — a
* point-in-time snapshot the history UI lists and offers to restore. A fingerprint identifies
* content, and many versions of a board can share one.
*/
export interface SnapshotFingerprint {
lastDocumentChangeClock: number
schemaHash: string
}
/**
* The highest clock at which a document was changed, deleted (tombstones), or pruned
* (tombstoneHistoryStartsAtClock) — i.e. the maximum `lastChangedClock` across the document lane.
*
* This is deliberately NOT `snapshot.documentClock`: that counter is shared with the object lane,
* so a comment or reaction write bumps it without changing anything `getSnapshot()` returns.
*/
export function getLastDocumentChangeClock(snapshot: RoomSnapshot): number {
let clock = snapshot.tombstoneHistoryStartsAtClock ?? 0
for (const { lastChangedClock } of snapshot.documents) {
if (lastChangedClock > clock) clock = lastChangedClock
}
if (snapshot.tombstones) {
for (const tombstoneClock of Object.values(snapshot.tombstones)) {
if (tombstoneClock > clock) clock = tombstoneClock
}
}
return clock
}
/**
* A hash of the snapshot's serialized schema, tracked alongside the document clock because
* `migrateStorage` writes a migrated schema but only calls `set` for records whose content
* actually changed. A migration that applies to no record on this board therefore leaves every
* clock untouched, and the clock alone would read that as nothing to persist — stranding the
* migrated schema in the DO's SQLite while R2 keeps the old one forever.
*/
function getSchemaHash(snapshot: RoomSnapshot): string {
return fnv1a64(canonicalJson(snapshot.schema))
}
export function getSnapshotFingerprint(snapshot: RoomSnapshot): SnapshotFingerprint {
return {
lastDocumentChangeClock: getLastDocumentChangeClock(snapshot),
schemaHash: getSchemaHash(snapshot),
}
}
/**
* The R2 metadata stamped on a persisted snapshot: the {@link SnapshotFingerprint} the next
* persist compares against, plus `documentClock` recording the clock the object was written at.
*
* `documentClock` is deliberately not part of the fingerprint and no read path compares it. It is
* the counter shared with the object lane, so a comment write moves it without changing a byte of
* the document — comparing on it would force an upload per comment and defeat the dedupe. It is
* stamped so a history entry records when it was taken; on the rooms object it therefore reads as
* the clock at the last document write, not the room's current clock.
*/
export function getSnapshotMetadata(snapshot: RoomSnapshot): Record<string, string> {
const fingerprint = getSnapshotFingerprint(snapshot)
const metadata: Record<string, string> = {
[LAST_DOCUMENT_CHANGE_CLOCK_METADATA_KEY]: String(fingerprint.lastDocumentChangeClock),
[SCHEMA_HASH_METADATA_KEY]: fingerprint.schemaHash,
}
const documentClock = snapshot.documentClock ?? snapshot.clock
if (documentClock !== undefined) {
metadata[DOCUMENT_CLOCK_METADATA_KEY] = String(documentClock)
}
return metadata
}
/**
* Reads the fingerprint stamped on an R2 object, or null if the object is missing or predates
* stamping. Null means "unknown" — callers must treat it as "not persisted" and write, never as
* "up to date".
*/
export async function readPersistedFingerprint(
bucket: R2Bucket,
key: string
): Promise<SnapshotFingerprint | null> {
const head = await bucket.head(key)
const rawClock = head?.customMetadata?.[LAST_DOCUMENT_CHANGE_CLOCK_METADATA_KEY]
const schemaHash = head?.customMetadata?.[SCHEMA_HASH_METADATA_KEY]
// A half-stamped object tells us nothing about the half that's missing.
if (rawClock === undefined || !schemaHash) return null
const lastDocumentChangeClock = Number(rawClock)
if (!Number.isFinite(lastDocumentChangeClock)) return null
return { lastDocumentChangeClock, schemaHash }
}
/**
* Resolves the fingerprint stamped on an R2 object at most once, returning `known` untouched after
* that. `undefined` means "not looked up yet"; `null` means "looked up, no usable stamp", and the
* two must stay distinct.
*
* Looking it up a second time would let a persist observe its own partial write. A persist that
* stamps the rooms object and then fails before writing the history entry retries with the
* fingerprint it resolved beforehand; re-reading would hand it back its own fresh stamp, so it
* would conclude everything was persisted and drop the history entry it still owes.
*/
export async function resolvePersistedFingerprint(
known: SnapshotFingerprint | null | undefined,
bucket: R2Bucket,
key: string
): Promise<SnapshotFingerprint | null> {
if (known !== undefined) return known
return await readPersistedFingerprint(bucket, key)
}
/** Unknown (null) never matches, so an unstamped object is always re-uploaded. */
export function isSameFingerprint(
a: SnapshotFingerprint | null,
b: SnapshotFingerprint | null
): boolean {
if (!a || !b) return false
return a.lastDocumentChangeClock === b.lastDocumentChangeClock && a.schemaHash === b.schemaHash
}
/**
* `JSON.stringify` with object keys sorted, so a serializer emitting the same object in a
* different key order cannot read as a change. (This is why `getHashForObject` from @tldraw/utils
* isn't used: it hashes `JSON.stringify` output directly, so key order changes the result.)
*
* Follows `JSON.stringify`'s own rules for what survives: properties whose value is `undefined`, a
* function or a symbol are omitted, such array items and holes become `null`, non-finite numbers
* become `null`, and `toJSON` is honoured. That contract is the point — the version chain hashes a
* snapshot from live objects when it is written and from decoded JSON when it is read back, and a
* naive key-sorted walk emits `{"a":undefined}` on one side and `{}` on the other. For any value
* `v`, `canonicalJson(v) === canonicalJson(JSON.parse(JSON.stringify(v)))`.
*
* A top-level `undefined` yields the string `'undefined'` rather than no string at all.
*/
export function canonicalJson(value: unknown): string {
return canonicalize(value) ?? 'undefined'
}
function canonicalize(value: unknown): string | undefined {
if (value !== null && typeof value === 'object' && typeof (value as any).toJSON === 'function') {
value = (value as any).toJSON()
}
if (value === null || typeof value !== 'object') {
// Covers primitives exactly as JSON.stringify does, including NaN → null and dropping
// undefined, functions and symbols (for which it returns undefined).
return JSON.stringify(value)
}
if (Array.isArray(value)) {
// Array.from, not map: map skips holes, and a hole must serialize as null like JSON does.
return `[${Array.from(value, (item) => canonicalize(item) ?? 'null').join(',')}]`
}
const entries: string[] = []
for (const key of Object.keys(value).sort()) {
const encoded = canonicalize((value as any)[key])
if (encoded !== undefined) entries.push(`${JSON.stringify(key)}:${encoded}`)
}
return `{${entries.join(',')}}`
}
// FNV-1a run with two offsets. Wider than @tldraw/utils' 32-bit getHashForString because a
// collision here silently skips an upload that was owed; 64 bits makes that implausible. Staying
// non-cryptographic is fine — this only answers "did the schema change?", and a false mismatch
// costs one redundant upload rather than losing a migration.
export function fnv1a64(value: string): string {
let h1 = 0x811c9dc5
let h2 = 0xc59d1c81
for (let i = 0; i < value.length; i++) {
const char = value.charCodeAt(i)
h1 = Math.imul(h1 ^ char, 0x01000193)
h2 = Math.imul(h2 ^ char, 0x85ebca6b)
}
return (h1 >>> 0).toString(16).padStart(8, '0') + (h2 >>> 0).toString(16).padStart(8, '0')
}
function toJsonString(value: unknown): string {
if (value === undefined) {
return 'null'
}
return JSON.stringify(value)
}
export function* generateSnapshotChunks(snapshot: RoomSnapshot): Generator<Uint8Array> {
const encoder = new TextEncoder()
yield encoder.encode('{')
const keys = Object.keys(snapshot) as Array<keyof RoomSnapshot>
let isFirstKey = true
for (const key of keys) {
if (isFirstKey) {
isFirstKey = false
} else {
yield encoder.encode(',')
}
yield encoder.encode(`"${key}":`)
const value = snapshot[key]
if (Array.isArray(value)) {
yield encoder.encode('[')
for (let i = 0; i < value.length; i++) {
if (i > 0) {
yield encoder.encode(',')
}
yield encoder.encode(toJsonString(value[i]))
}
yield encoder.encode(']')
} else {
yield encoder.encode(toJsonString(value))
}
}
yield encoder.encode('}')
}

View File

@@ -0,0 +1,66 @@
interface FakeObject {
body: Uint8Array
customMetadata: Record<string, string>
}
/**
* Enough of R2 for the version chain read paths: prefix listing in key order with `include`,
* `startAfter`, `get`, `put`, `head` and `delete`. Bodies are held as bytes because chain objects
* are gzipped. Deliberately not a general R2 mock.
*/
export function createFakeR2(): R2Bucket {
const objects = new Map<string, FakeObject>()
const toR2Object = (key: string, object: FakeObject, includeMetadata = true) => ({
key,
customMetadata: includeMetadata ? object.customMetadata : undefined,
size: object.body.byteLength,
// A real ReadableStream, so code that pipes `body` through a transform works unchanged.
body: new Blob([object.body as unknown as BlobPart]).stream(),
text: async () => new TextDecoder().decode(object.body),
json: async () => JSON.parse(new TextDecoder().decode(object.body)),
arrayBuffer: async () =>
object.body.buffer.slice(
object.body.byteOffset,
object.body.byteOffset + object.body.byteLength
),
})
return {
async put(key: string, body: any, options?: any) {
objects.set(key, {
body: typeof body === 'string' ? new TextEncoder().encode(body) : new Uint8Array(body),
customMetadata: options?.customMetadata ?? {},
})
return toR2Object(key, objects.get(key)!) as any
},
async get(key: string) {
const object = objects.get(key)
return object ? (toR2Object(key, object) as any) : null
},
async head(key: string) {
const object = objects.get(key)
return object ? (toR2Object(key, object) as any) : null
},
async delete(keys: string | string[]) {
for (const key of Array.isArray(keys) ? keys : [keys]) objects.delete(key)
},
async list(options?: any) {
const matching = [...objects.keys()]
.filter((key) => (options?.prefix ? key.startsWith(options.prefix) : true))
.filter((key) => (options?.startAfter ? key > options.startAfter : true))
// The cursor is the previous page's last key, so paging code driven by `truncated`
// actually advances instead of looping on page one forever.
.filter((key) => (options?.cursor ? key > options.cursor : true))
.sort()
const limit = options?.limit ?? 1000
const page = matching.slice(0, limit)
const includeMetadata = options?.include?.includes('customMetadata') ?? false
return {
objects: page.map((key) => toR2Object(key, objects.get(key)!, includeMetadata)),
truncated: matching.length > page.length,
cursor: page[page.length - 1],
} as any
},
} as unknown as R2Bucket
}

View File

@@ -0,0 +1,150 @@
import { TlaUserPartial } from '@tldraw/dotcom-shared'
import { DEFAULT_INITIAL_SNAPSHOT } from '@tldraw/sync-core'
import { IndexKey, lns, uniqueId } from '@tldraw/utils'
import { createRouter, notFound } from '@tldraw/worker-shared'
import { createPostgresConnectionPool } from './postgres'
import { getR2KeyForRoom, getR2KeyForSnapshot } from './r2'
import {
deleteEvalsFixtureSession,
evalsFixtureMcp,
planEvalsFixtureScreenshots,
putEvalsFixtureBoard,
} from './routes/tla/evalsLocalMcp'
import { isDebugLogging, type Environment } from './types'
import { getFileEffectProcessor, getRoomDurableObject } from './utils/durableObjects'
interface CreateLegacyRoomBody {
slug?: string
readonlySlug?: string
legacyReadonlySlug?: string
snapshotSlug?: string
historyTimestamp?: string
}
export const testRoutes = createRouter<Environment>()
.all('/app/__test__/*', (_, env) => {
if (!isDebugLogging(env)) return notFound()
return undefined
})
// Per-test DB isolation for the e2e suites: reset the user's prefs and delete every
// workspace they belong to (FK cascades take group_user, group_file and owned files;
// the file deletes flow through the effect outbox like any other delete), then recreate
// the home workspace. Local-only — isDebugLogging above also passes on preview, which is
// a real shared deployment.
.post('/app/__test__/user/:userId/prepare-for-test', async (req, env) => {
if (env.IS_LOCAL !== 'true') return notFound()
const userId = req.params.userId
const db = createPostgresConnectionPool(env, '/app/__test__/prepare-for-test')
try {
await db.transaction().execute(async (tx) => {
const user = await tx
.selectFrom('user')
.where('id', '=', userId)
.select('id')
.executeTakeFirst()
if (!user) return
await tx
.updateTable('user')
.set({
flags: '',
allowAnalyticsCookie: null,
enhancedA11yMode: null,
colorScheme: null,
locale: null,
exportBackground: true,
exportPadding: true,
exportFormat: 'png',
inputMode: null,
} satisfies Omit<TlaUserPartial, 'id'>)
.where('id', '=', userId)
.execute()
const userGroups = await tx
.selectFrom('group_user')
.where('userId', '=', userId)
.select('groupId')
.execute()
const groupIds = userGroups.map((g) => g.groupId)
if (groupIds.length > 0) {
await tx.deleteFrom('group').where('id', 'in', groupIds).execute()
}
await tx
.insertInto('group')
.values({
id: userId,
name: 'My workspace',
createdAt: Date.now(),
updatedAt: Date.now(),
isDeleted: false,
inviteSecret: null,
})
.onConflict((oc) => oc.doNothing())
.execute()
await tx
.insertInto('group_user')
.values({
userId,
groupId: userId,
createdAt: Date.now(),
updatedAt: Date.now(),
role: 'owner',
index: 'a1' as IndexKey,
userColor: '',
userName: '',
})
.onConflict((oc) => oc.doNothing())
.execute()
})
} finally {
await db.destroy()
}
// Best-effort nudge so the deleted files' outbox effects (session kicks, R2 cleanup)
// land before the next test rather than on the 30s sweep.
await getFileEffectProcessor(env)
.poke()
.catch(() => {})
return new Response('ok')
})
.post('/app/__test__/evals/plan', planEvalsFixtureScreenshots)
.put('/app/__test__/evals/sessions/:sessionId/boards/:boardId', putEvalsFixtureBoard)
.post('/app/__test__/evals/sessions/:sessionId/mcp', evalsFixtureMcp)
.delete('/app/__test__/evals/sessions/:sessionId', deleteEvalsFixtureSession)
.post('/app/__test__/legacy-room', async (req, env) => {
const body = (await req.json().catch(() => ({}))) as CreateLegacyRoomBody
const slug = body.slug ?? uniqueId()
const readonlySlug = body.readonlySlug ?? uniqueId()
const legacyReadonlySlug = body.legacyReadonlySlug ?? uniqueId()
const legacyReadonlyActualSlug = lns(legacyReadonlySlug)
const snapshotSlug = body.snapshotSlug ?? `v2_c_${uniqueId()}`
const historyTimestamp = body.historyTimestamp ?? new Date().toISOString()
const snapshot = DEFAULT_INITIAL_SNAPSHOT
await Promise.all([
getRoomDurableObject(env, slug).__admin__createLegacyRoom(slug),
getRoomDurableObject(env, legacyReadonlyActualSlug).__admin__createLegacyRoom(
legacyReadonlyActualSlug
),
env.SLUG_TO_READONLY_SLUG.put(slug, readonlySlug),
env.READONLY_SLUG_TO_SLUG.put(readonlySlug, slug),
env.ROOMS_HISTORY_EPHEMERAL.put(
`${getR2KeyForRoom({ slug, isApp: false })}/${historyTimestamp}`,
JSON.stringify(snapshot)
),
env.SNAPSHOT_SLUG_TO_PARENT_SLUG.put(snapshotSlug, slug),
env.ROOM_SNAPSHOTS.put(
getR2KeyForSnapshot({ parentSlug: slug, snapshotSlug, isApp: false }),
JSON.stringify({ parent_slug: slug, drawing: snapshot })
),
])
return Response.json({
slug,
readonlySlug,
legacyReadonlySlug,
snapshotSlug,
historyTimestamp,
})
})
.all('*', notFound)

View File

@@ -0,0 +1,358 @@
// https://developers.cloudflare.com/analytics/analytics-engine/
import { Queue } from '@cloudflare/workers-types'
import { RoomSnapshot } from '@tldraw/sync-core'
import type { TLFileDurableObject } from './TLFileDurableObject'
import type { TLFileEffectProcessor } from './TLFileEffectProcessor'
import type { TLLoggerDurableObject } from './TLLoggerDurableObject'
import type { KeyframeReason } from './versionChain'
// The Browser Rendering binding's Quick Actions method. Cloudflare exposes `env.BROWSER.quickAction`
// so a Worker can call the Quick Actions endpoints (`screenshot`, `pdf`, …) straight through the
// binding — no API token, no @cloudflare/puppeteer. Requires compatibility_date >= 2026-03-24. Not
// in @cloudflare/workers-types yet, so the small surface we use is declared here; it resolves to a
// standard `Response` (PNG bytes for `screenshot`, with an `X-Browser-Ms-Used` header).
export interface BrowserBinding {
quickAction(action: 'screenshot', options: unknown): Promise<Response>
}
// This type isn't available in @cloudflare/workers-types yet
export interface Analytics {
writeDataPoint(data: {
blobs?: string[]
doubles?: number[]
indexes?: [string] // only one here
}): void
}
export interface Environment {
// bindings
TLDR_DOC: DurableObjectNamespace<TLFileDurableObject>
TL_FILE_EFFECTS: DurableObjectNamespace<TLFileEffectProcessor>
TL_LOGGER: DurableObjectNamespace<TLLoggerDurableObject>
BOTCOM_POSTGRES_CONNECTION_STRING: string
BOTCOM_POSTGRES_POOLED_CONNECTION_STRING: string
DISCORD_FEEDBACK_WEBHOOK_URL?: string
PLAIN_API_KEY?: string
PLAIN_LABEL_TYPE_ID?: string
PLAIN_WORKSPACE_ID?: string
MEASURE: Analytics | undefined
// Workers Static Assets binding — reads the committed default welcome snapshot
// (assets/welcome-snapshot.json) at seed time; the worker never serves the directory
// publicly (run_worker_first is set).
ASSETS: Fetcher
ROOMS: R2Bucket
ROOMS_HISTORY_EPHEMERAL: R2Bucket
// Delta chains. ROOMS_HISTORY_EPHEMERAL keeps every version written before cut-over, so both
// buckets stay on the read path until the standing history is compacted.
ROOMS_HISTORY: R2Bucket
ROOM_SNAPSHOTS: R2Bucket
SNAPSHOT_SLUG_TO_PARENT_SLUG: KVNamespace
UPLOADS: R2Bucket
SLUG_TO_READONLY_SLUG: KVNamespace
READONLY_SLUG_TO_SLUG: KVNamespace
FEATURE_FLAGS: KVNamespace
CF_VERSION_METADATA: WorkerVersionMetadata
// env vars
SUPABASE_URL: string | undefined
SUPABASE_KEY: string | undefined
CLERK_SECRET_KEY: string | undefined
CLERK_PUBLISHABLE_KEY: string | undefined
TLDRAW_ENV: string | undefined
SENTRY_DSN: string | undefined
SENTRY_CSP_REPORT_URI: string | undefined
IS_LOCAL: string | undefined
WORKER_NAME: string | undefined
ASSET_UPLOAD_ORIGIN: string | undefined
USER_CONTENT_URL: string | undefined
MULTIPLAYER_SERVER: string | undefined
HEALTH_CHECK_BEARER_TOKEN: string | undefined
HEALTH_CHECK_DB_SIZE_THRESHOLD_GB: string | undefined
HEALTH_CHECK_CHANGELOG_SIZE_THRESHOLD_MB: string | undefined
HEALTH_CHECK_WAL_SIZE_THRESHOLD_MB: string | undefined
ANALYTICS_API_URL: string | undefined
ANALYTICS_API_TOKEN: string | undefined
RATE_LIMITER: RateLimit
// Rate limit bindings for the Browser Run-backed MCP screenshot tool, declared in wrangler.toml.
// All three bound what an agent calling the public MCP endpoint can spend; board thumbnail
// rendering is subject to none of them. Separate bindings because a binding carries one `limit`
// applied per key, so budgets with different numbers cannot share one. The route falls back to an
// isolate-local guard when they are absent (local dev, tests).
/** One per-account budget (`user:`) across every Browser Run-spending MCP tool. */
MCP_SCREENSHOT_RATE_LIMITER: RateLimit | undefined
/** Per-board Browser Run captures, applied only on cache misses. Measures are not counted here. */
MCP_SERVER_BOARD_RATE_LIMITER: RateLimit | undefined
/** Total Browser Run sessions the tools spend, captures and measures alike, on one shared key. */
MCP_SERVER_BROWSER_RATE_LIMITER: RateLimit | undefined
/** Per-account `search_boards` calls. Bounds Postgres, not Browser Run, which search never spends. */
MCP_SERVER_SEARCH_RATE_LIMITER: RateLimit | undefined
QUEUE: Queue<QueueMessage>
// R2 cache for board OG images (`og/…` keys), their pending-render markers and render token
// records. None of those keys carry a version, so a board costs one object per key however often it
// re-renders, nothing accumulates, and the bucket must have NO expiration rule — the current
// thumbnail has to outlive any lifecycle window.
// Optional so tests and unconfigured environments degrade to cacheless rendering.
THUMBNAILS: R2Bucket | undefined
// R2 storage for MCP tool output (`mcp/…` keys, screenshots today). Its own bucket rather than a
// prefix inside THUMBNAILS because these keys include the board's content version, so the set grows
// without bound and needs the expiration rule that would be actively wrong on THUMBNAILS. See "Why
// two buckets" in browser-run-thumbnails.md.
// Optional on the same terms as THUMBNAILS.
MCP_DATA_BUCKET: R2Bucket | undefined
// Cloudflare Browser Rendering binding. The worker takes thumbnails by calling the binding's
// `quickAction` Quick Actions method (e.g. `screenshot`) directly — no @cloudflare/puppeteer and
// no API token. Chrome runs in Cloudflare's fleet, not in this isolate. The dev binding is
// deliberately NOT marked `remote` (that would make plain `wrangler dev` require a
// CLOUDFLARE_API_TOKEN, breaking the credential-free e2e stack), so under `wrangler dev` it is a
// non-functional local binding and the render path fails closed; real local captures need
// `wrangler dev --remote` with credentials or a preview deploy. Undefined in tests.
BROWSER: BrowserBinding | undefined
// Kill switch for the MCP screenshot server (POST /app/mcp). Absent means enabled, so an
// environment that never configured it behaves as it did before the flag existed. Anything other
// than 'true' turns the endpoint off, so a typo fails in the safe direction. Editing this var in
// the Cloudflare dashboard takes the server down without a rebuild or a code deploy — but the
// next deploy restores the wrangler.toml value, so follow an emergency flip with a config change.
MCP_SERVER_ENABLED: string | undefined
/**
* Whether `search_boards` will match on board names. Unset means yes, so previews, local dev and
* tests keep working; production sets it to "false" while the unindexed `ILIKE` scan it drives is
* still being watched.
*/
MCP_SEARCH_NAME_MATCHING_ENABLED: string | undefined
// Origin serving the client thumbnail render page (THUMBNAIL_RENDER_PATH). Set per
// environment in wrangler.toml.
MCP_SCREENSHOT_RENDER_ORIGIN: string | undefined
// 'true' opts MCP screenshots into live-canvas capture: the page prunes the canvas to the
// requested shapes, settles and fits, and the screenshotting browser rasterizes it instead of
// the page exporting itself. Skips the export phase, the expensive part on heavy boards, at the
// cost of the export path's pixel-exact sizing. Off everywhere it is unset.
THUMBNAIL_RENDER_LIVE_CAPTURE: string | undefined
// HMAC secret for short-lived thumbnail render job tokens.
MCP_SCREENSHOT_TOKEN_SECRET: string | undefined
// The MCP server's public URL, and the resource identifier it advertises in RFC 9728 protected
// resource metadata and in the `WWW-Authenticate` challenge. Not compared against anything on an
// incoming token: Clerk stamps no `aud`, so there is no audience binding to check — see
// authenticateMcpRequest for what stands in for one. Set per environment in wrangler.toml;
// previews have no vars block there, so deploy-dotcom.ts injects it as a deploy var. Left unset,
// it is derived from the request's own origin, which is fine locally and wrong anywhere a Host
// header can be forged — see getMcpResourceUrl.
MCP_SERVER_URL: string | undefined
// Overrides the OAuth authorization server advertised to MCP clients. Normally unset: the value is
// derived from CLERK_PUBLISHABLE_KEY so it cannot drift from the instance whose tokens we verify.
MCP_OAUTH_AUTHORIZATION_SERVER: string | undefined
// Development only: a local HTTP screenshot service to use instead of the BROWSER binding, which
// cannot reach Browser Run in local dev. Set in [env.dev.vars] to the client dev server's
// screenshot endpoint; unset everywhere else, which is what keeps deployed environments on
// Browser Run.
LOCAL_SCREENSHOT_SERVICE_URL: string | undefined
}
export function isDebugLogging(env: Environment) {
return env.TLDRAW_ENV === 'development' || env.TLDRAW_ENV === 'preview'
}
/**
* The word a boolean-ish env var holds: trimmed, lowercased, with unset and empty folded together.
* Used by MCP_SERVER_ENABLED. Kept as a shared helper rather than inlined so a second
* boolean-ish var cannot arrive parsing its value differently — each call site keeps its own
* fail-safe direction, this owns what a value *is*.
*/
export function envFlagWord(value: string | undefined): string | undefined {
const word = value?.trim().toLowerCase()
return word ? word : undefined
}
export interface DBLoadResult {
snapshot: RoomSnapshot
roomSizeMB: number
}
// Events written by TLFileDurableObject. None of them carry a room id: the object serves exactly
// one room, and its `writeEvent` indexes every data point on that object's durable object id. A
// roomId here would only ever restate what the object already knows, while implying call sites can
// attribute an event to some other room.
export type TLServerEvent =
| {
type: 'client'
name: 'room_create' | 'room_reopen' | 'enter' | 'leave' | 'last_out'
instanceId: string
// `enter` only: the client bundle's build timestamp from the `?v=` connect param,
// so bundle age is queryable per connect. Absent = a bundle from before the param,
// or a param that didn't validate as an epoch-ms number.
clientBuildTimestamp?: string
}
| {
type: 'client'
name: 'rate_limited'
userId: string | undefined
}
| {
type: 'room'
name:
| 'failed_load_from_db'
| 'failed_persist_to_db'
| 'failed_persist_comments_to_db'
| 'comment_author_deleted_prune'
| 'comment_thread_emptied_prune'
| 'comment_soft_delete_prune'
| 'comment_reaction_orphan_prune'
| 'room_empty'
| 'fail_persist'
}
| {
type: 'room'
name: 'room_start'
/**
* How many hibernated sockets this boot resumed. Zero means a cold boot, and anything
* higher means the durable object woke with clients still attached — which nothing else
* in the dataset distinguishes, since both emit the same `room_start`.
*/
resumedSockets: number
}
// Discriminated on `wrote`: only a keyframe carries the reason that forced it.
| ({
type: 'version_chain_write'
bytes: number
depth: number
} & ({ wrote: 'keyframe'; reason: KeyframeReason } | { wrote: 'delta' }))
// Discriminated on `outcome`: only a failure or a skip carries a reason.
| ({
/** A cadence keyframe retired a chain; did that chain reconstruct the state it claims? */
type: 'version_chain_verify'
} & (
| { outcome: 'ok' }
| { outcome: 'fail'; reason: 'missing' | 'legacy-fallback' | 'head-mismatch' | 'error' }
// Not a failure: the chain was left unchecked because reconstructing it here would risk
// the isolate's memory. Carries the keyframe size so the threshold can be tuned.
| { outcome: 'skipped'; reason: 'keyframe-size'; keyframeBytes: number }
))
| {
/** A chain write failed in dual mode and was swallowed so the persist could complete. */
type: 'version_chain_error'
}
| {
type: 'send_message'
messageType: string
messageLength: number
}
| {
type: 'persist_success'
attempts: number
/**
* Whether this board is link-shared. The shared fraction of *actively edited* boards is what
* sizes thumbnail spend, and nothing else can answer it: Postgres knows which files are shared
* but not which are being edited, and this event's index is one-way, so the dataset cannot be
* joined back to a file row.
*
* `unknown` is an app file whose record has not loaded yet; `legacy` a non-app room, which has
* no shareable board identity; `deleted` an app file whose record has been deleted. All three
* stay distinct from `private` so the denominator is honest, and all are computed by
* `getBoardRenderState`, which is also what gates the render.
*/
sharedState: 'shared' | 'private' | 'unknown' | 'legacy' | 'deleted'
}
export interface AssetUploadQueueMessage {
type: 'asset-upload'
objectName: string
fileId: string
userId: string | null
}
/**
* The two kinds of publicly viewable board the thumbnail/OG screenshot surfaces render:
* `published` is a frozen tldraw.com/p/:slug snapshot; `shared_file` is the live snapshot of an
* anonymously-shared tldraw.com/f/:slug file.
*/
export type ThumbnailBoardKind = 'published' | 'shared_file'
/**
* Which board, in which namespace. Everything that keys, enqueues, or cleans up a thumbnail takes
* this and nothing more — the pair is what a cache key is built from, so a function that took only a
* slug could silently address the wrong board's image.
*/
export interface ThumbnailBoardRef {
kind: ThumbnailBoardKind
slug: string
}
/**
* Which page of which board a stored MCP cluster index belongs to. The object it is stored in is
* already the board's file, so this addresses a page within that. See mcpClusterIndexStorage.ts.
*/
export interface McpClusterIndexKey {
kind: ThumbnailBoardKind
pageId: string
/** The board's content version, so an index is only read back for the content it was built from. */
version: string
}
/**
* How much of a board a caller is entitled to. `public` is the anonymous gate: the board must be
* shared via link. `render` is for generating a thumbnail we will store but not necessarily serve
* publicly, so it only requires that the board exists and has content.
*
* Required at every call site rather than defaulted: a default would be wrong for half of them, and
* silence is the wrong way to pick a gate. It also rides inside the signed render job, so the gate a
* board was resolved under is the same one the snapshot route applies when it is read.
*/
export type ThumbnailBoardAccess = 'public' | 'render'
/**
* Which pipeline asked for a render. `og` covers the social-preview route and its queue consumer;
* `mcp` is the board screenshot MCP server.
*
* Unlike `OgImageRenderReason` this is not telemetry — it is signed into the render job and
* namespaces the minted-token record, so two surfaces rendering the same board at the same time do
* not overwrite each other's proof of mint. See `recordMintedRenderToken`.
*/
export type ThumbnailRenderSurface = 'og' | 'mcp'
// What prompted a board thumbnail render, so renders can be attributed to the thing that asked for
// them. `publish` and `edit` are the trigger producers; `crawler` is the OG route's published-board
// repair (`repairMissingPublishedImage`) and doubles as the fallback for a queued message that
// carries no reason of its own. Telemetry with one exception: when a job burns its whole retry
// budget, the consumer arms the repair cooldown only if a crawler asked (see `retryOrDrop`), so
// traffic an outside caller controls cannot re-arm the retry chain on a board that just proved it
// cannot render.
export type OgImageRenderReason = 'crawler' | 'publish' | 'edit'
// Asks the queue consumer to render a board's OG image through Browser Run and refresh the R2
// cache read by GET /app/social-preview/:prefix/:slug/image. Board state (share gate, content
// version) is deliberately not carried in the message; the consumer re-resolves it at render time.
export interface OgImageRenderQueueMessage {
type: 'og-image-render'
kind: ThumbnailBoardKind
slug: string
// Optional only because a message may already be in the queue without one; every producer sets it.
reason?: OgImageRenderReason
/**
* Set on a job the consumer enqueued for itself, having found a `published` board changed while it
* was capturing — the one kind whose dropped ask nothing re-asks for. Shared files never get one:
* the DO's debounce alarm re-asks by construction (see enqueueFollowUpIfBoardMoved). A follow-up
* never spawns another: a board republished without pause would otherwise render continuously.
*/
followUp?: boolean
}
export type QueueMessage = AssetUploadQueueMessage | OgImageRenderQueueMessage

View File

@@ -0,0 +1,159 @@
import { TlaFile } from '@tldraw/dotcom-shared'
import { describe, expect, it } from 'vitest'
import { undeleteFile } from './undeleteFile'
function makeFile(overrides: Partial<TlaFile> = {}): TlaFile {
return {
id: 'file-1',
name: 'My file',
owningGroupId: 'group-9',
ownerName: '',
thumbnail: '',
shared: true,
sharedLinkType: 'edit',
published: false,
lastPublished: 0,
publishedSlug: '',
createdAt: 0,
updatedAt: 0,
isEmpty: false,
isDeleted: true,
createSource: undefined,
...overrides,
} as TlaFile
}
// Stubs the chains undeleteFile uses:
// selectFrom('file').where().selectAll().executeTakeFirst()
// selectFrom('group').select().where().executeTakeFirst()
// updateTable('file').set().where().execute()
// insertInto(table).values().onConflict().execute()
function makeFakeDb(
fileRow: TlaFile | undefined,
opts: {
// Use 'none' to simulate a missing group row; omit for a live (isDeleted: false) group.
groupRow?: { isDeleted: boolean } | 'none'
} = {}
) {
const { groupRow = { isDeleted: false } } = opts
const resolvedGroupRow = groupRow === 'none' ? undefined : groupRow
const updates: Array<{ table: string; values: any }> = []
const inserts: Array<{ table: string; values: any }> = []
let transactionCount = 0
const db: any = {
transaction: () => ({
execute: async (cb: (tx: any) => Promise<any>) => {
transactionCount++
return cb(db)
},
}),
selectFrom: (table: string) => {
if (table === 'file') {
return {
where: () => ({
selectAll: () => ({ executeTakeFirst: async () => fileRow }),
}),
}
}
if (table === 'group') {
return {
select: () => ({
where: () => ({ executeTakeFirst: async () => resolvedGroupRow }),
}),
}
}
throw new Error(`unexpected selectFrom('${table}')`)
},
updateTable: (table: string) => ({
set: (values: any) => ({
where: () => ({
execute: async () => {
updates.push({ table, values })
},
}),
}),
}),
insertInto: (table: string) => ({
values: (values: any) => ({
onConflict: () => ({
execute: async () => {
inserts.push({ table, values })
},
}),
}),
}),
}
return { db: db as any, updates, inserts, getTransactionCount: () => transactionCount }
}
describe('undeleteFile', () => {
it('returns not_found for a missing file and writes nothing', async () => {
const { db, updates, inserts } = makeFakeDb(undefined)
expect(await undeleteFile(db, 'nope')).toEqual({ result: 'not_found' })
expect(updates).toEqual([])
expect(inserts).toEqual([])
})
it('returns not_deleted for a live file and writes nothing', async () => {
const file = makeFile({ isDeleted: false })
const { db, updates, inserts } = makeFakeDb(file)
expect(await undeleteFile(db, file.id)).toEqual({ result: 'not_deleted', file })
expect(updates).toEqual([])
expect(inserts).toEqual([])
})
it('bumps lastPublished when restoring a published file, to trigger a republish', async () => {
const file = makeFile({ published: true, lastPublished: 123 })
const { db, updates } = makeFakeDb(file)
await undeleteFile(db, file.id)
expect(updates).toEqual([
{
table: 'file',
values: {
isDeleted: false,
updatedAt: expect.any(Number),
lastPublished: expect.any(Number),
},
},
])
expect(updates[0].values.lastPublished).not.toBe(123)
})
it('restores the group_file link for a group-owned file', async () => {
const file = makeFile()
const { db, inserts } = makeFakeDb(file, { groupRow: { isDeleted: false } })
const result = await undeleteFile(db, file.id)
expect(result.result).toBe('restored')
expect(inserts).toEqual([
{
table: 'group_file',
values: {
fileId: 'file-1',
groupId: 'group-9',
createdAt: expect.any(Number),
updatedAt: expect.any(Number),
},
},
])
})
it('returns group_deleted and writes nothing when the owning group is soft-deleted', async () => {
const file = makeFile()
const { db, updates, inserts } = makeFakeDb(file, {
groupRow: { isDeleted: true },
})
expect(await undeleteFile(db, file.id)).toEqual({ result: 'group_deleted', file })
expect(updates).toEqual([])
expect(inserts).toEqual([])
})
it('returns group_deleted and writes nothing when the owning group row is missing', async () => {
const file = makeFile()
const { db, updates, inserts } = makeFakeDb(file, {
groupRow: 'none',
})
expect(await undeleteFile(db, file.id)).toEqual({ result: 'group_deleted', file })
expect(updates).toEqual([])
expect(inserts).toEqual([])
})
})

View File

@@ -0,0 +1,55 @@
import { DB, TlaFile } from '@tldraw/dotcom-shared'
import { Kysely } from 'kysely'
export type UndeleteFileResult =
| { result: 'not_found' }
| { result: 'not_deleted'; file: TlaFile }
| { result: 'group_deleted'; file: TlaFile }
| { result: 'restored'; file: TlaFile }
// Restores a soft-deleted file: clears isDeleted and re-creates the owning group's group_file
// link (if owningGroupId). The caller pokes the file effect processor's outbox after a
// successful restore; Zero replicates the row changes to clients on its own.
export async function undeleteFile(db: Kysely<DB>, fileId: string): Promise<UndeleteFileResult> {
return db.transaction().execute(async (tx) => {
const file = await tx.selectFrom('file').where('id', '=', fileId).selectAll().executeTakeFirst()
if (!file) return { result: 'not_found' }
if (!file.isDeleted) return { result: 'not_deleted', file }
// owningGroupId stays optional in the client schema until #10592 tightens it, a release
// after the NOT NULL migration has replicated everywhere; this guard goes with that.
if (file.owningGroupId) {
const group = await tx
.selectFrom('group')
.select(['isDeleted'])
.where('id', '=', file.owningGroupId)
.executeTakeFirst()
if (!group || group.isDeleted) return { result: 'group_deleted', file }
}
const now = Date.now()
await tx
.updateTable('file')
.set({
isDeleted: false,
updatedAt: now,
// Bumping lastPublished on a published file produces a publish transition, so the
// effect re-uploads current content - otherwise the published URL stays dead until
// the user manually republishes (restoring from trash produces no publish transition
// of its own).
...(file.published ? { lastPublished: now } : {}),
})
.where('id', '=', fileId)
.execute()
if (file.owningGroupId) {
await tx
.insertInto('group_file')
.values({ fileId, groupId: file.owningGroupId, createdAt: now, updatedAt: now })
.onConflict((oc) => oc.columns(['fileId', 'groupId']).doNothing())
.execute()
}
return { result: 'restored', file }
})
}

View File

@@ -0,0 +1,34 @@
import { createSentry } from '@tldraw/worker-shared'
import { Analytics, Environment } from '../types'
export interface EventData {
blobs?: string[]
indexes?: [string]
doubles?: number[]
}
export function writeDataPoint(
sentry: ReturnType<typeof createSentry> | undefined,
measure: Analytics | undefined,
env: Environment,
name: string,
{ blobs, indexes, doubles }: EventData
) {
try {
measure?.writeDataPoint({
// We put the worker name in the second spot for legacy reasons: when we first introduced analytics
// we only included the name. If we were to change the order it would be hard to query old data.
blobs: [name, env.WORKER_NAME ?? 'development-tldraw-multiplayer', ...(blobs ?? [])],
doubles,
indexes,
})
} catch {
// // eslint-disable-next-line @typescript-eslint/no-deprecated
// sentry?.withScope((scope) => {
// scope.setExtra('name', name)
// // eslint-disable-next-line @typescript-eslint/no-deprecated
// sentry.captureException(e)
// })
// console.error('Failed to write data point', e)
}
}

View File

@@ -0,0 +1,28 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { arrayBufferToBase64, base64ToArrayBuffer } from './base64'
describe('arrayBufferToBase64', () => {
const proto = Uint8Array.prototype as { toBase64?(): string }
const original = proto.toBase64
afterEach(() => {
if (original) proto.toBase64 = original
else delete proto.toBase64
})
it('round-trips through the chunked fallback', () => {
delete proto.toBase64
const bytes = new Uint8Array(0x8000 * 3 + 7).map((_, i) => i % 251)
const base64 = arrayBufferToBase64(bytes.buffer)
expect(new Uint8Array(base64ToArrayBuffer(base64))).toEqual(bytes)
})
it('prefers the native encoder when the runtime has one', () => {
const native = vi.fn(function (this: Uint8Array) {
return `native:${this.length}`
})
proto.toBase64 = native
expect(arrayBufferToBase64(new Uint8Array([1, 2, 3]).buffer)).toBe('native:3')
expect(native).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,41 @@
// Chunked base64 helpers. String.fromCharCode(...bytes) overflows the engine's argument limit on
// large buffers (a PNG screenshot is megabytes), so encoding walks the bytes in 32k chunks.
export function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
// workerd (V8 14+) has the native encoder, which skips the intermediate binary string that
// matters when the .tldr download inlines multi-MB assets inside a memory-capped DO. Node < 25
// under vitest does not have it.
if (typeof bytes.toBase64 === 'function') return bytes.toBase64()
return btoa(bytesToBinaryString(bytes))
}
export function base64ToArrayBuffer(base64: string): ArrayBuffer {
return binaryStringToBytes(atob(base64)).buffer
}
// Base64url (RFC 4648 §5): the URL-safe alphabet with padding stripped, used for the signed
// render tokens that ride in the render page's query string.
export function base64UrlEncode(bytes: Uint8Array): string {
return btoa(bytesToBinaryString(bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
export function base64UrlDecode(value: string): Uint8Array<ArrayBuffer> {
return binaryStringToBytes(atob(value.replace(/-/g, '+').replace(/_/g, '/')))
}
function bytesToBinaryString(bytes: Uint8Array): string {
let binary = ''
for (let i = 0; i < bytes.length; i += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
}
return binary
}
function binaryStringToBytes(binary: string): Uint8Array<ArrayBuffer> {
const bytes = new Uint8Array(new ArrayBuffer(binary.length))
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return bytes
}

View File

@@ -0,0 +1,40 @@
import { createClient } from '@supabase/supabase-js'
import { vi } from 'vitest'
import { Environment } from '../types'
import { createSupabaseClient } from './createSupabaseClient'
const SUPABASE_URL = 'https://stub.supabase.co'
const SUPABASE_KEY = 'stub-anon-key'
const env = { SUPABASE_URL, SUPABASE_KEY } as Environment
describe('createSupabaseClient', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('schedules no timers, so the durable object that builds it can hibernate', async () => {
const client = createSupabaseClient(env)
expect(client).toBeDefined()
// GoTrueClient arms its auto-refresh interval asynchronously, from initialize()'s finally
// block — flush the microtask queue so any timer it would arm has been armed.
await vi.advanceTimersByTimeAsync(0)
expect(vi.getTimerCount()).toBe(0)
})
// Negative control: prove this harness catches the timer when it is armed. If a supabase-js
// bump moves the auto-refresh interval somewhere these fake timers can't see, this fails
// rather than letting the test above pass vacuously.
it('a client with default auth options does arm the auto-refresh interval', async () => {
createClient(SUPABASE_URL, SUPABASE_KEY)
await vi.advanceTimersByTimeAsync(0)
expect(vi.getTimerCount()).toBeGreaterThan(0)
})
})

View File

@@ -0,0 +1,26 @@
import { createClient } from '@supabase/supabase-js'
import { Environment } from '../types'
// `autoRefreshToken` is what keeps a durable object out of hibernation. GoTrueClient's constructor
// calls initialize(), whose finally block always calls _handleVisibilityChange(); outside a browser
// (`isBrowser()` checks for `window` and `document`, neither of which exist in workerd) that branch
// calls startAutoRefresh() whenever `autoRefreshToken` is enabled (which it is by default), arming
// a 30 second setInterval that nothing ever clears.
// A durable object cannot hibernate while any timer is scheduled, so a single one of these makes
// TLFileDurableObject billable for its entire residency — it constructs one of these in its
// constructor, on every wake.
// https://developers.cloudflare.com/durable-objects/concepts/durable-object-lifecycle/
//
// We never sign a user in through this client — it reads legacy rooms with the anon key — so there
// is no token to refresh, no session to persist, and no URL to detect a session in.
export function createSupabaseClient(env: Environment) {
return env.SUPABASE_URL && env.SUPABASE_KEY
? createClient(env.SUPABASE_URL, env.SUPABASE_KEY, {
auth: { autoRefreshToken: false, persistSession: false, detectSessionInUrl: false },
})
: undefined
}
export function noSupabaseSorry() {
return new Response(JSON.stringify({ error: true, message: 'Could not create supabase client' }))
}

View File

@@ -0,0 +1,27 @@
import { ROOM_PREFIX } from '@tldraw/dotcom-shared'
import { TLFileDurableObject } from '../TLFileDurableObject'
import type { TLFileEffectProcessor } from '../TLFileEffectProcessor'
import { TLLoggerDurableObject } from '../TLLoggerDurableObject'
import { Environment } from '../types'
export function getFileEffectProcessor(env: Environment) {
return env.TL_FILE_EFFECTS.get(env.TL_FILE_EFFECTS.idFromName('0'), {
locationHint: 'weur',
}) as any as TLFileEffectProcessor
}
export function getLogger(env: Environment) {
return env.TL_LOGGER.get(env.TL_LOGGER.idFromName('logger')) as any as TLLoggerDurableObject
}
export function getRoomDurableObjectId(env: Environment, roomId: string) {
return env.TLDR_DOC.idFromName(`/${ROOM_PREFIX}/${roomId}`)
}
export function getRoomDurableObject(env: Environment, roomId: string) {
return env.TLDR_DOC.get(getRoomDurableObjectId(env, roomId)) as any as TLFileDurableObject
}
export function getRoomDurableObjectById(env: Environment, objectId: string) {
return env.TLDR_DOC.get(env.TLDR_DOC.idFromString(objectId)) as any as TLFileDurableObject
}

View File

@@ -0,0 +1,430 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
evaluateFlagForUser,
getAllFeatureFlagValues,
getFeatureFlagValue,
getFeatureFlags,
hashToPercentage,
parseAllowlistEmails,
setFeatureFlag,
} from './featureFlags'
vi.mock('./tla/getAuth', () => ({
getAuth: vi.fn(),
}))
function makeEnv(
kvData: Record<string, string> = {},
tldrawEnv = 'production'
): { FEATURE_FLAGS: any; TLDRAW_ENV: string } {
return {
TLDRAW_ENV: tldrawEnv,
FEATURE_FLAGS: {
get: vi.fn(async (key: string) => kvData[key] ?? null),
put: vi.fn(async () => {}),
},
}
}
describe('hashToPercentage', () => {
it('returns a number between 0 and 99 inclusive', () => {
for (let i = 0; i < 200; i++) {
const result = hashToPercentage(`user-${i}`, 'some_flag')
expect(result).toBeGreaterThanOrEqual(0)
expect(result).toBeLessThanOrEqual(99)
}
})
it('produces a reasonable spread across buckets', () => {
const buckets = new Set<number>()
for (let i = 0; i < 100; i++) {
buckets.add(hashToPercentage(`user-${i}`, 'some_flag'))
}
expect(buckets.size).toBeGreaterThan(10)
})
})
describe('evaluateFlagForUser', () => {
it('returns false when flag is disabled regardless of type', () => {
expect(
evaluateFlagForUser({ type: 'boolean', enabled: false, description: '' }, 'test', 'user-1')
).toBe(false)
expect(
evaluateFlagForUser(
{ type: 'percentage', enabled: false, percentage: 100, description: '' },
'test',
'user-1'
)
).toBe(false)
})
it('returns true for enabled boolean flags', () => {
expect(
evaluateFlagForUser({ type: 'boolean', enabled: true, description: '' }, 'test', 'user-1')
).toBe(true)
})
it('returns true for enabled boolean flags even without userId', () => {
expect(
evaluateFlagForUser({ type: 'boolean', enabled: true, description: '' }, 'test', null)
).toBe(true)
})
it('returns false for percentage flags when userId is null', () => {
expect(
evaluateFlagForUser(
{ type: 'percentage', enabled: true, percentage: 100, description: '' },
'test',
null
)
).toBe(false)
})
it('returns true for percentage 100 (all users)', () => {
// 100% should include everyone since hash produces 0-99
for (let i = 0; i < 50; i++) {
expect(
evaluateFlagForUser(
{ type: 'percentage', enabled: true, percentage: 100, description: '' },
'some_flag',
`user-${i}`
)
).toBe(true)
}
})
it('returns false for percentage 0 (no users)', () => {
for (let i = 0; i < 50; i++) {
expect(
evaluateFlagForUser(
{ type: 'percentage', enabled: true, percentage: 0, description: '' },
'some_flag',
`user-${i}`
)
).toBe(false)
}
})
it('includes a subset of users for intermediate percentages', () => {
let included = 0
const total = 1000
for (let i = 0; i < total; i++) {
if (
evaluateFlagForUser(
{ type: 'percentage', enabled: true, percentage: 50, description: '' },
'some_flag',
`user-${i}`
)
) {
included++
}
}
// Should be roughly 50% — allow wide margin for hash distribution
expect(included).toBeGreaterThan(total * 0.3)
expect(included).toBeLessThan(total * 0.7)
})
it('rollout is monotonic: increasing percentage never removes existing users', () => {
const flag = 'some_flag'
const users = Array.from({ length: 200 }, (_, i) => `user-${i}`)
const makeFlag = (pct: number) =>
({ type: 'percentage', enabled: true, percentage: pct, description: '' }) as const
const at10 = new Set(users.filter((u) => evaluateFlagForUser(makeFlag(10), flag, u)))
const at25 = new Set(users.filter((u) => evaluateFlagForUser(makeFlag(25), flag, u)))
const at50 = new Set(users.filter((u) => evaluateFlagForUser(makeFlag(50), flag, u)))
const at100 = new Set(users.filter((u) => evaluateFlagForUser(makeFlag(100), flag, u)))
// Every user in a smaller bucket must also be in every larger bucket
for (const u of at10) expect(at25.has(u)).toBe(true)
for (const u of at25) expect(at50.has(u)).toBe(true)
for (const u of at50) expect(at100.has(u)).toBe(true)
})
})
// The type a percentage rollout cannot stand in for: a percentage buckets users by hash, so it gives
// you *a* subset of the right size but never *the* subset you picked.
describe('evaluateFlagForUser (allowlist)', () => {
const flag = (userIds: string[], enabled = true) =>
({
type: 'allowlist',
enabled,
users: userIds.map((userId) => ({ userId, email: `${userId}@example.com` })),
description: '',
}) as const
it('is on only for the named users', () => {
expect(evaluateFlagForUser(flag(['user-1', 'user-2']), 'test', 'user-1')).toBe(true)
expect(evaluateFlagForUser(flag(['user-1', 'user-2']), 'test', 'user-2')).toBe(true)
expect(evaluateFlagForUser(flag(['user-1', 'user-2']), 'test', 'user-3')).toBe(false)
})
it('is off for everyone when the master toggle is off', () => {
expect(evaluateFlagForUser(flag(['user-1'], false), 'test', 'user-1')).toBe(false)
})
it('is off for an anonymous caller', () => {
expect(evaluateFlagForUser(flag(['user-1']), 'test', null)).toBe(false)
})
it('is off for an empty list', () => {
expect(evaluateFlagForUser(flag([]), 'test', 'user-1')).toBe(false)
})
// The value comes from KV, where a hand-edited entry can arrive as anything. A malformed list must
// deny rather than admit — the alternative fails open on a flag whose whole job is to keep people
// out.
it('denies when users is missing or not an array of entries', () => {
expect(
evaluateFlagForUser({ type: 'allowlist', enabled: true, description: '' } as any, 't', 'u')
).toBe(false)
expect(
evaluateFlagForUser(
{ type: 'allowlist', enabled: true, users: 'user-1', description: '' } as any,
't',
'u'
)
).toBe(false)
expect(
evaluateFlagForUser(
{ type: 'allowlist', enabled: true, users: ['user-1'], description: '' } as any,
't',
'user-1'
)
).toBe(false)
})
})
// The admin edits an allowlist as emails; this is the input half of that. Resolution to user ids
// happens against the database in the admin route.
describe('parseAllowlistEmails', () => {
it('splits on newlines and commas, trims, lowercases, and drops blanks', () => {
expect(parseAllowlistEmails(' Friend@Example.com \n\nother@example.com, third@x.co ')).toEqual([
'friend@example.com',
'other@example.com',
'third@x.co',
])
})
it('accepts an array as well as a block of text', () => {
expect(parseAllowlistEmails(['a@example.com', 'b@example.org'])).toEqual([
'a@example.com',
'b@example.org',
])
})
it('drops duplicates that differ only in case or whitespace', () => {
expect(parseAllowlistEmails('a@example.com\nA@Example.com\n a@example.com ')).toEqual([
'a@example.com',
])
})
it('treats empty input as an empty list rather than an error', () => {
expect(parseAllowlistEmails('')).toEqual([])
expect(parseAllowlistEmails('\n\n, ,\n')).toEqual([])
expect(parseAllowlistEmails(undefined)).toEqual([])
})
// Rejected before the lookup runs, so the admin gets "that isn't an email" rather than the
// blanker "no account for that".
it('rejects anything that is not an email address', () => {
expect(() => parseAllowlistEmails('tldraw.com')).toThrow('not an email address')
expect(() => parseAllowlistEmails('@tldraw.com')).toThrow('not an email address')
expect(() => parseAllowlistEmails('friend@localhost')).toThrow('not an email address')
expect(() => parseAllowlistEmails('a@b@example.com')).toThrow('not an email address')
expect(() => parseAllowlistEmails('friend @example.com')).toThrow('not an email address')
})
it('names the offending entry so the admin knows which line to fix', () => {
expect(() => parseAllowlistEmails('ok@example.com\ntldraw.com')).toThrow('"tldraw.com"')
})
})
describe('getFeatureFlagValue', () => {
it('returns defaults when KV has no value', async () => {
const env = makeEnv()
const value = await getFeatureFlagValue(env as any, 'rum_enabled')
expect(value).toMatchObject({ type: 'percentage', enabled: false, percentage: 0 })
})
it('merges KV value over defaults', async () => {
const env = makeEnv({
rum_enabled: JSON.stringify({ enabled: true, percentage: 25 }),
})
const value = await getFeatureFlagValue(env as any, 'rum_enabled')
expect(value).toMatchObject({
type: 'percentage',
enabled: true,
percentage: 25,
})
// Description should come from defaults
expect(value.description).toBeTruthy()
})
// The defaults table is the schema and KV holds only state, so a stored `type` never gets a say.
// `"allowList"` — a capital L — used to reach evaluateFlagForUser as a type none of its arms
// matched, and the fall-through admitted everyone holding any token.
it('discards a stored type that disagrees with the default', async () => {
const env = makeEnv({
mcp_server_access: JSON.stringify({ type: 'allowList', enabled: true }),
})
const value = await getFeatureFlagValue(env as any, 'mcp_server_access')
expect(value.type).toBe('allowlist')
expect(evaluateFlagForUser(value, 'mcp_server_access', 'user-1')).toBe(false)
})
it('returns defaults on KV error', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const env = makeEnv()
env.FEATURE_FLAGS.get = vi.fn(async () => {
throw new Error('KV down')
})
const value = await getFeatureFlagValue(env as any, 'rum_enabled')
expect(value).toMatchObject({ type: 'percentage', enabled: false })
consoleSpy.mockRestore()
})
})
describe('setFeatureFlag', () => {
it('updates enabled', async () => {
const env = makeEnv()
await setFeatureFlag(env as any, 'rum_enabled', { type: 'percentage', enabled: true })
expect(env.FEATURE_FLAGS.put).toHaveBeenCalledWith(
'rum_enabled',
expect.stringContaining('"enabled":true')
)
})
it('updates percentage', async () => {
const env = makeEnv()
await setFeatureFlag(env as any, 'rum_enabled', { type: 'percentage', percentage: 42 })
const putCall = env.FEATURE_FLAGS.put.mock.calls[0]
const stored = JSON.parse(putCall[1])
expect(stored.percentage).toBe(42)
})
// Replaced rather than merged, so removing someone is an ordinary save.
it('replaces the allowlist wholesale', async () => {
const env = makeEnv({
mcp_server_access: JSON.stringify({
type: 'allowlist',
enabled: true,
users: [
{ userId: 'user-1', email: 'one@example.com' },
{ userId: 'user-2', email: 'two@example.com' },
],
}),
})
await setFeatureFlag(env as any, 'mcp_server_access', {
type: 'allowlist',
users: [{ userId: 'user-2', email: 'two@example.com' }],
})
expect(JSON.parse(env.FEATURE_FLAGS.put.mock.calls[0][1]).users).toEqual([
{ userId: 'user-2', email: 'two@example.com' },
])
})
// A field that means nothing for this flag's type is refused rather than dropped. It used to be
// dropped in silence, and the admin route still answered `{success: true, users: […]}` — so an
// allowlist sent to a percentage flag reported a save that stored nothing anywhere.
it('refuses an update naming a different type than the flag', async () => {
const env = makeEnv()
await expect(
setFeatureFlag(env as any, 'rum_enabled', {
type: 'allowlist',
users: [{ userId: 'user-1', email: 'one@example.com' }],
})
).rejects.toThrow('is a percentage flag')
expect(env.FEATURE_FLAGS.put).not.toHaveBeenCalled()
})
})
describe('getFeatureFlags (route handler)', () => {
beforeEach(async () => {
const { getAuth } = await import('./tla/getAuth')
vi.mocked(getAuth).mockReset()
})
it('returns evaluated flags with x-authenticated header for authenticated user', async () => {
const { getAuth } = await import('./tla/getAuth')
vi.mocked(getAuth).mockResolvedValue({ userId: 'user-abc' } as any)
const env = makeEnv({
rum_enabled: JSON.stringify({ enabled: true, percentage: 100 }),
})
const response = await getFeatureFlags({} as any, env as any)
const body: any = await response.json()
expect(response.headers.get('x-authenticated')).toBe('1')
// percentage 100 includes every userId
expect(body.rum_enabled.enabled).toBe(true)
})
it('returns x-authenticated=0 for unauthenticated user', async () => {
const { getAuth } = await import('./tla/getAuth')
vi.mocked(getAuth).mockResolvedValue(null)
const env = makeEnv()
const response = await getFeatureFlags({} as any, env as any)
expect(response.headers.get('x-authenticated')).toBe('0')
})
it('returns false for percentage flags when not authenticated', async () => {
const { getAuth } = await import('./tla/getAuth')
vi.mocked(getAuth).mockResolvedValue(null)
const env = makeEnv({
rum_enabled: JSON.stringify({ enabled: true, percentage: 100 }),
})
const response = await getFeatureFlags({} as any, env as any)
const body: any = await response.json()
// Percentage flags require a userId
expect(body.rum_enabled.enabled).toBe(false)
})
it('forces legacy zero_enabled/zero_kill_switch flags on for old client bundles, even unauthenticated', async () => {
const { getAuth } = await import('./tla/getAuth')
vi.mocked(getAuth).mockResolvedValue(null)
const env = makeEnv()
const response = await getFeatureFlags({} as any, env as any)
const body: any = await response.json()
expect(body.zero_enabled.enabled).toBe(true)
expect(body.zero_kill_switch.enabled).toBe(false)
expect(body.rum_enabled).toBeDefined()
})
})
describe('getAllFeatureFlagValues', () => {
it('returns raw flag values including percentage and description', async () => {
const env = makeEnv({
rum_enabled: JSON.stringify({ enabled: true, percentage: 30 }),
})
const flags = await getAllFeatureFlagValues(env as any)
expect(flags.rum_enabled).toMatchObject({
type: 'percentage',
enabled: true,
percentage: 30,
})
expect(flags.rum_enabled.description).toBeTruthy()
})
it('returns all flags even when KV is empty', async () => {
const env = makeEnv()
const flags = await getAllFeatureFlagValues(env as any)
expect(Object.keys(flags).sort()).toEqual([
'commenting_enabled',
'first_load_rum',
'mcp_server_access',
'rum_enabled',
'version_chain',
'version_chain_legacy_writes',
])
})
})

View File

@@ -0,0 +1,286 @@
import {
AllowlistEntry,
EvaluatedFeatureFlag,
FEATURE_FLAG_KEYS,
FeatureFlagKey,
FeatureFlagValue,
} from '@tldraw/dotcom-shared'
import { exhaustiveSwitchError } from '@tldraw/utils'
import { IRequest } from 'itty-router'
import { Environment } from '../types'
import { getAuth } from './tla/getAuth'
function getFlagDefaults(env: Environment): Record<FeatureFlagKey, FeatureFlagValue> {
return {
rum_enabled: {
type: 'percentage',
percentage: 0,
enabled: false,
description: 'Real User Monitoring for editor performance metrics',
},
first_load_rum: {
type: 'percentage',
// Master toggle on, 0%: turning it on for a debugging session is one percentage change in
// the admin panel, no deploy. Users with a @tldraw.com email send it regardless.
percentage: 0,
enabled: true,
description:
'Send the per-step first_load timing event (client marks + sync server echo) to PostHog. Users with a @tldraw.com email always send it, regardless of this flag',
},
commenting_enabled: {
type: 'percentage',
percentage: 0,
enabled: false,
description:
'Commenting on files (tool, pins, threads, sidebar, notifications). Users with a @tldraw.com email always have it, regardless of this flag',
},
mcp_server_access: {
type: 'allowlist',
users: [],
enabled: false,
description:
'Access to the board screenshot MCP server at /api/app/mcp. Off by default: the endpoint requires auth, so an unset flag denies everyone rather than leaving it open',
},
version_chain: {
type: 'percentage',
// Non-production environments exercise the new write path by default; production waits for
// an explicit flip. Replaces the old VERSION_CHAIN_MODE wrangler vars.
percentage: env.TLDRAW_ENV === 'production' ? 0 : 100,
enabled: env.TLDRAW_ENV !== 'production',
description:
'Version cache entries written as segmented delta chains. Bucketed per ROOM, not per user: the sync worker passes the room R2 key as the id, and the per-user value browsers see is meaningless',
},
version_chain_legacy_writes: {
type: 'boolean',
enabled: true,
description:
'While a room is on version chains, also write legacy full copies (the dual-write bake). Evaluated per ROOM by the sync worker. Only consulted for rooms the version_chain rollout covers — rooms outside it always write legacy',
},
}
}
export { FEATURE_FLAG_KEYS } from '@tldraw/dotcom-shared'
/**
* FNV-1a hash producing a deterministic bucket 0–99.
* Hashes userId + flagName so a user gets independent buckets per flag.
* https://en.wikipedia.org/wiki/Fowler%E2%80%93Noll%E2%80%93Vo_hash_function#FNV-1a_hash
*/
export function hashToPercentage(userId: string, flagName: string): number {
const input = userId + flagName
let hash = 2166136261
for (let i = 0; i < input.length; i++) {
hash ^= input.charCodeAt(i)
hash = Math.imul(hash, 16777619)
}
return (hash >>> 0) % 100
}
/**
* Get feature flag value from KV store
*/
export async function getFeatureFlagValue(
env: Environment,
flag: FeatureFlagKey
): Promise<FeatureFlagValue> {
const defaults = getFlagDefaults(env)[flag]
try {
const value = await env.FEATURE_FLAGS.get(flag)
if (!value) {
return defaults
}
// The defaults table is the schema; KV holds only state. A stored `type` is therefore discarded
// rather than spread over the default one: `{"type":"allowList"}` — a capital L, or any other
// typo — would otherwise reach `evaluateFlagForUser` as a shape none of its arms recognise, and
// the value it lands on decides who is let in.
return { ...defaults, ...JSON.parse(value), type: defaults.type } as FeatureFlagValue
} catch (e) {
console.error(`Failed to get feature flag ${flag}:`, e)
return defaults
}
}
/**
* The type a flag is. Read from the defaults table rather than from KV, which is what makes it a
* schema: a caller can tell which fields apply to a flag without a KV round trip, and without a
* stored value getting a say in the answer.
*/
export function getFeatureFlagType(
env: Environment,
flag: FeatureFlagKey
): FeatureFlagValue['type'] {
return getFlagDefaults(env)[flag].type
}
/**
* Evaluate a flag for a specific user. Percentage flags use a deterministic
* hash of userId+flagName. Allowlist flags check the user against the named ids.
* Boolean flags use the `enabled` field directly.
*/
export function evaluateFlagForUser(
flag: FeatureFlagValue,
flagName: string,
userId: string | null
): boolean {
if (!flag.enabled) return false
// Switched exhaustively rather than ending in a fall-through, so a fourth flag type is a compile
// error here instead of a flag that quietly evaluates true for everyone. The type itself can only
// be one the defaults table names — see getFeatureFlagValue.
switch (flag.type) {
case 'boolean':
// `enabled` is the whole evaluation, and it was checked above.
return true
case 'percentage':
if (!userId) return false
return hashToPercentage(userId, flagName) < flag.percentage
case 'allowlist':
// An anonymous caller is never on a list of users. Stated rather than left to `some`, which
// would also be false but only by accident of `null` matching nobody.
if (!userId) return false
// Missing or malformed `users` denies rather than admits: this is read from KV, where a
// hand-edited value can arrive as anything, and the failure mode of the alternative is a flag
// that silently opens to everyone.
return (
Array.isArray(flag.users) && flag.users.some((entry) => entry && entry.userId === userId)
)
default:
exhaustiveSwitchError(flag)
}
}
/**
* Whether a flag is on for one user, server-side. The counterpart to `getFeatureFlags` (which
* evaluates every flag for a browser) for a route that gates itself on a single one.
*/
export async function isFeatureFlagEnabledForUser(
env: Environment,
flag: FeatureFlagKey,
userId: string
): Promise<boolean> {
return evaluateFlagForUser(await getFeatureFlagValue(env, flag), flag, userId)
}
/**
* A change to one flag, discriminated by the type of flag it applies to.
*
* Discriminated rather than an optional-field bag, because the fields are not interchangeable: a bag
* of `{ enabled?, percentage?, users? }` is matched by independent `if` guards that silently drop a
* field belonging to another type — a `users` list sent for a percentage flag wrote nothing and still
* reported success. Here a mismatch is a compile error at the call site, and a fourth flag type makes
* the compiler name every place that has to learn about it.
*/
export type FeatureFlagUpdate =
| { type: 'boolean'; enabled?: boolean }
| { type: 'percentage'; enabled?: boolean; percentage?: number }
| { type: 'allowlist'; enabled?: boolean; users?: AllowlistEntry[] }
/** Thrown when an update names a different type than the flag it addresses. */
export class FeatureFlagTypeError extends Error {}
function expectFlagType<T extends FeatureFlagValue['type']>(
flag: FeatureFlagKey,
current: FeatureFlagValue,
type: T
): Extract<FeatureFlagValue, { type: T }> {
if (current.type !== type) {
throw new FeatureFlagTypeError(
`"${flag}" is a ${current.type} flag; ${type} fields do not apply to it`
)
}
return current as Extract<FeatureFlagValue, { type: T }>
}
/**
* Set feature flag value in KV store. Admin only.
*/
export async function setFeatureFlag(
env: Environment,
flag: FeatureFlagKey,
update: FeatureFlagUpdate
): Promise<void> {
const current = await getFeatureFlagValue(env, flag)
const put = (value: FeatureFlagValue) => env.FEATURE_FLAGS.put(flag, JSON.stringify(value))
switch (update.type) {
case 'boolean': {
const value = expectFlagType(flag, current, 'boolean')
return put({ ...value, enabled: update.enabled ?? value.enabled })
}
case 'percentage': {
const value = expectFlagType(flag, current, 'percentage')
return put({
...value,
enabled: update.enabled ?? value.enabled,
percentage: update.percentage ?? value.percentage,
})
}
case 'allowlist': {
const value = expectFlagType(flag, current, 'allowlist')
return put({
...value,
enabled: update.enabled ?? value.enabled,
// Replaces the list rather than merging into it, so removing someone is a normal save and
// not a separate operation the admin UI would have to model.
users: update.users ?? value.users,
})
}
default:
exhaustiveSwitchError(update)
}
}
export { parseAllowlistEmails } from '@tldraw/dotcom-shared'
/**
* Route handler: Get all feature flags evaluated for the requesting user.
* Returns only `{ enabled }` per flag — no server internals like percentage.
*/
export async function getFeatureFlags(request: IRequest, env: Environment): Promise<Response> {
const auth = await getAuth(request, env)
const userId = auth?.userId ?? null
const flags: Record<string, EvaluatedFeatureFlag> = {}
await Promise.all(
FEATURE_FLAG_KEYS.map(async (key) => {
const raw = await getFeatureFlagValue(env, key)
flags[key] = {
enabled: evaluateFlagForUser(raw, key, userId),
}
})
)
// Legacy client compat: bundles built before the polyfill removal still read
// these flags to choose a sync path. Force them onto Zero. Remove once stale
// bundles have aged out.
flags.zero_enabled = { enabled: true }
flags.zero_kill_switch = { enabled: false }
return new Response(JSON.stringify(flags), {
headers: {
'Content-Type': 'application/json',
'x-authenticated': userId ? '1' : '0',
},
})
}
/**
* Every flag's stored value, unevaluated — the admin view. Does NOT evaluate per-user: the stored
* percentage, enabled and user list are returned as-is.
*
* Returns the record rather than a Response so the admin route can decorate it before answering —
* see the allowlist label resolution there, which needs Postgres and has no business in here.
*/
export async function getAllFeatureFlagValues(
env: Environment
): Promise<Record<string, FeatureFlagValue>> {
const flags: Record<string, FeatureFlagValue> = {}
await Promise.all(
FEATURE_FLAG_KEYS.map(async (key) => {
flags[key] = await getFeatureFlagValue(env, key)
})
)
return flags
}

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { Environment } from '../types'
import { getPublicOrigin } from './getPublicOrigin'
describe('getPublicOrigin', () => {
function requestWithHeaders(headers: Record<string, string>) {
return new Request('https://main-tldraw-multiplayer.workers.dev/app/social-preview/f/x/image', {
headers,
}) as any
}
it('prefers the configured origin and ignores a spoofed forwarded host', () => {
const origin = getPublicOrigin(
requestWithHeaders({ 'x-forwarded-host': 'evil.com', 'x-forwarded-proto': 'https' }),
{ MCP_SCREENSHOT_RENDER_ORIGIN: 'https://www.tldraw.com' } as Environment
)
expect(origin).toBe('https://www.tldraw.com')
})
it('uses a trusted forwarded host when no origin is configured', () => {
const origin = getPublicOrigin(
requestWithHeaders({
'x-forwarded-host': 'staging.tldraw.com',
'x-forwarded-proto': 'https',
}),
{} as Environment
)
expect(origin).toBe('https://staging.tldraw.com')
})
it('rejects an untrusted forwarded host and falls back to the request origin', () => {
const origin = getPublicOrigin(
requestWithHeaders({ 'x-forwarded-host': 'evil.com' }),
{} as Environment
)
expect(origin).toBe('https://main-tldraw-multiplayer.workers.dev')
})
it('takes the proxy-appended (rightmost) forwarded host, not a client-injected one', () => {
const origin = getPublicOrigin(
requestWithHeaders({ 'x-forwarded-host': 'evil.com, www.tldraw.com' }),
{} as Environment
)
expect(origin).toBe('https://www.tldraw.com')
})
})

View File

@@ -0,0 +1,31 @@
import { IRequest } from 'itty-router'
import { Environment } from '../types'
// The origin used to build the URLs we emit into crawler HTML (og:image, canonical) and 302
// redirect `Location`s. We prefer the configured, trusted client origin so a spoofed
// `x-forwarded-host` / `Host` can't steer those URLs at an attacker origin (open redirect + unfurl
// spoofing). Only when no origin is configured do we fall back to a request-derived host, and only
// if that host is a recognized tldraw/localhost origin.
export function getPublicOrigin(request: IRequest, env: Environment) {
if (env.MCP_SCREENSHOT_RENDER_ORIGIN) return env.MCP_SCREENSHOT_RENDER_ORIGIN
const forwardedHost = request.headers.get('x-forwarded-host')?.split(',').at(-1)?.trim()
const host = forwardedHost ?? request.headers.get('host') ?? undefined
const proto = request.headers.get('x-forwarded-proto')?.split(',').at(-1)?.trim() ?? 'https'
if (host && isTrustedPublicHost(host)) {
return `${proto}://${host}`
}
return new URL(request.url).origin
}
// Hosts we're willing to emit into public URLs. Anything else (a spoofed header, a *.workers.dev
// direct hit) falls through to the request's own origin rather than being trusted.
function isTrustedPublicHost(host: string) {
const hostname = host.split(':')[0].toLowerCase()
return (
hostname === 'tldraw.com' ||
hostname.endsWith('.tldraw.com') ||
hostname === 'localhost' ||
hostname === '127.0.0.1'
)
}

View File

@@ -0,0 +1,10 @@
import { Environment } from '../types'
export function getSnapshotsTable(env: Environment) {
if (env.TLDRAW_ENV === 'production') {
return 'snapshots'
} else if (env.TLDRAW_ENV === 'staging' || env.TLDRAW_ENV === 'preview') {
return 'snapshots_staging'
}
return 'snapshots_dev'
}

Some files were not shown because too many files have changed in this diff Show More