deepspace 0.27.1 → 0.28.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/CHANGELOG.md +50 -0
- package/README.md +19 -8
- package/dist/{chunk-CRIG6LPA.js → chunk-652TIGI4.js} +2 -2
- package/dist/{chunk-CRIG6LPA.js.map → chunk-652TIGI4.js.map} +1 -1
- package/dist/cli.js +1560 -867
- package/dist/cli.js.map +1 -1
- package/dist/documentation-client-core.js +1 -1
- package/dist/documentation-default-renderer.cjs +1 -1
- package/dist/documentation-react.js +1 -1
- package/dist/documentation-runtime.js +1 -1
- package/dist/documentation-server-core.js +1 -1
- package/dist/documentation.js +1 -1
- package/dist/documentation.js.map +1 -1
- package/dist/server.d.ts +3 -1
- package/dist/server.js +13 -7
- package/dist/server.js.map +1 -1
- package/dist/testing.js +2 -2
- package/dist/testing.js.map +1 -1
- package/dist/worker.js +13 -7
- package/dist/worker.js.map +1 -1
- package/package.json +1 -1
- package/dist/chunk-Q5YJGF5Z.js +0 -769
- package/dist/chunk-Q5YJGF5Z.js.map +0 -1
- package/dist/repo-api-7GDPVSFV.js +0 -14
- package/dist/repo-api-7GDPVSFV.js.map +0 -1
package/dist/testing.js
CHANGED
|
@@ -73,7 +73,7 @@ function findTestAccountByName(name) {
|
|
|
73
73
|
if (!match) {
|
|
74
74
|
throw new Error(
|
|
75
75
|
`No test account named "${name}" is usable from ${TEST_ACCOUNTS_PATH}.
|
|
76
|
-
If it exists in the pool but was created on another machine, its
|
|
76
|
+
If it exists in the pool but was created on another machine, fetch its credential:
|
|
77
77
|
deepspace test accounts recover --all (note: --all only covers accounts missing a local credential; recover a stale one by its email)
|
|
78
78
|
Otherwise create it:
|
|
79
79
|
deepspace test accounts create --email ${testAccountEmailSlug(name)}@deepspace.test --password <pw> --name "${name}"
|
|
@@ -169,7 +169,7 @@ function formatSignInFailure(email, failure) {
|
|
|
169
169
|
}
|
|
170
170
|
const code = failure.code ? ` ${failure.code}` : "";
|
|
171
171
|
const detail = failure.message ? `: ${failure.message}` : "";
|
|
172
|
-
const nextStep = failure.code === "INVALID_ORIGIN" ? "Add this app origin to the auth allowlist, then retry." : "The stored password may be stale \u2014
|
|
172
|
+
const nextStep = failure.code === "INVALID_ORIGIN" ? "Add this app origin to the auth allowlist, then retry." : "The stored password may be stale \u2014 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`.";
|
|
173
173
|
return `Sign-in failed for ${email} (HTTP ${failure.status}${code})${detail}. ${nextStep}`;
|
|
174
174
|
}
|
|
175
175
|
async function ensureStorageState(browser, account, baseURL, options = {}) {
|
package/dist/testing.js.map
CHANGED
|
@@ -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, its password is not ` +\n `stored here — re-issue it:\\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 — re-issue it 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"]}
|
|
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":"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","names":["existsSync","mkdirSync","homedir","join","join","homedir","existsSync","mkdirSync"]}
|
package/dist/worker.js
CHANGED
|
@@ -1345,7 +1345,7 @@ function handleDelete(ctx, ws, attachment, payload) {
|
|
|
1345
1345
|
ctx.send(ws, serverBuild.error(result.error));
|
|
1346
1346
|
}
|
|
1347
1347
|
}
|
|
1348
|
-
function putRecord(ctx, collection, recordId, data, userId, userRole, skipUserRbac = false, systemUpdate = false) {
|
|
1348
|
+
function putRecord(ctx, collection, recordId, data, userId, userRole, skipUserRbac = false, systemUpdate = false, updateOnly = false) {
|
|
1349
1349
|
const resolved = resolveCollection(ctx, collection);
|
|
1350
1350
|
if (!resolved.ok) return { success: false, error: resolved.error };
|
|
1351
1351
|
const schema = resolved.schema;
|
|
@@ -1363,6 +1363,12 @@ function putRecord(ctx, collection, recordId, data, userId, userRole, skipUserRb
|
|
|
1363
1363
|
}
|
|
1364
1364
|
const existing = getRecord(ctx.sql, collection, recordId, schema);
|
|
1365
1365
|
const isUpdate = existing !== null;
|
|
1366
|
+
if (updateOnly && !isUpdate) {
|
|
1367
|
+
return {
|
|
1368
|
+
success: false,
|
|
1369
|
+
error: `records.update: no record "${recordId}" in ${collection} \u2014 updates never create; use records.create.`
|
|
1370
|
+
};
|
|
1371
|
+
}
|
|
1366
1372
|
const mergedData = isUpdate ? { ...existing.data, ...data } : { ...data };
|
|
1367
1373
|
for (const col of columns) {
|
|
1368
1374
|
if (col.readonly) continue;
|
|
@@ -2521,7 +2527,7 @@ async function executeTool(ctx, toolName, params, userId, userRole, skipUserRbac
|
|
|
2521
2527
|
if (!collection) return { success: false, error: "Missing required param: collection" };
|
|
2522
2528
|
if (!recordId) return { success: false, error: "Missing required param: recordId" };
|
|
2523
2529
|
if (!data) return { success: false, error: "Missing required param: data" };
|
|
2524
|
-
return putRecord(ctx, collection, recordId, data, userId, userRole, skipUserRbac);
|
|
2530
|
+
return putRecord(ctx, collection, recordId, data, userId, userRole, skipUserRbac, false, true);
|
|
2525
2531
|
}
|
|
2526
2532
|
case "records.delete": {
|
|
2527
2533
|
const collection = params.collection;
|
|
@@ -6186,10 +6192,10 @@ async function writeQuotaSummary(bucket, key, previousEtag, summary) {
|
|
|
6186
6192
|
});
|
|
6187
6193
|
return object ? { etag: object.etag, summary } : null;
|
|
6188
6194
|
}
|
|
6189
|
-
async function ensureQuotaSummary(bucket, storage, prefixes,
|
|
6195
|
+
async function ensureQuotaSummary(bucket, storage, prefixes, measure = "reuse", excludedMarkerKey) {
|
|
6190
6196
|
for (let attempt = 0; attempt < 4; attempt++) {
|
|
6191
6197
|
const current = await readQuotaSummary(bucket, storage.quotaKey);
|
|
6192
|
-
if (
|
|
6198
|
+
if (measure !== "force" && current.summary && accountPrefixesEqual(current.summary.prefixes, prefixes) && !(measure === "repairDirty" && current.summary.dirty && Date.now() - current.summary.measuredAt >= QUOTA_REPAIR_COOLDOWN_MS)) {
|
|
6193
6199
|
return current;
|
|
6194
6200
|
}
|
|
6195
6201
|
const usedBytes = await measuredAllocationUsage(bucket, storage, prefixes, excludedMarkerKey);
|
|
@@ -6377,7 +6383,7 @@ async function reserveQuotaSummary(bucket, storage, prefixes, initial, chargeByt
|
|
|
6377
6383
|
bucket,
|
|
6378
6384
|
storage,
|
|
6379
6385
|
prefixes,
|
|
6380
|
-
|
|
6386
|
+
"reuse",
|
|
6381
6387
|
markerKey ?? void 0
|
|
6382
6388
|
);
|
|
6383
6389
|
if (!ensured) {
|
|
@@ -6402,7 +6408,7 @@ async function reserveQuotaSummary(bucket, storage, prefixes, initial, chargeByt
|
|
|
6402
6408
|
bucket,
|
|
6403
6409
|
storage,
|
|
6404
6410
|
prefixes,
|
|
6405
|
-
|
|
6411
|
+
"force",
|
|
6406
6412
|
markerKey ?? void 0
|
|
6407
6413
|
);
|
|
6408
6414
|
if (!refreshed?.summary) {
|
|
@@ -6495,7 +6501,7 @@ async function storageUsage(bucket, storage) {
|
|
|
6495
6501
|
try {
|
|
6496
6502
|
const prefixes = await allocationPrefixes(storage);
|
|
6497
6503
|
if (prefixes === null) return null;
|
|
6498
|
-
const current = await ensureQuotaSummary(bucket, storage, prefixes);
|
|
6504
|
+
const current = await ensureQuotaSummary(bucket, storage, prefixes, "repairDirty");
|
|
6499
6505
|
return current?.summary?.usedBytes ?? null;
|
|
6500
6506
|
} catch (error) {
|
|
6501
6507
|
console.error("[files:quota] usage:", error);
|