deepspace 0.18.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/testing.d.ts CHANGED
@@ -28,8 +28,8 @@ declare const test: _playwright_test.TestType<_playwright_test.PlaywrightTestArg
28
28
  *
29
29
  * Reads test accounts from the auth-origin-scoped registry at
30
30
  * `~/.deepspace/test-accounts.json` (the same file
31
- * `deepspace test accounts create` writes). Each account has at minimum
32
- * `{email, password}`; `name` and `label` are optional.
31
+ * `deepspace test accounts create` writes). Persisted accounts are bound to a
32
+ * remote account id; `name` and `label` are optional.
33
33
  *
34
34
  * Used by the multi-user Playwright fixture in `./fixtures.ts` and is
35
35
  * also exported standalone in case suites want to do their own
package/dist/testing.js CHANGED
@@ -21,36 +21,30 @@ function normalizeTestAccountScope(value) {
21
21
  }
22
22
  function loadAllTestAccounts(scope = currentTestAccountScope()) {
23
23
  const store = readTestAccountStore();
24
- return mergeTestAccounts(store.scopes[scope] ?? [], store.unscoped);
24
+ return store.scopes[scope] ?? [];
25
25
  }
26
26
  function readTestAccountStore() {
27
- if (!existsSync(TEST_ACCOUNTS_PATH)) return { version: 2, scopes: {}, unscoped: [] };
27
+ if (!existsSync(TEST_ACCOUNTS_PATH)) return { version: 2, scopes: {} };
28
28
  try {
29
29
  const raw = JSON.parse(readFileSync(TEST_ACCOUNTS_PATH, "utf-8"));
30
- if (Array.isArray(raw)) return { version: 2, scopes: {}, unscoped: validTestAccounts(raw) };
31
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
32
- return { version: 2, scopes: {}, unscoped: [] };
30
+ if (Array.isArray(raw)) return { version: 2, scopes: {} };
31
+ if (!raw || typeof raw !== "object") {
32
+ return { version: 2, scopes: {} };
33
33
  }
34
34
  const candidate = raw;
35
35
  const scopes = candidate.scopes && typeof candidate.scopes === "object" && !Array.isArray(candidate.scopes) ? Object.fromEntries(Object.entries(candidate.scopes).map(([key, value]) => [key, validTestAccounts(value)])) : {};
36
- return { version: 2, scopes, unscoped: validTestAccounts(candidate.unscoped) };
36
+ return { version: 2, scopes };
37
37
  } catch {
38
- return { version: 2, scopes: {}, unscoped: [] };
38
+ return { version: 2, scopes: {} };
39
39
  }
40
40
  }
41
41
  function validTestAccounts(value) {
42
42
  if (!Array.isArray(value)) return [];
43
- return value.filter((item) => Boolean(
44
- item && typeof item === "object" && typeof item.email === "string" && typeof item.password === "string"
45
- ));
46
- }
47
- function mergeTestAccounts(...groups) {
48
- const accounts = /* @__PURE__ */ new Map();
49
- for (const account of groups.flat()) {
50
- const key = account.id ? `id:${account.id}` : `email:${account.email}`;
51
- if (!accounts.has(key)) accounts.set(key, account);
52
- }
53
- return [...accounts.values()];
43
+ return value.filter(
44
+ (item) => Boolean(
45
+ item && typeof item === "object" && typeof item.id === "string" && typeof item.email === "string" && typeof item.password === "string"
46
+ )
47
+ );
54
48
  }
55
49
  function pickTestAccounts(count, options) {
56
50
  const all = loadAllTestAccounts();
@@ -106,28 +100,37 @@ async function signInAndSaveState(browser, account, baseURL, outPath) {
106
100
  try {
107
101
  const page = await ctx.newPage();
108
102
  await page.goto("/");
109
- const ok = await page.evaluate(
103
+ const result = await page.evaluate(
110
104
  async ({ email, password }) => {
111
105
  const res = await fetch("/api/auth/sign-in/email", {
112
106
  method: "POST",
113
107
  headers: { "Content-Type": "application/json" },
114
108
  body: JSON.stringify({ email, password })
115
109
  });
116
- return res.ok;
110
+ const body = await res.json().catch(() => null);
111
+ const error = body && typeof body === "object" ? body : null;
112
+ return {
113
+ ok: res.ok,
114
+ status: res.status,
115
+ code: typeof error?.code === "string" ? error.code : null,
116
+ message: typeof error?.message === "string" ? error.message : null
117
+ };
117
118
  },
118
119
  account
119
120
  );
120
- if (!ok) {
121
- throw new Error(
122
- `Sign-in failed for ${account.email}. Check the password is current; if the account was deleted on the server, re-create with \`deepspace test accounts create\`.`
123
- );
124
- }
121
+ if (!result.ok) throw new Error(formatSignInFailure(account.email, result));
125
122
  await ctx.storageState({ path: outPath });
126
123
  return outPath;
127
124
  } finally {
128
125
  await ctx.close();
129
126
  }
130
127
  }
128
+ function formatSignInFailure(email, failure) {
129
+ const code = failure.code ? ` ${failure.code}` : "";
130
+ const detail = failure.message ? `: ${failure.message}` : "";
131
+ const nextStep = failure.code === "INVALID_ORIGIN" ? "Add this app origin to the auth allowlist, then retry." : "Check the stored credential or recreate the account with `deepspace test accounts create`.";
132
+ return `Sign-in failed for ${email} (HTTP ${failure.status}${code})${detail}. ${nextStep}`;
133
+ }
131
134
  async function ensureStorageState(browser, account, baseURL, options = {}) {
132
135
  const path = statePathForEmail(account.email);
133
136
  const maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/testing/fixtures.ts","../src/testing/accounts.ts","../src/testing/storage-state.ts"],"sourcesContent":["/**\n * Multi-user Playwright fixture for DeepSpace apps.\n *\n * Usage:\n *\n * import { test, expect } from 'deepspace/testing'\n *\n * test('A sends, B sees', async ({ users }) => {\n * const [a, b] = await users(2)\n * await a.page.goto('/chat')\n * await b.page.goto('/chat')\n * await a.page.getByTestId('message-input').fill('hi')\n * await a.page.getByTestId('send-message-btn').click()\n * await expect(b.page.getByText('hi')).toBeVisible()\n * })\n *\n * `users(N)` picks the first N test accounts from\n * `~/.deepspace/test-accounts.json` (created via\n * `deepspace test accounts create`). `users(['Alice', 'Bob'])` picks\n * specific accounts by name.\n *\n * Each user gets a separate browser context with cached storageState\n * (per-account JSON in `~/.deepspace/playwright-states/`). Sign-in\n * happens once per account, not once per test, sidestepping Better\n * Auth's per-IP rate limit on `/api/auth/sign-in/email`.\n *\n * Contexts are auto-closed when the test finishes.\n */\n\nimport { test as baseTest, type Browser, type BrowserContext, type Page } from '@playwright/test'\nimport {\n type TestAccount,\n pickTestAccounts,\n findTestAccountByName,\n} from './accounts'\nimport { ensureStorageState } from './storage-state'\n\nexport interface MultiplayerUser {\n context: BrowserContext\n page: Page\n email: string\n name: string\n /** Test account user ID, if known from the accounts registry. */\n userId?: string\n}\n\n/**\n * Fixture function signature. Pass either a count (`users(2)`) or an\n * array of account names (`users(['Alice', 'Bob'])`). Returns one entry\n * per requested user.\n */\nexport type UsersFixture = (\n selector: number | string[],\n options?: { label?: string },\n) => Promise<MultiplayerUser[]>\n\ninterface MultiplayerFixtures {\n users: UsersFixture\n}\n\nasync function buildUser(\n browser: Browser,\n account: TestAccount,\n baseURL: string,\n): Promise<{ user: MultiplayerUser; cleanup: () => Promise<void> }> {\n const statePath = await ensureStorageState(browser, account, baseURL)\n const context = await browser.newContext({ storageState: statePath, baseURL })\n const page = await context.newPage()\n return {\n user: {\n context,\n page,\n email: account.email,\n name: account.name ?? account.email.split('@')[0],\n userId: account.userId,\n },\n cleanup: async () => {\n await context.close()\n },\n }\n}\n\nexport const test = baseTest.extend<MultiplayerFixtures>({\n users: async ({ browser, baseURL }, use) => {\n if (!baseURL) {\n throw new Error(\n '`users` fixture requires a baseURL. Set it on your Playwright config ' +\n '(e.g. `use: { baseURL: \"http://localhost:5173\" }`) or via the ' +\n 'webServer.url shortcut.',\n )\n }\n\n const cleanups: Array<() => Promise<void>> = []\n\n const usersFn: UsersFixture = async (selector, options) => {\n const accounts: TestAccount[] = Array.isArray(selector)\n ? selector.map((name) => findTestAccountByName(name))\n : pickTestAccounts(selector, options)\n\n const built = await Promise.all(\n accounts.map((acct) => buildUser(browser, acct, baseURL)),\n )\n for (const b of built) cleanups.push(b.cleanup)\n return built.map((b) => b.user)\n }\n\n await use(usersFn)\n\n // Tear down any contexts we created. Errors are swallowed — a test\n // failure shouldn't be masked by a cleanup error.\n for (const cleanup of cleanups) {\n await cleanup().catch(() => {})\n }\n },\n})\n\nexport { expect } from '@playwright/test'\n","/**\n * Account-credential plumbing for the testing module.\n *\n * Reads test accounts from the auth-origin-scoped registry at\n * `~/.deepspace/test-accounts.json` (the same file\n * `deepspace test accounts create` writes). Each account has at minimum\n * `{email, password}`; `name` and `label` are optional.\n *\n * Used by the multi-user Playwright fixture in `./fixtures.ts` and is\n * also exported standalone in case suites want to do their own\n * orchestration.\n */\n\nimport { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'\nimport { homedir } from 'node:os'\nimport { join } from 'node:path'\n\nexport interface TestAccount {\n email: string\n password: string\n name?: string\n label?: string | null\n id?: string\n userId?: string\n createdAt?: number\n}\n\nexport const TEST_ACCOUNTS_PATH = join(homedir(), '.deepspace', 'test-accounts.json')\n\nconst PRODUCTION_AUTH_ORIGIN = 'https://auth.deep.space'\nconst STAGING_AUTH_ORIGIN = 'https://auth.deepspacesites.com'\n\nexport interface TestAccountCredentialStore {\n version: 2\n scopes: Record<string, TestAccount[]>\n /** Legacy credentials stay here until a remote id proves their plane. */\n unscoped: TestAccount[]\n}\n\nexport interface RemoteTestAccount {\n id: string\n email: string\n userId: string\n label: string | null\n createdAt: number\n}\n\n/**\n * Load all test accounts the developer has created. Returns an empty\n * array if the file doesn't exist yet.\n */\nexport function currentTestAccountScope(env: NodeJS.ProcessEnv = process.env): string {\n const selected = env.DEEPSPACE_AUTH_URL ??\n (env.DEEPSPACE_ENV === 'staging' ? STAGING_AUTH_ORIGIN : PRODUCTION_AUTH_ORIGIN)\n return normalizeTestAccountScope(selected)\n}\n\nexport function normalizeTestAccountScope(value: string): string {\n try {\n return new URL(value).origin\n } catch {\n return value\n }\n}\n\nexport function loadAllTestAccounts(scope = currentTestAccountScope()): TestAccount[] {\n const store = readTestAccountStore()\n return mergeTestAccounts(store.scopes[scope] ?? [], store.unscoped)\n}\n\n/**\n * Replace the local credential registry with the subset that still exists\n * remotely. Passwords and display names remain local-only; a recreated email\n * with a different id is deliberately treated as a different account.\n */\nexport function reconcileTestAccountStore(\n remote: RemoteTestAccount[],\n scope = currentTestAccountScope(),\n): {\n accounts: TestAccount[]\n removed: number\n} {\n const result = reconcileTestAccountScopes(readTestAccountStore(), remote, scope)\n saveTestAccountStore(result.store)\n return { accounts: result.accounts, removed: result.removed }\n}\n\nexport function reconcileTestAccountScopes(\n store: TestAccountCredentialStore,\n remote: RemoteTestAccount[],\n scope: string,\n): { store: TestAccountCredentialStore; accounts: TestAccount[]; removed: number } {\n const scoped = store.scopes[scope] ?? []\n const current = reconcileTestAccounts(scoped, remote)\n const migrated = reconcileTestAccounts(store.unscoped, remote)\n const accounts = mergeTestAccounts(current, migrated)\n return {\n accounts,\n removed: Math.max(0, scoped.length - current.length),\n store: {\n version: 2,\n scopes: { ...store.scopes, [scope]: accounts },\n unscoped: store.unscoped.filter((account) => !remote.some((item) => accountMatchesRemote(account, item))),\n },\n }\n}\n\nexport function reconcileTestAccounts(\n local: TestAccount[],\n remote: RemoteTestAccount[],\n): TestAccount[] {\n const localById = new Map(local.filter((account) => account.id).map((account) => [account.id, account]))\n const legacyByEmail = new Map(local.filter((account) => !account.id).map((account) => [account.email, account]))\n return remote.flatMap((account) => {\n const stored = localById.get(account.id) ?? legacyByEmail.get(account.email)\n return stored ? [{ ...stored, ...account }] : []\n })\n}\n\n/** Add or replace one locally stored credential without creating duplicates. */\nexport function upsertTestAccount(account: TestAccount, scope = currentTestAccountScope()): void {\n const store = readTestAccountStore()\n const accounts = (store.scopes[scope] ?? []).filter(\n (stored) => stored.id !== account.id && stored.email !== account.email,\n )\n saveTestAccountStore({\n version: 2,\n scopes: { ...store.scopes, [scope]: [...accounts, account] },\n unscoped: store.unscoped.filter(\n (stored) => stored.id !== account.id && stored.email !== account.email,\n ),\n })\n}\n\n/** Remove credentials after the corresponding remote delete succeeds. */\nexport function removeTestAccounts(\n ids: string[],\n emails: string[] = [],\n scope = currentTestAccountScope(),\n): void {\n const idSet = new Set(ids)\n const emailSet = new Set(emails)\n const keep = (account: TestAccount): boolean =>\n !idSet.has(account.id ?? '') && !emailSet.has(account.email)\n const store = readTestAccountStore()\n saveTestAccountStore({\n version: 2,\n scopes: { ...store.scopes, [scope]: (store.scopes[scope] ?? []).filter(keep) },\n unscoped: store.unscoped.filter(keep),\n })\n}\n\nfunction readTestAccountStore(): TestAccountCredentialStore {\n if (!existsSync(TEST_ACCOUNTS_PATH)) return { version: 2, scopes: {}, unscoped: [] }\n try {\n const raw: unknown = JSON.parse(readFileSync(TEST_ACCOUNTS_PATH, 'utf-8'))\n if (Array.isArray(raw)) return { version: 2, scopes: {}, unscoped: validTestAccounts(raw) }\n if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {\n return { version: 2, scopes: {}, unscoped: [] }\n }\n const candidate = raw as { scopes?: unknown; unscoped?: unknown }\n const scopes = candidate.scopes && typeof candidate.scopes === 'object' && !Array.isArray(candidate.scopes)\n ? Object.fromEntries(Object.entries(candidate.scopes).map(([key, value]) => [key, validTestAccounts(value)]))\n : {}\n return { version: 2, scopes, unscoped: validTestAccounts(candidate.unscoped) }\n } catch {\n return { version: 2, scopes: {}, unscoped: [] }\n }\n}\n\nfunction validTestAccounts(value: unknown): TestAccount[] {\n if (!Array.isArray(value)) return []\n return value.filter((item): item is TestAccount => Boolean(\n item && typeof item === 'object' &&\n typeof (item as TestAccount).email === 'string' &&\n typeof (item as TestAccount).password === 'string',\n ))\n}\n\nfunction mergeTestAccounts(...groups: TestAccount[][]): TestAccount[] {\n const accounts = new Map<string, TestAccount>()\n for (const account of groups.flat()) {\n const key = account.id ? `id:${account.id}` : `email:${account.email}`\n if (!accounts.has(key)) accounts.set(key, account)\n }\n return [...accounts.values()]\n}\n\nfunction accountMatchesRemote(local: TestAccount, remote: RemoteTestAccount): boolean {\n return local.id ? local.id === remote.id : local.email === remote.email\n}\n\nfunction saveTestAccountStore(store: TestAccountCredentialStore): void {\n mkdirSync(join(homedir(), '.deepspace'), { recursive: true })\n writeFileSync(TEST_ACCOUNTS_PATH, JSON.stringify(store, null, 2), { mode: 0o600 })\n}\n\n/**\n * Pick `count` test accounts from the local registry, optionally\n * filtered by `label`. Order-stable: sorted by `createdAt` ascending so\n * the first test account you created is always the first one returned.\n *\n * Throws with a helpful message if not enough accounts exist.\n */\nexport function pickTestAccounts(\n count: number,\n options?: { label?: string },\n): TestAccount[] {\n const all = loadAllTestAccounts()\n const filtered = options?.label\n ? all.filter((a) => a.label === options.label)\n : all\n const sorted = filtered.slice().sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0))\n\n if (sorted.length < count) {\n const labelHint = options?.label ? ` (label=\"${options.label}\")` : ''\n throw new Error(\n `Multiplayer test needs ${count} test accounts${labelHint}, but only ${sorted.length} ` +\n `are present in ${TEST_ACCOUNTS_PATH}. Create more with:\\n` +\n ` deepspace test accounts create --email <name>@deepspace.test --password <pw> --name \"<name>\"${\n options?.label ? ` --label ${options.label}` : ''\n }`,\n )\n }\n\n return sorted.slice(0, count)\n}\n\n/**\n * Find a single account by name. Used by `users(['Alice', 'Bob'])`.\n * Throws if not found.\n */\nexport function findTestAccountByName(name: string): TestAccount {\n const all = loadAllTestAccounts()\n const match = all.find((a) => a.name === name)\n if (!match) {\n throw new Error(\n `No test account named \"${name}\" in ${TEST_ACCOUNTS_PATH}. Create with:\\n` +\n ` deepspace test accounts create --email ${name.toLowerCase()}@deepspace.test --password <pw> --name \"${name}\"`,\n )\n }\n return match\n}\n","/**\n * Per-account `storageState` cache for Playwright.\n *\n * Better Auth applies a per-IP rate limit on `/api/auth/sign-in/email`\n * (~5 sign-ins / 60s / endpoint). A multiplayer suite that spins up\n * 5+ users per spec would hit the limit and start failing in non-obvious\n * ways. This module signs each account in *once*, persists the\n * resulting browser cookies + storage to disk, and reuses the file on\n * subsequent runs.\n *\n * Cache layout:\n * ~/.deepspace/playwright-states/<sha256(email)>.json\n *\n * Validity: a cached file is reused if Playwright successfully loads it\n * AND the resulting context produces a non-anonymous session on the\n * target app. If validation fails, we fall back to a fresh sign-in and\n * overwrite the cache.\n */\n\nimport { createHash } from 'node:crypto'\nimport { existsSync, mkdirSync, readFileSync, statSync } from 'node:fs'\nimport { homedir } from 'node:os'\nimport { join } from 'node:path'\nimport type { Browser, BrowserContext } from '@playwright/test'\n\nconst STATES_DIR = join(homedir(), '.deepspace', 'playwright-states')\n\n/** Default freshness window — re-sign-in if the cache is older than this. */\nconst DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 // 7 days\n\nfunction statePathForEmail(email: string): string {\n const hash = createHash('sha256').update(email.trim().toLowerCase()).digest('hex').slice(0, 24)\n return join(STATES_DIR, `${hash}.json`)\n}\n\nfunction ensureStatesDir() {\n if (!existsSync(STATES_DIR)) mkdirSync(STATES_DIR, { recursive: true })\n}\n\n/** Returns true if a cache file exists and is younger than `maxAgeMs`. */\nfunction isCacheFresh(path: string, maxAgeMs: number): boolean {\n if (!existsSync(path)) return false\n try {\n const age = Date.now() - statSync(path).mtimeMs\n return age < maxAgeMs\n } catch {\n return false\n }\n}\n\n/**\n * Sign `account` in by POSTing `/api/auth/sign-in/email` against\n * `baseURL` and capturing the resulting cookies. Returns the path to the\n * Playwright `storageState` JSON file.\n *\n * The browser-driven approach is intentional: we want the cookies set\n * on the same origin Playwright contexts will load from, otherwise the\n * Set-Cookie domain attribute won't match.\n */\nasync function signInAndSaveState(\n browser: Browser,\n account: { email: string; password: string },\n baseURL: string,\n outPath: string,\n): Promise<string> {\n ensureStatesDir()\n const ctx = await browser.newContext({ baseURL })\n try {\n const page = await ctx.newPage()\n // Hit the app first so the origin is established (some auth plugins\n // require a same-origin Origin header on sign-in).\n await page.goto('/')\n const ok = await page.evaluate(\n async ({ email, password }: { email: string; password: string }) => {\n const res = await fetch('/api/auth/sign-in/email', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ email, password }),\n })\n return res.ok\n },\n account,\n )\n if (!ok) {\n throw new Error(\n `Sign-in failed for ${account.email}. Check the password is current; ` +\n `if the account was deleted on the server, re-create with ` +\n `\\`deepspace test accounts create\\`.`,\n )\n }\n await ctx.storageState({ path: outPath })\n return outPath\n } finally {\n await ctx.close()\n }\n}\n\nexport interface EnsureStorageStateOptions {\n /** Max age of a cached state file before we re-sign-in. Default 7 days. */\n maxAgeMs?: number\n /** Force a fresh sign-in even if the cache is fresh. */\n force?: boolean\n}\n\n/**\n * Ensure a Playwright `storageState` file exists for `account` and\n * return its path. Signs in once if the cache is missing, stale, or\n * `force: true`.\n */\nexport async function ensureStorageState(\n browser: Browser,\n account: { email: string; password: string },\n baseURL: string,\n options: EnsureStorageStateOptions = {},\n): Promise<string> {\n const path = statePathForEmail(account.email)\n const maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS\n\n if (!options.force && isCacheFresh(path, maxAgeMs)) {\n return path\n }\n\n return signInAndSaveState(browser, account, baseURL, path)\n}\n\n/**\n * Convenience: open a fresh `BrowserContext` for `account` using the\n * cached storage state (signing in if needed). The caller is responsible\n * for closing the context.\n */\nexport async function newSignedInContext(\n browser: Browser,\n account: { email: string; password: string },\n baseURL: string,\n options: EnsureStorageStateOptions = {},\n): Promise<BrowserContext> {\n const statePath = await ensureStorageState(browser, account, baseURL, options)\n return browser.newContext({ storageState: statePath, baseURL })\n}\n\n/** Read a cached storageState file (mostly for debugging). */\nexport function readCachedState(email: string): unknown | null {\n const path = statePathForEmail(email)\n if (!existsSync(path)) return null\n try {\n return JSON.parse(readFileSync(path, 'utf-8'))\n } catch {\n return null\n }\n}\n\n/** Where the cache for `email` lives. Useful for diagnostics. */\nexport function getStatePathForEmail(email: string): string {\n return statePathForEmail(email)\n}\n"],"mappings":"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","names":["existsSync","mkdirSync","readFileSync","homedir","join"]}
1
+ {"version":3,"sources":["../src/testing/fixtures.ts","../src/testing/accounts.ts","../src/testing/storage-state.ts"],"sourcesContent":["/**\n * Multi-user Playwright fixture for DeepSpace apps.\n *\n * Usage:\n *\n * import { test, expect } from 'deepspace/testing'\n *\n * test('A sends, B sees', async ({ users }) => {\n * const [a, b] = await users(2)\n * await a.page.goto('/chat')\n * await b.page.goto('/chat')\n * await a.page.getByTestId('message-input').fill('hi')\n * await a.page.getByTestId('send-message-btn').click()\n * await expect(b.page.getByText('hi')).toBeVisible()\n * })\n *\n * `users(N)` picks the first N test accounts from\n * `~/.deepspace/test-accounts.json` (created via\n * `deepspace test accounts create`). `users(['Alice', 'Bob'])` picks\n * specific accounts by name.\n *\n * Each user gets a separate browser context with cached storageState\n * (per-account JSON in `~/.deepspace/playwright-states/`). Sign-in\n * happens once per account, not once per test, sidestepping Better\n * Auth's per-IP rate limit on `/api/auth/sign-in/email`.\n *\n * Contexts are auto-closed when the test finishes.\n */\n\nimport { test as baseTest, type Browser, type BrowserContext, type Page } from '@playwright/test'\nimport {\n type TestAccount,\n pickTestAccounts,\n findTestAccountByName,\n} from './accounts'\nimport { ensureStorageState } from './storage-state'\n\nexport interface MultiplayerUser {\n context: BrowserContext\n page: Page\n email: string\n name: string\n /** Test account user ID, if known from the accounts registry. */\n userId?: string\n}\n\n/**\n * Fixture function signature. Pass either a count (`users(2)`) or an\n * array of account names (`users(['Alice', 'Bob'])`). Returns one entry\n * per requested user.\n */\nexport type UsersFixture = (\n selector: number | string[],\n options?: { label?: string },\n) => Promise<MultiplayerUser[]>\n\ninterface MultiplayerFixtures {\n users: UsersFixture\n}\n\nasync function buildUser(\n browser: Browser,\n account: TestAccount,\n baseURL: string,\n): Promise<{ user: MultiplayerUser; cleanup: () => Promise<void> }> {\n const statePath = await ensureStorageState(browser, account, baseURL)\n const context = await browser.newContext({ storageState: statePath, baseURL })\n const page = await context.newPage()\n return {\n user: {\n context,\n page,\n email: account.email,\n name: account.name ?? account.email.split('@')[0],\n userId: account.userId,\n },\n cleanup: async () => {\n await context.close()\n },\n }\n}\n\nexport const test = baseTest.extend<MultiplayerFixtures>({\n users: async ({ browser, baseURL }, use) => {\n if (!baseURL) {\n throw new Error(\n '`users` fixture requires a baseURL. Set it on your Playwright config ' +\n '(e.g. `use: { baseURL: \"http://localhost:5173\" }`) or via the ' +\n 'webServer.url shortcut.',\n )\n }\n\n const cleanups: Array<() => Promise<void>> = []\n\n const usersFn: UsersFixture = async (selector, options) => {\n const accounts: TestAccount[] = Array.isArray(selector)\n ? selector.map((name) => findTestAccountByName(name))\n : pickTestAccounts(selector, options)\n\n const built = await Promise.all(\n accounts.map((acct) => buildUser(browser, acct, baseURL)),\n )\n for (const b of built) cleanups.push(b.cleanup)\n return built.map((b) => b.user)\n }\n\n await use(usersFn)\n\n // Tear down any contexts we created. Errors are swallowed — a test\n // failure shouldn't be masked by a cleanup error.\n for (const cleanup of cleanups) {\n await cleanup().catch(() => {})\n }\n },\n})\n\nexport { expect } from '@playwright/test'\n","/**\n * Account-credential plumbing for the testing module.\n *\n * Reads test accounts from the auth-origin-scoped registry at\n * `~/.deepspace/test-accounts.json` (the same file\n * `deepspace test accounts create` writes). Persisted accounts are bound to a\n * remote account id; `name` and `label` are optional.\n *\n * Used by the multi-user Playwright fixture in `./fixtures.ts` and is\n * also exported standalone in case suites want to do their own\n * orchestration.\n */\n\nimport { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'\nimport { homedir } from 'node:os'\nimport { join } from 'node:path'\n\nexport interface TestAccount {\n email: string\n password: string\n name?: string\n label?: string | null\n id?: string\n userId?: string\n createdAt?: number\n}\n\nexport const TEST_ACCOUNTS_PATH = join(homedir(), '.deepspace', 'test-accounts.json')\n\nconst PRODUCTION_AUTH_ORIGIN = 'https://auth.deep.space'\nconst STAGING_AUTH_ORIGIN = 'https://auth.deepspacesites.com'\n\nexport interface TestAccountCredentialStore {\n version: 2\n scopes: Record<string, TestAccount[]>\n}\n\nexport interface RemoteTestAccount {\n id: string\n email: string\n userId: string\n label: string | null\n createdAt: number\n}\n\n/**\n * Load all test accounts the developer has created. Returns an empty\n * array if the file doesn't exist yet.\n */\nexport function currentTestAccountScope(env: NodeJS.ProcessEnv = process.env): string {\n const selected = env.DEEPSPACE_AUTH_URL ??\n (env.DEEPSPACE_ENV === 'staging' ? STAGING_AUTH_ORIGIN : PRODUCTION_AUTH_ORIGIN)\n return normalizeTestAccountScope(selected)\n}\n\nexport function normalizeTestAccountScope(value: string): string {\n try {\n return new URL(value).origin\n } catch {\n return value\n }\n}\n\nexport function loadAllTestAccounts(scope = currentTestAccountScope()): TestAccount[] {\n const store = readTestAccountStore()\n return store.scopes[scope] ?? []\n}\n\n/**\n * Replace the local credential registry with the subset that still exists\n * remotely. Passwords and display names remain local-only; a recreated email\n * with a different id is deliberately treated as a different account.\n */\nexport function reconcileTestAccountStore(\n remote: RemoteTestAccount[],\n scope = currentTestAccountScope(),\n): {\n accounts: TestAccount[]\n removed: number\n} {\n const result = reconcileTestAccountScopes(readTestAccountStore(), remote, scope)\n saveTestAccountStore(result.store)\n return { accounts: result.accounts, removed: result.removed }\n}\n\nexport function reconcileTestAccountScopes(\n store: TestAccountCredentialStore,\n remote: RemoteTestAccount[],\n scope: string,\n): { store: TestAccountCredentialStore; accounts: TestAccount[]; removed: number } {\n const scoped = store.scopes[scope] ?? []\n const accounts = reconcileTestAccounts(scoped, remote)\n return {\n accounts,\n removed: Math.max(0, scoped.length - accounts.length),\n store: {\n version: 2,\n scopes: { ...store.scopes, [scope]: accounts },\n },\n }\n}\n\nexport function reconcileTestAccounts(\n local: TestAccount[],\n remote: RemoteTestAccount[],\n): TestAccount[] {\n const localById = new Map(local.filter((account) => account.id).map((account) => [account.id, account]))\n return remote.flatMap((account) => {\n const stored = localById.get(account.id)\n return stored ? [{ ...stored, ...account }] : []\n })\n}\n\n/** Add or replace one locally stored credential without creating duplicates. */\nexport function upsertTestAccount(account: TestAccount, scope = currentTestAccountScope()): void {\n const store = readTestAccountStore()\n const accounts = (store.scopes[scope] ?? []).filter(\n (stored) => stored.id !== account.id && stored.email !== account.email,\n )\n saveTestAccountStore({\n version: 2,\n scopes: { ...store.scopes, [scope]: [...accounts, account] },\n })\n}\n\n/** Remove credentials after the corresponding remote delete succeeds. */\nexport function removeTestAccounts(\n ids: string[],\n emails: string[] = [],\n scope = currentTestAccountScope(),\n): void {\n const idSet = new Set(ids)\n const emailSet = new Set(emails)\n const keep = (account: TestAccount): boolean =>\n !idSet.has(account.id ?? '') && !emailSet.has(account.email)\n const store = readTestAccountStore()\n saveTestAccountStore({\n version: 2,\n scopes: { ...store.scopes, [scope]: (store.scopes[scope] ?? []).filter(keep) },\n })\n}\n\nfunction readTestAccountStore(): TestAccountCredentialStore {\n if (!existsSync(TEST_ACCOUNTS_PATH)) return { version: 2, scopes: {} }\n try {\n const raw: unknown = JSON.parse(readFileSync(TEST_ACCOUNTS_PATH, 'utf-8'))\n if (Array.isArray(raw)) return { version: 2, scopes: {} }\n if (!raw || typeof raw !== 'object') {\n return { version: 2, scopes: {} }\n }\n const candidate = raw as { scopes?: unknown }\n const scopes = candidate.scopes && typeof candidate.scopes === 'object' && !Array.isArray(candidate.scopes)\n ? Object.fromEntries(Object.entries(candidate.scopes).map(([key, value]) => [key, validTestAccounts(value)]))\n : {}\n return { version: 2, scopes }\n } catch {\n return { version: 2, scopes: {} }\n }\n}\n\nfunction validTestAccounts(value: unknown): TestAccount[] {\n if (!Array.isArray(value)) return []\n return value.filter((item): item is TestAccount =>\n Boolean(\n item &&\n typeof item === 'object' &&\n typeof (item as TestAccount).id === 'string' &&\n typeof (item as TestAccount).email === 'string' &&\n typeof (item as TestAccount).password === 'string',\n ),\n )\n}\n\nfunction saveTestAccountStore(store: TestAccountCredentialStore): void {\n mkdirSync(join(homedir(), '.deepspace'), { recursive: true })\n writeFileSync(TEST_ACCOUNTS_PATH, JSON.stringify(store, null, 2), { mode: 0o600 })\n}\n\n/**\n * Pick `count` test accounts from the local registry, optionally\n * filtered by `label`. Order-stable: sorted by `createdAt` ascending so\n * the first test account you created is always the first one returned.\n *\n * Throws with a helpful message if not enough accounts exist.\n */\nexport function pickTestAccounts(\n count: number,\n options?: { label?: string },\n): TestAccount[] {\n const all = loadAllTestAccounts()\n const filtered = options?.label\n ? all.filter((a) => a.label === options.label)\n : all\n const sorted = filtered.slice().sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0))\n\n if (sorted.length < count) {\n const labelHint = options?.label ? ` (label=\"${options.label}\")` : ''\n throw new Error(\n `Multiplayer test needs ${count} test accounts${labelHint}, but only ${sorted.length} ` +\n `are present in ${TEST_ACCOUNTS_PATH}. Create more with:\\n` +\n ` deepspace test accounts create --email <name>@deepspace.test --password <pw> --name \"<name>\"${\n options?.label ? ` --label ${options.label}` : ''\n }`,\n )\n }\n\n return sorted.slice(0, count)\n}\n\n/**\n * Find a single account by name. Used by `users(['Alice', 'Bob'])`.\n * Throws if not found.\n */\nexport function findTestAccountByName(name: string): TestAccount {\n const all = loadAllTestAccounts()\n const match = all.find((a) => a.name === name)\n if (!match) {\n throw new Error(\n `No test account named \"${name}\" in ${TEST_ACCOUNTS_PATH}. Create with:\\n` +\n ` deepspace test accounts create --email ${name.toLowerCase()}@deepspace.test --password <pw> --name \"${name}\"`,\n )\n }\n return match\n}\n","/**\n * Per-account `storageState` cache for Playwright.\n *\n * Better Auth applies a per-IP rate limit on `/api/auth/sign-in/email`\n * (~5 sign-ins / 60s / endpoint). A multiplayer suite that spins up\n * 5+ users per spec would hit the limit and start failing in non-obvious\n * ways. This module signs each account in *once*, persists the\n * resulting browser cookies + storage to disk, and reuses the file on\n * subsequent runs.\n *\n * Cache layout:\n * ~/.deepspace/playwright-states/<sha256(email)>.json\n *\n * Validity: a cached file is reused if Playwright successfully loads it\n * AND the resulting context produces a non-anonymous session on the\n * target app. If validation fails, we fall back to a fresh sign-in and\n * overwrite the cache.\n */\n\nimport { createHash } from 'node:crypto'\nimport { existsSync, mkdirSync, readFileSync, statSync } from 'node:fs'\nimport { homedir } from 'node:os'\nimport { join } from 'node:path'\nimport type { Browser, BrowserContext } from '@playwright/test'\n\nconst STATES_DIR = join(homedir(), '.deepspace', 'playwright-states')\n\n/** Default freshness window — re-sign-in if the cache is older than this. */\nconst DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 // 7 days\n\nfunction statePathForEmail(email: string): string {\n const hash = createHash('sha256').update(email.trim().toLowerCase()).digest('hex').slice(0, 24)\n return join(STATES_DIR, `${hash}.json`)\n}\n\nfunction ensureStatesDir() {\n if (!existsSync(STATES_DIR)) mkdirSync(STATES_DIR, { recursive: true })\n}\n\n/** Returns true if a cache file exists and is younger than `maxAgeMs`. */\nfunction isCacheFresh(path: string, maxAgeMs: number): boolean {\n if (!existsSync(path)) return false\n try {\n const age = Date.now() - statSync(path).mtimeMs\n return age < maxAgeMs\n } catch {\n return false\n }\n}\n\n/**\n * Sign `account` in by POSTing `/api/auth/sign-in/email` against\n * `baseURL` and capturing the resulting cookies. Returns the path to the\n * Playwright `storageState` JSON file.\n *\n * The browser-driven approach is intentional: we want the cookies set\n * on the same origin Playwright contexts will load from, otherwise the\n * Set-Cookie domain attribute won't match.\n */\nasync function signInAndSaveState(\n browser: Browser,\n account: { email: string; password: string },\n baseURL: string,\n outPath: string,\n): Promise<string> {\n ensureStatesDir()\n const ctx = await browser.newContext({ baseURL })\n try {\n const page = await ctx.newPage()\n // Hit the app first so the origin is established (some auth plugins\n // require a same-origin Origin header on sign-in).\n await page.goto('/')\n const result = await page.evaluate(\n async ({ email, password }: { email: string; password: string }) => {\n const res = await fetch('/api/auth/sign-in/email', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ email, password }),\n })\n const body: unknown = await res.json().catch(() => null)\n const error = body && typeof body === 'object' ? (body as Record<string, unknown>) : null\n return {\n ok: res.ok,\n status: res.status,\n code: typeof error?.code === 'string' ? error.code : null,\n message: typeof error?.message === 'string' ? error.message : null,\n }\n },\n account,\n )\n if (!result.ok) throw new Error(formatSignInFailure(account.email, result))\n await ctx.storageState({ path: outPath })\n return outPath\n } finally {\n await ctx.close()\n }\n}\n\ninterface SignInFailure {\n status: number\n code: string | null\n message: string | null\n}\n\n/** Keep the server's safe status/code so auth failures remain actionable. */\nexport function formatSignInFailure(email: string, failure: SignInFailure): string {\n const code = failure.code ? ` ${failure.code}` : ''\n const detail = failure.message ? `: ${failure.message}` : ''\n const nextStep =\n failure.code === 'INVALID_ORIGIN'\n ? 'Add this app origin to the auth allowlist, then retry.'\n : 'Check the stored credential or recreate the account with `deepspace test accounts create`.'\n return `Sign-in failed for ${email} (HTTP ${failure.status}${code})${detail}. ${nextStep}`\n}\n\nexport interface EnsureStorageStateOptions {\n /** Max age of a cached state file before we re-sign-in. Default 7 days. */\n maxAgeMs?: number\n /** Force a fresh sign-in even if the cache is fresh. */\n force?: boolean\n}\n\n/**\n * Ensure a Playwright `storageState` file exists for `account` and\n * return its path. Signs in once if the cache is missing, stale, or\n * `force: true`.\n */\nexport async function ensureStorageState(\n browser: Browser,\n account: { email: string; password: string },\n baseURL: string,\n options: EnsureStorageStateOptions = {},\n): Promise<string> {\n const path = statePathForEmail(account.email)\n const maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS\n\n if (!options.force && isCacheFresh(path, maxAgeMs)) {\n return path\n }\n\n return signInAndSaveState(browser, account, baseURL, path)\n}\n\n/**\n * Convenience: open a fresh `BrowserContext` for `account` using the\n * cached storage state (signing in if needed). The caller is responsible\n * for closing the context.\n */\nexport async function newSignedInContext(\n browser: Browser,\n account: { email: string; password: string },\n baseURL: string,\n options: EnsureStorageStateOptions = {},\n): Promise<BrowserContext> {\n const statePath = await ensureStorageState(browser, account, baseURL, options)\n return browser.newContext({ storageState: statePath, baseURL })\n}\n\n/** Read a cached storageState file (mostly for debugging). */\nexport function readCachedState(email: string): unknown | null {\n const path = statePathForEmail(email)\n if (!existsSync(path)) return null\n try {\n return JSON.parse(readFileSync(path, 'utf-8'))\n } catch {\n return null\n }\n}\n\n/** Where the cache for `email` lives. Useful for diagnostics. */\nexport function getStatePathForEmail(email: string): string {\n return statePathForEmail(email)\n}\n"],"mappings":"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","names":["existsSync","mkdirSync","readFileSync","homedir","join"]}
package/dist/worker.d.ts CHANGED
@@ -793,7 +793,7 @@ declare function encode<M extends BaseMessage<string, unknown>>(message: M): str
793
793
  * Provides:
794
794
  * - WebSocket upgrade with Cloudflare hibernation API
795
795
  * - Connection tracking (WebSocket -> UserAttachment)
796
- * - Auth: parse JWT-verified user info from URL search params
796
+ * - Auth: parse JWT-verified user info from internal request headers
797
797
  * - Presence: connected users list, awareness on connect/disconnect
798
798
  * - Message routing: JSON parse -> dispatch by `type` field, binary hook
799
799
  * - Raw SQLite access via this.sql
@@ -1148,8 +1148,8 @@ interface ConnectionAttachment extends UserAttachment {
1148
1148
  subscriptions: Subscription[];
1149
1149
  /** Yjs docs this connection is editing */
1150
1150
  yjsSubscriptions: YjsSubscription[];
1151
- /** Client-side Yjs awareness clientId (extracted from first awareness message) */
1152
- awarenessClientId?: number;
1151
+ /** Client-side Yjs awareness clientId for each subscribed document. */
1152
+ awarenessClientIds?: Partial<Record<YjsDocKey, number>>;
1153
1153
  }
1154
1154
  /**
1155
1155
  * Context passed to handlers for accessing shared resources.
@@ -1238,8 +1238,14 @@ declare function canDelete(schema: CollectionSchema, role: string, record: {
1238
1238
  createdBy: string;
1239
1239
  recordId?: string;
1240
1240
  }, userId: string, ctx?: PermissionContext): boolean;
1241
- /** Check if a field update violates writableFields restrictions. */
1241
+ /** Check if a caller-supplied field write violates writableFields restrictions. */
1242
1242
  declare function checkFieldPermissions(schema: CollectionSchema, role: string, newData: Record<string, unknown>, existingData?: Record<string, unknown>): string | null;
1243
+ /**
1244
+ * `unclaimed-or-own` lets a client claim an empty owner field, but never lets
1245
+ * them choose another user's id. Check the post-write record so the invariant
1246
+ * covers both creation and updates while still allowing an owner to unclaim.
1247
+ */
1248
+ declare function checkUnclaimedOwnerTransition(schema: CollectionSchema, role: string, nextData: Record<string, unknown>, userId: string): string | null;
1243
1249
  /** Names of columns managed by the system (registerUser), not client mutations. */
1244
1250
  declare const SYSTEM_MANAGED_COLUMNS: Set<string>;
1245
1251
  /** Standard user columns. Apps spread these into their users schema. */
@@ -1497,7 +1503,7 @@ declare class CanvasRoom<E = Record<string, unknown>> extends BaseRoom<E> {
1497
1503
  * Each scope ID maps to its own DO instance. Clients connect via
1498
1504
  * /ws/presence/:scopeId and receive real-time presence for that scope.
1499
1505
  *
1500
- * Peers can attach arbitrary state (cursor position, typing indicator,
1506
+ * Peers can attach small ephemeral state (cursor position, typing indicator,
1501
1507
  * viewport, selection, etc.) via MSG.PRESENCE_UPDATE.
1502
1508
  *
1503
1509
  * Message types: presence.*
@@ -1506,10 +1512,8 @@ declare class CanvasRoom<E = Record<string, unknown>> extends BaseRoom<E> {
1506
1512
  interface PresencePeer {
1507
1513
  userId: string;
1508
1514
  userName: string;
1509
- userEmail: string;
1510
- userImageUrl?: string;
1511
1515
  joinedAt: string;
1512
- /** Arbitrary per-user state (cursor, typing, viewport, etc.) */
1516
+ /** Small per-user state (cursor, typing, viewport, etc.) */
1513
1517
  state: Record<string, unknown>;
1514
1518
  }
1515
1519
  interface PresenceAttachment extends UserAttachment {
@@ -1672,6 +1676,13 @@ interface JobRoomConfig {
1672
1676
  snapshotLimit?: number;
1673
1677
  /** Delay before a failed attempt is retried, in ms. Default 1000. */
1674
1678
  retryBackoffMs?: number;
1679
+ /** Re-check whether a connected user may mutate the queue. Defaults to member/admin roles. */
1680
+ authorizeWrite?: (user: UserAttachment) => boolean | Promise<boolean>;
1681
+ /** Re-check whether a connected user may observe the queue. Defaults to authorizeWrite. */
1682
+ authorizeRead?: (user: UserAttachment) => boolean | Promise<boolean>;
1683
+ }
1684
+ interface JobAttachment extends UserAttachment {
1685
+ canWrite: boolean;
1675
1686
  }
1676
1687
  declare abstract class JobRoom<E = Record<string, unknown>, P = unknown, R = unknown> extends BaseRoom<E> {
1677
1688
  private initialized;
@@ -1679,6 +1690,9 @@ declare abstract class JobRoom<E = Record<string, unknown>, P = unknown, R = unk
1679
1690
  private readonly retentionMs;
1680
1691
  private readonly snapshotLimit;
1681
1692
  private readonly retryBackoffMs;
1693
+ private readonly authorizeWrite;
1694
+ private readonly authorizeRead;
1695
+ private broadcastQueue;
1682
1696
  /** In-flight jobs in this isolate; lets same-isolate JOB_CANCEL fire the signal. */
1683
1697
  private readonly inFlight;
1684
1698
  /** Set by `ctx.continue` inside `executeJob`; reset before every run. */
@@ -1686,7 +1700,7 @@ declare abstract class JobRoom<E = Record<string, unknown>, P = unknown, R = unk
1686
1700
  constructor(state: DurableObjectState, env: unknown, config?: JobRoomConfig);
1687
1701
  private ensureInitialized;
1688
1702
  fetch(request: Request): Promise<Response>;
1689
- protected onConnect(ws: WebSocket, user: UserAttachment): UserAttachment;
1703
+ protected onConnect(ws: WebSocket, user: UserAttachment): Promise<JobAttachment>;
1690
1704
  protected onMessage(ws: WebSocket, user: UserAttachment, message: {
1691
1705
  type: string;
1692
1706
  [key: string]: unknown;
@@ -1732,6 +1746,9 @@ declare abstract class JobRoom<E = Record<string, unknown>, P = unknown, R = unk
1732
1746
  private executeJob;
1733
1747
  private makeContext;
1734
1748
  private readStatus;
1749
+ /** Reauthorize every live event so role revocation does not leave a stale reader. */
1750
+ protected broadcast(message: ServerMessage, exclude?: WebSocket): void;
1751
+ private resolveAccess;
1735
1752
  private scheduleNextAlarm;
1736
1753
  private recoverStuckRunning;
1737
1754
  private pruneExpired;
@@ -2348,6 +2365,8 @@ declare function getMessageType(data: Uint8Array): number;
2348
2365
  /** Asset-layer misses became real 404s, and the worker owns the fallback. The
2349
2366
  * config and the worker's fallback path have to move together. */
2350
2367
  declare const WORKER_OWNED_NOT_FOUND_MIGRATION_ID = "2026-08-worker-owned-not-found";
2368
+ /** Room identity moved to internal headers and privileged room routes gained app-role checks. */
2369
+ declare const SECURE_ROOM_BOUNDARIES_MIGRATION_ID = "2026-08-secure-room-boundaries";
2351
2370
  type AppMigrationIdsValidation = {
2352
2371
  valid: true;
2353
2372
  ids: string[];
@@ -2610,6 +2629,25 @@ interface ProxyRoute {
2610
2629
  }
2611
2630
  declare const BROWSER_PROXY_ROUTES: ReadonlyArray<ProxyRoute>;
2612
2631
 
2632
+ /**
2633
+ * Identity claims cross from an app Worker to its private room Durable Object
2634
+ * in headers. Header values are ByteStrings, while verified profile claims may
2635
+ * contain arbitrary Unicode or control characters, so carry each value as
2636
+ * UTF-8 base64url rather than writing the claim directly.
2637
+ */
2638
+ interface RoomAuth {
2639
+ userId: string;
2640
+ claims: {
2641
+ name?: string;
2642
+ email?: string;
2643
+ image?: string;
2644
+ };
2645
+ }
2646
+ /** Strip public identity inputs and forward only verified room identity. */
2647
+ declare function authenticatedRoomRequest(request: Request, auth: RoomAuth | null, identity?: {
2648
+ role?: string;
2649
+ }): Request;
2650
+
2613
2651
  /**
2614
2652
  * Wildcard-aware CORS origin matching for the platform workers.
2615
2653
  *
@@ -3242,6 +3280,27 @@ interface CronEnv extends ApiWorkerEnv {
3242
3280
  */
3243
3281
  declare function buildCronContext(env: CronEnv, ownerUserId: string, roomId?: string): CronContext;
3244
3282
 
3283
+ /**
3284
+ * Standard DeepSpace role constants.
3285
+ *
3286
+ * Every DeepSpace app uses the same three roles.
3287
+ * Apps can import these instead of defining them locally.
3288
+ */
3289
+ declare const ROLES: {
3290
+ readonly VIEWER: "viewer";
3291
+ readonly MEMBER: "member";
3292
+ readonly ADMIN: "admin";
3293
+ };
3294
+ type Role = (typeof ROLES)[keyof typeof ROLES];
3295
+
3296
+ interface AppRoleEnv {
3297
+ RECORD_ROOMS: DurableObjectNamespace;
3298
+ DEEPSPACE_APP_ID: string;
3299
+ OWNER_USER_ID: string;
3300
+ }
3301
+ /** Resolve a user's current role from the app's canonical users collection. */
3302
+ declare function resolveAppRole(env: AppRoleEnv, userId: string): Promise<Role>;
3303
+
3245
3304
  /**
3246
3305
  * AI provider helpers — create Vercel AI SDK providers that route through
3247
3306
  * the DeepSpace API worker proxy for per-user billing.
@@ -5178,4 +5237,4 @@ interface AppLogsResponse {
5178
5237
  declare const APP_LOG_EVENT_KEYS: readonly ["id", "timestamp", "level", "eventType", "message", "source", "outcome", "request", "exception"];
5179
5238
  type AppLogEventKey = (typeof APP_LOG_EVENT_KEYS)[number];
5180
5239
 
5181
- export { ACCOUNT_STORAGE_LIMIT_BYTES, AI_CHATS_SCHEMA, AI_MESSAGES_SCHEMA, ALLOWED_BINDING_TYPES, APP_ID_RE, APP_LOG_EVENT_KEYS, APP_NAME_RULES, ASSET_NOT_FOUND_HANDLING_VALUES, AUTO_PROVISIONABLE_TYPES, AUTO_PROVISION_SENTINEL, type ActionContext, type ActionHandler, type ActionResult, type ActionTools, type ApiWorkerEnv, type AppLogEvent, type AppLogEventKey, type AppLogsResponse, type AppMigrationIdsValidation, type AppNameResolution, type AppNameValidation, type AssetNotFoundHandling, type AuthWorkerEnv, Awareness, type AwarenessState, type AwarenessStates, BASE_USERS_SCHEMA, BROWSER_PROXY_ROUTES, BUILT_IN_TOOLS, type BaseMessage, BaseRoom, CHANNELS_SCHEMA, CHANNEL_INVITATIONS_SCHEMA, CHANNEL_MEMBERS_SCHEMA, CLIENT_LOG_MARKER, CONVERSATION_SCHEMAS, COST_RATES, CanvasRoom, type CanvasShape, type ChatContextConfig, type ChatMessageRow, type ChatRow, type ChatTurn, type ClientErrorKind, type ClientErrorReport, type ClientMessage, type CollectionPermissionSummary, type CollectionSchema, type ColumnDefinition, type ColumnInterpretation, type ComposioToolsOptions, type ConnectionAttachment, type ConvMemberData, type ConvMessageData, type ConvReactionData, type ConvReadCursorData, type ConvVoteData, type ConversationStateData, type CronContext, type CronExecution, CronRoom, type CronRoomConfig, type CronTask, type CustomBinding, type CustomBindingManifest, DEEPSPACE_AGENT_PROFILES, DEEPSPACE_AI_DEFAULTS, DEEPSPACE_AI_MODELS, DEEPSPACE_MODEL_CATALOG_PROVENANCE, DEFAULT_CONTEXT_CONFIG, DEFAULT_DO_MANIFEST, DEFAULT_QUERY_LIMIT, DEPLOYED_APPS_DEFAULTS, DIRECTORY_SCHEMAS, type DOBindings, type DOManifest, type DOManifestEntry, type DeepSpaceAIEnv, type DeepSpaceAIModel, type DeepSpaceAIModelId, type DeepSpaceAIOptions, type DeepSpaceAIProvider, type DeepSpaceAgentDiagnosticContext, DeepSpaceAgentModelError, type DeepSpaceAgentProfile, DeepSpaceAgentProfileError, type DeepSpaceAgentProfileId, type DeepSpaceAgentStream, type DeepSpaceAgentStreamOptions, type DeepSpaceAgentSupport, type DeepSpaceAuth, type DeepSpaceAuthConfig, type DeepSpaceDocumentationRouteEnv, type DeepSpaceDocumentationRouteOptions, type DirectoryCommunityData, type DirectoryConversationData, type DirectoryMembershipData, type DirectoryPostData, type DoMigrationDirective, type DoMigrationPlan, type ExistingDOBinding, GLOBAL_DO_TYPES, GLOBAL_DO_TYPE_NAMES, type GetActionData, type GlobalDOType, type HandlerContext, type Job, type JobContext, JobRoom, type JobRoomConfig, type JobStatus, type JwtClaims, type JwtVerifierConfig, LOG_LEVELS, type LogLevel, MAX_APP_FILE_BYTES, MAX_DEPLOY_ASSET_FILE_BYTES, MAX_UPLOAD_PARTS, MAX_UPLOAD_REQUEST_BYTES, MESSAGES_SCHEMA, MSG, MSG_AWARENESS, MSG_SYNC, MSG_SYNC_STEP1, MSG_SYNC_STEP2, MSG_SYNC_UPDATE, MSG_YJS_AWARENESS, MSG_YJS_SYNC, type MessageHandlers, type MsgType, type MutateActionData, type PermissionAnalysis, type PermissionContext, type PermissionLevel, type PermissionSource, type PlatformWorkerEnv, type PrefixResult, type PresencePeer, PresenceRoom, type ProtocolMessage, type ProxyRoute, type QueryActionData, REACTIONS_SCHEMA, READ_RECEIPTS_SCHEMA, REGISTRY_INSTANCE, RELEASE_STAMP_PATH, REPO_STORE_LIMIT_BYTES, REQUIRED_COMPATIBILITY_FLAGS, RESERVED_BINDING_NAMES, RESERVED_COLLECTION_NAMES, RESERVED_RUN_WORKER_FIRST, RESOURCE_ID_RE, ROLE_ADMIN, ROLE_ANONYMOUS, ROLE_DEFAULT, RecordRoom, type RecordRoomConfig, RegistryClientError, type ResolvedColumn, type ResolvedDeepSpaceAgentModel, type ResolvedPermission, type RolePermissions, type RunMigrationsResult, SDK_RUN_WORKER_FIRST, STRICT_APP_ID_RE, SYSTEM_COLLECTIONS, SYSTEM_MANAGED_COLUMNS, SchemaRegistry, type ScopeContext, type ScopedR2Auth, type ScopedR2Config, type ScopedR2Handler, type ScreenshotEnv, type ScreenshotResult, type ServerMessage, type SharedConnection, type StorageAdmission, type StoredRecord, type Summarizer, type SyncResult, TEST_ACCOUNT_EMAIL_SUFFIX, type TokenDebugInfo, type ToolResult, type ToolSchema, UNDECLARED_ASSET_NOT_FOUND_HANDLING, UPLOAD_PART_BYTES, USERS_COLUMNS, type User, type UserAttachment, VOTING_SCHEMAS, type ValidationError, type VerifiedAuth, type VerifyOutcome, type VerifyResult, type Viewport, WORKER_OWNED_NOT_FOUND_MIGRATION_ID, WORKSPACE_SCHEMAS, YjsRoom, analyzePermissions, apiWorkerFetch, appendMessage, applyAiToolDefaults, applySlidingWindow, authWorkerFetch, bindingManifestFromOutputConfig, buildCronContext, buildTableSelect, buildUiParts, callRegistry, canCreate, canDelete, canRead, canUpdate, capToolResultSize, captureScreenshot, checkFieldPermissions, clientBuild, coerceValue, collectionTableName, columnId, composioTools, computeDoMigration, computeHmacHex, createChat, createDecoder, createDeepSpaceAI, createDeepSpaceAuth, createEncoder, createScopedR2Handler, dataToColumnValues, decodeJwtPayload, deepSpaceAgentErrorSummary, deleteChatCascade, dispatch, encode, encodeAwarenessMessage, encodeSyncStep1, encodeSyncStep2, encodeUpdate, enqueueJob, formatBytes, getChat, getDeepSpaceAIModel, getGlobalDOSchemas, getGlobalDOType, getMessageType, getRolePermissions, handleAwarenessMessage, handleClientErrorReport, handleSyncMessage, hasContent, isAssetNotFoundHandling, isAutoProvision, isFilesVerbPath, isOwner, isPlatformReservedPath, isTestAccountClaims, isTestAccountEmail, isTestAccountTier, lintSchema, listDeepSpaceAgentModels, loadMessages, makeDefaultSummarizer, matchOrigin, meterAi, meterUsage, meterVectorize, noopPermissionContext, parseQuotaLimit, platformCorsPatterns, platformWorkerFetch, prepareMessagesWithCompaction, priceBindingUsageEvent, readVarUint, readVarUint8Array, registerClientErrorRoute, registerDeepSpaceDocumentation, registryErrorJson, registryStub, resolveAppName, resolveColumn, resolveCompatibilityFlags, resolveDeepSpaceAgentModel, rowToData, runMigrations, serverBuild, storageLimitForTier, storageQuotaMessage, streamDeepSpaceAgent, timingSafeEqualHex, timingSafeEqualStrings, toUint8Array, totalChars, truncateOldToolResults, turnsToCoreMessages, unwrapToolOutput, updateChat, validateAppMigrationIds, validateAppName, validateBindingManifest, validateDoManifest, verifyJwt, workspaceAccountsSchema, workspaceContentSharesSchema, workspaceEmailHandlesSchema, workspaceFormResponsesSchema, workspacePeopleSchema, workspaceProjectsSchema, workspaceTagsSchema, workspaceTasksSchema, workspaceTeamMembersSchema, workspaceTeamsSchema, workspaceTransactionsSchema, writeVarUint, writeVarUint8Array };
5240
+ export { ACCOUNT_STORAGE_LIMIT_BYTES, AI_CHATS_SCHEMA, AI_MESSAGES_SCHEMA, ALLOWED_BINDING_TYPES, APP_ID_RE, APP_LOG_EVENT_KEYS, APP_NAME_RULES, ASSET_NOT_FOUND_HANDLING_VALUES, AUTO_PROVISIONABLE_TYPES, AUTO_PROVISION_SENTINEL, type ActionContext, type ActionHandler, type ActionResult, type ActionTools, type ApiWorkerEnv, type AppLogEvent, type AppLogEventKey, type AppLogsResponse, type AppMigrationIdsValidation, type AppNameResolution, type AppNameValidation, type AssetNotFoundHandling, type AuthWorkerEnv, Awareness, type AwarenessState, type AwarenessStates, BASE_USERS_SCHEMA, BROWSER_PROXY_ROUTES, BUILT_IN_TOOLS, type BaseMessage, BaseRoom, CHANNELS_SCHEMA, CHANNEL_INVITATIONS_SCHEMA, CHANNEL_MEMBERS_SCHEMA, CLIENT_LOG_MARKER, CONVERSATION_SCHEMAS, COST_RATES, CanvasRoom, type CanvasShape, type ChatContextConfig, type ChatMessageRow, type ChatRow, type ChatTurn, type ClientErrorKind, type ClientErrorReport, type ClientMessage, type CollectionPermissionSummary, type CollectionSchema, type ColumnDefinition, type ColumnInterpretation, type ComposioToolsOptions, type ConnectionAttachment, type ConvMemberData, type ConvMessageData, type ConvReactionData, type ConvReadCursorData, type ConvVoteData, type ConversationStateData, type CronContext, type CronExecution, CronRoom, type CronRoomConfig, type CronTask, type CustomBinding, type CustomBindingManifest, DEEPSPACE_AGENT_PROFILES, DEEPSPACE_AI_DEFAULTS, DEEPSPACE_AI_MODELS, DEEPSPACE_MODEL_CATALOG_PROVENANCE, DEFAULT_CONTEXT_CONFIG, DEFAULT_DO_MANIFEST, DEFAULT_QUERY_LIMIT, DEPLOYED_APPS_DEFAULTS, DIRECTORY_SCHEMAS, type DOBindings, type DOManifest, type DOManifestEntry, type DeepSpaceAIEnv, type DeepSpaceAIModel, type DeepSpaceAIModelId, type DeepSpaceAIOptions, type DeepSpaceAIProvider, type DeepSpaceAgentDiagnosticContext, DeepSpaceAgentModelError, type DeepSpaceAgentProfile, DeepSpaceAgentProfileError, type DeepSpaceAgentProfileId, type DeepSpaceAgentStream, type DeepSpaceAgentStreamOptions, type DeepSpaceAgentSupport, type DeepSpaceAuth, type DeepSpaceAuthConfig, type DeepSpaceDocumentationRouteEnv, type DeepSpaceDocumentationRouteOptions, type DirectoryCommunityData, type DirectoryConversationData, type DirectoryMembershipData, type DirectoryPostData, type DoMigrationDirective, type DoMigrationPlan, type ExistingDOBinding, GLOBAL_DO_TYPES, GLOBAL_DO_TYPE_NAMES, type GetActionData, type GlobalDOType, type HandlerContext, type Job, type JobContext, JobRoom, type JobRoomConfig, type JobStatus, type JwtClaims, type JwtVerifierConfig, LOG_LEVELS, type LogLevel, MAX_APP_FILE_BYTES, MAX_DEPLOY_ASSET_FILE_BYTES, MAX_UPLOAD_PARTS, MAX_UPLOAD_REQUEST_BYTES, MESSAGES_SCHEMA, MSG, MSG_AWARENESS, MSG_SYNC, MSG_SYNC_STEP1, MSG_SYNC_STEP2, MSG_SYNC_UPDATE, MSG_YJS_AWARENESS, MSG_YJS_SYNC, type MessageHandlers, type MsgType, type MutateActionData, type PermissionAnalysis, type PermissionContext, type PermissionLevel, type PermissionSource, type PlatformWorkerEnv, type PrefixResult, type PresencePeer, PresenceRoom, type ProtocolMessage, type ProxyRoute, type QueryActionData, REACTIONS_SCHEMA, READ_RECEIPTS_SCHEMA, REGISTRY_INSTANCE, RELEASE_STAMP_PATH, REPO_STORE_LIMIT_BYTES, REQUIRED_COMPATIBILITY_FLAGS, RESERVED_BINDING_NAMES, RESERVED_COLLECTION_NAMES, RESERVED_RUN_WORKER_FIRST, RESOURCE_ID_RE, ROLE_ADMIN, ROLE_ANONYMOUS, ROLE_DEFAULT, RecordRoom, type RecordRoomConfig, RegistryClientError, type ResolvedColumn, type ResolvedDeepSpaceAgentModel, type ResolvedPermission, type RolePermissions, type RunMigrationsResult, SDK_RUN_WORKER_FIRST, SECURE_ROOM_BOUNDARIES_MIGRATION_ID, STRICT_APP_ID_RE, SYSTEM_COLLECTIONS, SYSTEM_MANAGED_COLUMNS, SchemaRegistry, type ScopeContext, type ScopedR2Auth, type ScopedR2Config, type ScopedR2Handler, type ScreenshotEnv, type ScreenshotResult, type ServerMessage, type SharedConnection, type StorageAdmission, type StoredRecord, type Summarizer, type SyncResult, TEST_ACCOUNT_EMAIL_SUFFIX, type TokenDebugInfo, type ToolResult, type ToolSchema, UNDECLARED_ASSET_NOT_FOUND_HANDLING, UPLOAD_PART_BYTES, USERS_COLUMNS, type User, type UserAttachment, VOTING_SCHEMAS, type ValidationError, type VerifiedAuth, type VerifyOutcome, type VerifyResult, type Viewport, WORKER_OWNED_NOT_FOUND_MIGRATION_ID, WORKSPACE_SCHEMAS, YjsRoom, analyzePermissions, apiWorkerFetch, appendMessage, applyAiToolDefaults, applySlidingWindow, authWorkerFetch, authenticatedRoomRequest, bindingManifestFromOutputConfig, buildCronContext, buildTableSelect, buildUiParts, callRegistry, canCreate, canDelete, canRead, canUpdate, capToolResultSize, captureScreenshot, checkFieldPermissions, checkUnclaimedOwnerTransition, clientBuild, coerceValue, collectionTableName, columnId, composioTools, computeDoMigration, computeHmacHex, createChat, createDecoder, createDeepSpaceAI, createDeepSpaceAuth, createEncoder, createScopedR2Handler, dataToColumnValues, decodeJwtPayload, deepSpaceAgentErrorSummary, deleteChatCascade, dispatch, encode, encodeAwarenessMessage, encodeSyncStep1, encodeSyncStep2, encodeUpdate, enqueueJob, formatBytes, getChat, getDeepSpaceAIModel, getGlobalDOSchemas, getGlobalDOType, getMessageType, getRolePermissions, handleAwarenessMessage, handleClientErrorReport, handleSyncMessage, hasContent, isAssetNotFoundHandling, isAutoProvision, isFilesVerbPath, isOwner, isPlatformReservedPath, isTestAccountClaims, isTestAccountEmail, isTestAccountTier, lintSchema, listDeepSpaceAgentModels, loadMessages, makeDefaultSummarizer, matchOrigin, meterAi, meterUsage, meterVectorize, noopPermissionContext, parseQuotaLimit, platformCorsPatterns, platformWorkerFetch, prepareMessagesWithCompaction, priceBindingUsageEvent, readVarUint, readVarUint8Array, registerClientErrorRoute, registerDeepSpaceDocumentation, registryErrorJson, registryStub, resolveAppName, resolveAppRole, resolveColumn, resolveCompatibilityFlags, resolveDeepSpaceAgentModel, rowToData, runMigrations, serverBuild, storageLimitForTier, storageQuotaMessage, streamDeepSpaceAgent, timingSafeEqualHex, timingSafeEqualStrings, toUint8Array, totalChars, truncateOldToolResults, turnsToCoreMessages, unwrapToolOutput, updateChat, validateAppMigrationIds, validateAppName, validateBindingManifest, validateDoManifest, verifyJwt, workspaceAccountsSchema, workspaceContentSharesSchema, workspaceEmailHandlesSchema, workspaceFormResponsesSchema, workspacePeopleSchema, workspaceProjectsSchema, workspaceTagsSchema, workspaceTasksSchema, workspaceTeamMembersSchema, workspaceTeamsSchema, workspaceTransactionsSchema, writeVarUint, writeVarUint8Array };