deepspace 0.23.0 → 0.23.2

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 CHANGED
@@ -1,5 +1,26 @@
1
1
  # deepspace
2
2
 
3
+ ## 0.23.2
4
+
5
+ ### Patch Changes
6
+
7
+ - Fixes from the v0.23.1 production AX pass: `create-deepspace` exits nonzero when its registration step fails (and says "scaffolded, no app id yet" instead of "is ready"), renders the CLI's refusal sentence instead of a raw JSON envelope, offers `auth login` only when the failure was a missing login, names the account and plane it registered on, and takes `--no-register` (plus a hint that `--yes` is unnecessary); `records.query` refuses a `where` key that names no field (an ignored key returned the whole readable collection as if filtered — the same class `deleteWhere` refuses); `deepspace pull` checks the cloud repo before writing the `space` git remote, so a GitHub-source refusal no longer leaves the push-capable remote behind; `deploy --json` reports a stray server refusal under its real code instead of `deploy_failed` wrapping the JSON as text; `app undeploy` confirms at an interactive terminal (`--yes` skips; scripts and `--json` are never prompted); `test run --json` streams the suite on stderr so stdout is the single JSON line; `status` always states the environment (`env`, `services` in `--json`); the rename prompt says the display `APP_NAME` does not travel, and lives in one place for both the pre-build and commit-time paths.
8
+
9
+ ## 0.23.1
10
+
11
+ ### Patch Changes
12
+
13
+ - Chat writes can no longer resurrect a deleted chat, the delete cascade is bounded, and cookie-only server-action calls get a 401 instead of a 500. `updateChat` and `appendMessage` now re-check the chat through `getChat` and return `false` instead of writing when it is gone (`records.update`/`records.create` are upserts, so an unguarded post-stream write recreated a deleted chat as a title-less ghost). New `records.deleteWhere` tool on the room tools API — `{ collection, where, limit }` → `{ deleted }`, bounded per call, same delete permission check as `records.delete` and refusing the whole page if any match is denied — lets `deleteChatCascade` spend one subrequest per page instead of one per message. The scaffolded `POST /api/actions/:name` reads the bearer token defensively: an authenticated caller with no `Authorization` header is now refused 401 rather than throwing, and the file carries the trust-model note that action tools execute as the caller with RBAC off.
14
+ - CLI honesty pass: `deepspace test run`'s default suite now names every spec file it skipped (prose line + `skippedSpecs` in `--json`); `push` refuses a GitHub-sourced app in its preflight, naming the repository, instead of reconstructing a repository-less sentence from git's discarded 422; `push`'s `no_commits` refusal and `app init --new-id` both name their next step; and a scaffold whose identity registered no longer tells you to `auth login`.
15
+ - `deploy` settles two refusals before it builds and uploads: a collaborator whose app has no live `APP_OWNER_JWT` is refused with the platform's own sentence and a new `owner_jwt_missing` code (the deploy worker's commit-time 409 now carries that code too), and a changed wrangler `name` is confirmed or refused (`rename_required`) from the registered host instead of after the upload.
16
+ - Verification-pass fixes: `records.deleteWhere` refuses a `where` whose keys name no field (an unknown key used to be silently dropped, deleting an unfiltered page), refuses schemaless collections and non-numeric limits, and pages after the RBAC read filter; a users schema with an explicit `read: false` keeps an empty roster for that role, and a role change now refreshes every connected roster; the scaffold's chat route stops (rather than continuing) when a mid-stream delete makes `appendMessage` return `false`, and `PATCH /api/ai/chats/:id` answers 404 for a write that never landed; the build plugin's `.dev.vars` sweep clears every worker dir before reporting an unsafe one; a missing `wrangler.toml` is `not_in_app_repo` with a remedy instead of `invalid_config`, and `detectAppName` shares the one reader; `push`'s `no_commits` names the `__APP_ID__` case only when the placeholder is present (with the `app init` action); `app init` ships a `git commit … wrangler.toml` action when it did not commit; secrets refusals keep the server's `code` in `--json`; `app list` shows a renamed app's reserved old name.
17
+ - AX-pass fixes: `secrets` reads on an unregistered app id refuse `app_not_registered` instead of answering an empty list; the scaffold registers its AI chat routes only when the copilot schemas are present (they could only 500 without them) and names the valid model ids (`unknown_model`) on a bad `modelId`; `create-deepspace` says which plane it registered the app on and which id; deploy/push/pull/update/workspace share one `not_in_app_repo` sentence; `lintSchemas` warns when a role uses the `'team'` level with no `team_members` collection; an implausible command guess is no longer handed back as an executable `action` and `logout` counts as destructive; `transfer accept` names collaborators inherited from the previous owner; `secrets set/upload/delete --json` carry `appliesAtDeploy: true`; `rollback --help` states secrets are kept; `app update` leaves a `file:`/`link:` deepspace spec alone.
18
+ - Review follow-ups: `executeQuery`'s `limit` now counts the records the caller receives — when a per-row read filter applies it scans in bounded batches (a 5,000-row scan budget per call — worst case one batch of `max(limit, 200)` rows past it, and a `limit` above the budget is honored as asked — reported to callers that ask, so `limit` stays a bound on work) instead of letting a SQL `LIMIT` end the result early, and `read: 'own'` / `read: true` push into SQL like `'team'` already did (only when the declared field resolves to a real column — a misdeclared one falls back to the per-row check instead of throwing) (so `records.deleteWhere` pages boundedly under RBAC, and a partially-failed page is refused rather than read as "no more matches"); a new `roster: 'read-policy'` users-schema option scopes the `user.list` roster to the caller's read policy for tenant/team-partitioned apps; ranked command guesses are never handed back as executable actions (only an exact quoted path is); `classifyPushTransportFailure` keeps a last-resort 422 → `source_managed_by_github` fallback; `transfer accept` reports `inheritedCollaborators` on replays too; `app init` offers its `git commit` action only when wrangler.toml is actually uncommitted; one anonymous-identity helper (`isAnonymousUserId`).
19
+ - The users roster (`useUsers()`) now updates live: a room pushes `user.list` to every other connected socket when a user registers for the first time or changes name, avatar, or role — the client only asked once per connection, so a peer who joined after a tab connected rendered as "Unknown" there until reload.
20
+ - `app list` now surfaces incoming ownership offers and an undeployed app's reserved name, `transfer accept` reports a replayed acceptance as a replay instead of a fresh handshake, and `transfer status`/`offer` name the offerer and say that acceptance ends the offerer's access
21
+ - Fix `useUsers()` returning an empty roster (chat authors rendered as "Unknown") in rooms without an app `users` schema: the users list now returns every registered user's public identity to non-admin callers instead of filtering by the schema's row policy, which still guards full-row reads.
22
+ - One wrangler.toml reader behind the CLI and `deepspace/build` — one error shape (`WranglerConfigError`; a missing file is `not_in_app_repo`, a broken one `invalid_config`). **New refusal, `duplicate_app_id`:** a wrangler.toml that sets the same `DEEPSPACE_APP_ID` under more than one section (`[vars]` and `[env.<name>.vars]`) is refused by every CLI command and by `vite build` — each environment is its own app; run `deepspace app init --env <name>` to mint one for it. `test accounts recover` restores the display name the platform now returns.
23
+
3
24
  ## 0.23.0
4
25
 
5
26
  ### Minor Changes
package/dist/build.d.ts CHANGED
@@ -75,7 +75,8 @@ declare function appIdDefine(options: ResolveAppIdOptions): Record<string, strin
75
75
  * environment THIS build targets, never a literal frozen at scaffold time;
76
76
  * 2. removing the preview `.dev.vars` the Cloudflare plugin drops beside the
77
77
  * built worker — DeepSpace has one local runtime, so that second plaintext
78
- * copy is not kept;
78
+ * copy is not kept (`removeBuildDevVars` below; `deepspace deploy` calls
79
+ * the same function on the artifact it ships);
79
80
  * 3. the client `dedupe` hint — two copies of React (or better-auth) break
80
81
  * the SDK's hooks, and which packages must be single-instance is the SDK's
81
82
  * knowledge, not the app's.
package/dist/build.js CHANGED
@@ -1,25 +1,84 @@
1
1
  // src/build/app-id.ts
2
- import { readFileSync } from "fs";
3
2
  import { resolve } from "path";
4
- import { parse as parseToml } from "smol-toml";
5
3
 
6
4
  // src/server/utils/registry-client.ts
7
5
  var APP_ID_RE = /^app_[0-9A-HJKMNP-TV-Z]{26}$/;
8
6
 
7
+ // src/cli/lib/wrangler-env.ts
8
+ import { existsSync, readFileSync, rmSync, writeFileSync } from "fs";
9
+ import { join } from "path";
10
+ import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
11
+ var WranglerConfigError = class extends Error {
12
+ constructor(path, message, code = "invalid_config", cause) {
13
+ super(message);
14
+ this.path = path;
15
+ this.code = code;
16
+ this.cause = cause;
17
+ this.name = "WranglerConfigError";
18
+ }
19
+ };
20
+ function causeDetail(err) {
21
+ return err instanceof Error ? err.message : String(err);
22
+ }
23
+ function readWranglerConfigFile(path) {
24
+ let raw;
25
+ try {
26
+ raw = readFileSync(path, "utf-8");
27
+ } catch (err) {
28
+ const missing = err?.code === "ENOENT";
29
+ throw new WranglerConfigError(
30
+ path,
31
+ missing ? noWranglerConfigMessage(path) : `wrangler.toml: could not read ${path}: ${causeDetail(err)}`,
32
+ missing ? "not_in_app_repo" : "invalid_config",
33
+ err
34
+ );
35
+ }
36
+ let config;
37
+ try {
38
+ config = parseToml(raw);
39
+ } catch (err) {
40
+ throw new WranglerConfigError(
41
+ path,
42
+ `wrangler.toml: malformed TOML at ${path}: ${causeDetail(err)}`,
43
+ "invalid_config",
44
+ err
45
+ );
46
+ }
47
+ assertOneSectionPerAppId(config, path);
48
+ return config;
49
+ }
50
+ function readAppIdVar(config, envName) {
51
+ return (envName ? config.env?.[envName]?.vars : config.vars)?.DEEPSPACE_APP_ID;
52
+ }
53
+ function assertOneSectionPerAppId(config, path) {
54
+ const sections = /* @__PURE__ */ new Map();
55
+ const record = (section, value) => {
56
+ if (typeof value !== "string" || !APP_ID_RE.test(value)) return;
57
+ sections.set(value, [...sections.get(value) ?? [], section]);
58
+ };
59
+ record("[vars]", config.vars?.DEEPSPACE_APP_ID);
60
+ for (const envName of Object.keys(config.env ?? {})) {
61
+ record(`[env.${envName}.vars]`, config.env?.[envName]?.vars?.DEEPSPACE_APP_ID);
62
+ }
63
+ for (const [appId, where] of sections) {
64
+ if (where.length < 2) continue;
65
+ throw new WranglerConfigError(
66
+ path,
67
+ `wrangler.toml: ${where.join(" and ")} ${where.length === 2 ? "both" : "all"} set DEEPSPACE_APP_ID = "${appId}" in ${path}. One id under several sections points every environment's data, secrets, and billing at the same app while the deploys go to different places \u2014 each environment is its own app \u2014 run \`deepspace app init --env <name>\` to mint one for it.`,
68
+ "duplicate_app_id"
69
+ );
70
+ }
71
+ }
72
+ function noWranglerConfigMessage(where) {
73
+ return `No wrangler.toml at ${where}. Are you in a DeepSpace app directory? (Scaffold one with \`npm create deepspace\`, or cd into an existing app.)`;
74
+ }
75
+
9
76
  // src/build/app-id.ts
10
77
  function resolveAppId({ appDir, env = process.env }) {
11
78
  const configPath = env.CLOUDFLARE_VITE_WRANGLER_CONFIG_PATH ? resolve(env.CLOUDFLARE_VITE_WRANGLER_CONFIG_PATH) : resolve(appDir, "wrangler.toml");
12
79
  const envName = env.CLOUDFLARE_ENV;
13
80
  const section = envName ? `[env.${envName}.vars]` : "[vars]";
14
- let config;
15
- try {
16
- config = parseToml(readFileSync(configPath, "utf-8"));
17
- } catch (error) {
18
- throw new Error(
19
- `Could not read the app id: ${configPath} is missing or not valid TOML (${error instanceof Error ? error.message : String(error)}).`
20
- );
21
- }
22
- const appId = (envName ? config.env?.[envName]?.vars : config.vars)?.DEEPSPACE_APP_ID;
81
+ const appId = readAppIdVar(readWranglerConfigFile(configPath), envName);
23
82
  if (typeof appId !== "string" || !APP_ID_RE.test(appId)) {
24
83
  const uninitialized = appId === void 0 || appId === "__APP_ID__";
25
84
  throw new Error(
@@ -33,8 +92,17 @@ function appIdDefine(options) {
33
92
  }
34
93
 
35
94
  // src/build/plugin.ts
36
- import { existsSync, readdirSync, unlinkSync } from "fs";
37
- import { join, resolve as resolve2 } from "path";
95
+ import { existsSync as existsSync2, lstatSync, readdirSync, unlinkSync } from "fs";
96
+ import { join as join2, resolve as resolve2 } from "path";
97
+ function removeBuildDevVars(workerDir) {
98
+ const path = join2(workerDir, ".dev.vars");
99
+ if (!existsSync2(path)) return false;
100
+ if (!lstatSync(path).isFile()) {
101
+ throw new Error(`Refusing unsafe build secret path: ${path}`);
102
+ }
103
+ unlinkSync(path);
104
+ return true;
105
+ }
38
106
  var CLIENT_DEDUPE = ["react", "react-dom", "better-auth"];
39
107
  function deepspaceBuild(options) {
40
108
  const distDir = resolve2(options.appDir, "dist");
@@ -48,12 +116,17 @@ function deepspaceBuild(options) {
48
116
  };
49
117
  },
50
118
  closeBundle() {
51
- if (!existsSync(distDir)) return;
119
+ if (!existsSync2(distDir)) return;
120
+ const unsafe = [];
52
121
  for (const entry of readdirSync(distDir, { withFileTypes: true })) {
53
122
  if (!entry.isDirectory()) continue;
54
- const secretPath = join(distDir, entry.name, ".dev.vars");
55
- if (existsSync(secretPath)) unlinkSync(secretPath);
123
+ try {
124
+ removeBuildDevVars(join2(distDir, entry.name));
125
+ } catch (err) {
126
+ unsafe.push(err instanceof Error ? err.message : String(err));
127
+ }
56
128
  }
129
+ if (unsafe.length > 0) throw new Error(unsafe.join("\n"));
57
130
  }
58
131
  };
59
132
  }
package/dist/build.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/build/app-id.ts","../src/server/utils/registry-client.ts","../src/build/plugin.ts"],"sourcesContent":["/**\n * `deepspace/build` — build-time helpers an app's Vite config imports.\n *\n * Node-only. Nothing here may be reached from client or worker code; it runs\n * while `vite build` / `vite dev` is assembling the bundle, never inside it.\n *\n * The one job today is resolving the app's immutable id for the browser.\n * `wrangler.toml` is the single source of truth for app identity — the worker\n * reads `DEEPSPACE_APP_ID` off `env`, and every durable thing (data scope\n * `app:{appId}`, secrets, billing) keys to it. The browser has no `env`, so\n * the build stamps the same value in as `__DEEPSPACE_APP_ID__`, read from the\n * config THIS build is running against. A literal baked into the client once,\n * at scaffold time, is what let `deepspace deploy --env staging` ship a worker\n * writing staging's rooms beside a browser reading production's, with nothing\n * reporting the split.\n *\n * Living in the SDK rather than in a file copied into every app means one\n * definition, no per-app duplicate, and `smol-toml` stays the SDK's dependency\n * instead of every app's.\n */\n\nimport { readFileSync } from 'node:fs'\nimport { resolve } from 'node:path'\nimport { parse as parseToml } from 'smol-toml'\n\n/** `app_` + 26-char Crockford ULID. */\n// Single source: the shared registry client owns the id shape.\nimport { APP_ID_RE } from '../server/utils/registry-client'\n\ninterface WranglerIdentity {\n vars?: Record<string, unknown>\n env?: Record<string, { vars?: Record<string, unknown> } | undefined>\n}\n\n/**\n * The build's environment, typed structurally. This module is imported from an\n * app's `vite.config.ts`, which sits outside the app's tsconfig `include` and\n * runs where the app carries no `@types/node`, so nothing here leans on the\n * ambient `NodeJS` namespace.\n */\nexport type BuildEnv = Record<string, string | undefined>\n\nexport interface ResolveAppIdOptions {\n /**\n * The app root — the directory holding `wrangler.toml`. The caller passes\n * its own location (`fileURLToPath(new URL('.', import.meta.url))` from\n * `vite.config.ts`), so the config is found by an authoritative anchor\n * rather than a guessed `process.cwd()`.\n */\n appDir: string\n /** Defaults to `process.env`; injectable for tests. */\n env?: BuildEnv\n}\n\n/**\n * Resolve the app id the browser bundle must carry.\n *\n * Mirrors the Cloudflare Vite plugin so dev, a bare `vite build`, and\n * `deepspace deploy --env <name>` all agree:\n *\n * config ← CLOUDFLARE_VITE_WRANGLER_CONFIG_PATH, else <appDir>/wrangler.toml\n * section ← [env.<CLOUDFLARE_ENV>.vars] when that is set, else [vars]\n *\n * Env blocks do NOT inherit `[vars]` — each environment is its own app — so a\n * missing or malformed id throws and fails the build. It never falls back to\n * another environment's id: a fallback that silently picks the wrong one is\n * exactly the bug this exists to remove.\n */\nexport function resolveAppId({ appDir, env = process.env }: ResolveAppIdOptions): string {\n const configPath = env.CLOUDFLARE_VITE_WRANGLER_CONFIG_PATH\n ? resolve(env.CLOUDFLARE_VITE_WRANGLER_CONFIG_PATH)\n : resolve(appDir, 'wrangler.toml')\n const envName = env.CLOUDFLARE_ENV\n const section = envName ? `[env.${envName}.vars]` : '[vars]'\n\n let config: WranglerIdentity\n try {\n config = parseToml(readFileSync(configPath, 'utf-8')) as WranglerIdentity\n } catch (error) {\n throw new Error(\n `Could not read the app id: ${configPath} is missing or not valid TOML ` +\n `(${error instanceof Error ? error.message : String(error)}).`,\n )\n }\n\n const appId = (envName ? config.env?.[envName]?.vars : config.vars)?.DEEPSPACE_APP_ID\n if (typeof appId !== 'string' || !APP_ID_RE.test(appId)) {\n // A fresh-but-uninitialized scaffold (signed out at scaffold time) still\n // carries the __APP_ID__ placeholder — the remedy is registration, not\n // hand-writing an id (ids are server-minted at `deepspace app init`).\n const uninitialized = appId === undefined || appId === '__APP_ID__'\n throw new Error(\n `${configPath}: ${section} has no valid DEEPSPACE_APP_ID ` +\n `(found ${appId === undefined ? 'nothing' : JSON.stringify(appId)}). ` +\n `The browser keys its RecordRooms to this id, so the build stops rather than ship a wrong or empty one. ` +\n (uninitialized\n ? `Run \\`npx deepspace app init${envName ? ` --env ${envName}` : ''}\\` to register this app — ids are server-minted; never hand-write one.`\n : `Add the id your \\`deepspace app init\\` registered under ${section}.`) +\n (envName ? ` Environments do not inherit the top-level id; each one is its own app.` : ``),\n )\n }\n return appId\n}\n\n/**\n * Vite `define` entry consumed by an app's `vite.config.ts` and\n * `vitest.config.ts`, so unit tests see the same id the bundle is built with:\n *\n * ```ts\n * import { fileURLToPath } from 'node:url'\n * import { appIdDefine } from 'deepspace/build'\n * export default defineConfig({\n * define: appIdDefine({ appDir: fileURLToPath(new URL('.', import.meta.url)) }),\n * })\n * ```\n */\nexport function appIdDefine(options: ResolveAppIdOptions): Record<string, string> {\n return { __DEEPSPACE_APP_ID__: JSON.stringify(resolveAppId(options)) }\n}\n","/**\n * The ONE registry client. Every platform worker reaches the app-registry\n * Durable Object (hosted by the deploy worker, singleton `v1:global`) through\n * a cross-script REGISTRY binding — and before this module, each of the five\n * workers hand-rolled its own copy of the same mechanics: the instance name\n * appeared in 6 places, the `https://registry/` URL in 6, `APP_ID_RE` in 5,\n * and `RegistryClientError` + `registryErrorJson` existed byte-identical in\n * two workers. Copies that must agree and nothing enforcing it.\n *\n * This module owns the MECHANICS: instance name, stub lookup, the wire call,\n * the error type, and the standard route mapping. Each worker still declares\n * its own typed convenience wrapper listing only the actions it uses — that\n * subset is genuinely per-worker; the plumbing is not.\n */\n\n/** The singleton registry instance — one DO holds every app/route/collaborator. */\nexport const REGISTRY_INSTANCE = 'v1:global'\n\n/** Public app ids have one canonical ULID-minted shape. */\nexport const APP_ID_RE = /^app_[0-9A-HJKMNP-TV-Z]{26}$/\nexport const STRICT_APP_ID_RE = APP_ID_RE\n\n/** Internal physical locators survive identity migration and are not public ids. */\nexport const RESOURCE_ID_RE = /^(app_[0-9A-HJKMNP-TV-Z]{26}|[a-z0-9][a-z0-9_-]{0,63})$/\n\nconst CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'\n\n/** A 26-char ULID (48-bit ms timestamp + 80 random bits, Crockford base32) —\n * the id shape shared by app ids (`app_…`) and workspace ids (`ws_…`). */\nexport function mintUlid(now = Date.now()): string {\n let ts = ''\n let t = now\n for (let i = 0; i < 10; i++) {\n ts = CROCKFORD[t % 32] + ts\n t = Math.floor(t / 32)\n }\n const rand = new Uint8Array(10)\n crypto.getRandomValues(rand)\n let rs = ''\n let acc = 0\n let bits = 0\n for (const byte of rand) {\n acc = (acc << 8) | byte\n bits += 8\n while (bits >= 5) {\n bits -= 5\n rs += CROCKFORD[(acc >> bits) & 31]\n }\n }\n return `${ts}${rs}`.slice(0, 26)\n}\n\n/** Mint a fresh app id: `app_` + 26-char ULID. App ids are minted ONLY by the\n * deploy worker's authenticated `POST /api/apps/mint`, which registers the id\n * to its caller in the same step — an id with no owner never exists. */\nexport function mintAppId(now = Date.now()): string {\n return `app_${mintUlid(now)}`\n}\n\nexport class RegistryClientError extends Error {\n constructor(\n message: string,\n readonly code: string,\n readonly status: number,\n ) {\n super(message)\n this.name = 'RegistryClientError'\n }\n}\n\nexport function registryStub(ns: DurableObjectNamespace): DurableObjectStub {\n return ns.get(ns.idFromName(REGISTRY_INSTANCE))\n}\n\n/**\n * One registry action over the wire. Non-2xx answers throw\n * {@link RegistryClientError} carrying the DO's structured `code` (or\n * `'error'` for a transport-shaped body) — callers distinguish a registry-\n * REPORTED miss (`code === 'not_found'`) from an outage by that code, never\n * by treating every throw as \"not found\".\n */\nexport async function callRegistry<T>(\n stub: DurableObjectStub,\n body: { action: string } & Record<string, unknown>,\n): Promise<T> {\n const res = await stub.fetch('https://registry/', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(body),\n })\n const json = (await res.json().catch(() => ({}))) as { error?: string; code?: string }\n if (!res.ok) {\n throw new RegistryClientError(\n json.error ?? `Registry error (${res.status})`,\n json.code ?? 'error',\n res.status,\n )\n }\n return json as T\n}\n\n/** Standard mapping of a registry failure onto a route response. */\nexport function registryErrorJson(\n c: { json: (body: unknown, status?: number) => Response },\n err: unknown,\n): Response {\n if (err instanceof RegistryClientError) {\n return c.json({ error: err.message, code: err.code }, err.status as 400)\n }\n throw err\n}\n","/**\n * `deepspaceBuild()` — the one Vite plugin an app's build config needs.\n *\n * It carries the build-time wiring that is identical in every DeepSpace app\n * and belongs to the SDK's contract rather than to app code:\n *\n * 1. the app id `define` (see ./app-id) — so the browser is keyed to the\n * environment THIS build targets, never a literal frozen at scaffold time;\n * 2. removing the preview `.dev.vars` the Cloudflare plugin drops beside the\n * built worker — DeepSpace has one local runtime, so that second plaintext\n * copy is not kept;\n * 3. the client `dedupe` hint — two copies of React (or better-auth) break\n * the SDK's hooks, and which packages must be single-instance is the SDK's\n * knowledge, not the app's.\n *\n * Housing these here means a future fix to any of them ships in an SDK version\n * bump, with no edit to — and no migration of — a single app.\n *\n * The return is a structural Vite plugin: typing it against `vite` would make\n * the SDK carry a Vite dependency it otherwise does not need, and Vite's\n * plugin hooks are optional and method-shaped, so a compatible object is\n * assignable to `PluginOption` where an app composes it.\n */\n\nimport { existsSync, readdirSync, unlinkSync } from 'node:fs'\nimport { join, resolve } from 'node:path'\nimport { appIdDefine } from './app-id'\nimport type { ResolveAppIdOptions } from './app-id'\n\n/**\n * Packages that must resolve to a single instance in the client graph. Two\n * Reacts throw \"Cannot read properties of null (reading 'useState')\"; two\n * better-auth clients split the session. The SDK owns this list because it is\n * the SDK's hooks and auth client that break when it is wrong.\n */\nexport const CLIENT_DEDUPE: readonly string[] = ['react', 'react-dom', 'better-auth']\n\n/** The subset of Vite's `Plugin` this helper implements — declared locally so\n * `deepspace/build` needs no `vite` dependency. Assignable to `PluginOption`. */\ninterface VitePluginLike {\n name: string\n enforce?: 'pre' | 'post'\n config(): { define: Record<string, string>; resolve: { dedupe: string[] } }\n closeBundle(): void\n}\n\nexport type DeepspaceBuildOptions = ResolveAppIdOptions\n\nexport function deepspaceBuild(options: DeepspaceBuildOptions): VitePluginLike {\n const distDir = resolve(options.appDir, 'dist')\n return {\n name: 'deepspace-build',\n enforce: 'post',\n config() {\n return {\n define: appIdDefine(options),\n resolve: { dedupe: [...CLIENT_DEDUPE] },\n }\n },\n closeBundle() {\n // Cloudflare emits a preview-only `.dev.vars` beside each built worker.\n if (!existsSync(distDir)) return\n for (const entry of readdirSync(distDir, { withFileTypes: true })) {\n if (!entry.isDirectory()) continue\n const secretPath = join(distDir, entry.name, '.dev.vars')\n if (existsSync(secretPath)) unlinkSync(secretPath)\n }\n },\n }\n}\n"],"mappings":";AAqBA,SAAS,oBAAoB;AAC7B,SAAS,eAAe;AACxB,SAAS,SAAS,iBAAiB;;;ACJ5B,IAAM,YAAY;;;ADiDlB,SAAS,aAAa,EAAE,QAAQ,MAAM,QAAQ,IAAI,GAAgC;AACvF,QAAM,aAAa,IAAI,uCACnB,QAAQ,IAAI,oCAAoC,IAChD,QAAQ,QAAQ,eAAe;AACnC,QAAM,UAAU,IAAI;AACpB,QAAM,UAAU,UAAU,QAAQ,OAAO,WAAW;AAEpD,MAAI;AACJ,MAAI;AACF,aAAS,UAAU,aAAa,YAAY,OAAO,CAAC;AAAA,EACtD,SAAS,OAAO;AACd,UAAM,IAAI;AAAA,MACR,8BAA8B,UAAU,kCAClC,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,CAAC;AAAA,IAC9D;AAAA,EACF;AAEA,QAAM,SAAS,UAAU,OAAO,MAAM,OAAO,GAAG,OAAO,OAAO,OAAO;AACrE,MAAI,OAAO,UAAU,YAAY,CAAC,UAAU,KAAK,KAAK,GAAG;AAIvD,UAAM,gBAAgB,UAAU,UAAa,UAAU;AACvD,UAAM,IAAI;AAAA,MACR,GAAG,UAAU,KAAK,OAAO,yCACb,UAAU,SAAY,YAAY,KAAK,UAAU,KAAK,CAAC,gHAEhE,gBACG,+BAA+B,UAAU,UAAU,OAAO,KAAK,EAAE,gFACjE,2DAA2D,OAAO,QACrE,UAAU,4EAA4E;AAAA,IAC3F;AAAA,EACF;AACA,SAAO;AACT;AAcO,SAAS,YAAY,SAAsD;AAChF,SAAO,EAAE,sBAAsB,KAAK,UAAU,aAAa,OAAO,CAAC,EAAE;AACvE;;;AE9FA,SAAS,YAAY,aAAa,kBAAkB;AACpD,SAAS,MAAM,WAAAA,gBAAe;AAUvB,IAAM,gBAAmC,CAAC,SAAS,aAAa,aAAa;AAa7E,SAAS,eAAe,SAAgD;AAC7E,QAAM,UAAUC,SAAQ,QAAQ,QAAQ,MAAM;AAC9C,SAAO;AAAA,IACL,MAAM;AAAA,IACN,SAAS;AAAA,IACT,SAAS;AACP,aAAO;AAAA,QACL,QAAQ,YAAY,OAAO;AAAA,QAC3B,SAAS,EAAE,QAAQ,CAAC,GAAG,aAAa,EAAE;AAAA,MACxC;AAAA,IACF;AAAA,IACA,cAAc;AAEZ,UAAI,CAAC,WAAW,OAAO,EAAG;AAC1B,iBAAW,SAAS,YAAY,SAAS,EAAE,eAAe,KAAK,CAAC,GAAG;AACjE,YAAI,CAAC,MAAM,YAAY,EAAG;AAC1B,cAAM,aAAa,KAAK,SAAS,MAAM,MAAM,WAAW;AACxD,YAAI,WAAW,UAAU,EAAG,YAAW,UAAU;AAAA,MACnD;AAAA,IACF;AAAA,EACF;AACF;","names":["resolve","resolve"]}
1
+ {"version":3,"sources":["../src/build/app-id.ts","../src/server/utils/registry-client.ts","../src/cli/lib/wrangler-env.ts","../src/build/plugin.ts"],"sourcesContent":["/**\n * `deepspace/build` — build-time helpers an app's Vite config imports.\n *\n * Node-only. Nothing here may be reached from client or worker code; it runs\n * while `vite build` / `vite dev` is assembling the bundle, never inside it.\n *\n * The one job today is resolving the app's immutable id for the browser.\n * `wrangler.toml` is the single source of truth for app identity — the worker\n * reads `DEEPSPACE_APP_ID` off `env`, and every durable thing (data scope\n * `app:{appId}`, secrets, billing) keys to it. The browser has no `env`, so\n * the build stamps the same value in as `__DEEPSPACE_APP_ID__`, read from the\n * config THIS build is running against. A literal baked into the client once,\n * at scaffold time, is what let `deepspace deploy --env staging` ship a worker\n * writing staging's rooms beside a browser reading production's, with nothing\n * reporting the split.\n *\n * Living in the SDK rather than in a file copied into every app means one\n * definition, no per-app duplicate, and `smol-toml` stays the SDK's dependency\n * instead of every app's.\n */\n\nimport { resolve } from 'node:path'\n\n/** `app_` + 26-char Crockford ULID. */\n// Single source: the shared registry client owns the id shape.\nimport { APP_ID_RE } from '../server/utils/registry-client'\n// Single source: `cli/lib/wrangler-env` is the SDK's only wrangler.toml reader,\n// so the build sees the same parse, the same failure shape, and the same\n// whole-file guards the CLI does. Both are Node-only. Imported as that one\n// module, never through the CLI barrel — nothing else of the CLI belongs in an\n// app's build graph.\nimport { readAppIdVar, readWranglerConfigFile } from '../cli/lib/wrangler-env'\n\n/**\n * The build's environment, typed structurally. This module is imported from an\n * app's `vite.config.ts`, which sits outside the app's tsconfig `include` and\n * runs where the app carries no `@types/node`, so nothing here leans on the\n * ambient `NodeJS` namespace.\n */\nexport type BuildEnv = Record<string, string | undefined>\n\nexport interface ResolveAppIdOptions {\n /**\n * The app root — the directory holding `wrangler.toml`. The caller passes\n * its own location (`fileURLToPath(new URL('.', import.meta.url))` from\n * `vite.config.ts`), so the config is found by an authoritative anchor\n * rather than a guessed `process.cwd()`.\n */\n appDir: string\n /** Defaults to `process.env`; injectable for tests. */\n env?: BuildEnv\n}\n\n/**\n * Resolve the app id the browser bundle must carry.\n *\n * Mirrors the Cloudflare Vite plugin so dev, a bare `vite build`, and\n * `deepspace deploy --env <name>` all agree:\n *\n * config ← CLOUDFLARE_VITE_WRANGLER_CONFIG_PATH, else <appDir>/wrangler.toml\n * section ← [env.<CLOUDFLARE_ENV>.vars] when that is set, else [vars]\n *\n * Env blocks do NOT inherit `[vars]` — each environment is its own app — so a\n * missing or malformed id throws and fails the build. It never falls back to\n * another environment's id: a fallback that silently picks the wrong one is\n * exactly the bug this exists to remove.\n */\nexport function resolveAppId({ appDir, env = process.env }: ResolveAppIdOptions): string {\n const configPath = env.CLOUDFLARE_VITE_WRANGLER_CONFIG_PATH\n ? resolve(env.CLOUDFLARE_VITE_WRANGLER_CONFIG_PATH)\n : resolve(appDir, 'wrangler.toml')\n const envName = env.CLOUDFLARE_ENV\n const section = envName ? `[env.${envName}.vars]` : '[vars]'\n\n const appId = readAppIdVar(readWranglerConfigFile(configPath), envName)\n if (typeof appId !== 'string' || !APP_ID_RE.test(appId)) {\n // A fresh-but-uninitialized scaffold (signed out at scaffold time) still\n // carries the __APP_ID__ placeholder — the remedy is registration, not\n // hand-writing an id (ids are server-minted at `deepspace app init`).\n const uninitialized = appId === undefined || appId === '__APP_ID__'\n throw new Error(\n `${configPath}: ${section} has no valid DEEPSPACE_APP_ID ` +\n `(found ${appId === undefined ? 'nothing' : JSON.stringify(appId)}). ` +\n `The browser keys its RecordRooms to this id, so the build stops rather than ship a wrong or empty one. ` +\n (uninitialized\n ? `Run \\`npx deepspace app init${envName ? ` --env ${envName}` : ''}\\` to register this app — ids are server-minted; never hand-write one.`\n : `Add the id your \\`deepspace app init\\` registered under ${section}.`) +\n (envName ? ` Environments do not inherit the top-level id; each one is its own app.` : ``),\n )\n }\n return appId\n}\n\n/**\n * Vite `define` entry consumed by an app's `vite.config.ts` and\n * `vitest.config.ts`, so unit tests see the same id the bundle is built with:\n *\n * ```ts\n * import { fileURLToPath } from 'node:url'\n * import { appIdDefine } from 'deepspace/build'\n * export default defineConfig({\n * define: appIdDefine({ appDir: fileURLToPath(new URL('.', import.meta.url)) }),\n * })\n * ```\n */\nexport function appIdDefine(options: ResolveAppIdOptions): Record<string, string> {\n return { __DEEPSPACE_APP_ID__: JSON.stringify(resolveAppId(options)) }\n}\n","/**\n * The ONE registry client. Every platform worker reaches the app-registry\n * Durable Object (hosted by the deploy worker, singleton `v1:global`) through\n * a cross-script REGISTRY binding — and before this module, each of the five\n * workers hand-rolled its own copy of the same mechanics: the instance name\n * appeared in 6 places, the `https://registry/` URL in 6, `APP_ID_RE` in 5,\n * and `RegistryClientError` + `registryErrorJson` existed byte-identical in\n * two workers. Copies that must agree and nothing enforcing it.\n *\n * This module owns the MECHANICS: instance name, stub lookup, the wire call,\n * the error type, and the standard route mapping. Each worker still declares\n * its own typed convenience wrapper listing only the actions it uses — that\n * subset is genuinely per-worker; the plumbing is not.\n */\n\n/** The singleton registry instance — one DO holds every app/route/collaborator. */\nexport const REGISTRY_INSTANCE = 'v1:global'\n\n/** Public app ids have one canonical ULID-minted shape. */\nexport const APP_ID_RE = /^app_[0-9A-HJKMNP-TV-Z]{26}$/\nexport const STRICT_APP_ID_RE = APP_ID_RE\n\n/** Internal physical locators survive identity migration and are not public ids. */\nexport const RESOURCE_ID_RE = /^(app_[0-9A-HJKMNP-TV-Z]{26}|[a-z0-9][a-z0-9_-]{0,63})$/\n\nconst CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'\n\n/** A 26-char ULID (48-bit ms timestamp + 80 random bits, Crockford base32) —\n * the id shape shared by app ids (`app_…`) and workspace ids (`ws_…`). */\nexport function mintUlid(now = Date.now()): string {\n let ts = ''\n let t = now\n for (let i = 0; i < 10; i++) {\n ts = CROCKFORD[t % 32] + ts\n t = Math.floor(t / 32)\n }\n const rand = new Uint8Array(10)\n crypto.getRandomValues(rand)\n let rs = ''\n let acc = 0\n let bits = 0\n for (const byte of rand) {\n acc = (acc << 8) | byte\n bits += 8\n while (bits >= 5) {\n bits -= 5\n rs += CROCKFORD[(acc >> bits) & 31]\n }\n }\n return `${ts}${rs}`.slice(0, 26)\n}\n\n/** Mint a fresh app id: `app_` + 26-char ULID. App ids are minted ONLY by the\n * deploy worker's authenticated `POST /api/apps/mint`, which registers the id\n * to its caller in the same step — an id with no owner never exists. */\nexport function mintAppId(now = Date.now()): string {\n return `app_${mintUlid(now)}`\n}\n\nexport class RegistryClientError extends Error {\n constructor(\n message: string,\n readonly code: string,\n readonly status: number,\n ) {\n super(message)\n this.name = 'RegistryClientError'\n }\n}\n\nexport function registryStub(ns: DurableObjectNamespace): DurableObjectStub {\n return ns.get(ns.idFromName(REGISTRY_INSTANCE))\n}\n\n/**\n * One registry action over the wire. Non-2xx answers throw\n * {@link RegistryClientError} carrying the DO's structured `code` (or\n * `'error'` for a transport-shaped body) — callers distinguish a registry-\n * REPORTED miss (`code === 'not_found'`) from an outage by that code, never\n * by treating every throw as \"not found\".\n */\nexport async function callRegistry<T>(\n stub: DurableObjectStub,\n body: { action: string } & Record<string, unknown>,\n): Promise<T> {\n const res = await stub.fetch('https://registry/', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(body),\n })\n const json = (await res.json().catch(() => ({}))) as { error?: string; code?: string }\n if (!res.ok) {\n throw new RegistryClientError(\n json.error ?? `Registry error (${res.status})`,\n json.code ?? 'error',\n res.status,\n )\n }\n return json as T\n}\n\n/** Standard mapping of a registry failure onto a route response. */\nexport function registryErrorJson(\n c: { json: (body: unknown, status?: number) => Response },\n err: unknown,\n): Response {\n if (err instanceof RegistryClientError) {\n return c.json({ error: err.message, code: err.code }, err.status as 400)\n }\n throw err\n}\n","/**\n * Wrangler env resolution for DeepSpace's multi-environment deploys.\n *\n * Lets a single repo deploy to `<app>.app.space` and `<app>-staging.app.space`\n * (or any other named environment) from one `wrangler.toml`:\n *\n * name = \"hopkins\"\n * [vars]\n * APP_NAME = \"hopkins\"\n *\n * [env.staging]\n * name = \"hopkins-staging\"\n * [env.staging.vars]\n * APP_NAME = \"hopkins-staging\"\n *\n * Then `npx deepspace deploy --env staging` reads the override and ships to\n * `hopkins-staging.app.space`. The platform treats it as a fully separate\n * app — its own DO state, OAuth tokens, Stripe products, cron schedule.\n * No platform-side change needed beyond letting the CLI register a second\n * app under the same owner.\n *\n * DeepSpace keeps generated dev/test/deploy credentials in one `.dev.vars`\n * file. For `--env`, it flattens the selected Wrangler env into a generated\n * config for the Cloudflare Vite plugin instead of passing `CLOUDFLARE_ENV`;\n * otherwise Wrangler would look for a separate `.dev.vars.<env>` file.\n *\n * This module is also the ONE reader of `wrangler.toml` — `cli/lib/app-identity`\n * (ids) and `deepspace/build` (the browser's id define) both come through\n * {@link readWranglerConfigFile}, so there is one parse, one error shape, and\n * one place for a whole-file guard. It stays free of CLI-only imports for that\n * reason: the `deepspace/build` bundle pulls this in, and must not drag citty\n * or the prompt stack along with it.\n */\n\nimport { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'\nimport { join } from 'node:path'\nimport { parse as parseToml, stringify as stringifyToml } from 'smol-toml'\nimport { resolveAppName } from '../../server/rooms/app-name'\nimport { APP_ID_RE } from '../../server/utils/registry-client'\n\n/** Shape of the bits of wrangler.toml that DeepSpace CLI commands read.\n * Add an optional field here whenever a new command starts consulting\n * the config — avoids one-off `WranglerConfig & { … }` casts at\n * call sites and keeps the canonical type in one place. */\nexport interface WranglerConfig {\n name?: string\n env?: Record<\n string,\n ({ name?: string; vars?: Record<string, unknown> } & Record<string, unknown>) | undefined\n >\n /** `[assets]` block (`directory`, `binding`, `not_found_handling`,\n * `run_worker_first`). Consumed by deploy.ts to merge extra\n * worker-first routes into the deployed metadata. */\n assets?: {\n directory?: string\n binding?: string\n not_found_handling?: string\n run_worker_first?: unknown\n }\n /** `[vars]` block — string-valued environment variables baked into\n * the worker at deploy time. */\n vars?: Record<string, unknown>\n}\n\nexport interface PreparedWranglerEnvConfig {\n configPath?: string\n cleanup(): void\n}\n\ntype TomlRecord = Record<string, unknown>\n\nconst NON_INHERITABLE_ENV_KEYS = new Set([\n 'define',\n 'vars',\n 'durable_objects',\n 'kv_namespaces',\n 'r2_buckets',\n 'ai_search_namespaces',\n 'ai_search',\n 'ratelimits',\n 'vectorize',\n 'services',\n 'queues',\n 'workflows',\n 'tail_consumers',\n 'secrets',\n 'secrets_store_secrets',\n])\n\nlet generatedConfigSeq = 0\n\n/**\n * Resolve the appName that this CLI invocation should target.\n *\n * Without `envName`: returns the top-level `name`.\n * With `envName`: returns `[env.<envName>].name` and requires that the\n * env block exists and declares a name. We refuse to\n * fall back to the top-level name because that would\n * silently deploy staging code to production.\n *\n * Returns a discriminated union so callers can surface a precise error\n * (matches the existing `resolveAppName` contract). A failure may carry a\n * `code`: the stable machine contract callers emit as `{ ok: false, code }`.\n * Absent, the failure is about the name itself (`invalid_app_name`) — a\n * missing env block is not, so it names itself.\n */\nexport function resolveAppNameForEnv(\n config: WranglerConfig,\n envName: string | undefined,\n): { ok: true; name: string; warning?: string } | { ok: false; reason: string; code?: string } {\n if (!envName) {\n return resolveAppName(config.name)\n }\n const envBlock = config.env?.[envName]\n if (!envBlock) {\n return {\n ok: false,\n code: 'missing_env_block',\n reason:\n `wrangler.toml: no [env.${envName}] block found. ` +\n `Add one with a distinct \\`name\\` to deploy this environment.`,\n }\n }\n if (typeof envBlock.name !== 'string' || envBlock.name.trim() === '') {\n return {\n ok: false,\n reason:\n `wrangler.toml: [env.${envName}].name is missing. ` +\n `Set a distinct app name (e.g. \"${typeof config.name === 'string' ? config.name : 'myapp'}-${envName}\") so this environment doesn't overwrite the default.`,\n }\n }\n if (envBlock.name === config.name) {\n return {\n ok: false,\n reason:\n `wrangler.toml: [env.${envName}].name \"${envBlock.name}\" is the same as the top-level name. ` +\n `Pick a distinct name (e.g. \"${config.name}-${envName}\") so deploys don't collide.`,\n }\n }\n return resolveAppName(envBlock.name)\n}\n\n/** DeepSpace owns one generated local env file for every Wrangler environment. */\nexport function devVarsPathFor(appDir: string): string {\n return join(appDir, '.dev.vars')\n}\n\nexport function prepareWranglerEnvConfig(\n appDir: string,\n envName: string | undefined,\n): PreparedWranglerEnvConfig {\n if (!envName) return { cleanup() {} }\n\n const config = readWranglerConfig(appDir)\n const resolved = resolveAppNameForEnv(config, envName)\n if (!resolved.ok) throw new Error(resolved.reason)\n const flattened = flattenWranglerEnvConfig(config as TomlRecord, envName)\n const configPath = join(\n appDir,\n `.wrangler.deepspace.${safeGeneratedConfigSegment(envName)}.${process.pid}.${++generatedConfigSeq}.toml`,\n )\n writeFileSync(\n configPath,\n [\n '# Generated by DeepSpace for this CLI run.',\n '# Do not edit; wrangler.toml remains the source of truth.',\n stringifyToml(flattened),\n ]\n .join('\\n')\n .replace(/\\s*$/u, '\\n'),\n )\n return {\n configPath,\n cleanup() {\n rmSync(configPath, { force: true })\n },\n }\n}\n\nexport function wranglerViteEnv(\n baseEnv: NodeJS.ProcessEnv,\n prepared: PreparedWranglerEnvConfig,\n extraEnv: NodeJS.ProcessEnv = {},\n): NodeJS.ProcessEnv {\n const env = { ...baseEnv, ...extraEnv }\n if (prepared.configPath) {\n delete env.CLOUDFLARE_ENV\n env.CLOUDFLARE_VITE_WRANGLER_CONFIG_PATH = prepared.configPath\n }\n return env\n}\n\nfunction flattenWranglerEnvConfig(config: TomlRecord, envName: string): TomlRecord {\n const envRoot = asTomlRecord(config.env)\n const envBlock = asTomlRecord(envRoot?.[envName])\n if (!envBlock) {\n throw new Error(`wrangler.toml: no [env.${envName}] block found.`)\n }\n\n const flattened: TomlRecord = {}\n for (const [key, value] of Object.entries(config)) {\n if (key === 'env' || NON_INHERITABLE_ENV_KEYS.has(key)) continue\n flattened[key] = value\n }\n for (const [key, value] of Object.entries(envBlock)) {\n flattened[key] = value\n }\n // Durable Object declarations contain class bindings, not shared resource ids.\n // A named worker gets its own namespaces, so preserving the declarations is\n // both safe and required for SDK features to work outside the default env.\n if (!('durable_objects' in envBlock) && config.durable_objects) {\n flattened.durable_objects = config.durable_objects\n }\n const envVars = asTomlRecord(envBlock.vars) ?? {}\n if (!('APP_NAME' in envVars) && typeof envBlock.name === 'string') {\n flattened.vars = { ...envVars, APP_NAME: envBlock.name }\n }\n return flattened\n}\n\nfunction asTomlRecord(value: unknown): TomlRecord | undefined {\n return value && typeof value === 'object' && !Array.isArray(value)\n ? (value as TomlRecord)\n : undefined\n}\n\nfunction safeGeneratedConfigSegment(value: string): string {\n return value.replace(/[^A-Za-z0-9._-]/gu, '_')\n}\n\n/**\n * The one failure shape for reading wrangler.toml — unreadable, malformed, or\n * self-contradictory. Carries the absolute path so the user is not left\n * staring at a raw smol-toml stack trace guessing which config in a multi-app\n * repo is broken, and a machine `code` so `--json` callers keep getting one\n * (`cli-errors.errorCode` recognizes this class by name, since this module\n * must not import the CLI error stack — see the header).\n */\nexport class WranglerConfigError extends Error {\n constructor(\n public readonly path: string,\n message: string,\n public readonly code: string = 'invalid_config',\n public readonly cause?: unknown,\n ) {\n super(message)\n this.name = 'WranglerConfigError'\n }\n}\n\nfunction causeDetail(err: unknown): string {\n return err instanceof Error ? err.message : String(err)\n}\n\n/**\n * Read + parse one wrangler.toml by absolute path. The single reader: every\n * consumer of this file in the SDK (deploy/status/dev, `readAppId`, and the\n * build's `__DEEPSPACE_APP_ID__` define) lands here, so a config that is wrong\n * is refused once, the same way, wherever it is read.\n */\nexport function readWranglerConfigFile(path: string): WranglerConfig {\n let raw: string\n try {\n raw = readFileSync(path, 'utf-8')\n } catch (err) {\n const missing = (err as NodeJS.ErrnoException | null)?.code === 'ENOENT'\n throw new WranglerConfigError(\n path,\n missing ? noWranglerConfigMessage(path) : `wrangler.toml: could not read ${path}: ${causeDetail(err)}`,\n missing ? 'not_in_app_repo' : 'invalid_config',\n err,\n )\n }\n let config: WranglerConfig\n try {\n config = parseToml(raw) as WranglerConfig\n } catch (err) {\n throw new WranglerConfigError(\n path,\n `wrangler.toml: malformed TOML at ${path}: ${causeDetail(err)}`,\n 'invalid_config',\n err,\n )\n }\n assertOneSectionPerAppId(config, path)\n return config\n}\n\n/** Read + parse `<appDir>/wrangler.toml`. Caller may check existence first via\n * `hasWranglerConfig` to distinguish \"no app here\" from \"broken app here\". */\nexport function readWranglerConfig(appDir: string): WranglerConfig {\n return readWranglerConfigFile(join(appDir, 'wrangler.toml'))\n}\n\n/**\n * The raw `DEEPSPACE_APP_ID` of one section — `[env.<envName>.vars]` when an\n * env is named, `[vars]` otherwise. Env blocks do NOT inherit the top-level\n * id: each environment is its own app, and silently inheriting is what once\n * shipped a staging worker beside a production browser.\n */\nexport function readAppIdVar(config: WranglerConfig, envName?: string): unknown {\n return (envName ? config.env?.[envName]?.vars : config.vars)?.DEEPSPACE_APP_ID\n}\n\n/**\n * Refuse a config that gives one app id to more than one section.\n *\n * Copying the `[vars]` block into `[env.staging.vars]` leaves both halves of a\n * build agreeing on the same app while the deploy targets another — the client\n * id-mismatch guard cannot fire, because there is no mismatch to see. Only\n * real (`app_…`-shaped) ids count, so a fresh scaffold's repeated `__APP_ID__`\n * placeholder is still initializable.\n */\nfunction assertOneSectionPerAppId(config: WranglerConfig, path: string): void {\n const sections = new Map<string, string[]>()\n const record = (section: string, value: unknown) => {\n if (typeof value !== 'string' || !APP_ID_RE.test(value)) return\n sections.set(value, [...(sections.get(value) ?? []), section])\n }\n record('[vars]', config.vars?.DEEPSPACE_APP_ID)\n for (const envName of Object.keys(config.env ?? {})) {\n record(`[env.${envName}.vars]`, config.env?.[envName]?.vars?.DEEPSPACE_APP_ID)\n }\n for (const [appId, where] of sections) {\n if (where.length < 2) continue\n throw new WranglerConfigError(\n path,\n `wrangler.toml: ${where.join(' and ')} ${where.length === 2 ? 'both' : 'all'} set DEEPSPACE_APP_ID = \"${appId}\" in ${path}. ` +\n `One id under several sections points every environment's data, secrets, and billing at the ` +\n `same app while the deploys go to different places — each environment is its own app — ` +\n `run \\`deepspace app init --env <name>\\` to mint one for it.`,\n 'duplicate_app_id',\n )\n }\n}\n\n/**\n * The one \"not in an app directory\" sentence — the reader's own, and what\n * every command that pre-checks for wrangler.toml says (`not_in_app_repo`),\n * so an agent meets one text with one remedy wherever it hits this.\n */\nexport function noWranglerConfigMessage(where: string): string {\n return `No wrangler.toml at ${where}. Are you in a DeepSpace app directory? (Scaffold one with \\`npm create deepspace\\`, or cd into an existing app.)`\n}\n\n/** Convenience: true when wrangler.toml exists at the standard path. */\nexport function hasWranglerConfig(appDir: string): boolean {\n return existsSync(join(appDir, 'wrangler.toml'))\n}\n","/**\n * `deepspaceBuild()` — the one Vite plugin an app's build config needs.\n *\n * It carries the build-time wiring that is identical in every DeepSpace app\n * and belongs to the SDK's contract rather than to app code:\n *\n * 1. the app id `define` (see ./app-id) — so the browser is keyed to the\n * environment THIS build targets, never a literal frozen at scaffold time;\n * 2. removing the preview `.dev.vars` the Cloudflare plugin drops beside the\n * built worker — DeepSpace has one local runtime, so that second plaintext\n * copy is not kept (`removeBuildDevVars` below; `deepspace deploy` calls\n * the same function on the artifact it ships);\n * 3. the client `dedupe` hint — two copies of React (or better-auth) break\n * the SDK's hooks, and which packages must be single-instance is the SDK's\n * knowledge, not the app's.\n *\n * Housing these here means a future fix to any of them ships in an SDK version\n * bump, with no edit to — and no migration of — a single app.\n *\n * The return is a structural Vite plugin: typing it against `vite` would make\n * the SDK carry a Vite dependency it otherwise does not need, and Vite's\n * plugin hooks are optional and method-shaped, so a compatible object is\n * assignable to `PluginOption` where an app composes it.\n */\n\nimport { existsSync, lstatSync, readdirSync, unlinkSync } from 'node:fs'\nimport { join, resolve } from 'node:path'\nimport { appIdDefine } from './app-id'\nimport type { ResolveAppIdOptions } from './app-id'\n\n/**\n * Delete Cloudflare's preview-only `.dev.vars` copy from ONE built worker dir.\n * Returns whether a file was removed; throws rather than following a symlink\n * or unlinking a directory, so a booby-trapped build output cannot make this\n * delete something else.\n *\n * TWO call sites, deliberately, and this is the single implementation they\n * share (they used to have one each, with only the CLI's carrying the symlink\n * guard):\n *\n * 1. `closeBundle` below — every `vite build` of an app that uses\n * `deepspaceBuild()`, including plain `npm run build` in CI, so a generic\n * archive step cannot pick the plaintext file up.\n * 2. `cli/commands/deploy/build.ts` — the AUTHORITATIVE one: it runs against\n * the exact worker dir the deploy is about to read, covers apps that do\n * not use this plugin, and fails the deploy (`build_output_unsafe`) when\n * the path is unsafe.\n *\n * Neither subsumes the other — they have different lifetimes (any build vs.\n * the artifact that ships). See `docs/migrations/build-preview-secrets.md`.\n */\nexport function removeBuildDevVars(workerDir: string): boolean {\n const path = join(workerDir, '.dev.vars')\n if (!existsSync(path)) return false\n // lstat, not stat: a symlink reports as a link, not as its target's file.\n if (!lstatSync(path).isFile()) {\n throw new Error(`Refusing unsafe build secret path: ${path}`)\n }\n unlinkSync(path)\n return true\n}\n\n/**\n * Packages that must resolve to a single instance in the client graph. Two\n * Reacts throw \"Cannot read properties of null (reading 'useState')\"; two\n * better-auth clients split the session. The SDK owns this list because it is\n * the SDK's hooks and auth client that break when it is wrong.\n */\nexport const CLIENT_DEDUPE: readonly string[] = ['react', 'react-dom', 'better-auth']\n\n/** The subset of Vite's `Plugin` this helper implements — declared locally so\n * `deepspace/build` needs no `vite` dependency. Assignable to `PluginOption`. */\ninterface VitePluginLike {\n name: string\n enforce?: 'pre' | 'post'\n config(): { define: Record<string, string>; resolve: { dedupe: string[] } }\n closeBundle(): void\n}\n\nexport type DeepspaceBuildOptions = ResolveAppIdOptions\n\nexport function deepspaceBuild(options: DeepspaceBuildOptions): VitePluginLike {\n const distDir = resolve(options.appDir, 'dist')\n return {\n name: 'deepspace-build',\n enforce: 'post',\n config() {\n return {\n define: appIdDefine(options),\n resolve: { dedupe: [...CLIENT_DEDUPE] },\n }\n },\n closeBundle() {\n // Cloudflare emits a preview-only `.dev.vars` beside each built worker.\n // Sweep every worker dir before reporting an unsafe one: an early throw\n // would leave the plaintext copies after it in place (fail-open).\n if (!existsSync(distDir)) return\n const unsafe: string[] = []\n for (const entry of readdirSync(distDir, { withFileTypes: true })) {\n if (!entry.isDirectory()) continue\n try {\n removeBuildDevVars(join(distDir, entry.name))\n } catch (err) {\n unsafe.push(err instanceof Error ? err.message : String(err))\n }\n }\n if (unsafe.length > 0) throw new Error(unsafe.join('\\n'))\n },\n }\n}\n"],"mappings":"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","names":["existsSync","join","resolve","join","existsSync","resolve"]}
@@ -1274,7 +1274,7 @@ function renderInlineMarkdown(value) {
1274
1274
  import { useEffect as useEffect6, useRef as useRef5, useState as useState5 } from "react";
1275
1275
 
1276
1276
  // package.json
1277
- var version = "0.23.0";
1277
+ var version = "0.23.2";
1278
1278
 
1279
1279
  // src/documentation/runtime/navigation.tsx
1280
1280
  import { Fragment as Fragment4, jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
@@ -1954,4 +1954,4 @@ export {
1954
1954
  DefaultDocumentation,
1955
1955
  DocumentationContent
1956
1956
  };
1957
- //# sourceMappingURL=chunk-A7VKZRCB.js.map
1957
+ //# sourceMappingURL=chunk-2U2VG6L5.js.map