deepspace 0.34.0 → 0.36.0

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.js CHANGED
@@ -5,11 +5,53 @@ import { test as baseTest } from "@playwright/test";
5
5
  import { existsSync, mkdirSync, readFileSync } from "fs";
6
6
  import { homedir } from "os";
7
7
  import { join } from "path";
8
+
9
+ // src/shared/planes.ts
10
+ var PLANES = {
11
+ production: {
12
+ auth: "https://auth.deep.space",
13
+ api: "https://api-worker.deep.space",
14
+ platform: "https://platform-worker.deep.space",
15
+ deploy: "https://deploy-worker.deep.space",
16
+ appDomain: "app.space",
17
+ platformDomain: "deep.space",
18
+ home: "https://deep.space",
19
+ dashboard: "https://dashboard.deep.space",
20
+ privacyPolicy: "https://deep.space/privacy"
21
+ },
22
+ // Services on deepspacesites.com, apps on spacestest.com: production's
23
+ // two-zone split, because a Worker cannot fetch one in its own zone.
24
+ staging: {
25
+ auth: "https://auth.deepspacesites.com",
26
+ api: "https://api.deepspacesites.com",
27
+ platform: "https://platform.deepspacesites.com",
28
+ deploy: "https://deploy.deepspacesites.com",
29
+ appDomain: "spacestest.com",
30
+ platformDomain: "deepspacesites.com",
31
+ home: "https://spacestest.com",
32
+ dashboard: "https://dashboard.deepspacesites.com",
33
+ privacyPolicy: "https://deep.space/privacy"
34
+ },
35
+ // Services on deepspacemedical.com, apps on deepspacemedical.app: two zones
36
+ // in the medical Cloudflare account. The plane has no dashboard.
37
+ medical: {
38
+ auth: "https://auth.deepspacemedical.com",
39
+ api: "https://api.deepspacemedical.com",
40
+ platform: "https://platform.deepspacemedical.com",
41
+ deploy: "https://deploy.deepspacemedical.com",
42
+ appDomain: "deepspacemedical.app",
43
+ platformDomain: "deepspacemedical.com",
44
+ home: "https://deepspacemedical.com",
45
+ dashboard: null,
46
+ privacyPolicy: "https://deepspacemedical.com/privacy"
47
+ }
48
+ };
49
+
50
+ // src/testing/accounts.ts
8
51
  var TEST_ACCOUNTS_PATH = join(homedir(), ".deepspace", "test-accounts.json");
9
- var PRODUCTION_AUTH_ORIGIN = "https://auth.deep.space";
10
- var STAGING_AUTH_ORIGIN = "https://auth.deepspacesites.com";
11
52
  function currentTestAccountScope(env = process.env) {
12
- const selected = env.DEEPSPACE_AUTH_URL ?? (env.DEEPSPACE_ENV === "staging" ? STAGING_AUTH_ORIGIN : PRODUCTION_AUTH_ORIGIN);
53
+ const plane = env.DEEPSPACE_ENV;
54
+ const selected = env.DEEPSPACE_AUTH_URL ?? (plane === "staging" || plane === "medical" ? PLANES[plane].auth : PLANES.production.auth);
13
55
  return normalizeTestAccountScope(selected);
14
56
  }
15
57
  function normalizeTestAccountScope(value) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/testing/fixtures.ts","../src/testing/accounts.ts","../src/testing/storage-state.ts","../src/shared/jwt.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, staying below the auth\n * worker's password sign-in limit.\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, type EnsureStorageStateOptions } 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?: {\n label?: string\n /** Sign in again even if the cached session still validates. */\n force?: boolean\n },\n) => Promise<MultiplayerUser[]>\n\ninterface MultiplayerFixtures {\n users: UsersFixture\n}\n\nasync function buildUser(\n browser: Browser,\n account: TestAccount,\n baseURL: string,\n options: EnsureStorageStateOptions = {},\n): Promise<{ user: MultiplayerUser; cleanup: () => Promise<void> }> {\n const statePath = await ensureStorageState(browser, account, baseURL, options)\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, { force: options?.force })),\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 } from 'node:fs'\nimport { homedir } from 'node:os'\nimport { join } from 'node:path'\n// Node-only leaf (its only import is `node:fs`), and this module is Node-only\n// too — Playwright fixtures. Reusing it keeps one definition of \"write a file\n// holding plaintext secrets\" rather than a second hand-rolled copy here.\nimport { writeSecretFileSync } from '../cli/lib/secure-file'\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 =\n 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(\n local.filter((account) => account.id).map((account) => [account.id, account]),\n )\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 =\n candidate.scopes && typeof candidate.scopes === 'object' && !Array.isArray(candidate.scopes)\n ? Object.fromEntries(\n Object.entries(candidate.scopes).map(([key, value]) => [key, validTestAccounts(value)]),\n )\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 // Holds plaintext passwords, so it must end up 0600 even when the file\n // already exists with a wider mode — `writeFileSync`'s `mode` applies only\n // at creation, which would silently leave a pre-existing world-readable\n // file world-readable while writing fresh credentials into it.\n writeSecretFileSync(TEST_ACCOUNTS_PATH, JSON.stringify(store, null, 2))\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(count: number, options?: { label?: string }): TestAccount[] {\n const all = loadAllTestAccounts()\n const filtered = options?.label ? all.filter((a) => a.label === options.label) : 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 usable from ${TEST_ACCOUNTS_PATH}.\\n` +\n `The pool is global per developer, but passwords are stored only on the machine that ` +\n `created the account — so accounts created elsewhere are listed by ` +\n `\\`deepspace test accounts list\\` and count against the 10-cap, yet cannot be signed in ` +\n `as here. Re-issue their credentials, or create more:\\n` +\n ` deepspace test accounts recover --all (note: --all only covers accounts missing a local credential; recover a stale one by its email)\\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 * Turn a display name into the local-part of a test-account email.\n * \"Collab A\" -> \"collab-a\". Without this the suggested command in the\n * not-found error carried a space inside the address and could not be run.\n */\nexport function testAccountEmailSlug(name: string): string {\n const slug = name\n .trim()\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n return slug || 'tester'\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}\" is usable from ${TEST_ACCOUNTS_PATH}.\\n` +\n `If it exists in the pool but was created on another machine, fetch its credential:\\n` +\n ` deepspace test accounts recover --all (note: --all only covers accounts missing a local credential; recover a stale one by its email)\\n` +\n `Otherwise create it:\\n` +\n ` deepspace test accounts create --email ${testAccountEmailSlug(name)}@deepspace.test --password <pw> --name \"${name}\"\\n` +\n `Suites that do not depend on a specific identity should ask for any N accounts with ` +\n `\\`users(2)\\` instead of naming them.`,\n )\n }\n return match\n}\n","/**\n * Per-account `storageState` cache for Playwright.\n *\n * The auth worker limits password sign-ins per account. A\n * multiplayer suite that spins up\n * many users per spec can 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(authScope, appOrigin, 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 } from 'node:fs'\nimport { homedir } from 'node:os'\nimport { join } from 'node:path'\nimport { request, type Browser, type BrowserContext } from '@playwright/test'\nimport { decodeJwtPayload } from '../shared/jwt'\nimport { currentTestAccountScope } from './accounts'\n\nconst STATES_DIR = join(homedir(), '.deepspace', 'playwright-states')\n\n/**\n * Validation/sign-in per state path, in flight or settled. Memoizing the\n * promise — rather than flagging a Set after the await — makes concurrent\n * callers for the same account share one probe/sign-in instead of racing\n * duplicate sign-ins into the rate limit. A rejected attempt is evicted so\n * a later call can retry.\n */\nconst stateValidations = new Map<string, Promise<string>>()\n\ninterface StorageStateAccount {\n email: string\n password: string\n /** Remote user id, when the local registry knows it. */\n userId?: string\n}\n\n/** Where the cache for `email` against `baseURL`'s origin lives. */\nexport function getStatePathForEmail(email: string, baseURL: string): string {\n // Keyed by auth scope + app origin + email: one email signed into two\n // apps (or two auth environments) must never serve the wrong session.\n const key = `${currentTestAccountScope()}\\n${new URL(baseURL).origin}\\n${email.trim().toLowerCase()}`\n const hash = createHash('sha256').update(key).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/**\n * Does a token minted from a cached session belong to `account`? `sub` is\n * authoritative when the registry knows the remote user id; the `email`\n * claim covers states recorded before the id was known.\n */\nexport function tokenClaimsMatchAccount(\n claims: Record<string, unknown>,\n account: { email: string; userId?: string },\n): boolean {\n if (account.userId && claims.sub === account.userId) return true\n return (\n typeof claims.email === 'string' &&\n claims.email.trim().toLowerCase() === account.email.trim().toLowerCase()\n )\n}\n\n/**\n * Live-validate a cached state: load it into a request context and ask the\n * app to mint a token. Only a token for this account counts — a transport\n * failure, an anonymous session, or another user's session all report\n * false, so the caller signs in fresh (where a real outage surfaces as an\n * actionable sign-in error instead of silent reuse of a dead session).\n */\nasync function probeStoredSession(\n path: string,\n baseURL: string,\n account: { email: string; userId?: string },\n): Promise<boolean> {\n try {\n const ctx = await request.newContext({ storageState: path, baseURL })\n try {\n const res = await ctx.post('/api/auth/token')\n if (!res.ok()) return false\n const { token } = (await res.json()) as { token?: unknown }\n if (typeof token !== 'string') return false\n return tokenClaimsMatchAccount(decodeJwtPayload(token), account)\n } finally {\n await ctx.dispose()\n }\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 { email: account.email, password: account.password },\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 if (failure.status === 429) {\n return `Sign-in rate-limited for ${email} (HTTP 429). Wait about 60 seconds and retry.`\n }\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 : 'The stored password may be stale — fetch the current credential with `deepspace test accounts recover --email <email>` (--all only covers accounts with no local credential), or create a new 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 /** Force a fresh sign-in even if a cached state validates. */\n force?: boolean\n}\n\n/**\n * Ensure a validated Playwright `storageState` file exists for `account`\n * and return its path. A cached file is probed against the app before\n * reuse (once per worker process); a missing, expired, or wrong-account\n * state triggers one fresh sign-in that overwrites it.\n */\nexport async function ensureStorageState(\n browser: Browser,\n account: StorageStateAccount,\n baseURL: string,\n options: EnsureStorageStateOptions = {},\n): Promise<string> {\n const path = getStatePathForEmail(account.email, baseURL)\n if (options.force) stateValidations.delete(path)\n let pending = stateValidations.get(path)\n if (!pending) {\n pending = validateOrSignIn(browser, account, baseURL, path, options.force === true)\n stateValidations.set(path, pending)\n pending.catch(() => stateValidations.delete(path))\n }\n return pending\n}\n\nasync function validateOrSignIn(\n browser: Browser,\n account: StorageStateAccount,\n baseURL: string,\n path: string,\n force: boolean,\n): Promise<string> {\n if (!force && existsSync(path) && (await probeStoredSession(path, baseURL, account))) {\n return path\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: StorageStateAccount,\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","export function decodeJwtPayload<T = Record<string, unknown>>(token: string): T {\n const payloadSegment = token.split('.')[1]\n if (!payloadSegment) {\n throw new Error('JWT payload is missing')\n }\n return JSON.parse(Buffer.from(payloadSegment, 'base64url').toString('utf8')) as T\n}\n"],"mappings":";AA6BA,SAAS,QAAQ,gBAA8D;;;AChB/E,SAAS,YAAY,WAAW,oBAAoB;AACpD,SAAS,eAAe;AACxB,SAAS,YAAY;AAgBd,IAAM,qBAAqB,KAAK,QAAQ,GAAG,cAAc,oBAAoB;AAEpF,IAAM,yBAAyB;AAC/B,IAAM,sBAAsB;AAmBrB,SAAS,wBAAwB,MAAyB,QAAQ,KAAa;AACpF,QAAM,WACJ,IAAI,uBACH,IAAI,kBAAkB,YAAY,sBAAsB;AAC3D,SAAO,0BAA0B,QAAQ;AAC3C;AAEO,SAAS,0BAA0B,OAAuB;AAC/D,MAAI;AACF,WAAO,IAAI,IAAI,KAAK,EAAE;AAAA,EACxB,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEO,SAAS,oBAAoB,QAAQ,wBAAwB,GAAkB;AACpF,QAAM,QAAQ,qBAAqB;AACnC,SAAO,MAAM,OAAO,KAAK,KAAK,CAAC;AACjC;AA8EA,SAAS,uBAAmD;AAC1D,MAAI,CAAC,WAAW,kBAAkB,EAAG,QAAO,EAAE,SAAS,GAAG,QAAQ,CAAC,EAAE;AACrE,MAAI;AACF,UAAM,MAAe,KAAK,MAAM,aAAa,oBAAoB,OAAO,CAAC;AACzE,QAAI,MAAM,QAAQ,GAAG,EAAG,QAAO,EAAE,SAAS,GAAG,QAAQ,CAAC,EAAE;AACxD,QAAI,CAAC,OAAO,OAAO,QAAQ,UAAU;AACnC,aAAO,EAAE,SAAS,GAAG,QAAQ,CAAC,EAAE;AAAA,IAClC;AACA,UAAM,YAAY;AAClB,UAAM,SACJ,UAAU,UAAU,OAAO,UAAU,WAAW,YAAY,CAAC,MAAM,QAAQ,UAAU,MAAM,IACvF,OAAO;AAAA,MACL,OAAO,QAAQ,UAAU,MAAM,EAAE,IAAI,CAAC,CAAC,KAAK,KAAK,MAAM,CAAC,KAAK,kBAAkB,KAAK,CAAC,CAAC;AAAA,IACxF,IACA,CAAC;AACP,WAAO,EAAE,SAAS,GAAG,OAAO;AAAA,EAC9B,QAAQ;AACN,WAAO,EAAE,SAAS,GAAG,QAAQ,CAAC,EAAE;AAAA,EAClC;AACF;AAEA,SAAS,kBAAkB,OAA+B;AACxD,MAAI,CAAC,MAAM,QAAQ,KAAK,EAAG,QAAO,CAAC;AACnC,SAAO,MAAM;AAAA,IAAO,CAAC,SACnB;AAAA,MACE,QACA,OAAO,SAAS,YAChB,OAAQ,KAAqB,OAAO,YACpC,OAAQ,KAAqB,UAAU,YACvC,OAAQ,KAAqB,aAAa;AAAA,IAC5C;AAAA,EACF;AACF;AAkBO,SAAS,iBAAiB,OAAe,SAA6C;AAC3F,QAAM,MAAM,oBAAoB;AAChC,QAAM,WAAW,SAAS,QAAQ,IAAI,OAAO,CAAC,MAAM,EAAE,UAAU,QAAQ,KAAK,IAAI;AACjF,QAAM,SAAS,SAAS,MAAM,EAAE,KAAK,CAAC,GAAG,OAAO,EAAE,aAAa,MAAM,EAAE,aAAa,EAAE;AAEtF,MAAI,OAAO,SAAS,OAAO;AACzB,UAAM,YAAY,SAAS,QAAQ,YAAY,QAAQ,KAAK,OAAO;AACnE,UAAM,IAAI;AAAA,MACR,0BAA0B,KAAK,iBAAiB,SAAS,cAAc,OAAO,MAAM,oBAC/D,kBAAkB;AAAA;AAAA;AAAA,gGAOnC,SAAS,QAAQ,YAAY,QAAQ,KAAK,KAAK,EACjD;AAAA,IACJ;AAAA,EACF;AAEA,SAAO,OAAO,MAAM,GAAG,KAAK;AAC9B;AAOO,SAAS,qBAAqB,MAAsB;AACzD,QAAM,OAAO,KACV,KAAK,EACL,YAAY,EACZ,QAAQ,eAAe,GAAG,EAC1B,QAAQ,YAAY,EAAE;AACzB,SAAO,QAAQ;AACjB;AAMO,SAAS,sBAAsB,MAA2B;AAC/D,QAAM,MAAM,oBAAoB;AAChC,QAAM,QAAQ,IAAI,KAAK,CAAC,MAAM,EAAE,SAAS,IAAI;AAC7C,MAAI,CAAC,OAAO;AACV,UAAM,IAAI;AAAA,MACR,0BAA0B,IAAI,oBAAoB,kBAAkB;AAAA;AAAA;AAAA;AAAA,2CAItB,qBAAqB,IAAI,CAAC,2CAA2C,IAAI;AAAA;AAAA,IAGzH;AAAA,EACF;AACA,SAAO;AACT;;;AC7OA,SAAS,kBAAkB;AAC3B,SAAS,cAAAA,aAAY,aAAAC,kBAAiB;AACtC,SAAS,WAAAC,gBAAe;AACxB,SAAS,QAAAC,aAAY;AACrB,SAAS,eAAkD;;;ACvBpD,SAAS,iBAA8C,OAAkB;AAC9E,QAAM,iBAAiB,MAAM,MAAM,GAAG,EAAE,CAAC;AACzC,MAAI,CAAC,gBAAgB;AACnB,UAAM,IAAI,MAAM,wBAAwB;AAAA,EAC1C;AACA,SAAO,KAAK,MAAM,OAAO,KAAK,gBAAgB,WAAW,EAAE,SAAS,MAAM,CAAC;AAC7E;;;ADqBA,IAAM,aAAaC,MAAKC,SAAQ,GAAG,cAAc,mBAAmB;AASpE,IAAM,mBAAmB,oBAAI,IAA6B;AAUnD,SAAS,qBAAqB,OAAe,SAAyB;AAG3E,QAAM,MAAM,GAAG,wBAAwB,CAAC;AAAA,EAAK,IAAI,IAAI,OAAO,EAAE,MAAM;AAAA,EAAK,MAAM,KAAK,EAAE,YAAY,CAAC;AACnG,QAAM,OAAO,WAAW,QAAQ,EAAE,OAAO,GAAG,EAAE,OAAO,KAAK,EAAE,MAAM,GAAG,EAAE;AACvE,SAAOD,MAAK,YAAY,GAAG,IAAI,OAAO;AACxC;AAEA,SAAS,kBAAkB;AACzB,MAAI,CAACE,YAAW,UAAU,EAAG,CAAAC,WAAU,YAAY,EAAE,WAAW,KAAK,CAAC;AACxE;AAOO,SAAS,wBACd,QACA,SACS;AACT,MAAI,QAAQ,UAAU,OAAO,QAAQ,QAAQ,OAAQ,QAAO;AAC5D,SACE,OAAO,OAAO,UAAU,YACxB,OAAO,MAAM,KAAK,EAAE,YAAY,MAAM,QAAQ,MAAM,KAAK,EAAE,YAAY;AAE3E;AASA,eAAe,mBACb,MACA,SACA,SACkB;AAClB,MAAI;AACF,UAAM,MAAM,MAAM,QAAQ,WAAW,EAAE,cAAc,MAAM,QAAQ,CAAC;AACpE,QAAI;AACF,YAAM,MAAM,MAAM,IAAI,KAAK,iBAAiB;AAC5C,UAAI,CAAC,IAAI,GAAG,EAAG,QAAO;AACtB,YAAM,EAAE,MAAM,IAAK,MAAM,IAAI,KAAK;AAClC,UAAI,OAAO,UAAU,SAAU,QAAO;AACtC,aAAO,wBAAwB,iBAAiB,KAAK,GAAG,OAAO;AAAA,IACjE,UAAE;AACA,YAAM,IAAI,QAAQ;AAAA,IACpB;AAAA,EACF,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAWA,eAAe,mBACb,SACA,SACA,SACA,SACiB;AACjB,kBAAgB;AAChB,QAAM,MAAM,MAAM,QAAQ,WAAW,EAAE,QAAQ,CAAC;AAChD,MAAI;AACF,UAAM,OAAO,MAAM,IAAI,QAAQ;AAG/B,UAAM,KAAK,KAAK,GAAG;AACnB,UAAM,SAAS,MAAM,KAAK;AAAA,MACxB,OAAO,EAAE,OAAO,SAAS,MAA2C;AAClE,cAAM,MAAM,MAAM,MAAM,2BAA2B;AAAA,UACjD,QAAQ;AAAA,UACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,UAC9C,MAAM,KAAK,UAAU,EAAE,OAAO,SAAS,CAAC;AAAA,QAC1C,CAAC;AACD,cAAM,OAAgB,MAAM,IAAI,KAAK,EAAE,MAAM,MAAM,IAAI;AACvD,cAAM,QAAQ,QAAQ,OAAO,SAAS,WAAY,OAAmC;AACrF,eAAO;AAAA,UACL,IAAI,IAAI;AAAA,UACR,QAAQ,IAAI;AAAA,UACZ,MAAM,OAAO,OAAO,SAAS,WAAW,MAAM,OAAO;AAAA,UACrD,SAAS,OAAO,OAAO,YAAY,WAAW,MAAM,UAAU;AAAA,QAChE;AAAA,MACF;AAAA,MACA,EAAE,OAAO,QAAQ,OAAO,UAAU,QAAQ,SAAS;AAAA,IACrD;AACA,QAAI,CAAC,OAAO,GAAI,OAAM,IAAI,MAAM,oBAAoB,QAAQ,OAAO,MAAM,CAAC;AAC1E,UAAM,IAAI,aAAa,EAAE,MAAM,QAAQ,CAAC;AACxC,WAAO;AAAA,EACT,UAAE;AACA,UAAM,IAAI,MAAM;AAAA,EAClB;AACF;AASO,SAAS,oBAAoB,OAAe,SAAgC;AACjF,MAAI,QAAQ,WAAW,KAAK;AAC1B,WAAO,4BAA4B,KAAK;AAAA,EAC1C;AACA,QAAM,OAAO,QAAQ,OAAO,IAAI,QAAQ,IAAI,KAAK;AACjD,QAAM,SAAS,QAAQ,UAAU,KAAK,QAAQ,OAAO,KAAK;AAC1D,QAAM,WACJ,QAAQ,SAAS,mBACb,2DACA;AACN,SAAO,sBAAsB,KAAK,UAAU,QAAQ,MAAM,GAAG,IAAI,IAAI,MAAM,KAAK,QAAQ;AAC1F;AAaA,eAAsB,mBACpB,SACA,SACA,SACA,UAAqC,CAAC,GACrB;AACjB,QAAM,OAAO,qBAAqB,QAAQ,OAAO,OAAO;AACxD,MAAI,QAAQ,MAAO,kBAAiB,OAAO,IAAI;AAC/C,MAAI,UAAU,iBAAiB,IAAI,IAAI;AACvC,MAAI,CAAC,SAAS;AACZ,cAAU,iBAAiB,SAAS,SAAS,SAAS,MAAM,QAAQ,UAAU,IAAI;AAClF,qBAAiB,IAAI,MAAM,OAAO;AAClC,YAAQ,MAAM,MAAM,iBAAiB,OAAO,IAAI,CAAC;AAAA,EACnD;AACA,SAAO;AACT;AAEA,eAAe,iBACb,SACA,SACA,SACA,MACA,OACiB;AACjB,MAAI,CAAC,SAASD,YAAW,IAAI,KAAM,MAAM,mBAAmB,MAAM,SAAS,OAAO,GAAI;AACpF,WAAO;AAAA,EACT;AACA,SAAO,mBAAmB,SAAS,SAAS,SAAS,IAAI;AAC3D;AAOA,eAAsB,mBACpB,SACA,SACA,SACA,UAAqC,CAAC,GACb;AACzB,QAAM,YAAY,MAAM,mBAAmB,SAAS,SAAS,SAAS,OAAO;AAC7E,SAAO,QAAQ,WAAW,EAAE,cAAc,WAAW,QAAQ,CAAC;AAChE;;;AFvGA,SAAS,cAAc;AAzDvB,eAAe,UACb,SACA,SACA,SACA,UAAqC,CAAC,GAC4B;AAClE,QAAM,YAAY,MAAM,mBAAmB,SAAS,SAAS,SAAS,OAAO;AAC7E,QAAM,UAAU,MAAM,QAAQ,WAAW,EAAE,cAAc,WAAW,QAAQ,CAAC;AAC7E,QAAM,OAAO,MAAM,QAAQ,QAAQ;AACnC,SAAO;AAAA,IACL,MAAM;AAAA,MACJ;AAAA,MACA;AAAA,MACA,OAAO,QAAQ;AAAA,MACf,MAAM,QAAQ,QAAQ,QAAQ,MAAM,MAAM,GAAG,EAAE,CAAC;AAAA,MAChD,QAAQ,QAAQ;AAAA,IAClB;AAAA,IACA,SAAS,YAAY;AACnB,YAAM,QAAQ,MAAM;AAAA,IACtB;AAAA,EACF;AACF;AAEO,IAAM,OAAO,SAAS,OAA4B;AAAA,EACvD,OAAO,OAAO,EAAE,SAAS,QAAQ,GAAG,QAAQ;AAC1C,QAAI,CAAC,SAAS;AACZ,YAAM,IAAI;AAAA,QACR;AAAA,MAGF;AAAA,IACF;AAEA,UAAM,WAAuC,CAAC;AAE9C,UAAM,UAAwB,OAAO,UAAU,YAAY;AACzD,YAAM,WAA0B,MAAM,QAAQ,QAAQ,IAClD,SAAS,IAAI,CAAC,SAAS,sBAAsB,IAAI,CAAC,IAClD,iBAAiB,UAAU,OAAO;AAEtC,YAAM,QAAQ,MAAM,QAAQ;AAAA,QAC1B,SAAS,IAAI,CAAC,SAAS,UAAU,SAAS,MAAM,SAAS,EAAE,OAAO,SAAS,MAAM,CAAC,CAAC;AAAA,MACrF;AACA,iBAAW,KAAK,MAAO,UAAS,KAAK,EAAE,OAAO;AAC9C,aAAO,MAAM,IAAI,CAAC,MAAM,EAAE,IAAI;AAAA,IAChC;AAEA,UAAM,IAAI,OAAO;AAIjB,eAAW,WAAW,UAAU;AAC9B,YAAM,QAAQ,EAAE,MAAM,MAAM;AAAA,MAAC,CAAC;AAAA,IAChC;AAAA,EACF;AACF,CAAC;","names":["existsSync","mkdirSync","homedir","join","join","homedir","existsSync","mkdirSync"]}
1
+ {"version":3,"sources":["../src/testing/fixtures.ts","../src/testing/accounts.ts","../src/shared/planes.ts","../src/testing/storage-state.ts","../src/shared/jwt.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, staying below the auth\n * worker's password sign-in limit.\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, type EnsureStorageStateOptions } 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?: {\n label?: string\n /** Sign in again even if the cached session still validates. */\n force?: boolean\n },\n) => Promise<MultiplayerUser[]>\n\ninterface MultiplayerFixtures {\n users: UsersFixture\n}\n\nasync function buildUser(\n browser: Browser,\n account: TestAccount,\n baseURL: string,\n options: EnsureStorageStateOptions = {},\n): Promise<{ user: MultiplayerUser; cleanup: () => Promise<void> }> {\n const statePath = await ensureStorageState(browser, account, baseURL, options)\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, { force: options?.force })),\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 } from 'node:fs'\nimport { homedir } from 'node:os'\nimport { join } from 'node:path'\n// Node-only leaf (its only import is `node:fs`), and this module is Node-only\n// too — Playwright fixtures. Reusing it keeps one definition of \"write a file\n// holding plaintext secrets\" rather than a second hand-rolled copy here.\nimport { writeSecretFileSync } from '../cli/lib/secure-file'\nimport { PLANES } from '../shared/planes'\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\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 plane: string | undefined = env.DEEPSPACE_ENV\n const selected =\n env.DEEPSPACE_AUTH_URL ??\n (plane === 'staging' || plane === 'medical' ? PLANES[plane].auth : PLANES.production.auth)\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(\n local.filter((account) => account.id).map((account) => [account.id, account]),\n )\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 =\n candidate.scopes && typeof candidate.scopes === 'object' && !Array.isArray(candidate.scopes)\n ? Object.fromEntries(\n Object.entries(candidate.scopes).map(([key, value]) => [key, validTestAccounts(value)]),\n )\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 // Holds plaintext passwords, so it must end up 0600 even when the file\n // already exists with a wider mode — `writeFileSync`'s `mode` applies only\n // at creation, which would silently leave a pre-existing world-readable\n // file world-readable while writing fresh credentials into it.\n writeSecretFileSync(TEST_ACCOUNTS_PATH, JSON.stringify(store, null, 2))\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(count: number, options?: { label?: string }): TestAccount[] {\n const all = loadAllTestAccounts()\n const filtered = options?.label ? all.filter((a) => a.label === options.label) : 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 usable from ${TEST_ACCOUNTS_PATH}.\\n` +\n `The pool is global per developer, but passwords are stored only on the machine that ` +\n `created the account — so accounts created elsewhere are listed by ` +\n `\\`deepspace test accounts list\\` and count against the 10-cap, yet cannot be signed in ` +\n `as here. Re-issue their credentials, or create more:\\n` +\n ` deepspace test accounts recover --all (note: --all only covers accounts missing a local credential; recover a stale one by its email)\\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 * Turn a display name into the local-part of a test-account email.\n * \"Collab A\" -> \"collab-a\". Without this the suggested command in the\n * not-found error carried a space inside the address and could not be run.\n */\nexport function testAccountEmailSlug(name: string): string {\n const slug = name\n .trim()\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n return slug || 'tester'\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}\" is usable from ${TEST_ACCOUNTS_PATH}.\\n` +\n `If it exists in the pool but was created on another machine, fetch its credential:\\n` +\n ` deepspace test accounts recover --all (note: --all only covers accounts missing a local credential; recover a stale one by its email)\\n` +\n `Otherwise create it:\\n` +\n ` deepspace test accounts create --email ${testAccountEmailSlug(name)}@deepspace.test --password <pw> --name \"${name}\"\\n` +\n `Suites that do not depend on a specific identity should ask for any N accounts with ` +\n `\\`users(2)\\` instead of naming them.`,\n )\n }\n return match\n}\n","/**\n * DeepSpace's deployment planes, in one place: production, staging and\n * DeepSpace Medical, the BAA-covered plane for healthcare apps\n * (docs/proposals/deepspace-medical.md).\n *\n * Side-effect free. The CLI, the browser client, the test-account helpers and\n * the platform workers all read it, so a plane's hosts and its restricted-plane\n * rule cannot drift between them.\n */\n\nexport type PlaneName = 'production' | 'staging' | 'medical'\n\nexport interface PlaneUrls {\n auth: string\n api: string\n platform: string\n deploy: string\n /** Deployed apps live on `<name>.<appDomain>`. */\n appDomain: string\n /** The zone the platform services live on. */\n platformDomain: string\n /** The plane's public home page. */\n home: string\n /** The builder dashboard; null where the plane has none. */\n dashboard: string | null\n /** The privacy policy sign-in screens link to. */\n privacyPolicy: string\n}\n\nexport const PLANES: Readonly<Record<PlaneName, PlaneUrls>> = {\n production: {\n auth: 'https://auth.deep.space',\n api: 'https://api-worker.deep.space',\n platform: 'https://platform-worker.deep.space',\n deploy: 'https://deploy-worker.deep.space',\n appDomain: 'app.space',\n platformDomain: 'deep.space',\n home: 'https://deep.space',\n dashboard: 'https://dashboard.deep.space',\n privacyPolicy: 'https://deep.space/privacy',\n },\n // Services on deepspacesites.com, apps on spacestest.com: production's\n // two-zone split, because a Worker cannot fetch one in its own zone.\n staging: {\n auth: 'https://auth.deepspacesites.com',\n api: 'https://api.deepspacesites.com',\n platform: 'https://platform.deepspacesites.com',\n deploy: 'https://deploy.deepspacesites.com',\n appDomain: 'spacestest.com',\n platformDomain: 'deepspacesites.com',\n home: 'https://spacestest.com',\n dashboard: 'https://dashboard.deepspacesites.com',\n privacyPolicy: 'https://deep.space/privacy',\n },\n // Services on deepspacemedical.com, apps on deepspacemedical.app: two zones\n // in the medical Cloudflare account. The plane has no dashboard.\n medical: {\n auth: 'https://auth.deepspacemedical.com',\n api: 'https://api.deepspacemedical.com',\n platform: 'https://platform.deepspacemedical.com',\n deploy: 'https://deploy.deepspacemedical.com',\n appDomain: 'deepspacemedical.app',\n platformDomain: 'deepspacemedical.com',\n home: 'https://deepspacemedical.com',\n dashboard: null,\n privacyPolicy: 'https://deepspacemedical.com/privacy',\n },\n}\n\n/** Planes whose workers run without the medical restrictions. */\nconst OPEN_PLANES: ReadonlySet<string> = new Set(['production', 'staging'])\n\n/**\n * Whether a platform worker runs on a restricted plane (DeepSpace Medical\n * sets `DEEPSPACE_PLANE = \"medical\"`). Unset means production. Any value other\n * than `production` or `staging` restricts, so a typo fails closed.\n */\nexport function isRestrictedPlane(env: { DEEPSPACE_PLANE?: string }): boolean {\n const plane = env.DEEPSPACE_PLANE\n return plane !== undefined && plane !== '' && !OPEN_PLANES.has(plane)\n}\n","/**\n * Per-account `storageState` cache for Playwright.\n *\n * The auth worker limits password sign-ins per account. A\n * multiplayer suite that spins up\n * many users per spec can 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(authScope, appOrigin, 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 } from 'node:fs'\nimport { homedir } from 'node:os'\nimport { join } from 'node:path'\nimport { request, type Browser, type BrowserContext } from '@playwright/test'\nimport { decodeJwtPayload } from '../shared/jwt'\nimport { currentTestAccountScope } from './accounts'\n\nconst STATES_DIR = join(homedir(), '.deepspace', 'playwright-states')\n\n/**\n * Validation/sign-in per state path, in flight or settled. Memoizing the\n * promise — rather than flagging a Set after the await — makes concurrent\n * callers for the same account share one probe/sign-in instead of racing\n * duplicate sign-ins into the rate limit. A rejected attempt is evicted so\n * a later call can retry.\n */\nconst stateValidations = new Map<string, Promise<string>>()\n\ninterface StorageStateAccount {\n email: string\n password: string\n /** Remote user id, when the local registry knows it. */\n userId?: string\n}\n\n/** Where the cache for `email` against `baseURL`'s origin lives. */\nexport function getStatePathForEmail(email: string, baseURL: string): string {\n // Keyed by auth scope + app origin + email: one email signed into two\n // apps (or two auth environments) must never serve the wrong session.\n const key = `${currentTestAccountScope()}\\n${new URL(baseURL).origin}\\n${email.trim().toLowerCase()}`\n const hash = createHash('sha256').update(key).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/**\n * Does a token minted from a cached session belong to `account`? `sub` is\n * authoritative when the registry knows the remote user id; the `email`\n * claim covers states recorded before the id was known.\n */\nexport function tokenClaimsMatchAccount(\n claims: Record<string, unknown>,\n account: { email: string; userId?: string },\n): boolean {\n if (account.userId && claims.sub === account.userId) return true\n return (\n typeof claims.email === 'string' &&\n claims.email.trim().toLowerCase() === account.email.trim().toLowerCase()\n )\n}\n\n/**\n * Live-validate a cached state: load it into a request context and ask the\n * app to mint a token. Only a token for this account counts — a transport\n * failure, an anonymous session, or another user's session all report\n * false, so the caller signs in fresh (where a real outage surfaces as an\n * actionable sign-in error instead of silent reuse of a dead session).\n */\nasync function probeStoredSession(\n path: string,\n baseURL: string,\n account: { email: string; userId?: string },\n): Promise<boolean> {\n try {\n const ctx = await request.newContext({ storageState: path, baseURL })\n try {\n const res = await ctx.post('/api/auth/token')\n if (!res.ok()) return false\n const { token } = (await res.json()) as { token?: unknown }\n if (typeof token !== 'string') return false\n return tokenClaimsMatchAccount(decodeJwtPayload(token), account)\n } finally {\n await ctx.dispose()\n }\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 { email: account.email, password: account.password },\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 if (failure.status === 429) {\n return `Sign-in rate-limited for ${email} (HTTP 429). Wait about 60 seconds and retry.`\n }\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 : 'The stored password may be stale — fetch the current credential with `deepspace test accounts recover --email <email>` (--all only covers accounts with no local credential), or create a new 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 /** Force a fresh sign-in even if a cached state validates. */\n force?: boolean\n}\n\n/**\n * Ensure a validated Playwright `storageState` file exists for `account`\n * and return its path. A cached file is probed against the app before\n * reuse (once per worker process); a missing, expired, or wrong-account\n * state triggers one fresh sign-in that overwrites it.\n */\nexport async function ensureStorageState(\n browser: Browser,\n account: StorageStateAccount,\n baseURL: string,\n options: EnsureStorageStateOptions = {},\n): Promise<string> {\n const path = getStatePathForEmail(account.email, baseURL)\n if (options.force) stateValidations.delete(path)\n let pending = stateValidations.get(path)\n if (!pending) {\n pending = validateOrSignIn(browser, account, baseURL, path, options.force === true)\n stateValidations.set(path, pending)\n pending.catch(() => stateValidations.delete(path))\n }\n return pending\n}\n\nasync function validateOrSignIn(\n browser: Browser,\n account: StorageStateAccount,\n baseURL: string,\n path: string,\n force: boolean,\n): Promise<string> {\n if (!force && existsSync(path) && (await probeStoredSession(path, baseURL, account))) {\n return path\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: StorageStateAccount,\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","export function decodeJwtPayload<T = Record<string, unknown>>(token: string): T {\n const payloadSegment = token.split('.')[1]\n if (!payloadSegment) {\n throw new Error('JWT payload is missing')\n }\n return JSON.parse(Buffer.from(payloadSegment, 'base64url').toString('utf8')) as T\n}\n"],"mappings":"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","names":["existsSync","mkdirSync","homedir","join","join","homedir","existsSync","mkdirSync"]}
package/dist/worker.d.ts CHANGED
@@ -1572,7 +1572,7 @@ declare function armCronRoom(ctx: {
1572
1572
  * Persistent job queue with serial execution, progress, cancellation and retries.
1573
1573
  * Jobs run inside alarms unless listed in `backgroundJobTypes`.
1574
1574
  * Use `ctx.continue` to save a checkpoint for the next run.
1575
- * After a restart, running jobs older than 16 minutes are retried or failed.
1575
+ * After a restart, interrupted running jobs are retried or failed.
1576
1576
  */
1577
1577
 
1578
1578
  type JobStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'canceled';
@@ -1696,7 +1696,7 @@ declare abstract class JobRoom<E = Record<string, unknown>, P = unknown, R = unk
1696
1696
  private drainDueJobs;
1697
1697
  private executeJob;
1698
1698
  private makeContext;
1699
- private readStatus;
1699
+ private readRunState;
1700
1700
  /** Reauthorize every live event so role revocation does not leave a stale reader. */
1701
1701
  protected broadcast(message: ServerMessage, exclude?: WebSocket): void;
1702
1702
  private resolveAccess;
@@ -2630,6 +2630,42 @@ declare function authenticatedRoomRequest(request: Request, auth: RoomAuth | nul
2630
2630
  role?: string;
2631
2631
  }): Request;
2632
2632
 
2633
+ /**
2634
+ * DeepSpace's deployment planes, in one place: production, staging and
2635
+ * DeepSpace Medical, the BAA-covered plane for healthcare apps
2636
+ * (docs/proposals/deepspace-medical.md).
2637
+ *
2638
+ * Side-effect free. The CLI, the browser client, the test-account helpers and
2639
+ * the platform workers all read it, so a plane's hosts and its restricted-plane
2640
+ * rule cannot drift between them.
2641
+ */
2642
+ type PlaneName = 'production' | 'staging' | 'medical';
2643
+ interface PlaneUrls {
2644
+ auth: string;
2645
+ api: string;
2646
+ platform: string;
2647
+ deploy: string;
2648
+ /** Deployed apps live on `<name>.<appDomain>`. */
2649
+ appDomain: string;
2650
+ /** The zone the platform services live on. */
2651
+ platformDomain: string;
2652
+ /** The plane's public home page. */
2653
+ home: string;
2654
+ /** The builder dashboard; null where the plane has none. */
2655
+ dashboard: string | null;
2656
+ /** The privacy policy sign-in screens link to. */
2657
+ privacyPolicy: string;
2658
+ }
2659
+ declare const PLANES: Readonly<Record<PlaneName, PlaneUrls>>;
2660
+ /**
2661
+ * Whether a platform worker runs on a restricted plane (DeepSpace Medical
2662
+ * sets `DEEPSPACE_PLANE = "medical"`). Unset means production. Any value other
2663
+ * than `production` or `staging` restricts, so a typo fails closed.
2664
+ */
2665
+ declare function isRestrictedPlane(env: {
2666
+ DEEPSPACE_PLANE?: string;
2667
+ }): boolean;
2668
+
2633
2669
  /**
2634
2670
  * Wildcard-aware CORS origin matching for the platform workers.
2635
2671
  *
@@ -4447,6 +4483,12 @@ interface DeepSpaceAuthConfig {
4447
4483
  emailAndPassword?: boolean;
4448
4484
  /** Trusted origins for CORS */
4449
4485
  trustedOrigins?: string[];
4486
+ /**
4487
+ * Where a failed OAuth callback lands when the flow can't be tied back to
4488
+ * its own error URL (for example its state expired). Without it Better Auth
4489
+ * shows its own developer-facing error page.
4490
+ */
4491
+ errorURL?: string;
4450
4492
  /**
4451
4493
  * Server-owned fields to add to Better Auth's user model. Use `input: false`
4452
4494
  * for values that may only be populated by a database hook, and
@@ -5487,8 +5529,11 @@ declare function createDeepSpaceAuth(config: DeepSpaceAuthConfig): better_auth.A
5487
5529
  };
5488
5530
  };
5489
5531
  } | undefined;
5490
- socialProviders: Parameters<typeof betterAuth>[0]["socialProviders"];
5491
5532
  trustedOrigins: string[];
5533
+ onAPIError?: {
5534
+ errorURL: string;
5535
+ } | undefined;
5536
+ socialProviders: Parameters<typeof betterAuth>[0]["socialProviders"];
5492
5537
  user?: {
5493
5538
  additionalFields: {
5494
5539
  [x: string]: better_auth.DBFieldAttribute;
@@ -5760,4 +5805,4 @@ declare const MAX_LOG_TEXT_LENGTH: number;
5760
5805
  */
5761
5806
  declare function workerErrorHandler(prefix: string, respond?: (c: Context) => Response): ErrorHandler;
5762
5807
 
5763
- export { ACCOUNT_STORAGE_LIMIT_BYTES, ACTION_ROUTES_BEARER_GUARD_MIGRATION_ID, ACTION_TOOLS_DELETE_WHERE_MIGRATION_ID, AGENT_TOKEN_SCOPE, AGENT_TOOL_REQUEST_BODY_CAP, AGENT_TOOL_RESPONSE_BODY_CAP, AI_CHATS_SCHEMA, AI_MESSAGES_SCHEMA, AI_SDK_7_MIGRATION_ID, ALLOWED_BINDING_TYPES, ANONYMOUS_USER_ID_PREFIX, 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 AgentTargetPolicy, type AgentToolAccessResult, type AgentToolRouteEnv, type AgentToolRouteOptions, type ApiErrorIssue, type ApiWorkerEnv, type AppLogEvent, type AppLogEventKey, type AppLogsResponse, type AppMembership, type AppMigrationIdsValidation, type AppNameResolution, type AppNameValidation, type AssetNotFoundHandling, type AuthWorkerEnv, Awareness, type AwarenessState, type AwarenessStates, BASE_USERS_SCHEMA, BROWSER_PROXY_ROUTES, BUILD_INJECTED_APP_ID_MIGRATION_ID, BUILT_IN_TOOLS, type BaseMessage, BaseRoom, type BindingJsonValue, BodyTooLargeError, CHANNELS_SCHEMA, CHANNEL_MEMBERS_SCHEMA, COST_RATES, CanvasRoom, type CanvasShape, type ChatContextConfig, type ChatMessageRow, type ChatRow, type ChatTurn, type ClientMessage, type CollectionSchema, type ColumnDefinition, type ColumnInterpretation, type ComposioToolsOptions, type ConnectionAttachment, 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_DELETE_WHERE_LIMIT, DEFAULT_DO_MANIFEST, DEFAULT_QUERY_LIMIT, DEPLOYED_APPS_DEFAULTS, 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 DeepSpaceUserCreateContext, type DoMigrationDirective, type DoMigrationPlan, type ExistingDOBinding, type ExpoAuthBridgeOptions, type ExpoAuthWorkerEnv, FILES_SESSION_COOKIE_READS_MIGRATION_ID, type GetActionData, type HandlerContext, type Job, type JobContext, JobRoom, type JobRoomConfig, type JobStatus, type JwtClaims, type JwtVerifierConfig, type KnowledgeAddOptions, type KnowledgeAddResult, type KnowledgeClient, type KnowledgeEnv, KnowledgeError, type KnowledgeItem, type KnowledgeListOptions, type KnowledgeListResult, type KnowledgeSearchChunk, type KnowledgeSearchOptions, type KnowledgeSearchResult, type KnowledgeStatus, LOG_LEVELS, type LogLevel, MAX_APP_FILE_BYTES, MAX_BINDING_NAME_LENGTH, MAX_DELETE_WHERE_LIMIT, MAX_DEPLOY_ASSET_FILE_BYTES, MAX_ENV_VAR_BYTES, MAX_LOG_TEXT_LENGTH, 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 NormalizedApiError, PRODUCTION_OFFICIAL_AGENT_ORIGINS, type PermissionContext, type PermissionLevel, type PlatformWorkerEnv, type PrefixResult, type PresencePeer, PresenceRoom, type ProtocolMessage, type ProxyRoute, type QueryActionData, REACTIONS_SCHEMA, READ_RECEIPTS_SCHEMA, RECORD_NOT_FOUND, REGISTRY_INSTANCE, RELEASE_STAMP_PATH, REPO_STORE_LIMIT_BYTES, REQUIRED_COMPATIBILITY_FLAGS, RESERVED_BINDING_NAMES, RESERVED_RUN_WORKER_FIRST, RESOURCE_ID_RE, ROLE_ADMIN, ROLE_ANONYMOUS, ROLE_DEFAULT, RecordRoom, type RecordRoomConfig, RegistryClientError, type ResolvedColumn, type ResolvedDeepSpaceAgentModel, type RolePermissions, type RunMigrationsResult, SANDBOX_SCOPES, SANDBOX_SCOPE_HEADER, SDK_RUN_WORKER_FIRST, SECRET_NAME_RE, SECURE_ROOM_BOUNDARIES_MIGRATION_ID, SESSION_COOKIE, STRICT_APP_ID_RE, SYSTEM_ASSIGNED_COLUMNS, SYSTEM_COLLECTIONS, SYSTEM_MAINTAINED_COLUMNS, SYSTEM_MANAGED_COLUMNS, type SandboxFile, SandboxFileError, type SandboxFileList, type SandboxFilesClient, type SandboxFilesOptions, type SandboxScope, type SandboxUploadOptions, SchemaRegistry, type ScopeContext, type ScopedKnowledgeClient, type ScopedR2Auth, type ScopedR2Config, type ScopedR2Handler, type ScreenshotEnv, type ScreenshotResult, type ServerMessage, 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, type UserToolExecutorEnv, type ValidationError, type VerifiedAuth, type VerifyOutcome, type VerifyResult, type Viewport, WORKER_OWNED_NOT_FOUND_MIGRATION_ID, YjsRoom, agentTokenIssuer, apiWorkerFetch, appendMessage, applyAiToolDefaults, applySlidingWindow, armCronRoom, authWorkerFetch, authenticatedRoomRequest, bindingManifestFromOutputConfig, buildCronContext, buildTableSelect, buildUiParts, callRegistry, callableFromSandbox, canCreate, canDelete, canRead, canUpdate, capToolResultSize, captureScreenshot, checkFieldPermissions, checkUnclaimedOwnerTransition, clientBuild, codeExecutionTool, coerceValue, collectionTableName, columnId, composioTools, computeDoMigration, computeHmacHex, createChat, createDecoder, createDeepSpaceAI, createDeepSpaceAuth, createEncoder, createScopedR2Handler, createUserToolExecutor, dataToColumnValues, decodeJwtPayload, deepSpaceAgentErrorSummary, deleteChatCascade, dispatch, encode, encodeAwarenessMessage, encodeSyncStep1, encodeSyncStep2, encodeUpdate, enqueueJob, epochSeconds, folderFilter, formatBytes, forwardSandboxContainer, getChat, getDeepSpaceAIModel, getMessageType, getRolePermissions, handleAwarenessMessage, handleSyncMessage, hasContent, isAnonymousUserId, isAssetNotFoundHandling, isAutoProvision, isFilesVerbPath, isOwner, isPlatformReservedPath, isTestAccountClaims, isTestAccountEmail, isTestAccountTier, knowledge, lintSchema, lintSchemaSet, lintSchemas, listDeepSpaceAgentModels, loadMessages, logEventText, loggableError, makeDefaultSummarizer, matchOrigin, meterAi, meterUsage, meterVectorize, mintAppId, mintUlid, nativeAuthCallback, nativeAuthExchange, nativeAuthMe, nativeAuthSignOut, nativeAuthStart, nativeAuthToken, noopPermissionContext, normalizeAgentTargetOrigin, normalizeApiError, normalizeKnowledgeFolder, parseQuotaLimit, platformCorsPatterns, platformWorkerFetch, prepareMessagesWithCompaction, priceBindingUsageEvent, readBoundedBodyText, readVarUint, readVarUint8Array, registerAgentToolRoutes, registerDeepSpaceDocumentation, registryErrorJson, registryStub, resolveAppMembership, resolveAppName, resolveAppRole, resolveColumn, resolveCompatibilityFlags, resolveDeepSpaceAgentModel, resolveSessionReadAuth, reuseSandbox, rowToData, runMigrations, sandboxFiles, sandboxOutputs, sandboxUpload, serverBuild, storageLimitForTier, storageQuotaMessage, streamDeepSpaceAgent, timingSafeEqualHex, timingSafeEqualStrings, toUint8Array, totalChars, truncateLogText, truncateOldToolResults, turnsToCoreMessages, unwrapToolOutput, updateChat, validateAppMigrationIds, validateAppName, validateBindingManifest, validateDoManifest, verifyAgentToken, verifyJwt, workerErrorHandler, writeVarUint, writeVarUint8Array };
5808
+ export { ACCOUNT_STORAGE_LIMIT_BYTES, ACTION_ROUTES_BEARER_GUARD_MIGRATION_ID, ACTION_TOOLS_DELETE_WHERE_MIGRATION_ID, AGENT_TOKEN_SCOPE, AGENT_TOOL_REQUEST_BODY_CAP, AGENT_TOOL_RESPONSE_BODY_CAP, AI_CHATS_SCHEMA, AI_MESSAGES_SCHEMA, AI_SDK_7_MIGRATION_ID, ALLOWED_BINDING_TYPES, ANONYMOUS_USER_ID_PREFIX, 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 AgentTargetPolicy, type AgentToolAccessResult, type AgentToolRouteEnv, type AgentToolRouteOptions, type ApiErrorIssue, type ApiWorkerEnv, type AppLogEvent, type AppLogEventKey, type AppLogsResponse, type AppMembership, type AppMigrationIdsValidation, type AppNameResolution, type AppNameValidation, type AssetNotFoundHandling, type AuthWorkerEnv, Awareness, type AwarenessState, type AwarenessStates, BASE_USERS_SCHEMA, BROWSER_PROXY_ROUTES, BUILD_INJECTED_APP_ID_MIGRATION_ID, BUILT_IN_TOOLS, type BaseMessage, BaseRoom, type BindingJsonValue, BodyTooLargeError, CHANNELS_SCHEMA, CHANNEL_MEMBERS_SCHEMA, COST_RATES, CanvasRoom, type CanvasShape, type ChatContextConfig, type ChatMessageRow, type ChatRow, type ChatTurn, type ClientMessage, type CollectionSchema, type ColumnDefinition, type ColumnInterpretation, type ComposioToolsOptions, type ConnectionAttachment, 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_DELETE_WHERE_LIMIT, DEFAULT_DO_MANIFEST, DEFAULT_QUERY_LIMIT, DEPLOYED_APPS_DEFAULTS, 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 DeepSpaceUserCreateContext, type DoMigrationDirective, type DoMigrationPlan, type ExistingDOBinding, type ExpoAuthBridgeOptions, type ExpoAuthWorkerEnv, FILES_SESSION_COOKIE_READS_MIGRATION_ID, type GetActionData, type HandlerContext, type Job, type JobContext, JobRoom, type JobRoomConfig, type JobStatus, type JwtClaims, type JwtVerifierConfig, type KnowledgeAddOptions, type KnowledgeAddResult, type KnowledgeClient, type KnowledgeEnv, KnowledgeError, type KnowledgeItem, type KnowledgeListOptions, type KnowledgeListResult, type KnowledgeSearchChunk, type KnowledgeSearchOptions, type KnowledgeSearchResult, type KnowledgeStatus, LOG_LEVELS, type LogLevel, MAX_APP_FILE_BYTES, MAX_BINDING_NAME_LENGTH, MAX_DELETE_WHERE_LIMIT, MAX_DEPLOY_ASSET_FILE_BYTES, MAX_ENV_VAR_BYTES, MAX_LOG_TEXT_LENGTH, 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 NormalizedApiError, PLANES, PRODUCTION_OFFICIAL_AGENT_ORIGINS, type PermissionContext, type PermissionLevel, type PlaneName, type PlaneUrls, type PlatformWorkerEnv, type PrefixResult, type PresencePeer, PresenceRoom, type ProtocolMessage, type ProxyRoute, type QueryActionData, REACTIONS_SCHEMA, READ_RECEIPTS_SCHEMA, RECORD_NOT_FOUND, REGISTRY_INSTANCE, RELEASE_STAMP_PATH, REPO_STORE_LIMIT_BYTES, REQUIRED_COMPATIBILITY_FLAGS, RESERVED_BINDING_NAMES, RESERVED_RUN_WORKER_FIRST, RESOURCE_ID_RE, ROLE_ADMIN, ROLE_ANONYMOUS, ROLE_DEFAULT, RecordRoom, type RecordRoomConfig, RegistryClientError, type ResolvedColumn, type ResolvedDeepSpaceAgentModel, type RolePermissions, type RunMigrationsResult, SANDBOX_SCOPES, SANDBOX_SCOPE_HEADER, SDK_RUN_WORKER_FIRST, SECRET_NAME_RE, SECURE_ROOM_BOUNDARIES_MIGRATION_ID, SESSION_COOKIE, STRICT_APP_ID_RE, SYSTEM_ASSIGNED_COLUMNS, SYSTEM_COLLECTIONS, SYSTEM_MAINTAINED_COLUMNS, SYSTEM_MANAGED_COLUMNS, type SandboxFile, SandboxFileError, type SandboxFileList, type SandboxFilesClient, type SandboxFilesOptions, type SandboxScope, type SandboxUploadOptions, SchemaRegistry, type ScopeContext, type ScopedKnowledgeClient, type ScopedR2Auth, type ScopedR2Config, type ScopedR2Handler, type ScreenshotEnv, type ScreenshotResult, type ServerMessage, 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, type UserToolExecutorEnv, type ValidationError, type VerifiedAuth, type VerifyOutcome, type VerifyResult, type Viewport, WORKER_OWNED_NOT_FOUND_MIGRATION_ID, YjsRoom, agentTokenIssuer, apiWorkerFetch, appendMessage, applyAiToolDefaults, applySlidingWindow, armCronRoom, authWorkerFetch, authenticatedRoomRequest, bindingManifestFromOutputConfig, buildCronContext, buildTableSelect, buildUiParts, callRegistry, callableFromSandbox, canCreate, canDelete, canRead, canUpdate, capToolResultSize, captureScreenshot, checkFieldPermissions, checkUnclaimedOwnerTransition, clientBuild, codeExecutionTool, coerceValue, collectionTableName, columnId, composioTools, computeDoMigration, computeHmacHex, createChat, createDecoder, createDeepSpaceAI, createDeepSpaceAuth, createEncoder, createScopedR2Handler, createUserToolExecutor, dataToColumnValues, decodeJwtPayload, deepSpaceAgentErrorSummary, deleteChatCascade, dispatch, encode, encodeAwarenessMessage, encodeSyncStep1, encodeSyncStep2, encodeUpdate, enqueueJob, epochSeconds, folderFilter, formatBytes, forwardSandboxContainer, getChat, getDeepSpaceAIModel, getMessageType, getRolePermissions, handleAwarenessMessage, handleSyncMessage, hasContent, isAnonymousUserId, isAssetNotFoundHandling, isAutoProvision, isFilesVerbPath, isOwner, isPlatformReservedPath, isRestrictedPlane, isTestAccountClaims, isTestAccountEmail, isTestAccountTier, knowledge, lintSchema, lintSchemaSet, lintSchemas, listDeepSpaceAgentModels, loadMessages, logEventText, loggableError, makeDefaultSummarizer, matchOrigin, meterAi, meterUsage, meterVectorize, mintAppId, mintUlid, nativeAuthCallback, nativeAuthExchange, nativeAuthMe, nativeAuthSignOut, nativeAuthStart, nativeAuthToken, noopPermissionContext, normalizeAgentTargetOrigin, normalizeApiError, normalizeKnowledgeFolder, parseQuotaLimit, platformCorsPatterns, platformWorkerFetch, prepareMessagesWithCompaction, priceBindingUsageEvent, readBoundedBodyText, readVarUint, readVarUint8Array, registerAgentToolRoutes, registerDeepSpaceDocumentation, registryErrorJson, registryStub, resolveAppMembership, resolveAppName, resolveAppRole, resolveColumn, resolveCompatibilityFlags, resolveDeepSpaceAgentModel, resolveSessionReadAuth, reuseSandbox, rowToData, runMigrations, sandboxFiles, sandboxOutputs, sandboxUpload, serverBuild, storageLimitForTier, storageQuotaMessage, streamDeepSpaceAgent, timingSafeEqualHex, timingSafeEqualStrings, toUint8Array, totalChars, truncateLogText, truncateOldToolResults, turnsToCoreMessages, unwrapToolOutput, updateChat, validateAppMigrationIds, validateAppName, validateBindingManifest, validateDoManifest, verifyAgentToken, verifyJwt, workerErrorHandler, writeVarUint, writeVarUint8Array };
package/dist/worker.js CHANGED
@@ -4660,7 +4660,7 @@ var JobRoom = class extends BaseRoom {
4660
4660
  `);
4661
4661
  this.sql.exec(`CREATE INDEX IF NOT EXISTS jobs_status_next_run ON jobs (status, next_run_at)`);
4662
4662
  this.sql.exec(`CREATE INDEX IF NOT EXISTS jobs_completed_at ON jobs (completed_at)`);
4663
- this.recoverStuckRunning();
4663
+ this.recoverStuckRunning(true);
4664
4664
  this.scheduleNextAlarm();
4665
4665
  }
4666
4666
  // ==========================================================================
@@ -4906,8 +4906,7 @@ var JobRoom = class extends BaseRoom {
4906
4906
  }
4907
4907
  async executeJob(row) {
4908
4908
  const startedAt = row.started_at ?? (/* @__PURE__ */ new Date()).toISOString();
4909
- const attemptsBefore = row.attempts;
4910
- const attempt = attemptsBefore + 1;
4909
+ const attempt = row.attempts + 1;
4911
4910
  this.sql.exec(
4912
4911
  `UPDATE jobs SET status = 'running', started_at = ?, attempts = ? WHERE id = ?`,
4913
4912
  startedAt,
@@ -4927,7 +4926,7 @@ var JobRoom = class extends BaseRoom {
4927
4926
  let result;
4928
4927
  let errorMessage;
4929
4928
  try {
4930
- const ctx = this.makeContext(row.id, controller.signal);
4929
+ const ctx = this.makeContext(row.id, attempt, controller.signal);
4931
4930
  result = await this.onJob(job, ctx);
4932
4931
  if (this.continueState) outcome = "continued";
4933
4932
  } catch (e) {
@@ -4937,8 +4936,8 @@ var JobRoom = class extends BaseRoom {
4937
4936
  } finally {
4938
4937
  this.inFlight.delete(row.id);
4939
4938
  }
4940
- const currentStatus = this.readStatus(row.id);
4941
- if (currentStatus !== "running") {
4939
+ const current = this.readRunState(row.id);
4940
+ if (current?.status !== "running" || current.attempts !== attempt) {
4942
4941
  this.continueState = null;
4943
4942
  return;
4944
4943
  }
@@ -5017,9 +5016,11 @@ var JobRoom = class extends BaseRoom {
5017
5016
  const updated = this.getJobById(row.id);
5018
5017
  if (updated) this.broadcast(serverBuild.jobFailed(updated));
5019
5018
  }
5020
- makeContext(jobId, signal) {
5019
+ makeContext(jobId, attempt, signal) {
5021
5020
  return {
5022
5021
  progress: (value, message) => {
5022
+ const current = this.readRunState(jobId);
5023
+ if (current?.status !== "running" || current.attempts !== attempt) return;
5023
5024
  const clamped = Math.max(0, Math.min(1, value));
5024
5025
  this.sql.exec(
5025
5026
  `UPDATE jobs SET progress = ?, progress_message = ? WHERE id = ?`,
@@ -5039,9 +5040,9 @@ var JobRoom = class extends BaseRoom {
5039
5040
  signal
5040
5041
  };
5041
5042
  }
5042
- readStatus(jobId) {
5043
- const row = this.sql.exec(`SELECT status FROM jobs WHERE id = ?`, jobId).toArray()[0];
5044
- return row?.status ?? null;
5043
+ readRunState(jobId) {
5044
+ const row = this.sql.exec(`SELECT status, attempts FROM jobs WHERE id = ?`, jobId).toArray()[0];
5045
+ return row ?? null;
5045
5046
  }
5046
5047
  /** Reauthorize every live event so role revocation does not leave a stale reader. */
5047
5048
  broadcast(message, exclude) {
@@ -5099,11 +5100,12 @@ var JobRoom = class extends BaseRoom {
5099
5100
  if (targets.length === 0) return;
5100
5101
  this.state.storage.setAlarm(Math.max(Math.min(...targets), now + 50));
5101
5102
  }
5102
- recoverStuckRunning() {
5103
+ recoverStuckRunning(onStartup = false) {
5103
5104
  const cutoff = new Date(Date.now() - RUNNING_STALE_MS).toISOString();
5104
5105
  const stuck = this.sql.exec(
5105
5106
  `SELECT id, attempts, max_attempts FROM jobs
5106
- WHERE status = 'running' AND (started_at IS NULL OR started_at <= ?)`,
5107
+ WHERE status = 'running' AND (? = 1 OR started_at IS NULL OR started_at <= ?)`,
5108
+ onStartup ? 1 : 0,
5107
5109
  cutoff
5108
5110
  ).toArray();
5109
5111
  for (const r of stuck) {
@@ -6059,6 +6061,52 @@ var BROWSER_PROXY_ROUTES = [
6059
6061
  var SANDBOX_SCOPES = ["user", "app"];
6060
6062
  var SANDBOX_SCOPE_HEADER = "x-deepspace-sandbox-scope";
6061
6063
 
6064
+ // src/shared/planes.ts
6065
+ var PLANES = {
6066
+ production: {
6067
+ auth: "https://auth.deep.space",
6068
+ api: "https://api-worker.deep.space",
6069
+ platform: "https://platform-worker.deep.space",
6070
+ deploy: "https://deploy-worker.deep.space",
6071
+ appDomain: "app.space",
6072
+ platformDomain: "deep.space",
6073
+ home: "https://deep.space",
6074
+ dashboard: "https://dashboard.deep.space",
6075
+ privacyPolicy: "https://deep.space/privacy"
6076
+ },
6077
+ // Services on deepspacesites.com, apps on spacestest.com: production's
6078
+ // two-zone split, because a Worker cannot fetch one in its own zone.
6079
+ staging: {
6080
+ auth: "https://auth.deepspacesites.com",
6081
+ api: "https://api.deepspacesites.com",
6082
+ platform: "https://platform.deepspacesites.com",
6083
+ deploy: "https://deploy.deepspacesites.com",
6084
+ appDomain: "spacestest.com",
6085
+ platformDomain: "deepspacesites.com",
6086
+ home: "https://spacestest.com",
6087
+ dashboard: "https://dashboard.deepspacesites.com",
6088
+ privacyPolicy: "https://deep.space/privacy"
6089
+ },
6090
+ // Services on deepspacemedical.com, apps on deepspacemedical.app: two zones
6091
+ // in the medical Cloudflare account. The plane has no dashboard.
6092
+ medical: {
6093
+ auth: "https://auth.deepspacemedical.com",
6094
+ api: "https://api.deepspacemedical.com",
6095
+ platform: "https://platform.deepspacemedical.com",
6096
+ deploy: "https://deploy.deepspacemedical.com",
6097
+ appDomain: "deepspacemedical.app",
6098
+ platformDomain: "deepspacemedical.com",
6099
+ home: "https://deepspacemedical.com",
6100
+ dashboard: null,
6101
+ privacyPolicy: "https://deepspacemedical.com/privacy"
6102
+ }
6103
+ };
6104
+ var OPEN_PLANES = /* @__PURE__ */ new Set(["production", "staging"]);
6105
+ function isRestrictedPlane(env) {
6106
+ const plane = env.DEEPSPACE_PLANE;
6107
+ return plane !== void 0 && plane !== "" && !OPEN_PLANES.has(plane);
6108
+ }
6109
+
6062
6110
  // src/server/utils/cors.ts
6063
6111
  function matchOrigin(origin, patterns) {
6064
6112
  if (!origin) return null;
@@ -10479,6 +10527,7 @@ function createDeepSpaceAuth(config) {
10479
10527
  },
10480
10528
  ...config.userAdditionalFields ? { user: { additionalFields: config.userAdditionalFields } } : {},
10481
10529
  socialProviders,
10530
+ ...config.errorURL ? { onAPIError: { errorURL: config.errorURL } } : {},
10482
10531
  trustedOrigins: config.trustedOrigins ?? [
10483
10532
  "https://deep.space",
10484
10533
  "https://*.deep.space",
@@ -11141,6 +11190,7 @@ export {
11141
11190
  MSG_SYNC_UPDATE,
11142
11191
  MSG_YJS_AWARENESS,
11143
11192
  MSG_YJS_SYNC,
11193
+ PLANES,
11144
11194
  PRODUCTION_OFFICIAL_AGENT_ORIGINS,
11145
11195
  PresenceRoom,
11146
11196
  REACTIONS_SCHEMA,
@@ -11242,6 +11292,7 @@ export {
11242
11292
  isFilesVerbPath,
11243
11293
  isOwner,
11244
11294
  isPlatformReservedPath,
11295
+ isRestrictedPlane,
11245
11296
  isTestAccountClaims,
11246
11297
  isTestAccountEmail,
11247
11298
  isTestAccountTier,