import { LanguageModel, ToolSet, StreamTextResult, streamText, ModelMessage } from 'ai'; import * as Y from 'yjs'; import * as zod_v4_core from 'zod/v4/core'; import * as zod from 'zod'; import * as better_auth_plugins from 'better-auth/plugins'; import * as better_auth from 'better-auth'; import { betterAuth } from 'better-auth'; import { Context } from 'hono'; /** * Wildcard-aware CORS origin matching for the platform workers. * * Hono's array form of `cors({ origin: [...] })` is an exact `includes()` — it * never expands `*`. Every worker that listed `https://*.app.space` or * `http://localhost:*` in an array had a dead entry: the wildcard rows matched * nothing, silently narrowing the effective CORS surface to the literal * entries. This helper is the single implementation both the api and deploy * workers pass to Hono's function-form `origin`, so the pattern semantics * can't drift between them. * * `*` stands for exactly one host label or a port: it never crosses a dot or * a slash, so `https://*.app.space` matches `https://x.app.space` but neither * `https://a.b.app.space` nor `https://x.app.space.evil.com`. */ declare function matchOrigin(origin: string, patterns: string[]): string | null; /** * The platform's standard browser-origin allow-list, derived from the * environment's apexes so a second constellation (staging) trusts its own * hosts and only its own. Both values are explicit deployment-plane config. * Dedupes for environments that serve both roles from one apex. */ declare function platformCorsPatterns(env: { APP_DOMAIN: string; PLATFORM_DOMAIN: string; }): string[]; /** * Deployed-app quota defaults — the ONE copy. * * Two workers use these numbers for different jobs: the deploy worker * ENFORCES them at registration time, the api worker DISPLAYS them via * GET /api/plan-limits. They used to be independent literal sets (plus the * same numbers again in six wrangler [vars] blocks as overrides) and had * already diverged once — the display copy was missing the admin tier * entirely. The env vars remain the per-plane override mechanism; only the * fallbacks live here. */ declare const DEPLOYED_APPS_DEFAULTS: { readonly free: 1; readonly starter: 4; readonly premium: 10; readonly admin: 100; }; /** Parse a quota env override; anything non-integer/negative = fallback. */ declare function parseQuotaLimit(raw: string | undefined, fallback: number): number; /** * The ONE registry client. Every platform worker reaches the app-registry * Durable Object (hosted by the deploy worker, singleton `v1:global`) through * a cross-script REGISTRY binding — and before this module, each of the five * workers hand-rolled its own copy of the same mechanics: the instance name * appeared in 6 places, the `https://registry/` URL in 6, `APP_ID_RE` in 5, * and `RegistryClientError` + `registryErrorJson` existed byte-identical in * two workers. Copies that must agree and nothing enforcing it. * * This module owns the MECHANICS: instance name, stub lookup, the wire call, * the error type, and the standard route mapping. Each worker still declares * its own typed convenience wrapper listing only the actions it uses — that * subset is genuinely per-worker; the plumbing is not. */ /** The singleton registry instance — one DO holds every app/route/collaborator. */ declare const REGISTRY_INSTANCE = "v1:global"; /** Public app ids have one canonical ULID-minted shape. */ declare const APP_ID_RE: RegExp; declare const STRICT_APP_ID_RE: RegExp; /** Internal physical locators survive identity migration and are not public ids. */ declare const RESOURCE_ID_RE: RegExp; /** A 26-char ULID (48-bit ms timestamp + 80 random bits, Crockford base32) — * the id shape shared by app ids (`app_…`) and workspace ids (`ws_…`). */ declare function mintUlid(now?: number): string; /** Mint a fresh app id: `app_` + 26-char ULID. App ids are minted ONLY by the * deploy worker's authenticated `POST /api/apps/mint`, which registers the id * to its caller in the same step — an id with no owner never exists. */ declare function mintAppId(now?: number): string; declare class RegistryClientError extends Error { readonly code: string; readonly status: number; constructor(message: string, code: string, status: number); } declare function registryStub(ns: DurableObjectNamespace): DurableObjectStub; /** * One registry action over the wire. Non-2xx answers throw * {@link RegistryClientError} carrying the DO's structured `code` (or * `'error'` for a transport-shaped body) — callers distinguish a registry- * REPORTED miss (`code === 'not_found'`) from an outage by that code, never * by treating every throw as "not found". */ declare function callRegistry(stub: DurableObjectStub, body: { action: string; } & Record): Promise; /** Standard mapping of a registry failure onto a route response. */ declare function registryErrorJson(c: { json: (body: unknown, status?: number) => Response; }, err: unknown): Response; /** * Tools API Types and Definitions * * MCP-like interface for agent tool calls. * Built-in tools for records, schemas, users, and Yjs documents. */ interface ToolSchema { name: string; description: string; params: Record; } /** * Discriminated union so callers don't have to guard on `error` being * defined when `success` is false — TS enforces the invariant. */ type ToolResult = { success: true; data?: unknown; } | { success: false; error: string; }; /** * Page size the assistant defaults to for `records.query` when it omits * `limit`. Keeps a model-issued unbounded scan from blowing the tool-result * byte cap and gives the model a usable first page. Raise `limit` to page * through more (still subject to the cap; see * `DEFAULT_CONTEXT_CONFIG.toolResultCap` / `capToolResultSize`, which truncates * oversized pages gracefully rather than dropping them). * * Applied by the AI tool layer via `applyAiToolDefaults`, never by the shared * tools-api dispatch, so internal record readers (chat history, cron, app * `actions.query`) stay unbounded. */ declare const DEFAULT_QUERY_LIMIT = 50; /** * Page size `records.deleteWhere` uses when the caller omits `limit`, and the * ceiling it clamps any larger `limit` to. * * The tool exists so a cascade costs one subrequest per *page* instead of one * per row (see `deleteChatCascade`); the ceiling keeps a single call's SQL and * delete broadcasts bounded, and callers page by repeating the call until it * reports fewer deletions than the page size. */ declare const DEFAULT_DELETE_WHERE_LIMIT = 100; declare const MAX_DELETE_WHERE_LIMIT = 500; /** * Fill in assistant-only parameter defaults for a built-in tool call. * * Applied by the AI tool layer (`buildTools`) before a model-issued tool call * is dispatched, so the default only affects calls the assistant makes. * `records.query` doubles as the SDK's general record-read primitive (chat * history, cron, app `actions.query`); those callers reach the tools dispatch * directly and must return every row, so this default must not live in the * dispatch itself. Pure and non-mutating. */ declare function applyAiToolDefaults(toolName: string, params: Record): Record; /** * Built-in tool definitions for record, schema, user, and Yjs operations. */ declare const BUILT_IN_TOOLS: ToolSchema[]; /** * App-files limits, chunk planning, and failure reading — shared by every * caller of the files API: the browser hook (`client/storage/useR2Files.ts`), * the CLI (`cli/lib/app-files-api.ts`), the server handler * (`server/utils/scoped-r2-files.ts`), and the docs that quote them. * * There are three numbers here and they answer three different questions. * Conflating them is what produced the old 25 MiB per-file ceiling: that was * never a product decision, it was the size of one HTTP request leaking out as * a rule about files. A request is bounded by physics — Cloudflare rejects an * oversized body before any worker runs, and the worker's isolate is ~128 MiB. * A FILE is not, once it travels as a sequence of bounded requests. * * The other half of this module is failure reading. An upload refused at the * edge answers with a Cloudflare HTML page, which every caller used to feed * straight into `JSON.parse` and report as "Unexpected token '<' … is not * valid JSON". `describeFilesFailure` exists to make that impossible. */ /** * The largest single file the app-files API accepts. * * Reachable only through the multipart transport: a file above * {@link UPLOAD_PART_BYTES} is uploaded as a sequence of parts, so no request * and no isolate ever holds more than one part. Enforced by the shared handler * (`server/utils/scoped-r2-files.ts`) twice — once at init against the * declared total, so an impossible upload fails before a byte moves, and again * at complete against the assembled object, because the declared total is * client-supplied. Callers check it locally too, but only to fail early; * skipping the client check cannot widen what is accepted. */ declare const MAX_APP_FILE_BYTES: number; /** * The most any ONE request body may carry — a whole small file on the * single-request path, or one part on the multipart path. * * This is the transport's real physical bound, and the only thing 25 MiB ever * meant. Above it the worker risks exhausting its isolate holding the body; * well above it (~100 MB) Cloudflare refuses the request before the worker * runs at all, which is why callers must read failures as text first. */ declare const MAX_UPLOAD_REQUEST_BYTES: number; /** * The part size clients chunk at, and the threshold above which they must. * * Below the {@link MAX_UPLOAD_REQUEST_BYTES} bound with headroom for the * request envelope, and comfortably inside R2's own part rules: parts must be * at least 5 MiB and every non-final part must be exactly the same size * (R2 errors 10011 / 10048). The server advertises this at init — a client * chunks at the size it is told, not at this constant, so the number can move * without stranding older callers mid-upload. */ declare const UPLOAD_PART_BYTES: number; /** * The highest part number the server accepts. * * The server derives the only valid part number and byte length from the * declared total and {@link UPLOAD_PART_BYTES}. The maximum part count is * therefore the exact number needed to reach the file ceiling. * * R2 allows 10,000 parts, so this is far inside what the API permits. */ declare const MAX_UPLOAD_PARTS: number; /** * The customer-visible storage schedule, in bytes, PER ACCOUNT — the total an * owner's uploaded files may occupy across every app they own, not a budget * each app gets separately. This is the number the billing page advertises. * * Admitted by the shared files handler (`server/utils/scoped-r2-files.ts`), * which sums the owner's apps rather than the one being written to. * * It deliberately does NOT govern the repo store — see * `REPO_STORE_LIMIT_BYTES` below, and docs/platform/r2-storage.md. */ declare const ACCOUNT_STORAGE_LIMIT_BYTES: { readonly test: 0; readonly free: number; readonly starter: number; readonly premium: number; readonly admin: number; }; /** The account storage limit for a billing tier; unknown or missing tiers get free. */ declare function storageLimitForTier(tier: string | null | undefined): number; /** The one storage-quota refusal sentence. `usedBytes` is net of any object * the upload replaces, so an upsert is charged only for its growth. */ declare function storageQuotaMessage(incomingBytes: number, usedBytes: number, limitBytes: number): string; /** * The largest single file a deploy may ship as a static asset. * * Cloudflare Static Assets refuses anything larger, so this is a hard edge of * the platform rather than a policy dial — which is why the CLI may check it * locally. It lives here so the deploy worker and the CLI cannot drift; the * per-DEPLOY total is deliberately NOT here, because it is env-configurable * per environment and only the server can know it. */ declare const MAX_DEPLOY_ASSET_FILE_BYTES: number; declare function formatBytes(bytes: number): string; /** * Everything the files handler needs to enforce one account allocation. * * `quotaKey` is required because an exact scan followed by an uncoordinated * write cannot enforce a limit under concurrency. Callers that do not want a * quota omit `storage`; callers that do want one must name its serialized * summary. */ interface StorageAdmission { /** The app prefix receiving this request's write. */ prefix: string; /** Internal R2 key for the account's ETag-serialized usage summary. */ quotaKey: string; /** Resolve the account's limit. `null` fails writes closed. */ limitBytes: () => Promise; /** Resolve the other app prefixes in the account. `null` fails closed. */ siblingPrefixes?: () => Promise; } /** * Shared Scoped R2 Files Handler * * Provides a secure, prefix-scoped R2 files API that enforces: * 1. All R2 keys are validated against the resolved prefix (no bypass) * 2. Path traversal (`..`, `.`) is rejected * 3. Mutations (upload/delete) require authentication by default * * Each worker provides a `resolvePrefix` callback for its scoping rules. * The security invariants are enforced here once — not per-worker. * * Routes: * POST /api/files/upload → upload one file in one request * POST /api/files/multipart → begin a chunked upload * PUT /api/files/multipart/part → send one part * POST /api/files/multipart/complete → assemble the parts * DELETE /api/files/multipart → abandon a chunked upload * GET /api/files → list (prefix + optional user prefix) * GET /api/files/:key → download (validated against prefix; Range-aware) * HEAD /api/files/:key → the download's headers, no body * DELETE /api/files/:key → delete (validated against prefix) * * ## One transport discipline * * Both upload paths obey the rule the deploy asset transport established * (`docs/platform/deploy-asset-transport.md`): nothing here * ever holds more than one bounded request in memory. The single-request path * exists so a small file still costs one round trip; above * {@link UPLOAD_PART_BYTES} a file travels as parts, each streamed into R2 * through a `FixedLengthStream` with a required `Content-Length`. That is why * a 1 GiB file is admissible in a ~128 MiB isolate. * * Validation is identical on both paths and on both mounts, because it lives * here: the prefix scoping, the traversal check, the MIME refusals, and both * size bounds. A hand-rolled request that skips every client guard reaches * exactly these checks. */ interface ScopeContext { userId: string | null; url: URL; } type PrefixResult = { prefix: string; excludedPrefixes?: readonly string[]; error?: undefined; } | { prefix?: undefined; error: string; }; interface ScopedR2Config { /** * Resolve the R2 key prefix for the given scope. * Called with the `?scope=` query param value (default: 'self'). */ resolvePrefix: (scope: string, ctx: ScopeContext) => PrefixResult; /** * Require a non-null userId for upload and delete. * @default true */ requireAuthForMutations?: boolean; } /** * The storage-quota contract for one allocation, supplied per request by the * mount. The handler is the enforcer; the mount only knows whose limit * applies. */ interface ScopedR2Auth { userId: string | null; /** Absent = no quota on this mount (e.g. an app's own bucket). */ storage?: StorageAdmission; /** Account-wide quota details are private to an authorized owner surface. */ includeStorageUsage?: boolean; } type ScopedR2Handler = (request: Request, url: URL, bucket: R2Bucket, auth: ScopedR2Auth) => Promise; declare function isFilesVerbPath(method: string, subpath: string): boolean; /** * Create a scoped R2 files handler. * * Security guarantees: * - Download/delete keys are validated to start with the resolved prefix * - Path traversal (`..`) is rejected at the entry point * - Mutations require a non-null userId by default * * @returns A handler function: `(request, url, bucket, auth) => Promise` */ declare function createScopedR2Handler(config: ScopedR2Config): ScopedR2Handler; /** * Shared type definitions for the DeepSpace SDK * * Types used by both client-side SDK and server-side workers. */ type ColumnInterpretation = { kind: 'plain'; } | { kind: 'currency'; symbol: string; decimals: number; } | { kind: 'date'; format?: string; } | { kind: 'datetime'; format?: string; } | { kind: 'boolean'; trueLabel?: string; falseLabel?: string; } | { kind: 'percent'; decimals?: number; } | { kind: 'select'; options: string[]; } | { kind: 'multiselect'; options: string[]; } | { kind: 'url'; } | { kind: 'email'; } | { kind: 'json'; } | { kind: 'reference'; targetTable: string; displayColumn: string; }; interface ColumnDefinition { /** Stable ID override (survives renames). Falls back to `col_{name}`. */ id?: string; name: string; storage: 'number' | 'text'; interpretation: ColumnInterpretation | string; expression?: string; userBound?: boolean; immutable?: boolean; required?: boolean; default?: unknown; timestampTrigger?: { field: string; value?: unknown; }; } type PermissionLevel = boolean | 'own' | 'unclaimed-or-own' | 'collaborator' | 'team' | 'access' | 'published' | 'shared'; interface RolePermissions { read: PermissionLevel; /** Create has no existing record to evaluate, so it is an explicit grant. */ create: boolean; update: PermissionLevel; delete: PermissionLevel; /** If set, only these caller-supplied columns can be created or updated by this role. */ writableFields?: string[]; } interface CollectionSchema { name: string; /** Every collection is stored in typed SQL columns. */ columns: ColumnDefinition[]; /** Composite uniqueness constraint (for example `['userId', 'taskId']`). */ uniqueOn?: string[]; /** Ownership column; defaults to the row creator. */ ownerField?: string; /** Column containing a JSON array of collaborator user ids. */ collaboratorsField?: string; /** Column containing the team id used by team permission rules. */ teamField?: string; /** A string is public when equal to `public`; an object supplies an exact value. */ visibilityField?: string | { field: string; value: unknown; }; /** Permissions per role; `*` is the catch-all role. */ permissions: Record; /** Default role assigned on the `users` collection. */ defaultRole?: string; /** * `users` collection only: what the `user.list` roster shows a non-admin * whose `read` policy is row-scoped (`'own'`, `'team'`, …). Default * `'public-identity'`: every registered user's id/name/imageUrl/role/lastSeenAt * (`lastSeenAt` is what `usePresence` reads) — the * row policy keeps guarding full-row reads on the records/query path. * `'read-policy'`: the roster contains only the rows the caller's read * policy grants (still projected to public identity) — for apps that scope * users to a tenant/team and must not show names across it. */ roster?: 'public-identity' | 'read-policy'; } interface Query { collection: string; where?: Record; orderBy?: string; orderDir?: 'asc' | 'desc'; limit?: number; } interface Subscription { id: string; query: Query; } /** Key for Yjs doc: collection:recordId:fieldName */ type YjsDocKey = string; interface YjsSubscription { collection: string; recordId: string; fieldName: string; } interface RecordResult { recordId: string; data: Record; createdBy: string; createdAt: string; updatedAt: string; } interface SubscribePayload { subscriptionId: string; query: Query; } interface UnsubscribePayload { subscriptionId: string; } interface PutPayload { collection: string; recordId: string; data: Record; requestId?: string; } interface DeletePayload { collection: string; recordId: string; requestId?: string; } interface SetRolePayload { userId: string; role: string; } interface YjsJoinPayload { collection: string; recordId: string; fieldName: string; } interface YjsLeavePayload { collection: string; recordId: string; fieldName: string; } /** * Platform API error normalization — the one chokepoint that turns the * api-worker's error envelope into a render-safe + branch-safe shape. * * The platform emits errors as `{ error: , message?: , * ...details }` (e.g. 402 `{ error: 'insufficient_credits', message: * 'Insufficient credits.', availableCredits, requiredCredits }`; some routes * send the bare slug with no `message` at all, e.g. 409 * `{ error: 'owner_connect_not_ready' }`). Consumers must never show the slug * to a person or make an agent parse the sentence — this module separates the * two once, for the integration client, server actions, billing hooks, and the * CLI. */ /** One structured validation issue (Zod shape) from a `validation_failed` response. */ interface ApiErrorIssue { path?: string[]; message: string; code?: string; } /** * Server Action Types * * Types for app-defined server actions that run in the site worker. * Actions bypass user RBAC via the X-App-Action header — the app's * server-side code IS the trust boundary. */ /** * Discriminated result wrapper. Narrowing on `.success` lets TS know * `.data` is present in the success branch and `.error` in the failure * branch, so callers can't read the wrong field by accident. * * `TData` is the per-operation data shape — `tools.query` returns * `{ records, count }`, `tools.get` returns `{ record }`, etc. Apps * that compose their own server actions can specialize further. */ type ActionResult = { success: true; data: TData; error?: never; } | { success: false; data?: never; /** Human-readable error, safe to render directly. */ error: string; /** Machine slug for branching, when the failing tool supplied one. */ code?: string; /** HTTP status from an upstream platform request, when applicable. */ status?: number; /** Structured fields supplied with the error response. */ details?: Record; /** Validation issues supplied with a `validation_failed` response. */ issues?: ApiErrorIssue[]; }; /** Shape of the data field for `tools.query`. */ interface QueryActionData> { records: Array; count: number; } /** Shape of the data field for `tools.get`. */ interface GetActionData> { record: RecordResult & { data: T; }; } /** Shape of the data field for `tools.create`/`update`/`remove`. */ interface MutateActionData { recordId: string; } interface ActionTools { /** * Insert a new record. When `recordId` is omitted the DO generates one * (typical). Pass `recordId` to upsert against a known key — useful * for `users` where the row id must equal the auth user's id so * `tools.get('users', userId)` resolves. */ create = Record>(collection: string, data: T, recordId?: string): Promise>; update = Record>(collection: string, recordId: string, data: Partial): Promise>; remove(collection: string, recordId: string): Promise>; /** * Delete every record matching `where`, in one bounded batch — the cascade * primitive, so draining a set costs one subrequest per page instead of one * per row. Deletes at most `limit` (default 100, max 500) and returns * `{ deleted }`; repeat the same call until `deleted` is below the limit to * drain a larger set. * * `where` must be non-empty and every key must name a real field * (`recordId`, `createdBy`, or a schema column) — an unknown key is refused, * not ignored, so this can never truncate a whole collection. */ deleteWhere(collection: string, where: Record, limit?: number): Promise>; get = Record>(collection: string, recordId: string): Promise>>; query = Record>(collection: string, options?: { where?: Record; orderBy?: string; orderDir?: 'asc' | 'desc'; limit?: number; }): Promise>>; /** * Call an integration endpoint (e.g. 'openai/chat-completion') via the * api-worker. On success, `result.data` is the integration's response * body directly — there is no `.response` wrapper. So an OpenAI call * yields `result.data.choices`, a Freepik image call yields * `result.data.images`, etc. On failure, `result.error` is human-readable; * branch on `result.code` and inspect `status`, `details`, or `issues` when * present. */ integration(endpoint: string, data?: unknown): Promise>; /** * Insert or refresh the `users` row for an authenticated caller. * Mirrors the WS-connect registerUser flow — useful for CLI-only * actions (e.g. publishing via `deepspace foo publish`) where the * caller may never have opened the web app and has no `users` row * yet. Bypasses SYSTEM_MANAGED column stripping so name/email/ * imageUrl are actually written. * * Defaults `userId` to the action's caller. Pass `isAdmin: true` only * if the caller's platform-tier role is admin (worker.ts should * derive this from the verified JWT — never trust client input). */ registerUser(opts: { userId?: string; name?: string; email?: string; imageUrl?: string; isAdmin?: boolean; }): Promise>; } /** * `TEnv` lets apps type the worker-scoped env object passed to the * action handler. Defaults to a loose `Record` so * unparameterized handlers still compile; apps that want strict typing * can do `ActionHandler` where Env is their own worker's bindings * interface. */ interface ActionContext> { userId: string; params: Record; tools: ActionTools; /** * The worker's env bindings. Used by actions that need access to * secrets, bindings, or platform-injected values like `OWNER_USER_ID` * (e.g. for owner-only action gating). */ env: TEnv; /** * The caller's raw JWT. Forward this on outbound requests that need to * impersonate the user (e.g. checking `/api/apps` ownership on the * deploy worker, where the user — not the app owner — should be billed * / authorized). */ callerJwt: string; } type ActionHandler> = (ctx: ActionContext) => Promise; /** * Upstream worker proxy helpers. * * App workers reach the platform's other workers (api, platform, auth) through * one of two transports: * * 1. **Service binding** (`env.API_WORKER` / `env.PLATFORM_WORKER`) — the * preferred path in production. Configured via wrangler `[[services]]`. * Cross-worker calls over plain `*.workers.dev` URLs return Cloudflare * error 1042 in production, so the binding is the only working path * in deployed apps. * * 2. **HTTPS URL** (`env.API_WORKER_URL` / `env.PLATFORM_WORKER_URL`) — the * fallback used in local development. `deepspace dev start` writes these * into `.dev.vars`, which `wrangler dev` exposes as env vars. Service * bindings don't work cross-process under `wrangler dev` for SDK apps, * so the URL is the only working path in dev. * * The auth-worker has no service binding even in production — its responses * carry `Set-Cookie` headers we want preserved verbatim, which we get for * free over plain HTTPS. So `authWorkerFetch` is URL-only; the helper exists * for surface consistency, not to switch transports. * * Each helper: * - Prefers the binding if present, falls back to the URL otherwise. * - Throws an actionable Error if neither is configured. No silent 502s. * - Forwards `init` (method/headers/body) verbatim to the upstream worker. * * History: a previous in-tree helper folded the binding/URL fallback into * the AI module's `resolveTransport`. Inline call sites in the starter * template (integrations, files, debug) used `c.env.X.fetch(...)` directly, * which broke `npx deepspace dev start` for any app calling those routes — the * binding is undefined locally, so the fetch threw. These helpers * standardize on the same shape `resolveTransport` had, so every upstream * call works in both dev and prod. */ /** * Env shape required by `apiWorkerFetch`. App workers should extend this * (the starter template does) so the helper can be called with `c.env`. */ interface ApiWorkerEnv { /** Cloudflare service binding for the api-worker. Preferred. */ API_WORKER?: Fetcher; /** HTTPS URL for the api-worker. Used when the binding is absent (dev). */ API_WORKER_URL?: string; } /** Env shape required by `platformWorkerFetch`. */ interface PlatformWorkerEnv { /** Cloudflare service binding for the platform-worker. Preferred. */ PLATFORM_WORKER?: Fetcher; /** HTTPS URL for the platform-worker. Used when the binding is absent. */ PLATFORM_WORKER_URL?: string; } /** Env shape required by `authWorkerFetch`. URL-only. */ interface AuthWorkerEnv { /** HTTPS URL for the auth-worker. Always required. */ AUTH_WORKER_URL?: string; } /** * Fetch the api-worker. Prefers the `API_WORKER` service binding, falls * back to `API_WORKER_URL` over HTTPS. * * `path` is treated as path-only — any host in a passed-in URL is * stripped and replaced. This matches how `c.env.API_WORKER.fetch(...)` * already worked in the starter template (the host was always a * placeholder like `api-worker`). */ declare function apiWorkerFetch(env: ApiWorkerEnv, path: string, init?: RequestInit): Promise; /** * Fetch the platform-worker. Prefers the `PLATFORM_WORKER` service binding, * falls back to `PLATFORM_WORKER_URL` over HTTPS. * * Accepts a `Request` instance directly so callers can hand off * `c.req.raw`-derived requests with their original method/headers/body * intact. (`/api/files/*` does this — it forwards the caller's body * stream verbatim.) */ declare function platformWorkerFetch(env: PlatformWorkerEnv, pathOrRequest: string | Request, init?: RequestInit): Promise; /** * Fetch the auth-worker over HTTPS. URL-only — there is no auth-worker * service binding, by design (we want plain-HTTP cookie semantics). * * Kept as a helper for surface symmetry with `apiWorkerFetch` / * `platformWorkerFetch`. Throws if `AUTH_WORKER_URL` is unset. */ declare function authWorkerFetch(env: AuthWorkerEnv, path: string, init?: RequestInit): Promise; /** * Cron System — Server-Side Scheduled Tasks * * Provides CronContext for miniapp cron handlers and buildCronContext * to construct it from worker environment bindings. * * CronContext gives handlers access to: * - records: Query/create/update/delete via RecordRoom tools API * - integrations: Call platform integration endpoints, billed to the app owner * - ownerUserId: The app owner's user ID */ /** Context passed to cron handler functions */ interface CronContext { /** RecordRoom data access (queries the DO directly via tools API) */ records: { query(collection: string, opts?: { where?: Record; limit?: number; }): Promise; create(collection: string, data: Record): Promise; update(collection: string, recordId: string, data: Record): Promise; delete(collection: string, recordId: string): Promise; }; /** * Call a platform integration endpoint (e.g. "openai/chat-completion", * "fal/run-model"). Billed to the app owner using the same * APP_OWNER_JWT the rest of the SDK uses for server-side billed calls. */ integrations: { call(endpoint: string, params?: Record): Promise; }; /** App owner's user ID */ ownerUserId: string; } /** Environment bindings needed by buildCronContext */ interface CronEnv extends ApiWorkerEnv { RECORD_ROOMS: DurableObjectNamespace; /** * Long-lived app-owner JWT minted at deploy time. Required for * `integrations.call()` so the call is billed to the app owner. * Optional here so apps that don't use integrations from cron can omit * it without a type error; missing-at-call-time throws a clear message. */ APP_OWNER_JWT?: string; } /** * Build a CronContext from worker environment bindings. * * @param env - Worker environment with RECORD_ROOMS DO namespace, APP_OWNER_JWT, and an api-worker transport (API_WORKER binding or API_WORKER_URL) * @param ownerUserId - App owner's user ID (for RBAC and billing) * @param roomId - RecordRoom ID (defaults to 'default') */ declare function buildCronContext(env: CronEnv, ownerUserId: string, roomId?: string): CronContext; /** * Standard DeepSpace role constants. * * Every DeepSpace app uses the same three roles. * Apps can import these instead of defining them locally. */ declare const ROLES: { readonly VIEWER: "viewer"; readonly MEMBER: "member"; readonly ADMIN: "admin"; }; type Role = (typeof ROLES)[keyof typeof ROLES]; interface AppRoleEnv { RECORD_ROOMS: DurableObjectNamespace; DEEPSPACE_APP_ID: string; OWNER_USER_ID: string; } interface AppMembership { /** True when the caller's row exists in the app's canonical users collection. */ member: boolean; role: Role; } /** * Resolve a user's membership and role from the app's canonical users * collection in one read. This is the single definition of "is this user in * the app" — gate new surfaces with it rather than re-deriving membership * from another tool call. Returns null when the read itself failed (room * unreachable, aborted): callers must treat that as "could not verify", never * as "not a member". */ declare function resolveAppMembership(env: AppRoleEnv, userId: string, signal?: AbortSignal): Promise; /** Resolve a user's current role from the app's canonical users collection. */ declare function resolveAppRole(env: AppRoleEnv, userId: string): Promise; /** * Canonical DeepSpace model catalog and agent policy. * * Provider catalogs are discovery inputs, not a safe runtime allowlist: a * model is promoted here only after its tool loop, transport, pricing, and * streaming contract have been verified end to end through the DeepSpace * proxy. Every client picker and server agent profile consumes this module. */ type DeepSpaceAIProvider = 'anthropic' | 'openai' | 'cerebras'; type DeepSpaceAgentProfileId = 'application' | 'documentation'; type DeepSpaceAgentSupport = 'multi-step' | 'single-step' | 'none'; interface DeepSpaceAIModel { id: string; label: string; provider: DeepSpaceAIProvider; providerLabel: string; family: string; /** Whether the model is eligible for DeepSpace's server-owned agent loop. */ agentSupport: DeepSpaceAgentSupport; /** Profiles in which the model has passed the complete runtime contract. */ agentProfiles: readonly DeepSpaceAgentProfileId[]; /** Provider transport currently used by the DeepSpace proxy adapter. */ transport: 'messages' | 'chat-completions'; recommendation: 'frontier' | 'balanced' | 'fast' | 'available' | 'limited'; note?: string; } interface DeepSpaceAgentProfile { id: DeepSpaceAgentProfileId; defaultModel: string; maxSteps: number; maxToolCalls?: number; allowedTools: 'application-defined' | readonly string[]; } interface ResolvedDeepSpaceAgentModel { modelId: string; provider: DeepSpaceAIProvider; model: DeepSpaceAIModel; profile: DeepSpaceAgentProfile; } /** * AI provider helpers — create Vercel AI SDK providers that route through * the DeepSpace API worker proxy for per-user billing. * * Supported providers: anthropic, openai, cerebras. * * The API worker can be reached in two ways: * - Service binding `env.API_WORKER` (Cloudflare Fetcher) — preferred in * production if the app has declared the binding in wrangler.toml. * - HTTPS URL `env.API_WORKER_URL` — used in local dev and in production * for apps that don't declare the binding. `deepspace dev start` writes this * into `.dev.vars` automatically. * * Auth is automatic by default: * - For server-side autonomous calls (cron, DO alarms, background agents), * the helper reads the long-lived `env.APP_OWNER_JWT` minted at deploy * time (or by `deepspace dev start` in local development) and uses it for the * proxy auth header. The owner is billed automatically via the JWT sub. * - For user-initiated calls (e.g. an `/api/ai/chat` route handling a * browser request), pass `options.authToken` explicitly with the user's * own JWT so the call is billed to the user. * * Usage: * * // Server-side autonomous — no auth config needed * import { createDeepSpaceAI } from 'deepspace/worker' * const cerebras = createDeepSpaceAI(env, 'cerebras') * const result = await generateText({ model: cerebras('llama-3.3-70b'), ... }) * * // User-initiated (inside a request handler) * const jwt = c.req.header('Authorization')!.slice(7) * const anthropic = createDeepSpaceAI(c.env, 'anthropic', { authToken: jwt }) */ /** * Model factory: `(modelId) => LanguageModel`. The explicit return type * keeps tsup's DTS build from leaking unportable `.pnpm/@ai-sdk+provider/...` * paths into the published `dist/index.d.ts`. */ type DeepSpaceModelFactory = (modelId: string) => LanguageModel; interface DeepSpaceAIEnv extends ApiWorkerEnv { /** * Long-lived owner-scoped JWT minted at deploy time (or by `deepspace dev start`). * Used as the default proxy auth token when `options.authToken` is absent. * Bills the app owner. */ APP_OWNER_JWT?: string; } interface DeepSpaceAIOptions { /** * Explicit auth token for this call. Use this for user-initiated flows * where the caller's own JWT should be billed. If omitted, the helper * falls back to `env.APP_OWNER_JWT` (bills the app owner). * * Billing is always against the JWT subject — to bill a different user, * pass a JWT whose subject is that user. The proxy does not accept any * client-supplied billing override. */ authToken?: string; } /** * Build an AI SDK provider that routes through the DeepSpace API worker. * * Resolves the transport (service binding or URL) and the auth token * (explicit or `env.APP_OWNER_JWT`) automatically. Throws a clear error if * either is unconfigured. */ declare function createDeepSpaceAI(env: DeepSpaceAIEnv, provider: DeepSpaceAIProvider, options?: DeepSpaceAIOptions): DeepSpaceModelFactory; type StreamTextOptions = Parameters>[0]; type DeepSpaceAgentStreamOptions = Omit, 'model' | 'providerOptions' | 'stopWhen'> & { profile: DeepSpaceAgentProfileId; modelId?: unknown; authToken?: string; }; interface DeepSpaceAgentStream { selection: ResolvedDeepSpaceAgentModel; result: StreamTextResult; } declare class DeepSpaceAgentModelError extends Error { readonly modelId: unknown; readonly profile: DeepSpaceAgentProfileId; readonly code = "unsupported_agent_model"; constructor(modelId: unknown, profile: DeepSpaceAgentProfileId); } declare class DeepSpaceAgentProfileError extends Error { readonly profile: DeepSpaceAgentProfileId; readonly toolName: string; readonly code = "agent_profile_violation"; constructor(profile: DeepSpaceAgentProfileId, toolName: string); } /** The sole provider/model/tool-loop implementation for every DeepSpace agent surface. */ declare function streamDeepSpaceAgent(env: DeepSpaceAIEnv, options: DeepSpaceAgentStreamOptions): DeepSpaceAgentStream; interface DeepSpaceAgentDiagnosticContext { profile?: DeepSpaceAgentProfileId; provider?: string; modelId?: string; } /** Preserve safe structured provider diagnostics in bounded Worker logs. */ declare function deepSpaceAgentErrorSummary(error: unknown, context?: DeepSpaceAgentDiagnosticContext): string; /** * Composio tools for the Vercel AI SDK. * * Turns the platform's Composio integration into ready-to-use Vercel AI SDK * tools, so an agent (or the DeepSpace AI chat) can take actions on a user's * connected apps. Each returned tool's `execute()` calls our own * `composio/execute-tool` endpoint through the api-worker proxy, so our auth * and per-user billing apply. This is the managed-proxy equivalent of * Composio's `@composio/vercel` provider, which talks straight to Composio. * * `authToken` must be the END USER's JWT: Composio resolves the user's * connected accounts by the JWT subject, and that user is billed. * * Usage (inside a request handler, e.g. /api/ai/chat): * * import { composioTools, createDeepSpaceAI } from 'deepspace/worker' * const jwt = c.req.header('Authorization')!.slice(7) * const tools = await composioTools(c.env, { toolkit: 'gmail', authToken: jwt }) * const ai = createDeepSpaceAI(c.env, 'anthropic', { authToken: jwt }) * const result = streamText({ model: ai('claude-sonnet-5'), prompt, tools }) * * Merge with your own tools: `tools: { ...buildTools(executor), ...composio }`. */ interface ComposioToolsOptions { /** End user's JWT. Required: the tools run as this user and bill them. */ authToken: string; /** Restrict to one toolkit (e.g. 'gmail', 'github'). */ toolkit?: string; /** Explicit tool slugs to expose (e.g. ['GMAIL_SEND_EMAIL']). */ tools?: string[]; /** Natural-language search to pick relevant tools. */ search?: string; /** Cap how many tools are exposed (default 20). Keep small: the LLM sees them all. */ limit?: number; } /** * Fetch Composio tools for the given scope and return them as a Vercel AI SDK * `ToolSet`. One `list-tools` call provides each tool's JSON Schema; each tool * executes via `composio/execute-tool` as the authenticated user. */ declare function composioTools(env: ApiWorkerEnv, options: ComposioToolsOptions): Promise; /** * captureScreenshot — call platform-worker /internal/screenshot. * * Apps don't ship CF Browser Rendering bindings or puppeteer in their * own bundle. The platform holds the binding; consumers call this * helper to get PNG bytes for a URL. * * The platform enforces HTTPS, a DeepSpace host allowlist, a per-app rate * limit, and one fixed viewport-only capture profile. Returns `null` on any * non-2xx — callers should treat that as "no preview available". * * Auth is the same HMAC-of-appId pattern `/internal/files` uses: * x-app-identity-token = hmac(PLATFORM_IDENTITY_SECRET, DEEPSPACE_APP_ID) * x-app-id = DEEPSPACE_APP_ID * * Apps already have both as bindings (APP_IDENTITY_TOKEN + DEEPSPACE_APP_ID), * so this helper is a thin wrapper — no extra secrets to manage. */ interface ScreenshotEnv extends PlatformWorkerEnv { /** Immutable app id — the identity the platform verifies (HMAC input). */ DEEPSPACE_APP_ID: string; /** Absent until the app's first deploy injects it — see appendAppIdentity. */ APP_IDENTITY_TOKEN?: string; } interface ScreenshotResult { /** PNG bytes. */ body: ArrayBuffer; /** `image/png`. */ contentType: string; } /** * Capture a screenshot of `url` and return the PNG bytes. * * Returns null on capture failure (target unreachable, timeout, BR * binding misconfigured platform-side). The platform logs a generic failure * without target details; callers should surface their own fallback UX. */ declare function captureScreenshot(env: ScreenshotEnv, url: string): Promise; /** * Chat context pipeline — keeps the per-request payload to the LLM bounded. * * `prepareMessagesWithCompaction` runs before `streamText`: truncate old tool * results, apply a cached summary if available, otherwise summarize the older * half of history when over budget. Falls back to a sliding window if * summarization fails. `capToolResultSize` caps individual tool calls. */ interface ChatTurn { id?: string; role: 'user' | 'assistant' | 'system'; content: string; parts?: unknown[]; } type Summarizer = (messages: ChatTurn[]) => Promise; interface ChatContextConfig { contextBudget: number; toolResultCap: number; keepRecentToolResults: number; minKept: number; } declare const DEFAULT_CONTEXT_CONFIG: ChatContextConfig; declare function totalChars(messages: ChatTurn[]): number; /** * Replace older tool-result payloads with a small marker. Keeps the last * `keepRecent` tool results intact. Errors (`success: false`) are preserved — * they're small and the agent needs them for reasoning. */ declare function truncateOldToolResults(messages: ChatTurn[], keepRecent: number): ChatTurn[]; /** * Drop oldest messages until total character count is under `charCap`, * never going below `minKept` messages. System messages (e.g. compaction * summaries) are pinned — dropping them would discard the most condensed * context first. */ declare function applySlidingWindow(messages: ChatTurn[], charCap: number, minKept: number): ChatTurn[]; /** * Keep an individual tool result under `byteCap`. * * If the payload carries a list of items (e.g. a `records.query` result), it is * degraded gracefully: as many leading items as fit under the cap are returned, * the `success: true` shape is preserved, and `{ truncated, returned, total }` * flags are merged in next to the array so callers can still use the partial * data and paginate for the rest. * * Only when there is no array to trim (or even an empty list still overflows * because of oversized sibling fields) does it fall back to replacing the * result with an error + small preview telling the agent to narrow its query. */ declare function capToolResultSize(result: unknown, byteCap: number): unknown; /** * Convert persisted ChatTurns into AI SDK ModelMessages. * * Persisted assistant rows store `parts` in UI shape (text + tool-invocation, * each invocation carrying its own `result`). When fed back to the LLM, the * shape MUST match the original multi-step flow: an assistant message * containing a `tool_use` block must end with that block, the IMMEDIATELY * NEXT message must be a tool/user message containing the matching * `tool_result`, and any text the model produced AFTER seeing the tool * result belongs in a SEPARATE assistant message after the tool message. * * Anthropic specifically rejects an assistant message of the form * `[text, tool_use, text]` — the trailing text breaks its `tool_use` → * `tool_result` pairing check. So we walk the parts in order and split at * each tool-invocation boundary, emitting a fresh assistant + tool pair per * tool call, and a final trailing assistant message for any post-tool text. * * Tool-invocation entries with `state: 'call'` (no result — typically an * interrupted stream) are dropped on both sides. */ declare function turnsToCoreMessages(turns: ChatTurn[]): ModelMessage[]; /** * Convert AI SDK response messages into our persisted UI shape (text + * tool-invocation parts), pairing each assistant tool-call with its tool- * result from the following tool message. Order is chronological. * * Inverse of `turnsToCoreMessages`: takes the v5 `ModelMessage[]` returned * from `streamText`'s `onFinish` and produces the flat `parts` array we * persist on `ai-messages` rows. Reads `c.input` / `c.output` (v5 wire * names) and unwraps `output`'s tagged-union via `unwrapToolOutput`. */ declare function buildUiParts(responseMessages: ModelMessage[]): unknown[]; /** * Unwrap v5's tagged tool-result `output` to the flat shape we persist. * Errors get remapped to `{ success: false, error }` because * `truncateOldToolResults` preserves entries with that shape across turns — * without the remap, error context would get truncated like a normal result. */ declare function unwrapToolOutput(output: unknown): unknown; /** * Pre-stream pipeline with compaction. * * 1. Truncate old tool results. * 2. If a cached summary covers a known message id, replace prior turns with it. * 3. If still over budget, summarize the older half of `working` and return a * `newSummary` for persistence — runs even after cached-summary application * so a long-running chat can re-summarize on subsequent turns. * 4. On summarizer error or missing ids, fall back to a sliding window * (which preserves system messages — see `applySlidingWindow`). */ declare function prepareMessagesWithCompaction(messages: ChatTurn[], config: ChatContextConfig, options: { summarizer: Summarizer; cachedSummary?: { text: string; throughId: string; }; }): Promise<{ messages: ChatTurn[]; newSummary?: { text: string; throughId: string; }; }>; /** * Build a default summarizer backed by Claude Haiku. * * Billing: defaults to the app owner via `APP_OWNER_JWT` — summarization is * usually infrastructure, not user work. Pass `{ authToken }` to bill a * specific user (e.g. the caller's JWT) instead. */ declare function makeDefaultSummarizer(env: DeepSpaceAIEnv, options?: { authToken?: string; }): Summarizer; /** * Chat history helpers — wrap RecordRoom's tools API for ai-chats / ai-messages. * * Trust model: every helper here runs through `executeToolAsApp`, which sends * `X-App-Action: 'true'` and therefore **bypasses RecordRoom's per-record * RBAC entirely**. The `userId` argument these helpers take is the identity * they act *as* — it is NOT an authorization boundary, and RecordRoom will * not check it. The worker is the only trust boundary. * * `getChat` is that boundary for this module: it compares the stored * `userId` against the caller and returns null on a mismatch, so the * worker's `/api/ai/chat`, `PATCH /api/ai/chats/:id`, and * `DELETE /api/ai/chats/:id` routes 404 when a row is missing *or* owned by * someone else. Every write helper (`updateChat`, `appendMessage`, * `deleteChatCascade`) is reachable only behind that precheck; a new caller * that skips it is writing across users, so route new consumers through * `getChat` first. * * `updateChat` and `appendMessage` re-run that precheck themselves and report * `false` instead of writing when the chat is gone: `records.update` / * `records.create` are upserts, so a write racing the user's delete would * resurrect the chat as a ghost row or orphan messages under a chat that no * longer exists. The guard lives in the helpers, not the routes, so every copy * of the scaffolded chat routes gets it. * * The tools API returns records as `{ recordId, data, createdAt, updatedAt }` * envelopes; helpers below flatten them into ChatRow / ChatMessageRow. */ type ChatRow = { recordId: string; userId: string; title: string; model?: string; compactedSummary?: string; compactedThroughId?: string; createdAt: string; updatedAt: string; }; type ChatMessageRow = { recordId: string; chatId: string; userId: string; role: 'user' | 'assistant' | 'system'; content: string; parts?: unknown[]; createdAt: string; }; /** * Fetch a chat the caller owns, or null. * * This is the module's authorization boundary. `records.get` runs with * `X-App-Action`, so RecordRoom applies no per-record RBAC and will happily * return another user's row — the ownership comparison below is the only * thing standing between a caller and someone else's chat. A miss and a * cross-user hit deliberately look identical to the caller (both null, both * 404 at the route) so chat ids stay unenumerable. */ declare function getChat(stub: DurableObjectStub, chatId: string, userId: string): Promise; declare function createChat(stub: DurableObjectStub, userId: string, opts?: { title?: string; model?: string; }): Promise; /** * Patch a chat the caller owns. Returns true if the row was written, false if * the chat no longer exists (or never belonged to the caller). * * The `getChat` precheck is not redundant with the route's own: `records.update` * is an upsert on the DO — same code path as create — so an unguarded patch of * a chat the user deleted mid-stream *recreates* it as a title-less ghost whose * messages are already cascaded away. Writing only over a row that still exists * is the one place that can be prevented for every caller, so the guard lives * here rather than at each call site. A tiny TOCTOU window remains between the * read and the write (closing it needs a compare-and-set on the DO); a delete * landing inside that window is the same rare race as before, not the routine * "user deleted the chat while the stream ran" case this closes. */ declare function updateChat(stub: DurableObjectStub, chatId: string, userId: string, patch: Partial>): Promise; declare function deleteChatCascade(stub: DurableObjectStub, chatId: string, userId: string): Promise; declare function loadMessages(stub: DurableObjectStub, chatId: string, userId: string): Promise; /** * Append one message to a chat the caller owns. Returns true if the row was * written, false if the chat is gone (or never belonged to the caller). * * Same write-after-delete guard as `updateChat`, for the same reason: a turn * that finishes after the user deleted the chat would otherwise write messages * whose parent row no longer exists — invisible in every listing and never * cascaded again. */ declare function appendMessage(stub: DurableObjectStub, msg: { id: string; chatId: string; userId: string; role: 'user' | 'assistant' | 'system'; content: string; parts?: unknown[]; }): Promise; /** * Per-binding usage metering — record Vectorize / Workers AI / etc. costs * to the auto-attached `USAGE_EVENTS` Analytics Engine dataset. * * Why: the platform's tail-worker captures per-invocation compute (CPU + wall * time + script name) but it can't see which model an AI call hit, how many * tokens it embedded, or how many vectors a Vectorize query scanned. Without * those signals there's no way to surface per-tenant binding cost on the * billing dashboard. * * The deploy-worker auto-attaches a `USAGE_EVENTS` AE binding to every app * (dataset: `deepspace_binding_usage`). Apps don't need to declare it. They * just call `meterAi(...)` / `meterVectorize(...)` / `meterUsage(...)` after * each call and the dashboard rolls it up by `ownerUserId`. * * Schema written: * indexes: [ownerUserId] * blobs: [appName, kind, model_or_index, op] * doubles: [units, count] * * Use: * await meterAi(env, '@cf/qwen/qwen3-embedding-0.6b', { inputChars: 5000 }) * await meterVectorize(env, 'unison-candidates', 'query', { vectors: 1000 }) * await meterUsage(env, 'custom-thing', { units: 1 }) */ interface MeteringEnv { USAGE_EVENTS?: AnalyticsEngineDataset; OWNER_USER_ID?: string; DEEPSPACE_RESOURCE_ID?: string; APP_NAME?: string; } /** * Generic event recorder. Returns `false` if the binding isn't present * (dev / not yet deployed) or if AnalyticsEngine throws — metering must * never break the calling code path. */ declare function meterUsage(env: MeteringEnv, kind: string, fields?: { id?: string; op?: string; units?: number; count?: number; }): boolean; /** * Record a Workers AI call. * * Cloudflare prices input and output tokens at different rates for LLMs * (output is typically more expensive); embedding models bill input only. * Emits up to two events per call so the dashboard rollup can group by * `op` and apply the right per-token rate: * * op='input' units=inputChars * op='output' units=outputChars * * For a pure embedding call (outputChars=0), only the input event fires. * Pass `inputChars` and `outputChars` raw — the rough chars-to-token * conversion happens at price time using `COST_RATES.ai.embedInputPerChar`. * * Note: only embedding-input has an authoritative rate today. LLM-output * pricing varies wildly per model so `priceBindingUsageEvent` returns 0 * for `op='output'` until per-model rates are wired. The events are still * recorded so the dashboard can show that the calls happened. */ declare function meterAi(env: MeteringEnv, model: string, fields?: { inputChars?: number; outputChars?: number; calls?: number; }): boolean; /** * Record a Vectorize operation. * * Cloudflare's published model (https://developers.cloudflare.com/vectorize/platform/pricing/): * * "If you have 10,000 vectors with 384-dimensions in an index, and make * 100 queries against that index, your total queried vector dimensions * would sum to 3.878 million ((10000 + 100) * 384)." * * So query billing is *additive* — `(stored + queries) * dims` summed * across the call, not per-query-multiplied-by-stored. Translating to a * per-call meter: * * op='query': units = (vectors + storedCount) * dims * Without `storedCount` we significantly undercount: a single * query against a 100K-vector index produces ~100K queried * dims, not just `dims`. * op='upsert': CF doesn't bill upserts directly; the chargeable delta is * the change to stored-vector-month. `units = vectors * dims` * approximates the per-call storage delta. * op='delete' / 'getByIds': recorded for observability; no direct cost. * * Edge case: querying an empty index gives `(1 + 0) * dims = dims`, which * matches CF's formula (the `+ queries` term is always added, even at 0 * stored). If CF later changes that and an empty-index query bills 0, * adjust here — `metering` is the single place to update the math. */ declare function meterVectorize(env: MeteringEnv, indexName: string, op: 'query' | 'upsert' | 'delete' | 'getByIds', fields?: { vectors?: number; dims?: number; storedCount?: number; }): boolean; /** * Per-`units` USD multipliers, matched to the (`kind`, `op`) the meter * helpers above record. Dashboard rollup can multiply * * SUM(_sample_interval * doubles[1]) -- units * * by these to surface $-figures without re-querying CF's billing API. */ declare const COST_RATES: { readonly ai: { /** * USD per character of *embedding input* (bge-m3 / qwen3-embedding tier). * Renamed from `perChar` to make explicit that this rate does NOT * apply to LLM-generation output — see `priceBindingUsageEvent`. */ readonly embedInputPerChar: number; }; readonly vectorize: { /** USD per queried dimension (per query, per stored vector compared). */ readonly queriedPerDim: number; /** USD per stored dimension per month. */ readonly storedPerDimPerMonth: number; }; readonly aiSearch: { readonly ingestPerToken: number; readonly ingestImagePerToken: number; readonly storagePerByteMonth: number; readonly hybridOrSemanticPerQuery: number; readonly fulltextPerQuery: number; }; }; /** * Price a single rolled-up `deepspace_binding_usage` row. Lives next to * `COST_RATES` so the (kind, op) → rate mapping stays paired with the schema * the meter helpers write. * * Returns 0 for combinations without an authoritative per-unit rate; the row * still surfaces in dashboards for observability. Notable zeros: * - `ai/output`: LLM-output prices vary per model and `meterAi` doesn't * carry a model-family signal. Pricing it at the embedding rate would * silently under-bill chat-LLM use. * - `vectorize.storedPerDimPerMonth`: events are per-call deltas, not * monthly snapshots, so a windowed SUM isn't meaningful here. */ declare function priceBindingUsageEvent(kind: string, op: string, units: number): number; /** * App-identity headers for platform calls (api-worker, platform-worker). * * The pair is `x-app-identity-token` (HMAC(PLATFORM_IDENTITY_SECRET, appId), * minted at deploy time and injected as a binding) + `x-app-id`. Pre-first- * deploy the token binding is ABSENT — `deepspace dev start` can only fetch it once * the app is in the deploy registry. Attaching headers built from an undefined * binding sends the literal string "undefined", which upstream identity * verification rejects as a *tampered* token (401 invalid) instead of the * truthful "missing app identity" — so this helper fails closed by attaching * nothing when the token is unset. The starter's worker.ts proxies follow the * same policy (see reassertAppIdentity there). */ interface AppIdentityEnv { /** The app's immutable id (wrangler.toml [vars] DEEPSPACE_APP_ID). */ DEEPSPACE_APP_ID: string; /** Absent until the app's first deploy injects it. */ APP_IDENTITY_TOKEN?: string; } interface KnowledgeEnv extends ApiWorkerEnv, AppIdentityEnv { } type KnowledgeStatus = 'queued' | 'running' | 'completed' | 'error' | 'skipped' | 'outdated'; interface KnowledgeItem { id: string; key: string; status: KnowledgeStatus; fileSize?: number; chunksCount?: number; createdAt?: string; updatedAt?: string; error?: string; } interface KnowledgeAddOptions { folder?: string; } interface KnowledgeListOptions { folder?: string; page?: number; perPage?: number; status?: KnowledgeStatus; search?: string; } interface KnowledgeSearchOptions { folder?: string; mode?: 'hybrid' | 'semantic' | 'fulltext'; limit?: number; matchThreshold?: number; queryRewrite?: boolean; } interface KnowledgeAddResult { items: KnowledgeItem[]; } interface KnowledgeListResult { items: KnowledgeItem[]; page: number; perPage: number; total?: number; totalPages?: number; } interface KnowledgeSearchChunk { id: string; score: number; text: string; key?: string; filename?: string; folder?: string; timestamp?: string; } interface KnowledgeSearchResult { chunks: KnowledgeSearchChunk[]; queryKind?: string; } interface ScopedKnowledgeClient { add(file: File): Promise; list(options?: Omit): Promise; search(query: string, options?: Omit): Promise; } interface KnowledgeClient extends ScopedKnowledgeClient { add(file: File, options?: KnowledgeAddOptions): Promise; list(options?: KnowledgeListOptions): Promise; remove(itemId: string): Promise; search(query: string, options?: KnowledgeSearchOptions): Promise; scoped(scope: { folder: string; }): ScopedKnowledgeClient; } declare class KnowledgeError extends Error { readonly status: number; readonly code: string; readonly uploadedItems?: KnowledgeItem[] | undefined; constructor(status: number, code: string, message: string, uploadedItems?: KnowledgeItem[] | undefined); } declare function normalizeKnowledgeFolder(folder: string): string; declare function folderFilter(folder: string): { folder: { $gte: string; $lt: string; }; }; declare function knowledge(env: KnowledgeEnv): KnowledgeClient; /** * Lightweight D1 schema bootstrapping for apps that use auto-provisioned * `[[d1_databases]]` bindings. * * The auto-provisioner gives apps an empty D1; the app needs to create its * own tables before using them. This helper runs ordered SQL fragments and * tracks which have applied via a `_dpc_migrations` meta-table so re-running * is a no-op: * * ```ts * import { runMigrations } from 'deepspace/worker' * * await runMigrations(env.CARDS_DB, [ * `CREATE TABLE cards (id INTEGER PRIMARY KEY, json TEXT NOT NULL);`, * `CREATE INDEX idx_cards_updated ON cards(updated_at);`, * ]) * ``` * * **SQL formatting:** statements may span multiple lines freely. The runner * splits each migration string on `;` and runs each fragment via * `prepare().run()`. That sidesteps `db.exec()`'s newline-or-semicolon quirks * (it's optimized for migration files, not inline strings) and gets you * predictable single-statement semantics. Trailing `;` after the last * statement is fine; semicolons inside string literals are not (we use a * naive split, since DDL almost never has them). * * Each entry in the array is one migration. The runner records the index of * each successfully-applied migration in `_dpc_migrations`; subsequent calls * skip rows already recorded. Adding a new migration means appending to the * array; never reorder or delete entries. * * Why a meta-table instead of `PRAGMA user_version`: D1's SQLite authorizer * rejects PRAGMA writes with `SQLITE_AUTH`, even though the same statements * work in raw SQLite. A real table works on any D1 database and stays a * trivial bootstrap (one CREATE TABLE IF NOT EXISTS). * * Concurrency: D1 serializes statements per database, but two simultaneous * `runMigrations` callers could race the same migration index. The duplicate * INSERT collides on the primary key and the second caller sees the failure * — but the migration itself uses `IF NOT EXISTS` so the schema is correct * either way. Apps invoke this at startup which is single-threaded per * worker isolate; the cross-isolate race is rare and self-healing. * * This is the simplest possible migration story (option 1 in * docs/proposals/binding-auto-provisioning.md). Apps that outgrow it can * adopt CF's `wrangler d1 migrations apply` directly without breaking the * helper. */ interface RunMigrationsResult { /** Version before this run started. Equals the count of migrations already applied. */ fromVersion: number; /** Version after migrations applied. Equals fromVersion if nothing ran. */ toVersion: number; /** Number of migrations applied this call. */ applied: number; } /** * Apply ordered SQL migrations to a D1 database. Idempotent: the next call * with the same array is a no-op until the array grows. * * Throws on any individual migration failure. The migrations meta-row is * only inserted after a migration succeeds, so a partial failure leaves a * recoverable state — fix the SQL, redeploy, and the failed migration runs * on next startup. */ declare function runMigrations(db: D1Database, migrations: readonly string[]): Promise; /** * Wire protocol constants. * * The JSON WebSocket protocol uses dotted string identifiers (e.g. * `"records.query"`) as the `type` discriminator. All message types are * grouped under a single `MSG` object so imports stay tidy: * * import { MSG, dispatch, clientBuild } from 'deepspace' * * dispatch(raw, { * }) * * Each key's value is the on-wire string — grep-friendly, self- * documenting, and infinite per namespace. Adding a new message is one * line here plus one arm in the discriminated union in `./messages.ts`. * * Yjs binary protocol constants (`MSG_YJS_SYNC`, `MSG_YJS_AWARENESS`) are * intentionally kept numeric and separate from `MSG` — they ride a * binary WebSocket frame format and aren't part of the JSON dispatcher. */ declare const MSG: { readonly SUBSCRIBE: "core.subscribe"; readonly UNSUBSCRIBE: "core.unsubscribe"; readonly QUERY_RESULT: "core.query_result"; readonly RECORD_CHANGE: "core.record_change"; readonly PUT: "core.put"; readonly DELETE: "core.delete"; readonly ERROR: "core.error"; readonly USER_INFO: "user.info"; readonly USER_LIST: "user.list"; readonly SET_ROLE: "user.set_role"; readonly USER_UPDATE: "user.update"; readonly AUTH: "auth"; readonly YJS_JOIN: "yjs.join"; readonly YJS_LEAVE: "yjs.leave"; readonly ACK: "records.ack"; readonly LIST_SCHEMAS: "records.list_schemas"; readonly RESUBSCRIBE: "records.resubscribe"; readonly CANVAS_SHAPES: "canvas.shapes"; readonly CANVAS_ADD: "canvas.add"; readonly CANVAS_MOVE: "canvas.move"; readonly CANVAS_RESIZE: "canvas.resize"; readonly CANVAS_DELETE: "canvas.delete"; readonly CANVAS_UPDATE: "canvas.update"; readonly CANVAS_VIEWPORT: "canvas.viewport"; readonly CANVAS_UNDO: "canvas.undo"; readonly CANVAS_REDO: "canvas.redo"; readonly CRON_TASKS: "cron.tasks"; readonly CRON_HISTORY: "cron.history"; readonly CRON_TRIGGER: "cron.trigger"; readonly CRON_PAUSE: "cron.pause"; readonly CRON_RESUME: "cron.resume"; readonly CRON_STATUS: "cron.status"; readonly CRON_ACK: "cron.ack"; readonly JOB_ENQUEUE: "job.enqueue"; readonly JOB_CANCEL: "job.cancel"; readonly JOB_RETRY: "job.retry"; readonly JOB_UPDATE: "job.update"; readonly PRESENCE_SYNC: "presence.sync"; readonly PRESENCE_JOIN: "presence.join"; readonly PRESENCE_LEAVE: "presence.leave"; readonly PRESENCE_UPDATE: "presence.update"; }; /** * Typed wire-protocol layer — discriminated unions, typed builders, and a * type-safe dispatcher for every `MSG.*` the SDK understands. * * Why this exists * --------------- * * The string `MSG.*` constants in `./constants.ts` are the authoritative * wire protocol, but using them directly is error-prone: a typo picks the * wrong message with the wrong payload shape and fails silently at * runtime. This module pairs every constant with its payload type, so * that: * * 1. Building a message with `clientBuild.canvasAdd(...)` is payload- * checked at the call site — the compiler refuses to ship a wrong * shape. * * 2. Parsing an inbound message via `dispatch(raw, handlers)` narrows the * payload type inside each handler automatically, replacing the * unsafe `switch (msg.type) { case MSG.X: (payload as any).foo }` * pattern. * * 3. Tightening `BaseRoom.sendTo` / `BaseRoom.broadcast` / * `HandlerContext.send` / `SubscriptionContext.send` to accept * `ServerMessage` turns the type layer into enforcement: any room * that ships a payload inconsistent with its declared arm fails to * compile. Without that, the discriminated union is documentation, * not contract. * * 4. Adding a new `MSG.*` is localized: one entry in the discriminated * union, one builder function, one handler key in every dispatcher * that cares. No grep-and-fix across the codebase. * * 5. Apps can extend the SDK protocol without forking: `dispatch` is * generic over any `M extends ProtocolMessage`, and builders are * plain objects so apps compose via spread (`{ ...clientBuild, * myMessage: ... }`). * * Direction split * --------------- * * Some message types carry different payloads depending on who's sending. * `MSG.RECORDS_RESUBSCRIBE`, for example, is `{}` when the client asks to * but `{ state, tick }` when the server broadcasts the start event. * `MSG.CANVAS_ADD` is a flat shape dict on the way in and a `{ shape }` * wrapper on the way out. Modelling these with one union would force * handlers to juggle a union payload — clunky and error-prone. Instead we * split by direction: * * - `ClientMessage` — what the client sends to the server * - `ServerMessage` — what the server sends to the client * - `ProtocolMessage = ClientMessage | ServerMessage` (for code that * really doesn't care — avoid when possible) * * Each side gets its own builder (`clientBuild` / `serverBuild`) and each * side's dispatcher is parameterised with the union it expects. * * Payload strictness * ------------------ * * Where payload shapes are stable + narrow (ids, flags), we type them * precisely. Where they're opaque or escape the protocol layer (record * data blobs, Yjs binary frames, canvas shapes), we use `unknown` and * defer narrowing to the caller. This is intentional: over-typing opaque * payloads would require the protocol layer to import application types * and defeat the "thin wire contract" goal. */ /** * The outer shape of every wire message. `T` is the string discriminator * (e.g. `"canvas.add"`) — keeping it as a generic literal type lets the * discriminated-union narrowing in `dispatch()` pick the right payload. * * Callers extending the protocol should pass a string-literal type for * `T`, not the widened `string`. `BaseMessage` collapses the * discriminated union and handler-map key inference falls back to a * single untyped `string` key, losing all narrowing. */ interface BaseMessage { type: T; payload: P; } /** Matches when a payload is intentionally empty — `{}` on the wire. */ type EmptyPayload = Record; /** * Every message the server can send. Room and handler `send` / `broadcast` * signatures are tightened to this union so outbound payloads are * compile-checked against the wire contract. As with `ClientMessage`, * extend via a string-literal union arm in app code when adding new * server-side broadcasts. */ type ServerMessage = BaseMessage | BaseMessage | BaseMessage | BaseMessage | BaseMessage | BaseMessage | BaseMessage | BaseMessage | BaseMessage | BaseMessage | BaseMessage | BaseMessage | BaseMessage | BaseMessage | BaseMessage | BaseMessage; }> | BaseMessage | BaseMessage | BaseMessage | BaseMessage | BaseMessage | BaseMessage | BaseMessage | BaseMessage | BaseMessage | BaseMessage; }>; /** * BaseRoom — Abstract base class for all DeepSpace Durable Objects. * * Provides: * - WebSocket upgrade with Cloudflare hibernation API * - Connection tracking (WebSocket -> UserAttachment) * - Auth: parse JWT-verified user info from internal request headers * - Presence: connected users list, awareness on connect/disconnect * - Message routing: JSON parse -> dispatch by `type` field, binary hook * - Raw SQLite access via this.sql * - Broadcast helpers: broadcast(), sendTo() * - HTTP fetch handler with WebSocket upgrade detection * - Internal control-plane endpoint: POST /internal/disconnect-sockets * (force every client to reconnect and resync after out-of-band writes) * * Subclasses implement lifecycle hooks: * onConnect, onMessage, onBinaryMessage, onDisconnect, onRequest, onAlarm */ interface UserAttachment { userId: string; userName: string; userEmail: string; userImageUrl?: string; /** Subclass-specific data serialized alongside user info */ [key: string]: unknown; } declare abstract class BaseRoom> { protected state: DurableObjectState; protected env: E; protected sql: SqlStorage; constructor(state: DurableObjectState, env: unknown); fetch(request: Request): Promise; /** * Handle built-in `/internal/*` control-plane routes shared by every room * type. Returns a `Response` if the request was an internal route, or `null` * to let the caller continue normal dispatch. * * Currently: * - `POST /internal/disconnect-sockets` — close every live WebSocket so * clients reconnect and resync. Optional JSON body `{ code?, reason? }` * overrides the defaults (1012 / 'state-refresh'). Responds with * `{ success: true, closed: }`. * * See the security note on `fetch()`: this path is only reachable via DO * stub fetch from the app worker, never from the public internet. */ protected handleInternalRequest(request: Request, url: URL): Promise; private handleWebSocketUpgrade; webSocketMessage(ws: WebSocket, message: ArrayBuffer | string): Promise; webSocketClose(ws: WebSocket, _code: number, _reason: string): Promise; webSocketError(_ws: WebSocket, error: unknown): Promise; alarm(): Promise; /** * Called when a new WebSocket connects (after auth parsing). * Return an augmented attachment to serialize on the WebSocket, * or void to use the default attachment. */ protected onConnect(_ws: WebSocket, _user: UserAttachment): UserAttachment | void | Promise; /** * Called for each parsed JSON message. */ protected abstract onMessage(ws: WebSocket, user: UserAttachment, message: { type: string; [key: string]: unknown; }): void | Promise; /** * Called for binary messages (Yjs, custom protocols). */ protected onBinaryMessage?(ws: WebSocket, user: UserAttachment, data: ArrayBuffer): void | Promise; /** * Called when a WebSocket disconnects. */ protected onDisconnect(_ws: WebSocket, _user: UserAttachment): void | Promise; /** * Called for HTTP requests that are NOT WebSocket upgrades. */ protected onRequest?(request: Request): Response | Promise; /** * Called on DO alarm. */ protected onAlarm?(): void | Promise; /** * Get all connected WebSockets. */ protected getWebSockets(): WebSocket[]; /** * Force every connected client to reconnect and resync by closing all live * WebSockets. Returns the number of sockets closed. * * Use this after an **out-of-band, server-side write** to the room's records * — an admin import route, a migration script, a cron job, or a server * action — that connected clients have no way to learn about. Without it, a * browser tab holding stale in-memory state keeps operating on (and may * autosave over) data that changed underneath it. Closing the socket makes * the client SDK reconnect and pull fresh query results. * * The default close code is 1012 ("service restart") with reason * 'state-refresh'. The DeepSpace client treats *any* close as a reconnect * trigger (it does not special-case clean/1000 closes), so on reconnect it * re-subscribes every active query and receives fresh `QUERY_RESULT`s — * `useQuery` consumers see the new data without re-subscribing. * * Each close is guarded so one already-closing socket can't abort the sweep. * * @param options.code WebSocket close code (default 1012). * @param options.reason WebSocket close reason (default 'state-refresh'). * @returns the number of sockets that were closed. */ disconnectAllSockets(options?: { code?: number; reason?: string; }): number; /** * Get the user attachment for a WebSocket. */ protected getAttachment(ws: WebSocket): UserAttachment | null; /** * Get all currently connected users. */ protected getConnectedUsers(): UserAttachment[]; /** * Send a JSON message to a specific WebSocket. * * Typed as `ServerMessage` so every room's outbound traffic is * compile-checked against the wire protocol contract. Passing * `{ type: 'whatever', payload: {...} }` with a non-matching arm fails * to compile — that's the whole point of the typed layer. Apps that * need to send an app-specific message should override `sendTo` in * their subclass with a widened union (`ServerMessage | MyAppMessage`). */ protected sendTo(ws: WebSocket, message: ServerMessage): void; /** * Send binary data to a specific WebSocket. */ protected sendBinaryTo(ws: WebSocket, data: Uint8Array | ArrayBuffer): void; /** * Broadcast a JSON message to all connected WebSockets. * Optionally exclude a specific WebSocket (e.g. the sender). * * See `sendTo` for the reasoning behind typing as `ServerMessage`. */ protected broadcast(message: ServerMessage, exclude?: WebSocket): void; /** * Broadcast binary data to all connected WebSockets. */ protected broadcastBinary(data: Uint8Array | ArrayBuffer, exclude?: WebSocket): void; } /** * Server-specific protocol types * * These depend on Cloudflare Workers / Yjs imports and can't live in shared/types. * All other protocol types (Query, payloads, etc.) live in shared/types/index.ts. */ /** Stored on WebSocket attachment (survives hibernation) */ interface ConnectionAttachment extends UserAttachment { role: string; subscriptions: Subscription[]; /** Yjs docs this connection is editing */ yjsSubscriptions: YjsSubscription[]; /** Client-side Yjs awareness clientId for each subscribed document. */ awarenessClientIds?: Partial>; } /** * Collection Schema Definitions & Validation * * All collections use typed SQL columns. No document-mode / fields-based storage. */ interface ResolvedColumn { id: string; name: string; storage: 'number' | 'text'; interpretation: ColumnInterpretation; expression?: string; readonly: boolean; userBound?: boolean; immutable?: boolean; required?: boolean; default?: unknown; timestampTrigger?: { field: string; value?: unknown; }; } declare function collectionTableName(name: string): string; declare function columnId(name: string): string; declare function resolveColumn(col: ColumnDefinition): ResolvedColumn; declare function rowToData(row: Record, columns: ResolvedColumn[]): Record; declare function dataToColumnValues(data: Record, columns: ResolvedColumn[]): Record; declare function coerceValue(value: unknown, storage: 'number' | 'text', interpretation: ColumnInterpretation): unknown; /** Canonical numeric representation for date/datetime storage and triggers. */ declare function epochSeconds(date?: Date): number; declare function buildTableSelect(collectionName: string, columns: ResolvedColumn[]): string; interface User { id: string; email: string; name: string; imageUrl?: string; role: string; createdAt: string; lastSeenAt: string; } interface StoredRecord { collection: string; recordId: string; data: Record; createdBy: string; createdAt: string; updatedAt: string; } interface PermissionContext { isTeamMember: (teamId: string, userId: string) => boolean; } declare const noopPermissionContext: PermissionContext; declare function getRolePermissions(schema: CollectionSchema, role: string): RolePermissions; declare function isOwner(schema: CollectionSchema, record: { data: Record; createdBy: string; }, userId: string): boolean; declare function canRead(schema: CollectionSchema, role: string, record: { data: Record; createdBy: string; recordId?: string; }, userId: string, ctx?: PermissionContext): boolean; declare function canCreate(schema: CollectionSchema, role: string): boolean; declare function canUpdate(schema: CollectionSchema, role: string, record: { data: Record; createdBy: string; recordId?: string; }, userId: string, ctx?: PermissionContext): boolean; declare function canDelete(schema: CollectionSchema, role: string, record: { data: Record; createdBy: string; recordId?: string; }, userId: string, ctx?: PermissionContext): boolean; /** Check if a caller-supplied field write violates writableFields restrictions. */ declare function checkFieldPermissions(schema: CollectionSchema, role: string, newData: Record, existingData?: Record): string | null; /** * `unclaimed-or-own` lets a client claim an empty owner field, but never lets * them choose another user's id. Check the post-write record so the invariant * covers both creation and updates while still allowing an owner to unclaim. */ declare function checkUnclaimedOwnerTransition(schema: CollectionSchema, role: string, nextData: Record, userId: string): string | null; /** * Columns the system *assigns* — from the identity provider at registration, * or from an admin action. A caller who sends one of these has chosen a value * and means it, so a record write that would change one is refused loudly * rather than silently dropped. */ declare const SYSTEM_ASSIGNED_COLUMNS: Set; /** * Columns the system *maintains* on its own schedule, without telling anyone. * `registerUser` bumps `lastSeenAt` on every WS connect and `handleUserUpdate` * rewrites it on every 60s presence heartbeat; neither broadcasts, so every * client's copy goes stale silently and there is no heal path short of a * refetch. Read-modify-write echo (`put(id, { ...rec.data, field })`) is the * SDK's own house style, so refusing these would refuse writes over a value the * caller never chose and cannot keep current. They are preserved silently. */ declare const SYSTEM_MAINTAINED_COLUMNS: Set; /** Every `users` column owned by the system rather than by record writes. */ declare const SYSTEM_MANAGED_COLUMNS: Set; /** Standard user columns. Apps spread these into their users schema. */ declare const USERS_COLUMNS: ColumnDefinition[]; declare const BASE_USERS_SCHEMA: CollectionSchema; /** * Lint a CollectionSchema for declarations that look like they should * enforce something but don't, due to interactions between top-level * fields (visibilityField, ownerField) and per-role permission levels. * * The SDK has historically silently accepted schemas that imply more * enforcement than they actually deliver — e.g., `visibilityField` set * but every role's `read: true` means "anyone can read everything" * regardless of `visibility`. Warn loudly at registration so app * authors notice before shipping a privacy bug. * * Returns an array of warning messages. Empty = clean. */ declare function lintSchema(schema: CollectionSchema): string[]; /** * Lint a whole schema set: every per-schema warning, plus the rules that * need to see the set. The `'team'` permission level resolves membership from * a `team_members` collection (`teamId`, `userId`, `status`); without one it * denies everything, silently — a mistake that presents as a sync bug, not * as a schema error, so name it here. */ declare function lintSchemas(schemas: CollectionSchema[]): string[]; /** The rules that need to see the whole schema set (per-schema rules live in * `lintSchema`; `lintSchemas` composes both). */ declare function lintSchemaSet(schemas: CollectionSchema[]): string[]; declare class SchemaRegistry { private trusted; constructor(schemas?: CollectionSchema[]); registerTrusted(schema: CollectionSchema): void; get(name: string): CollectionSchema | undefined; has(name: string): boolean; all(): CollectionSchema[]; names(): string[]; } /** * RecordRoom Durable Object * * SQLite-based storage with query-based real-time subscriptions. * Extends BaseRoom for WebSocket/connection infrastructure. * * Architecture: * - Data stored in SQLite (single `records` table) * - Clients subscribe to QUERIES, not collections * - On record change, server evaluates which subscriptions match * - Only matching subscribers receive updates * * Protocol: * - SUBSCRIBE { subscriptionId, query } → QUERY_RESULT { subscriptionId, records } * - UNSUBSCRIBE { subscriptionId } * - PUT { collection, recordId, data } → broadcasts RECORD_CHANGE to matching * - DELETE { collection, recordId } → broadcasts RECORD_CHANGE to matching */ /** * RecordRoom configuration options */ interface RecordRoomConfig { /** * User ID of the app owner. * This user automatically gets 'admin' role on connect. */ ownerUserId?: string; } /** * RecordRoom Durable Object */ declare class RecordRoom> extends BaseRoom { private schemaRegistry; private initPromise; /** Yjs docs loaded in memory (key: collection:recordId:fieldName) */ private yjsDocs; /** Owner user ID — gets admin role automatically */ private ownerUserId; /** True until the first fetch() completes — detects hibernation wake-up */ private freshConstruct; /** * Per-connection `[DO Perf]` timing logs are noisy on every hot path, so * they're gated behind a `DEEPSPACE_DO_PERF` env binding (set it to any * truthy value on the worker to opt in). Off by default. */ private get perfLogEnabled(); /** * The HTTP debug API (`/api/debug/*`) runs arbitrary SQL and role changes * with no auth of its own, so it is gated here at the DO's single ingress. * Off unless a deployment opts in with `ALLOW_DEBUG_ROUTES=true` * (`deepspace dev start`/`test run` set it automatically). Deployments holding shared * data override this to always return false. */ protected get debugRoutesEnabled(): boolean; constructor(state: DurableObjectState, env: unknown, schemas?: CollectionSchema[], config?: RecordRoomConfig); private getPermissionContext; fetch(request: Request): Promise; /** Timing info from the current fetch(), used by onConnect for logging */ private _fetchTiming; private ensureInitialized; private initializeDatabase; private ensureCollectionTable; private ensureAllCollectionTables; protected onConnect(ws: WebSocket, user: UserAttachment): Promise; /** * Required to satisfy BaseRoom's abstract contract, but never invoked: * RecordRoom overrides `webSocketMessage`/`webSocketClose` directly (below), * so the BaseRoom dispatch path that would call this never runs. All real * message handling lives in `handleRecordMessage`. */ protected onMessage(): void; webSocketMessage(ws: WebSocket, message: ArrayBuffer | string): Promise; webSocketClose(ws: WebSocket, code: number, reason: string): Promise; private handleRecordMessage; private handleListSchemas; private createHandlerContext; private createRecordContext; private createUserContext; private createYjsContext; private send; private sendBinaryHelper; } /** * YjsRoom — Lightweight Durable Object for collaborative Yjs documents. * Extends BaseRoom for WebSocket/connection infrastructure. * * Unlike RecordRoom (schemas, RBAC, queries, user state), YjsRoom is * purpose-built for Yjs: sync, relay, persist. One DO per document. * * Architecture (SOTA for Yjs + Cloudflare DOs): * - Auth verified at the worker edge, role passed to DO via URL params * - DO is a thin Yjs sync relay: receive → apply → persist → broadcast * - Viewers can observe but not write; members/admins can write * - State persisted as a single binary blob in SQLite * * Uses the shared yjs-protocol.ts encoding utilities — no duplication. */ interface YjsAttachment extends UserAttachment { role: string; canWrite: boolean; awarenessClientId: number | null; } declare class YjsRoom> extends BaseRoom { private doc; private initialized; private awarenessStates; constructor(state: DurableObjectState, env: unknown); private ensureInitialized; private getDoc; private persistDoc; protected onConnect(ws: WebSocket, user: UserAttachment): YjsAttachment; protected onMessage(_ws: WebSocket, _user: UserAttachment, _message: { type: string; [key: string]: unknown; }): void; protected onBinaryMessage(ws: WebSocket, _user: UserAttachment, data: ArrayBuffer): void; protected onDisconnect(ws: WebSocket, _user: UserAttachment): void; private handleSync; private handleAwareness; private readAwarenessUpdates; private encodeAwarenessMessage; private sendAwarenessSnapshot; private broadcastRaw; } /** * CanvasRoom — Spatial canvas Durable Object (tldraw-style). * * Extends BaseRoom with Yjs-backed spatial operations. * Each shape is a Y.Map entry, enabling multi-user concurrent editing. * * Features: * - Shape CRUD (add, move, resize, delete, update properties) * - Viewport awareness (each user's visible region) * - Per-user undo/redo stacks * * Message types: canvas.* */ interface CanvasShape { id: string; type: string; x: number; y: number; width: number; height: number; rotation?: number; props: Record; createdBy: string; createdAt: string; updatedAt: string; } interface Viewport { userId: string; x: number; y: number; width: number; height: number; zoom: number; } interface CanvasAttachment extends UserAttachment { viewport: Viewport | null; /** True for member/admin roles; false for viewers and unauthenticated anon. */ canWrite: boolean; } declare class CanvasRoom> extends BaseRoom { private doc; private initialized; private viewports; private undoStacks; private redoStacks; constructor(state: DurableObjectState, env: unknown); private ensureInitialized; private getDoc; private persistDoc; private getShapesMap; fetch(request: Request): Promise; protected onConnect(ws: WebSocket, user: UserAttachment): CanvasAttachment; protected onMessage(ws: WebSocket, user: UserAttachment, message: { type: string; [key: string]: unknown; }): Promise; protected onDisconnect(_ws: WebSocket, user: UserAttachment): void; private pushUndo; private clearRedo; private handleUndo; private handleRedo; private getAllShapes; } /** * PresenceRoom — Ephemeral presence-tracking Durable Object. * * Extends BaseRoom. No SQLite — purely in-memory presence state. * Tracks who is present in a given scope (canvas, doc, thread, etc.) * and broadcasts join/leave/state-update events to all connected peers. * * Each scope ID maps to its own DO instance. Clients connect via * /ws/presence/:scopeId and receive real-time presence for that scope. * * Peers can attach small ephemeral state (cursor position, typing indicator, * viewport, selection, etc.) via MSG.PRESENCE_UPDATE. * * Message types: presence.* */ interface PresencePeer { userId: string; userName: string; joinedAt: string; /** Small per-user state (cursor, typing, viewport, etc.) */ state: Record; } interface PresenceAttachment extends UserAttachment { joinedAt: string; } declare class PresenceRoom> extends BaseRoom { private peers; private peerSockets; constructor(state: DurableObjectState, env: unknown); /** * Durable Objects can hibernate and clear heap while Cloudflare keeps * WebSocket connections. Deserialize attachments from already-connected * sockets so `peers` matches reality before we send PRESENCE_SYNC. */ private hydratePeersFromLiveSockets; protected onConnect(ws: WebSocket, user: UserAttachment): PresenceAttachment; protected onMessage(ws: WebSocket, user: UserAttachment, message: { type: string; [key: string]: unknown; }): Promise; protected onDisconnect(ws: WebSocket, user: UserAttachment): void; } /** Pure validation and evaluation for CronRoom schedules. */ interface CronTask { name: string; /** Interval in minutes (interval mode) — mutually exclusive with `schedule`. */ intervalMinutes?: number; /** 5-field cron expression (cron mode) — requires `timezone`. */ schedule?: string; /** IANA timezone string (e.g. "America/New_York"). Required with `schedule`. */ timezone?: string; /** Whether the task starts paused. */ paused?: boolean; } /** * CronRoom — Per-app scheduled task execution Durable Object. * * Extends BaseRoom. One DO per app shards cron work and avoids the * dispatch-worker's global KV-poll bottleneck. The DO alarm triggers * `onTask(name)` on the configured cadence; each execution is recorded * to a per-app `cron_history` table. Subscribers (admin clients via the * `useCronMonitor` hook) get pushes over the WebSocket. * * Tasks declare *either* `intervalMinutes` (run every N minutes) *or* * `schedule` + `timezone` (5-field cron expression evaluated against an * IANA timezone via `Intl.DateTimeFormat`). Cron mode is DST-aware * because the wall-clock comparison happens after the timezone shift, * not before. * * Message types: cron.* */ interface CronRoomConfig { tasks: CronTask[]; } interface CronExecution { taskName: string; startedAt: string; completedAt: string | null; success: boolean; durationMs: number; error?: string; } interface CronAttachment extends UserAttachment { /** True for member/admin roles; false for viewers and unauthenticated anon. */ canWrite: boolean; } declare abstract class CronRoom> extends BaseRoom { private tasks; private initialized; constructor(state: DurableObjectState, env: unknown, config: CronRoomConfig); private ensureInitialized; fetch(request: Request): Promise; protected handleInternalRequest(request: Request, url: URL): Promise; protected onConnect(ws: WebSocket, user: UserAttachment): CronAttachment; protected onMessage(ws: WebSocket, user: UserAttachment, message: { type: string; [key: string]: unknown; }): Promise; private dispatchMessage; protected onAlarm(): Promise; private executeTask; private scheduleNextAlarm; private getTaskStates; private getRecentHistory; private broadcastStatus; /** * Receipt for a mutation frame, addressed only to its sender. Receipts * are opt-in by correlation id: frames without a `requestId` keep the * fire-and-forget contract, so the (untyped) `requestId` from the wire * is the single gate here rather than a check at every call site. * Frames go through the shared `serverBuild` constructors so the wire * shape is enforced by the protocol types, not re-spelled here. */ private ackMutation; /** * Execute a scheduled task by name. * Called both by the alarm scheduler and manual trigger. */ protected abstract onTask(taskName: string): void | Promise; } /** * Wake the app's CronRoom so its alarm is armed. * * A Durable Object does not exist until something fetches it, and CronRoom * arms its alarm inside that first fetch — so a deployed schedule that no * client has opened yet runs nothing, and nothing reports it. The app worker * calls this from its request path (once per isolate is enough); the first * request the worker handles after a deploy then arms the schedule. A no-op * when the app declares no tasks. The wake runs under `ctx.waitUntil`, so it * never delays the response. */ declare function armCronRoom(ctx: { waitUntil(promise: Promise): void; }, namespace: DurableObjectNamespace, roomId: string, tasks: readonly CronTask[]): void; /** * JobRoom — Per-app durable background-job execution Durable Object. * * Solves the "long job dies when the response goes out" problem on * Cloudflare Workers. `ctx.waitUntil` only gets 30s after a response; * jobs that need minutes-to-hours live here instead: rows in SQLite, * picked up by DO alarms (15-min wall budget per tick), resumable * across ticks via `ctx.continue` for the rare longer cases. * * Lifecycle: queued → running → succeeded | failed | canceled * * Crash recovery: if an isolate is recycled mid-run, the row stays at * `running`. On next init, rows older than ~16 min are either retried * (if attempts left) or marked failed. * * See the abstract `onJob` method below for the subclass contract. * Message types: job.* */ type JobStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'canceled'; /** * Job record exposed to `onJob` handlers and clients. Payloads are typed * as `unknown` at the SDK layer; subclasses narrow via a generic param. */ interface Job

{ id: string; type: string; status: JobStatus; payload: P; result?: unknown; error?: string; progress?: number; progressMessage?: string; attempts: number; maxAttempts: number; enqueuedAt: string; startedAt?: string | null; completedAt?: string | null; enqueuedBy?: string | null; /** Set when a previous run called `ctx.continue(state)`. */ resumeFrom?: unknown; } interface JobContext { /** Set progress (0..1) with an optional message. Broadcasts. */ progress(value: number, message?: string): void; /** * Save a resumable checkpoint and re-run `onJob` on the next alarm * with `job.resumeFrom = state`. The return value of `onJob` is * ignored once `continue` has been called. */ continue(state: unknown, options?: { afterMs?: number; }): void; /** * Fires when a JOB_CANCEL arrives while this job is running in this * isolate. Cross-isolate cancels still mark the row `canceled` but * can't fire this signal. */ signal: AbortSignal; } interface JobRoomConfig { /** Default for jobs enqueued without explicit `maxAttempts`. Default 1 (no auto-retry). */ defaultMaxAttempts?: number; /** TTL for terminal rows (succeeded/failed/canceled) in ms. Default 24h. */ retentionMs?: number; /** Job count in the initial snapshot to a new subscriber. Default 100. */ snapshotLimit?: number; /** Delay before a failed attempt is retried, in ms. Default 1000. */ retryBackoffMs?: number; /** Re-check whether a connected user may mutate the queue. Defaults to member/admin roles. */ authorizeWrite?: (user: UserAttachment) => boolean | Promise; /** Re-check whether a connected user may observe the queue. Defaults to authorizeWrite. */ authorizeRead?: (user: UserAttachment) => boolean | Promise; } interface JobAttachment extends UserAttachment { canWrite: boolean; } declare abstract class JobRoom, P = unknown, R = unknown> extends BaseRoom { private initialized; private readonly defaultMaxAttempts; private readonly retentionMs; private readonly snapshotLimit; private readonly retryBackoffMs; private readonly authorizeWrite; private readonly authorizeRead; private broadcastQueue; /** In-flight jobs in this isolate; lets same-isolate JOB_CANCEL fire the signal. */ private readonly inFlight; /** Set by `ctx.continue` inside `executeJob`; reset before every run. */ private continueState; constructor(state: DurableObjectState, env: unknown, config?: JobRoomConfig); private ensureInitialized; fetch(request: Request): Promise; protected onConnect(ws: WebSocket, user: UserAttachment): Promise; protected onMessage(ws: WebSocket, user: UserAttachment, message: { type: string; [key: string]: unknown; }): Promise; protected onAlarm(): Promise; /** * One internal endpoint, `POST /enqueue`, used by the `enqueueJob` * helper at the bottom of this file. The room is not meant to be * reachable from the public internet; callers route through the app * worker (or call `this.enqueue(...)` directly from a subclass). * * Subclasses overriding `onRequest` should call `super.onRequest(req)` * for paths they don't handle. */ protected onRequest(request: Request): Promise; /** * Synchronously persist a new job and arm the alarm. Returns the new * job. This is the in-isolate path — useful for a subclass `onJob` * that wants to chain follow-up work without paying a fetch hop: * * protected async onJob(job: Job, ctx: JobContext) { * // ... do work ... * if (needsFollowup) this.enqueue('followup', { parent: job.id }) * } * * Workers code that lives outside the DO isolate (HTTP routes, cron * tasks, server actions) cannot call this directly — they must go * through the `enqueueJob` helper at the bottom of this file, which * routes via the DO's `/enqueue` HTTP endpoint. */ enqueue(type: string, payload?: unknown, options?: { maxAttempts?: number; enqueuedBy?: string; }): Job; private insertJob; private cancelJob; private retryJob; /** * Drain due jobs in FIFO order. All due jobs share the alarm's * 15-min wall budget (a CF property, not a per-job allocation). */ private drainDueJobs; private executeJob; private makeContext; private readStatus; /** Reauthorize every live event so role revocation does not leave a stale reader. */ protected broadcast(message: ServerMessage, exclude?: WebSocket): void; private resolveAccess; private scheduleNextAlarm; private recoverStuckRunning; private pruneExpired; private getRecentJobs; private getJobById; /** * Run a single job. Return a value for success; throw for failure * (retried until attempts >= maxAttempts). Use `ctx.progress` for * progress, `ctx.continue(state)` to span multiple alarms, and * `ctx.signal` to honor client cancellations. */ protected abstract onJob(job: Job

, ctx: JobContext): Promise | R | void; } /** * Enqueue a job from worker code that has the DO namespace. Routed by * `roomId` (typically `app:`; pass different ids for sharded * queues). Returns the new jobId; throws on failure. * * const jobId = await enqueueJob( * env.JOB_ROOMS, * `app:${env.APP_NAME}`, * 'ai-summarize', * { text }, * { maxAttempts: 3 }, * ) */ declare function enqueueJob(namespace: DurableObjectNamespace, roomId: string, type: string, payload?: unknown, options?: { maxAttempts?: number; enqueuedBy?: string; }): Promise; /** * DO Manifest — Dynamic Durable Object binding declarations. * * Apps export a `__DO_MANIFEST__` array in their worker.ts. * The CLI extracts it and sends it to the deploy worker, * which uses it to generate dynamic CF API bindings and migrations. */ interface DOManifestEntry { /** CF binding name, e.g. 'RECORD_ROOMS' */ binding: string; /** Exported class name, e.g. 'AppRecordRoom' */ className: string; /** Whether this DO uses SQLite storage */ sqlite: boolean; } type DOManifest = DOManifestEntry[]; /** * Utility type: auto-generates Env bindings from a manifest. * * @example * const manifest = [ * { binding: 'RECORD_ROOMS', className: 'AppRecordRoom', sqlite: true }, * ] as const satisfies DOManifest * * type Env = BaseEnv & DOBindings * // => { RECORD_ROOMS: DurableObjectNamespace } */ type DOBindings = { [K in T[number]['binding']]: DurableObjectNamespace; }; /** Default manifest for apps that don't declare one */ declare const DEFAULT_DO_MANIFEST: DOManifest; /** * Shape-validate a DO manifest received over the wire (e.g. from the CLI's * deploy form-field). Without this, malformed input gets passed straight to * `deployToWfP`'s `.filter(...).map(...)` chain and crashes the route mid-deploy. * * Mirrors the contract of `validateBindingManifest` for non-DO bindings. */ declare function validateDoManifest(manifest: unknown): { valid: true; manifest: DOManifest; } | { valid: false; reason: string; }; /** * Binding Manifest — non-DO bindings declared by an app's wrangler.toml that * the deploy-worker should pass through to Cloudflare's WfP upload API. * * Apps don't export a `__BINDING_MANIFEST__`; the CLI extracts these from the * normalized vite/wrangler output config at deploy time. This file just owns * the types + validation so both sides (CLI client and deploy-worker server) * agree on the shape. */ /** * A single non-DO binding the app declares. Mirrors CF's WfP binding API. * * Provisionable resources (d1, kv_namespace, vectorize, r2_bucket, queue) accept * the literal string `"auto"` in their ID field to request platform-side * provisioning at deploy time. When `"auto"` is used, the deploy-worker creates * the resource on the platform CF account, persists the resulting CF ID in the * app registry, and substitutes the real ID before forwarding to WfP. The * sentinel sticks around in this type because: * 1. `wrangler` parsing requires a non-empty string in the id field * 2. The CLI passes the unresolved manifest through to the deploy-worker * 3. The deploy-worker is the only side with CF API credentials * * Companion fields (`database_name`, `title`, `dimensions`, `metric`) are only * used when `"auto"` is set — they tell the provisioner how to create the * resource. After provisioning these fields are still present on the wire but * ignored by WfP. */ type CustomBinding = { type: 'plain_text'; name: string; text: string; } | { type: 'json'; name: string; json: BindingJsonValue; } | { /** Managed by DeepSpace; never forwarded into the customer Worker env. */ type: 'ai_search'; name: string; /** Only the untrusted `auto` sentinel is accepted from wrangler.toml. */ instance_name: string; } | { type: 'vectorize'; name: string; /** Either a pre-existing index name or the literal `"auto"`. */ index_name: string; /** Required when `index_name === "auto"`. */ dimensions?: number; /** Required when `index_name === "auto"`. */ metric?: 'cosine' | 'euclidean' | 'dot-product'; } | { type: 'ai'; name: string; } | { type: 'r2_bucket'; name: string; /** Either a pre-existing bucket name or the literal `"auto"`. */ bucket_name: string; } | { type: 'kv_namespace'; name: string; /** Either a pre-existing KV namespace ID or the literal `"auto"`. */ namespace_id: string; /** Required when `namespace_id === "auto"`. Human-readable namespace title. */ title?: string; } | { type: 'd1'; name: string; /** Either a pre-existing D1 database UUID or the literal `"auto"`. */ id: string; /** Required when `id === "auto"`. Human-readable database name. */ database_name?: string; } | { type: 'queue'; name: string; /** Either a pre-existing queue name or the literal `"auto"`. */ queue_name: string; } | { type: 'browser_rendering'; name: string; } | { type: 'analytics_engine'; name: string; dataset?: string; } | { type: 'hyperdrive'; name: string; id: string; } | { type: 'ratelimit'; name: string; namespace_id: string; simple: { limit: number; period: 10 | 60; }; }; type CustomBindingManifest = CustomBinding[]; /** Values Wrangler accepts for JSON environment-variable bindings. */ type BindingJsonValue = null | boolean | number | string | BindingJsonValue[] | { [key: string]: BindingJsonValue; }; /** Cloudflare's maximum UTF-8 payload for one environment variable. */ declare const MAX_ENV_VAR_BYTES: number; /** * Bound on names that become Cloudflare binding names verbatim (declared * bindings and app secrets). Cloudflare itself caps a binding name at 2712 * bytes but only rejects at deploy time — after a too-long secret name was * already accepted into the store, which bricks every later deploy. Refuse at * write time instead, with a bound no sane name exceeds. */ declare const MAX_BINDING_NAME_LENGTH = 256; /** One rule for user secret names, shared by the CLI's fail-fast validation * and the deploy-worker's authoritative store so the layers cannot drift. */ declare const SECRET_NAME_RE: RegExp; /** Sentinel string in an ID field that requests platform-side provisioning. */ declare const AUTO_PROVISION_SENTINEL = "auto"; /** Binding types whose ID field accepts the `"auto"` sentinel for provisioning. */ declare const AUTO_PROVISIONABLE_TYPES: Set; /** * True if a binding has the `"auto"` sentinel in its primary ID field. Used by * the deploy-worker to decide which entries need provisioning and by the * validator to enforce companion-field requirements. */ declare function isAutoProvision(b: CustomBinding): boolean; /** Binding `type` values an app is allowed to declare. */ declare const ALLOWED_BINDING_TYPES: Set; /** * Binding NAMES the SDK reserves on every app — apps may not redeclare them. * * Includes: * - Static-asset + service bindings the platform sets up automatically. * - SDK-managed env (auth, identity, owner JWT). * - The auto-attached cost-tracking AE dataset (`USAGE_EVENTS`). * * DO binding names (RECORD_ROOMS, YJS_ROOMS, etc.) are NOT in this set * because they live in a separate manifest (`__DO_MANIFEST__`). */ declare const RESERVED_BINDING_NAMES: Set; /** * Per-binding validation error. `binding` is undefined for top-level * shape failures (e.g. manifest is not an array). */ interface ValidationError { binding?: CustomBinding; reason: string; } /** * Validate a binding manifest. Returns errors; an empty array means valid. * * Used both client-side (CLI) for friendly fail-fast and server-side * (deploy-worker) as a security boundary — apps can't sneak in reserved * binding names by editing the wire format. */ declare function validateBindingManifest(manifest: unknown): { valid: true; bindings: CustomBindingManifest; } | { valid: false; errors: ValidationError[]; }; /** * Convert vite/wrangler's normalized config (from `.wrangler/deploy/config.json`) * into a CustomBindingManifest. * * Vite normalizes wrangler.toml into object/array structures with shapes like * `{ ai: { binding: 'AI' } }`, `{ vectorize: [{ binding, index_name }] }`, * etc. We extract each known shape with explicit field plucks (no broad * `as` casts) and return a flat array. */ declare function bindingManifestFromOutputConfig(outputConfig: Record): CustomBindingManifest; /** * Pure helpers for computing Cloudflare Durable Object migrations from a * declared manifest + the bindings already registered on a deployed script. * * Lives in the SDK (not deploy-worker) so the logic is testable with vitest * and reusable from other CF deploy paths if we ever add them. */ /** Subset of CF's `bindings` API response we read from. */ interface ExistingDOBinding { /** Binding name in `env`, e.g. 'RECORD_ROOMS' */ name: string; /** Always `'durable_object_namespace'` for DO bindings. */ type: string; /** SDK class name, e.g. 'AppRecordRoom' */ class_name?: string; } /** What goes in the CF script-upload `migrations` block. */ interface DoMigrationDirective { tag: string; new_sqlite_classes?: string[]; deleted_classes?: string[]; } interface DoMigrationPlan { /** New SQLite classes to register (present in manifest, absent in existing). */ newSqliteClasses: string[]; /** Classes to delete (present in existing, absent in manifest). */ deletedClasses: string[]; /** True when there's actual delta — only then should the migrations block be sent. */ needsMigration: boolean; /** The full directive to splat into the CF script-upload metadata. Null if no migration is needed. */ directive: DoMigrationDirective | null; } interface ComputeDoMigrationOptions { /** * Override for the timestamp baked into the migration tag. Tests pass a * fixed value to assert determinism; production omits this and gets * `Date.now()`, which guarantees lifetime tag uniqueness even across * cycles like `[A]→[A,B]→[A]→[A,B]`. */ now?: number; } /** * Compute the migration plan for a deploy. * * manifest: what the app declares now * existing: what CF currently has registered for this script * * Behavior: * - new_sqlite_classes ← in manifest, not in existing, sqlite=true * - deleted_classes ← in existing, not in manifest * - needsMigration ← either of the above is non-empty * - tag ← content-addressed by (add, remove) so: * - identical re-deploy → unchanged tag → no-op (and * `needsMigration` is false anyway) * - any class change → unique tag → CF processes * * Bug history: an earlier version computed `tag = v${count}`. Removing a class * dropped the count, the migration block was skipped (no NEW classes), and CF * retained the orphaned class registration with its SQLite storage. The * `deleted_classes` path closes that gap; the content-addressed tag prevents * tag collisions when class sets are added and removed in different orders. */ declare function computeDoMigration(manifest: readonly DOManifestEntry[], existing: readonly ExistingDOBinding[], options?: ComputeDoMigrationOptions): DoMigrationPlan; /** * App-name validation + sanitization helpers. * * Strategy: validate strictly so we have a precise definition of "valid", * but DON'T reject non-conforming names — sanitize them, warn the user, and * proceed. Hard rejection would break apps whose `wrangler.toml name` was * something like `My_App` (previously deployed as `my-app` via silent * server-side sanitization). The new behavior preserves "still deploys," * but the CLI now surfaces a warning so the user can fix the name when * convenient instead of being silently surprised by their hostname. * * Rules track Cloudflare's WfP script-name constraints (RFC 1035 host label, * no consecutive dashes) plus our 2-char minimum so subdomains read sensibly. */ declare const APP_NAME_RULES: { /** ^[a-z0-9](-?[a-z0-9])+$ — RFC 1035 host label, no consecutive dashes. */ readonly pattern: RegExp; readonly minLength: 2; readonly maxLength: 63; }; type AppNameValidation = { valid: true; name: string; } | { valid: false; reason: string; }; /** * Strict validation: returns valid only if the name already conforms. * Useful as a precondition test or for CI lints. */ declare function validateAppName(raw: unknown): AppNameValidation; type AppNameResolution = { ok: true; name: string; warning?: string; } | { ok: false; reason: string; }; /** * Resolve an app name for deploy: prefer the input as-is if valid, otherwise * sanitize and warn. Hard-fail only if even sanitization can't produce a * valid name (empty, all-non-alphanumeric, too short, too long). * * The intent is "what previously worked still works, with a friendly warning * about non-conforming names." */ declare function resolveAppName(raw: unknown): AppNameResolution; /** * AI Chat Schemas * * Pre-built collection schemas for DO-backed AI chat history. * The worker is the only writer; the client reads via `useQuery`. */ declare const AI_CHATS_SCHEMA: CollectionSchema; declare const AI_MESSAGES_SCHEMA: CollectionSchema; /** * Messaging Schemas * * Pre-built collection schemas for messaging functionality. * These schemas intentionally model public channels only. RecordRoom row * permissions cannot make a channel private by themselves; advertising DMs * or private groups here would expose their records to every room member. * * @example * ```typescript * import { CHANNELS_SCHEMA, MESSAGES_SCHEMA, REACTIONS_SCHEMA } from 'deepspace/worker' * export const schemas = [usersSchema, CHANNELS_SCHEMA, MESSAGES_SCHEMA, REACTIONS_SCHEMA] * ``` */ declare const CHANNELS_SCHEMA: CollectionSchema; declare const MESSAGES_SCHEMA: CollectionSchema; declare const REACTIONS_SCHEMA: CollectionSchema; declare const CHANNEL_MEMBERS_SCHEMA: CollectionSchema; declare const READ_RECEIPTS_SCHEMA: CollectionSchema; /** * Subscription handlers for RecordRoom * * All collections use table-mode storage (c_* tables with typed columns). */ interface SubscriptionContext { sql: SqlStorage; schemaRegistry: SchemaRegistry; state: DurableObjectState; /** * The app-owned scope this room was addressed by — for example * `app:{appId}` or `chat:{channelId}`. Records are sharded across rooms * by scope, so the scope is what names the room in an unknown-collection * failure. Undefined only for rooms addressed by a unique id rather than a * name (`idFromName` populates `DurableObjectId.name`). */ scopeId?: string; getPermissionContext(): PermissionContext; /** Typed against `ServerMessage` so outbound broadcasts are * compile-checked against the wire contract. */ send(ws: WebSocket, message: ServerMessage): void; } /** * Handle a new subscription request. * * We no longer store subscriptions server-side - broadcasts go to all clients * and they filter locally. This avoids hibernation issues. */ declare function handleSubscribe(ctx: SubscriptionContext, ws: WebSocket, attachment: ConnectionAttachment, payload: SubscribePayload): void; /** * Handle unsubscribe request. * * Since we no longer store subscriptions server-side, this is a no-op. * The client handles unsubscription locally. */ declare function handleUnsubscribe(_ctx: SubscriptionContext, _ws: WebSocket, _attachment: ConnectionAttachment, _payload: UnsubscribePayload): void; /** Out-param for {@link executeQuery}: `capped` is set when a limited, * row-filtered scan stopped on {@link MAX_FILTERED_SCAN_ROWS} rather than on * exhausting the rows — the one case a short result does NOT mean "no more * matches". */ interface QueryScanState { capped: boolean; } declare function executeQuery(ctx: SubscriptionContext, query: Query, userId: string, userRole: string, skipUserRbac?: boolean, scanState?: QueryScanState): RecordResult[]; /** * Check if a record matches a subscription's query and permissions */ declare function recordMatchesSubscription(record: { recordId: string; data: Record; createdBy: string; }, collection: string, query: Query, userId: string, userRole: string, schema: CollectionSchema, ctx: PermissionContext): boolean; /** * Broadcast a record change to all connected clients who can read it. * * Instead of matching subscriptions (which are lost on hibernation), * we broadcast to ALL clients and include the collection name. * The client filters based on its local subscriptions. */ declare function broadcastChange(ctx: SubscriptionContext, state: DurableObjectState, collection: string, record: RecordResult, changeType: 'create' | 'update' | 'delete'): void; /** * Record operation handlers for RecordRoom (PUT/DELETE) * * All collections use table-mode storage (c_* tables with typed columns). */ interface RecordContext extends SubscriptionContext { state: DurableObjectState; } /** * Get a single record from its c_* table. */ declare function getRecord(sql: SqlStorage, collection: string, recordId: string, schema?: CollectionSchema): { data: Record; createdBy: string; createdAt: string; updatedAt: string; } | null; /** * Handle PUT (create/update) record request via WebSocket. * Thin wrapper around putRecord() — translates ToolResult errors to WS messages. */ declare function handlePut(ctx: RecordContext, ws: WebSocket, attachment: ConnectionAttachment, payload: PutPayload): void; /** * Handle DELETE record request via WebSocket. * Thin wrapper around deleteRecord() — translates ToolResult errors to WS messages. */ declare function handleDelete(ctx: RecordContext, ws: WebSocket, attachment: ConnectionAttachment, payload: DeletePayload): void; /** * Put (create/update) a record. Returns ToolResult instead of sending WS messages. * Performs schema validation, RBAC checks, and broadcasts changes. * * @param skipUserRbac - When true, skip user role checks. Used by server actions * that have already been authorized at the app level. * @param systemUpdate - When true, also skip system-managed field stripping. * Used for server-initiated updates to system fields (e.g. user profile sync). * @param updateOnly - When true, an absent recordId is refused instead of * created. `records.update`'s mode: updates never create. */ declare function putRecord(ctx: RecordContext, collection: string, recordId: string, data: Record, userId: string, userRole: string, skipUserRbac?: boolean, systemUpdate?: boolean, updateOnly?: boolean): ToolResult; /** * Delete a record. Returns ToolResult instead of sending WS messages. * Performs RBAC check and broadcasts the deletion. * * @param skipUserRbac - When true, skip user role checks. Used by server actions. */ declare function deleteRecord(ctx: RecordContext, collection: string, recordId: string, userId: string, userRole: string, skipUserRbac?: boolean): ToolResult; /** * Read a single record with RBAC check. Returns ToolResult. * * @param skipUserRbac - When true, skip user role checks. Used by server actions. */ declare function readRecord(ctx: RecordContext, collection: string, recordId: string, userId: string, userRole: string, skipUserRbac?: boolean): ToolResult; /** * User management handlers for RecordRoom * * Users are stored in the c_users table (table-mode). * System-managed fields (email, name, role, etc.) can only be set by registerUser(). */ interface UserContext { sql: SqlStorage; state: DurableObjectState; schemaRegistry: SchemaRegistry; getPermissionContext(): PermissionContext; send(ws: WebSocket, message: { type: string; payload: unknown; }): void; } /** * Get a single user by ID from the c_users table. */ declare function getUser(sql: SqlStorage, userId: string, schemaRegistry?: SchemaRegistry): User | null; /** * Get all users from the c_users table. */ declare function getAllUsers(sql: SqlStorage, schemaRegistry?: SchemaRegistry): User[]; /** * Register or update a user in the c_users table. * * This is the ONLY way to set system-managed fields (email, name, role, etc.). * Normal mutations via handlePut will reject changes to system-managed fields. * * Role derivation (in order of priority): * 1. isAdmin=true (global admin, canvas owner, or app owner) → always 'admin' * 2. Existing role in users collection (preserved) * 3. Default role (configurable per-app, defaults to 'member') * * This allows each miniapp to define its own role hierarchy while * ensuring admins and owners always have full access. */ declare function registerUser(sql: SqlStorage, userId: string, name: string, email: string, imageUrl: string | undefined, isAdmin: boolean, defaultRole?: string, schemaRegistry?: SchemaRegistry): Promise; /** Handle user list requests without bypassing the users collection's privacy boundary. */ declare function handleUserList(ctx: UserContext, ws: WebSocket, attachment: ConnectionAttachment): void; /** * Push the current roster to every connected socket (except `except`). The * client asks for `user.list` once per connection and never again, so anything * that changes what the roster shows — a user registering, a rename, a new * avatar, a role change — must push, or every open tab keeps rendering the * peer it has never seen as "Unknown" until it reconnects. The roster is read * and projected once; each socket then gets its own view of it. */ declare function broadcastUserList(ctx: UserContext, except?: WebSocket | ReadonlySet): void; /** * Handle user profile update. * * Called when the client's profile loads after the initial WS connection. * Updates the user's name/email/imageUrl in c_users and pushes the updated * roster to every connected client so names refresh in real time. */ interface UserUpdatePayload { name?: string; email?: string; imageUrl?: string; } declare function handleUserUpdate(ctx: RecordContext, _ws: WebSocket, attachment: ConnectionAttachment, payload: UserUpdatePayload): void; /** * Handle set role request (admin only). * Updates the role field in the c_users table. */ declare function handleSetRole(ctx: UserContext, ws: WebSocket, attachment: ConnectionAttachment, payload: SetRolePayload): Promise; /** * Yjs collaborative editing handlers for RecordRoom */ /** * Schemas for system collections. * These have empty columns arrays — they only use system columns * (_row_id, _created_by, _created_at, _updated_at). * Yjs data is stored in the yjs_docs table, not in the record itself. */ declare const SYSTEM_COLLECTION_SCHEMAS: CollectionSchema[]; interface YjsContext { sql: SqlStorage; state: DurableObjectState; yjsDocs: Map; schemaRegistry: SchemaRegistry; getPermissionContext(): PermissionContext; send(ws: WebSocket, message: { type: string; payload: unknown; }): void; sendBinary(ws: WebSocket, data: Uint8Array): void; } /** * Create a Yjs doc key from collection, recordId, and fieldName */ declare function getYjsDocKey(collection: string, recordId: string, fieldName: string): YjsDocKey; /** * Get or create a Y.Doc for a record field. * Loads from database if exists, creates new if not. */ declare function getOrCreateYjsDoc(ctx: YjsContext, docKey: YjsDocKey): Y.Doc; /** * Save Yjs doc state to database. */ declare function saveYjsDoc(sql: SqlStorage, docKey: YjsDocKey, doc: Y.Doc): void; /** * Handle request to join Yjs sync for a record field. * System collections (see SYSTEM_COLLECTIONS) are permissive; others require schema. */ declare function handleYjsJoin(ctx: YjsContext, ws: WebSocket, attachment: ConnectionAttachment, payload: YjsJoinPayload): void; /** * Handle request to leave Yjs sync for a record field. */ declare function handleYjsLeave(ctx: YjsContext, ws: WebSocket, attachment: ConnectionAttachment, payload: YjsLeavePayload): void; /** * Handle binary Yjs sync messages from clients. * * Protocol: * - MSG_SYNC_STEP1: Client sends state vector → Server responds with SYNC_STEP2 (diff) * - MSG_SYNC_STEP2/UPDATE: Client sends update → Server applies and broadcasts */ declare function handleYjsBinaryMessage(ctx: YjsContext, ws: WebSocket, attachment: ConnectionAttachment, data: Uint8Array): void; /** * Broadcast a Yjs update to all subscribers of a doc, except the sender. */ declare function broadcastYjsUpdate(ctx: YjsContext, docKey: YjsDocKey, update: Uint8Array, excludeWs: WebSocket | null): void; /** * HTTP Debug API handlers for RecordRoom */ interface DebugApiContext extends SubscriptionContext { state: DurableObjectState; yjsDocs: Map; sendBinary: (ws: WebSocket, data: Uint8Array) => void; } /** * Handle HTTP API requests (for debugging) */ declare function handleApiRequest(ctx: DebugApiContext, request: Request, url: URL): Promise; /** * Tools API HTTP handlers for RecordRoom * * Provides an HTTP interface for agent tool calls (records, schemas, users). * * Caller identity is supplied via HTTP headers: * * X-User-Id: — identifies the caller (required for any * tool that touches user-bound data). * X-App-Action: 'true' — bypass user RBAC because the app's * server-side code is already the trust * boundary. Used by server actions and * cron jobs; unsafe to pass from clients. * * The userId is looked up in the users collection to derive the caller's * role. All operations go through the same RBAC checks as the WebSocket * path unless flagged as an app action. */ interface ToolsApiContext extends SubscriptionContext { state: DurableObjectState; yjsDocs: Map; sendBinary: (ws: WebSocket, data: Uint8Array) => void; ownerUserId?: string; } /** * Handle /tools/ API requests. * Called from handleApiRequest when path starts with 'tools/'. */ declare function handleToolsRequest(ctx: ToolsApiContext, request: Request, path: string): Promise; /** * Auth types for the DeepSpace SDK. * * Provider-agnostic shapes for JWT verification (issuer, audience, azp * matching, ES256 public key) and the HMAC-signed internal-request * envelope used for worker-to-worker calls. */ interface JwtVerifierConfig { /** PEM-encoded public key (ES256) for JWT verification */ publicKey: string; /** Expected issuer (e.g. "https://auth.deep.space/api/auth") */ issuer: string; /** Expected audience (usually the configured platform API URL) */ audience?: string | string[]; /** Allowed origins / authorized parties (supports wildcards like "https://*.app.space") */ authorizedParties?: string[]; /** Clock skew tolerance in milliseconds (default: 5000) */ clockSkewMs?: number; } interface JwtClaims { sub: string; iss?: string; aud?: string | string[]; azp?: string; exp?: number; iat?: number; name?: string; email?: string; image?: string; [key: string]: unknown; } interface VerifiedAuth { userId: string; claims: JwtClaims; } type VerifyResult = VerifiedAuth; interface TokenDebugInfo { iss?: string | null; aud?: string | string[] | null; azp?: string | null; exp?: number | null; iat?: number | null; } interface VerifyOutcome { result: VerifyResult | null; debug?: TokenDebugInfo; error?: unknown; } /** * JWT verification for DeepSpace workers. * * jose-based ES256 verification (jose runs on the Cloudflare Workers * edge runtime). Imported public keys are cached per-PEM to avoid * re-importing on every request, and `azp` is matched against an * optional list of authorized-party patterns supporting `*` wildcards. */ /** * Verify a DeepSpace JWT token. * * @param config - Verification configuration (public key, issuer, audience) * @param token - The JWT string to verify * @returns VerifyOutcome with either the verified result or error details */ declare function verifyJwt(config: JwtVerifierConfig, token: string | null | undefined): Promise; /** * Session-cookie identity for header-less file reads. * * ``, `