@objectstack/types 17.0.0-rc.3 → 17.0.0-rc.5
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 +376 -2
- package/dist/index.d.mts +289 -14
- package/dist/index.d.ts +289 -14
- package/dist/index.js +131 -2
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +117 -1
- package/dist/index.mjs.map +1 -1
- package/dist/node.d.mts +120 -6
- package/dist/node.d.ts +120 -6
- package/dist/node.js +132 -8
- package/dist/node.js.map +1 -1
- package/dist/node.mjs +125 -7
- package/dist/node.mjs.map +1 -1
- package/package.json +2 -2
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/degraded-boot.ts","../src/env.ts","../src/error-leak.ts","../src/keyset-walk.ts","../src/module-not-found.ts","../src/response-envelope.ts"],"sourcesContent":["// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nexport * from './degraded-boot.js';\nexport * from './env.js';\nexport * from './error-leak.js';\n// Seek-based pagination for batch walks — the offset alternative that neither\n// skips rows when the walk mutates as it goes, nor costs O(n²/p) (#4363).\nexport * from './keyset-walk.js';\nexport * from './module-not-found.js';\nexport * from './response-envelope.js';\n\n// Placeholder for Kernel interface to avoid circular dependency\n// The actual Kernel implementation will satisfy this interface.\nexport interface IKernel {\n // We can add specific methods here that plugins are allowed to call\n // forcing a stricter contract than exposing the whole class.\n ql?: any; // ObjectQL instance (optional to support initialization phase)\n start(): Promise<void>;\n // ... expose other needed public methods\n [key: string]: any; \n}\n\nexport interface RuntimeContext {\n engine: IKernel;\n}\n\nexport interface RuntimePlugin {\n name: string;\n install?: (ctx: RuntimeContext) => void | Promise<void>;\n onStart?: (ctx: RuntimeContext) => void | Promise<void>;\n}\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * Degraded-boot reporting, shared by every subsystem that can be told to boot\n * without a datasource it needs.\n *\n * Two of them exist today and they opt in through the *same* operator flag\n * (`OS_ALLOW_DRIVER_CONNECT_FAILURE`, see {@link resolveAllowDriverConnectFailure}):\n *\n * - `ObjectQLEngine.init()` — a boot-registered driver whose `connect()`\n * rejected (framework#3741).\n * - `DatasourceConnectionService` — a declared datasource that objects bind to\n * explicitly, or an `external` one with `validation.onMismatch:'fail'`,\n * that could not be connected (framework#3758).\n *\n * They live in different packages but owe the operator the same thing: the\n * degraded state must be impossible to miss.\n */\n\n/**\n * Emit the degraded-boot banner on a channel the host cannot accidentally\n * silence.\n *\n * `OS_ALLOW_DRIVER_CONNECT_FAILURE` only justifies itself if the state it opts\n * into is impossible to miss — and a logger-only banner is missable, because\n * the logger answers to a level the operator sets. `Logger.write()` returns\n * before emitting anything when the record is below `config.level`, so at\n * `--log-level error`, `fatal`, or `silent` this `warn` never reaches ANY\n * stream. A production host running at `error` is exactly the deployment this\n * flag exists for, and is exactly where the banner would vanish. Writing to\n * stderr as well is the same belt-and-braces the kernel already uses for\n * plugin startup failures.\n *\n * A second reason used to be load-bearing and no longer is: `os serve` blanked\n * ALL of stdout while the kernel booted, and `Logger` routes `warn` to stdout,\n * so a boot-phase banner was swallowed at every level. That was framework#4012\n * and is fixed — the boot window buffers and replays `warn`-and-above instead\n * of discarding it. Do not re-derive this helper's necessity from the\n * boot-quiet capture; the level filter is what keeps it alive.\n *\n * Best-effort and never throws: falls back to `console.error`, then to silence\n * on runtimes that have neither (the logger still carries the structured\n * record either way).\n */\nexport function emitDegradedBootBanner(message: string): void {\n const proc = (globalThis as {\n process?: { stderr?: { write?: (chunk: string) => unknown } };\n }).process;\n try {\n if (typeof proc?.stderr?.write === 'function') {\n proc.stderr.write(`${message}\\n`);\n return;\n }\n } catch {\n /* stderr unavailable / closed — fall through to console */\n }\n try {\n (globalThis as { console?: { error?: (msg: string) => void } }).console?.error?.(message);\n } catch {\n /* no output channel at all — the logger record is the remaining trace */\n }\n}\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * Environment-variable helpers shared across `@objectstack/*` packages.\n *\n * The framework standardises on `OS_*` prefixed env vars (see AGENTS.md\n * \"Environment Variables\" section). Some historical names predate this\n * convention — `AUTH_SECRET`, `ROOT_DOMAIN`, `OBJECTSTACK_*`, …\n *\n * To migrate without breaking user `.env` files mid-release, call\n * {@link readEnvWithDeprecation} at every legacy read site:\n *\n * const v = readEnvWithDeprecation('OS_AUTH_SECRET', 'AUTH_SECRET');\n *\n * If only the legacy name is set, the value is still returned but a\n * one-shot `console.warn` fires (per-process per-variable) telling\n * operators to rename it.\n */\n\nimport {\n normalizeTenancyPosture,\n TENANCY_POSTURES,\n type TenancyPosture,\n} from '@objectstack/spec/security';\n\nconst _warnedKeys = new Set<string>();\n\n/**\n * Read an env var, preferring the canonical `OS_*` name and falling\n * back to one or more legacy aliases.\n *\n * When only a legacy alias is set, emits a one-shot deprecation warning.\n * The warning is process-wide deduplicated: identical (preferred, legacy)\n * pairs will only warn once even if read from multiple call sites.\n *\n * Legacy aliases are checked in order; the first one with a defined\n * value wins (and triggers the warning for that specific alias).\n *\n * Safe to call from environments where `process` is unavailable (returns\n * `undefined`); the warning is suppressed when running outside Node-like\n * runtimes that lack `console.warn`.\n *\n * @param preferred Canonical OS_*-prefixed env var name.\n * @param legacy Older name (or array of older names) to fall back on.\n * @param options Optional behaviour flags. Set `silent: true` for aliases\n * that remain accepted conventions rather than true legacy\n * names — e.g. `PORT`, which PaaS platforms (Render, Railway,\n * Heroku, Fly, …) inject automatically. Warning on those\n * would nag operators about env they never set.\n * @returns The resolved value, or `undefined` if neither is set.\n */\nexport function readEnvWithDeprecation(\n preferred: string,\n legacy: string | readonly string[],\n options?: { silent?: boolean },\n): string | undefined {\n const env = (globalThis as { process?: { env?: Record<string, string | undefined> } })\n .process?.env;\n if (!env) return undefined;\n\n const preferredValue = env[preferred];\n if (preferredValue !== undefined) return preferredValue;\n\n const legacyList = typeof legacy === 'string' ? [legacy] : legacy;\n for (const legacyName of legacyList) {\n const legacyValue = env[legacyName];\n if (legacyValue !== undefined) {\n const dedupeKey = `${preferred}|${legacyName}`;\n if (!options?.silent && !_warnedKeys.has(dedupeKey)) {\n _warnedKeys.add(dedupeKey);\n const consoleRef = (globalThis as { console?: { warn?: (msg: string) => void } }).console;\n try {\n consoleRef?.warn?.(\n `[ObjectStack] Env var \\`${legacyName}\\` is deprecated; rename it to \\`${preferred}\\`. ` +\n `The legacy name still works for now but will be removed in a future major release.`,\n );\n } catch {\n /* `console.warn` unavailable (exotic runtime) — ignore */\n }\n }\n return legacyValue;\n }\n }\n\n return undefined;\n}\n\n/**\n * Resolve whether the deployment runs in multi-org (a.k.a. multi-tenant) mode.\n *\n * Single source of truth for the `OS_MULTI_ORG_ENABLED` flag. Resolution: the\n * canonical `OS_MULTI_ORG_ENABLED`; else `false`. Any value other than a\n * case-insensitive `'false'` enables it. (The legacy `OS_MULTI_TENANT` alias was\n * removed in 11.0.)\n *\n * Every site that needs to know \"is this multi-org?\" — the SQL driver's\n * tenant-audit gate, the auth manager's `/auth/config` feature flag and\n * org-create guard, the CLI / dev / runtime org-scoping plugin wiring — MUST\n * call this instead of re-reading the env, so the driver, the security layer,\n * and the UI can never disagree about the mode. Previously each site inlined\n * its own `String(... ?? 'false').toLowerCase() !== 'false'` (and the SQL\n * driver read `process.env` directly, skipping the deprecation warning).\n *\n * Reads `process.env` live on each call; memoise at the call site if the\n * result must be stable for the process lifetime.\n */\nexport function resolveMultiOrgEnabled(): boolean {\n const raw = readEnvWithDeprecation('OS_MULTI_ORG_ENABLED', []);\n return String(raw ?? 'false').toLowerCase() !== 'false';\n}\n\n/**\n * [ADR-0105 D1] Resolve the deployment's REQUESTED tenancy posture —\n * `single` | `group` | `isolated`.\n *\n * `OS_TENANCY_POSTURE` is the canonical knob and generalizes the boolean\n * `OS_MULTI_ORG_ENABLED` it supersedes:\n *\n * - set → that posture (the legacy spelling `multi` normalizes to `isolated`)\n * - unset → derived from `OS_MULTI_ORG_ENABLED`: `true` ⇒ `isolated`, else `single`\n *\n * so every existing deployment keeps its current posture with no config change.\n *\n * An unrecognized value THROWS rather than falling back. A typo'd posture that\n * quietly resolved to `single` would silently remove the organization wall —\n * the deployment-layer form of the \"declared but unenforced\" defect ADR-0049\n * forbids, and the same reasoning behind ADR-0093 D5's refusal to boot into\n * undeclared degradation.\n *\n * This resolves what the operator ASKED FOR. Whether the posture is actually\n * enforced is the `tenancy` service's answer (`isolationActive` / `degraded`).\n */\nexport function resolveTenancyPosture(): TenancyPosture {\n // Read through `globalThis` like `readEnvWithDeprecation` does — this package\n // targets non-Node runtimes too, where a bare `process` reference throws.\n const raw = (globalThis as { process?: { env?: Record<string, string | undefined> } })\n .process?.env?.OS_TENANCY_POSTURE;\n if (raw != null && String(raw).trim() !== '') {\n const posture = normalizeTenancyPosture(raw);\n if (!posture) {\n throw new Error(\n `Invalid OS_TENANCY_POSTURE=${JSON.stringify(String(raw))}. ` +\n `Expected one of: ${TENANCY_POSTURES.join(', ')} (or the legacy alias 'multi' = 'isolated'). ` +\n 'Refusing to boot rather than silently falling back to a posture with no organization wall.',\n );\n }\n return posture;\n }\n return resolveMultiOrgEnabled() ? 'isolated' : 'single';\n}\n\n/**\n * Escape hatch for the degraded-tenancy boot guard (ADR-0093 D5).\n *\n * When `OS_MULTI_ORG_ENABLED=true` but the enterprise `@objectstack/organizations`\n * package cannot provide tenant isolation, the platform refuses to boot — a\n * deployment that asked for tenant isolation must not serve traffic pretending\n * to have it (ADR-0049 at the deployment layer). Setting this to a truthy value\n * (`true`/`1`/`on`/`yes`, case-insensitive) boots anyway in an explicitly\n * *degraded* state that is branded everywhere an operator looks. Defaults OFF —\n * an unset flag means \"fail fast\".\n */\nexport function resolveAllowDegradedTenancy(): boolean {\n const raw = readEnvWithDeprecation('OS_ALLOW_DEGRADED_TENANCY', [], { silent: true });\n if (raw == null) return false;\n return ['1', 'true', 'on', 'yes'].includes(String(raw).trim().toLowerCase());\n}\n\n/**\n * Escape hatch for the driver-connect boot guard (framework#3741).\n *\n * `ObjectQLEngine.init()` connects every boot-registered driver and, by\n * default, refuses to boot when any of them fails — a server whose database is\n * unreachable must not report itself started and then 500 every request with an\n * error that reads nothing like \"the database is down\". Failing there is also\n * what gives a driver the ability to REFUSE STARTUP at all: any fatal startup\n * check a driver wants to run (licence, server version, incompatible\n * configuration, missing capability) can simply throw from `connect()`.\n *\n * Setting this to a truthy value (`true`/`1`/`on`/`yes`, case-insensitive)\n * boots anyway, in an explicitly degraded state that is logged loudly at\n * startup. Every query routed to a failed driver fails until the datasource\n * becomes reachable — the underlying clients do re-establish connections on\n * their own (framework#3759) — but the boot-time schema sync those drivers\n * missed is never re-run, so their tables may simply not exist afterwards.\n * Defaults OFF — an unset flag means \"fail fast\".\n */\nexport function resolveAllowDriverConnectFailure(): boolean {\n const raw = readEnvWithDeprecation('OS_ALLOW_DRIVER_CONNECT_FAILURE', [], { silent: true });\n if (raw == null) return false;\n return ['1', 'true', 'on', 'yes'].includes(String(raw).trim().toLowerCase());\n}\n\n/**\n * Escape hatch for plugin-dev's production boot guard (ADR-0115 D6, #3900).\n *\n * `DevPlugin.init()` refuses to run under `NODE_ENV=production`: the stack it\n * assembles is built around an auth secret published inside the npm package and\n * an in-memory driver with persistence off, neither of which a production\n * deployment should acquire by accident. Setting this to a truthy value\n * (`true`/`1`/`on`/`yes`, case-insensitive) boots anyway, in an explicitly\n * degraded state that is branded in the boot log and on the ready banner.\n * Defaults OFF — an unset flag means \"fail fast\".\n *\n * Lives here rather than as a bare `process.env[…] === '1'` inside plugin-dev so\n * that the whole `OS_ALLOW_*` family answers to one truthy vocabulary: the\n * strict `=== '1'` it replaced fails CLOSED on `OS_ALLOW_DEV_PLUGIN=true`, which\n * is safe but reads to an operator as the flag being broken.\n */\nexport function resolveAllowDevPlugin(): boolean {\n const raw = readEnvWithDeprecation('OS_ALLOW_DEV_PLUGIN', [], { silent: true });\n if (raw == null) return false;\n return ['1', 'true', 'on', 'yes'].includes(String(raw).trim().toLowerCase());\n}\n\n/**\n * SINGLE decision point for \"is the MCP HTTP surface (`/api/v1/mcp`) on?\".\n *\n * MCP is a core platform capability and defaults ON: an unset\n * `OS_MCP_SERVER_ENABLED` means the surface is served. Operators opt OUT with\n * an explicit falsy value (`false`/`0`/`off`/`no`, case-insensitive); any\n * other value — including the historical `true` — keeps it on.\n *\n * Every consumer of the flag — the runtime dispatcher's `/mcp` route gate,\n * the CLI's MCP plugin auto-load, the REST `/discovery` advertisement, and\n * the auth service's OAuth/DCR follow-defaults — MUST call this instead of\n * re-reading the env, so the served route, the advertised route, and the\n * authorization track can never disagree.\n *\n * Note the asymmetry with the MCP plugin's *stdio* auto-start\n * ({@link resolveMcpStdioAutoStart}), which stays opt-in and is gated by a\n * SEPARATE switch: attaching a long-lived stdio transport to every process is\n * a side effect no default should impose, while the HTTP surface is served\n * statelessly per-request.\n */\nexport function isMcpServerEnabled(): boolean {\n const raw = readEnvWithDeprecation('OS_MCP_SERVER_ENABLED', 'MCP_SERVER_ENABLED', {\n silent: true,\n });\n if (raw == null) return true;\n return !['0', 'false', 'off', 'no'].includes(raw.trim().toLowerCase());\n}\n\n/**\n * SINGLE decision point for \"should the MCP plugin auto-start a long-lived\n * (stdio) transport?\" — distinct from {@link isMcpServerEnabled}, which governs\n * the stateless HTTP surface.\n *\n * The stdio transport is a different, stricter posture: the plugin bridges the\n * RAW metadata service + data engine onto the long-lived server with NO\n * per-request principal (unscoped — see the `mcp-stdio-authority` conformance\n * row), so it is safe only as a single-operator LOCAL tool and MUST stay\n * opt-in. It defaults OFF.\n *\n * Canonical switch: `OS_MCP_STDIO_ENABLED` (truthy). The plugin also starts it\n * when constructed with `{ autoStart: true }` (that path is checked by the\n * caller, not here).\n *\n * DEPRECATED alias: `OS_MCP_SERVER_ENABLED=true` historically ALSO started\n * stdio — overloading the very var that gates the HTTP surface, so an operator\n * setting it to \"make sure MCP is on\" silently attached an unscoped transport.\n * That trigger still works (with a one-time warning from the caller) for one\n * release; prefer the dedicated var. Note `OS_MCP_SERVER_ENABLED=false` only\n * ever gated the HTTP surface and never started stdio, so it is unaffected.\n *\n * @returns `enabled` — whether stdio auto-start is requested by the env; and\n * `viaDeprecatedAlias` — whether it came through the legacy\n * `OS_MCP_SERVER_ENABLED=true` trigger (so the caller can warn once).\n */\nexport function resolveMcpStdioAutoStart(): { enabled: boolean; viaDeprecatedAlias: boolean } {\n const stdio = readEnvWithDeprecation('OS_MCP_STDIO_ENABLED', [], { silent: true });\n if (stdio != null && ['1', 'true', 'on', 'yes'].includes(stdio.trim().toLowerCase())) {\n return { enabled: true, viaDeprecatedAlias: false };\n }\n // Legacy trigger: only the literal `true` ever started stdio (preserved\n // exactly). `OS_MCP_SERVER_ENABLED=false`/other values never did.\n const legacy = readEnvWithDeprecation('OS_MCP_SERVER_ENABLED', 'MCP_SERVER_ENABLED', { silent: true });\n if (legacy != null && legacy.trim().toLowerCase() === 'true') {\n return { enabled: true, viaDeprecatedAlias: true };\n }\n return { enabled: false, viaDeprecatedAlias: false };\n}\n\n/**\n * Maximum number of organizations a single user may CREATE, from `OS_ORG_LIMIT`.\n * The auth plugin forwards this as better-auth's `organizationLimit` in function\n * form, counting only the caller's `role=owner` memberships — so it caps\n * self-created orgs (each of which can auto-provision a free environment on the\n * cloud control plane) without penalising a user invited into many orgs.\n *\n * Only meaningful when multi-org is enabled ({@link resolveMultiOrgEnabled}).\n * Returns `undefined` when unset or non-positive → no limit (better-auth treats\n * an absent `organizationLimit` as unlimited), preserving self-host behaviour.\n * Deployments that let users self-create orgs SHOULD set a generous cap.\n */\nexport function resolveOrgLimit(): number | undefined {\n const raw = readEnvWithDeprecation('OS_ORG_LIMIT', [], { silent: true });\n if (raw == null || String(raw).trim() === '') return undefined;\n const n = Number.parseInt(String(raw), 10);\n return Number.isFinite(n) && n > 0 ? n : undefined;\n}\n\n/**\n * SINGLE decision point for \"is pinyin search recall on?\" (#2486).\n *\n * Pinyin search is a deployment/locale-level capability, not field metadata:\n * Chinese deployments want it, pure-Japanese/English deployments don't. The\n * flag gates the whole feature end-to-end — the SchemaRegistry's compile-time\n * `__search` companion-column seam AND the `plugin-pinyin-search` populate\n * hooks — so there is no half-state where a column exists but nobody fills it\n * (ADR-0049: no declared-but-unenforced capability).\n *\n * Resolution:\n * 1. An explicit `OS_SEARCH_PINYIN_ENABLED` always wins — truthy\n * (`1`/`true`/`on`/`yes`) enables, anything else disables.\n * 2. When unset, the default derives from the deployment's configured\n * locales (`opts.locales`, e.g. the stack's `i18n.defaultLocale` +\n * `supportedLocales`): any `zh-*` locale turns it on.\n * 3. No env var and no `zh-*` locale → off. OSS / non-Chinese deployments\n * never load `pinyin-pro` and pay zero compute cost.\n *\n * Hosts that know the stack's i18n config — the CLI `serve` boot path AND the\n * standalone artifact boot (`createStandaloneStack`, which `os migrate`\n * plan/apply and embedders go through) — resolve once with locales and stamp\n * the decision back into the env via {@link stampSearchPinyinEnabled}, so\n * downstream consumers constructed without config access (per-engine\n * SchemaRegistry) read the same answer via the no-arg form (#3955).\n */\nexport function resolveSearchPinyinEnabled(opts?: { locales?: readonly string[] }): boolean {\n const raw = readEnvWithDeprecation('OS_SEARCH_PINYIN_ENABLED', [], { silent: true });\n if (raw != null && String(raw).trim() !== '') {\n return ['1', 'true', 'on', 'yes'].includes(String(raw).trim().toLowerCase());\n }\n return (opts?.locales ?? []).some((l) => /^zh([-_]|$)/i.test(String(l ?? '').trim()));\n}\n\n/**\n * The locales a stack's `i18n` config declares — `defaultLocale`,\n * `fallbackLocale`, then `supportedLocales`. Accepts the config loosely typed\n * (`unknown`) so any boot path can pass whatever its stack config or compiled\n * artifact carries without importing spec schemas; non-string entries and a\n * non-object config collapse to `[]`.\n */\nexport function collectConfiguredLocales(i18n: unknown): string[] {\n const cfg = (i18n && typeof i18n === 'object' ? i18n : {}) as {\n defaultLocale?: unknown;\n fallbackLocale?: unknown;\n supportedLocales?: unknown;\n };\n return [\n cfg.defaultLocale,\n cfg.fallbackLocale,\n ...(Array.isArray(cfg.supportedLocales) ? cfg.supportedLocales : []),\n ].filter((l): l is string => typeof l === 'string');\n}\n\n/**\n * Resolve the pinyin-search decision from a stack's `i18n` config and stamp a\n * positive result back into `OS_SEARCH_PINYIN_ENABLED` (#2486, #3955).\n *\n * Every boot path that SEES the stack config must stamp, because consumers\n * constructed later without config access (each engine's `SchemaRegistry`\n * provisioning the `__search` companion column, the `plugin-pinyin-search`\n * gate) read the decision through the no-arg\n * {@link resolveSearchPinyinEnabled}. A boot path that skips the stamp\n * computes a schema view WITHOUT the companion columns — which is how\n * `os migrate` came to flag the dev runtime's live `__search` columns as\n * destructive orphans (#3955). Call sites: the CLI `serve`/`dev` boot\n * (`objectstack.config.ts`) and `createStandaloneStack` (compiled artifact —\n * `os migrate plan`/`apply`, embedders).\n *\n * An explicit `OS_SEARCH_PINYIN_ENABLED` always wins — the resolver reads it\n * before consulting locales, so the stamp only materializes the\n * locale-derived default. Only a positive decision is written: \"unset\" and\n * \"off\" read identically through the no-arg resolver, and leaving the var\n * untouched keeps a later boot free to re-derive from ITS config.\n */\nexport function stampSearchPinyinEnabled(i18n: unknown): boolean {\n const enabled = resolveSearchPinyinEnabled({ locales: collectConfiguredLocales(i18n) });\n // Write through `globalThis` like `readEnvWithDeprecation` reads — this\n // package has no Node type dependency (edge-safe); no env object → no stamp.\n const env = (globalThis as { process?: { env?: Record<string, string | undefined> } })\n .process?.env;\n if (enabled && env) env.OS_SEARCH_PINYIN_ENABLED = 'true';\n return enabled;\n}\n\n/**\n * SINGLE decision point for a sandbox script-runner DEFAULT (ms), resolved from\n * the environment (framework#3259 / ADR-0102).\n *\n * The QuickJS sandbox meters each hook/action invocation against a per-invocation\n * budget. Two dimensions are env-tunable:\n * - the **CPU-time budget** for hooks / actions — how much *VM-active* time a\n * body may burn (built-in 250ms hooks / 5000ms actions); and\n * - the **wall-clock ceiling** — the backstop bounding a body parked forever on\n * a host call that never settles (built-in 30_000ms).\n *\n * The built-in defaults suit a warm, idle host; a heavily loaded or slow host\n * (an oversubscribed CI runner, constrained production hardware) may need a\n * higher floor. This lets an operator raise it once, deployment-wide, instead of\n * re-tuning every call site.\n *\n * Canonical vars (OS_{DOMAIN}_{NAME}, DOMAIN=SANDBOX):\n * - hook → `OS_SANDBOX_HOOK_TIMEOUT_MS`\n * - action → `OS_SANDBOX_ACTION_TIMEOUT_MS`\n * - wallCeiling → `OS_SANDBOX_WALL_CEILING_MS`\n *\n * Only a positive integer is honored; unset / empty / non-numeric / non-positive\n * falls back to `fallback`, so behaviour is byte-for-byte unchanged when the var\n * is absent. This is a FALLBACK default ONLY: an explicit constructor option\n * still wins over it, and (for the CPU budget) a body's own declared `timeoutMs`\n * still wins over the resolved default per the runner's resolution rule.\n */\nexport function resolveSandboxTimeoutMs(\n kind: 'hook' | 'action' | 'wallCeiling',\n fallback: number,\n): number {\n const name =\n kind === 'hook'\n ? 'OS_SANDBOX_HOOK_TIMEOUT_MS'\n : kind === 'action'\n ? 'OS_SANDBOX_ACTION_TIMEOUT_MS'\n : 'OS_SANDBOX_WALL_CEILING_MS';\n const raw = readEnvWithDeprecation(name, [], { silent: true });\n if (raw == null || String(raw).trim() === '') return fallback;\n const n = Number.parseInt(String(raw).trim(), 10);\n return Number.isFinite(n) && n > 0 ? n : fallback;\n}\n\n/**\n * Internal: clear the dedupe set. Test-only; exposed so suite-wide\n * deprecation warnings don't bleed between tests.\n *\n * @internal\n */\nexport function _resetEnvDeprecationWarnings(): void {\n _warnedKeys.clear();\n}\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * Shared \"does this error message leak server internals?\" heuristic (#3867).\n *\n * ObjectStack has more than one HTTP boundary. `@objectstack/rest` guards the\n * REST data routes inside `mapDataError`; the dispatcher-plugin routes\n * (`/analytics`, `/packages`, `/i18n`, `/automation`, …) exit\n * through `errorResponseBase`. Before #3867 only the first of those sanitised\n * anything, so a driver error raised under `/analytics/query` reached the\n * client verbatim — a real SQL statement in the response body:\n *\n * ```\n * {\"success\":false,\"error\":{\"message\":\"SELECT FROM \\\"sqlite_sequence\\\" - near \\\"FROM\\\": syntax error\",\"code\":500}}\n * ```\n *\n * \"Do not ship driver internals to clients\" is a property of the HTTP\n * boundary, not of one router, so the predicate lives here — the package both\n * `@objectstack/rest` and `@objectstack/runtime` already depend on — and each\n * boundary applies it in its own envelope. One heuristic, one place to widen\n * when a new dialect's phrasing shows up.\n *\n * Deliberately a *heuristic over the message*, not a driver taxonomy: these\n * errors arrive as plain `Error`s from a half-dozen dialects with no shared\n * shape. It is applied only where the outcome is already a 5xx, so a false\n * positive costs a caller nothing but detail on a response that was a server\n * fault anyway — while the full text still reaches server logs and the\n * error reporter.\n */\n\n/** Generic replacement text for a message that trips {@link looksLikeInternalErrorLeak}. */\nexport const INTERNAL_ERROR_MESSAGE = 'Internal server error';\n\n/**\n * Whether `message` looks like a raw SQL statement or driver/engine dump that\n * must not be returned to an API client.\n *\n * Matches: dialect error codes (`SQLSTATE`, `sqlite_*`), bare statements\n * (a message that *starts* as `SELECT`/`INSERT INTO`/`UPDATE`/`DELETE FROM` —\n * drivers prefix the offending SQL to their message), and constraint-violation\n * dumps, which name physical tables and columns.\n *\n * Does NOT match ordinary business or validation messages, which is why the\n * statement forms are anchored with `startsWith`: a legitimate message may\n * *mention* \"update\" without being one.\n */\nexport function looksLikeInternalErrorLeak(message: string | undefined | null): boolean {\n if (!message) return false;\n const lower = String(message).toLowerCase();\n return (\n lower.includes('sqlite_') ||\n lower.includes('sqlstate') ||\n lower.startsWith('insert into ') ||\n lower.startsWith('update ') ||\n lower.startsWith('select ') ||\n lower.startsWith('delete from ') ||\n lower.includes('constraint failed') ||\n lower.includes('unique constraint') ||\n lower.includes('foreign key')\n );\n}\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * Seek-based (keyset) pagination for the batch walks that read a whole object.\n *\n * # Why this exists rather than `limit`/`offset`\n *\n * A background walk that pages with a growing `offset` — rebuild an index,\n * verify file references, backfill a projection — is wrong in two ways that a\n * seek fixes at once.\n *\n * **It can skip rows.** `LIMIT n OFFSET k` is a slice of an arrangement, and\n * the arrangement has to be the *same* one on every page for the slices to\n * partition the set. Drivers now guarantee that for a single read\n * (objectstack#4363), but not across a walk that *mutates as it goes*: a\n * backfill that updates each page, or a rebuild that deletes, changes the very\n * set the next offset counts into. Rows shift past the cursor and are never\n * visited. For a verifier that decides which files are still referenced, or an\n * index rebuild that deletes what it did not see, a skipped row is not a slow\n * page — it is a wrong answer that looks like a clean run. A seek predicate\n * carries the position *in the data* instead of counting from the start, so an\n * update cannot move a row past it and a delete cannot shift one under it.\n *\n * **It is quadratic.** The database must produce and discard every skipped row\n * to honor an offset, so walking n rows in pages of p costs O(n²/p). On a\n * 2M-row table the last pages were measured at ~1.1 s each against ~0.09 s for\n * the first. A seek starts each page at the cursor, so every page costs the\n * same: O(n) for the walk, and index-served throughout.\n *\n * # What it requires\n *\n * A column that is **unique and orderable** — `id` by default, which every\n * object this driver-managed platform creates carries. An object without one\n * (a federated table, ADR-0015) cannot be walked this way; callers that scan\n * arbitrary registry objects already skip what they cannot read, and that is\n * the correct outcome here too rather than a silent partial scan.\n *\n * # Shape\n *\n * `read` is the caller's own query — this owns the loop, the cursor and the\n * `where` merge, and nothing else. Deliberately one implementation rather than\n * the six hand-rolled copies it replaces: the cursor merge is the part that is\n * easy to get subtly wrong (an object whose own `where` already constrains the\n * key), and six copies of it drift silently.\n *\n * @example\n * const walk = keysetWalk<Row>(\n * (q) => engine.find('sys_approval_request', { ...q, fields: ['id'], context: SYSTEM_CTX }),\n * { where: { status: 'pending' }, pageSize: 500 },\n * );\n * for await (const page of walk.pages()) { … }\n * if (walk.truncated) { … }\n */\n\n/** The query a {@link keysetWalk} hands its reader: the caller's `where`, narrowed by the cursor. */\nexport interface KeysetPageQuery {\n /** The caller's `where`, AND-ed with the seek predicate once the walk has a cursor. */\n where?: unknown;\n /** Always ascending on the key column — the walk's order IS the seek order. */\n orderBy: Array<{ field: string; order: 'asc' }>;\n /** Page size. */\n limit: number;\n}\n\nexport interface KeysetWalkOptions {\n /** The caller's filter, applied to every page. */\n where?: unknown;\n /** Rows per page. */\n pageSize: number;\n /**\n * Stop after this many rows and set {@link KeysetWalk.truncated}. Omit for an\n * unbounded walk. A cap is not a failure — it is how a scan bounds its own\n * cost — but it must be reported, or a partial scan reads as a complete one.\n */\n max?: number;\n /** Unique, orderable column to seek on. Defaults to `id`. */\n key?: string;\n}\n\nexport interface KeysetWalk<T> {\n /** Pages, in key order, until the source is exhausted or `max` is reached. */\n pages(): AsyncGenerator<T[]>;\n /** Rows yielded so far. */\n readonly scanned: number;\n /** True when `max` stopped the walk before the source was exhausted. */\n readonly truncated: boolean;\n}\n\n/**\n * AND the seek predicate onto the caller's filter.\n *\n * Uses `$and` rather than spreading the key into the same object: a caller\n * whose own `where` already constrains the key column (`{ id: { $in: [...] } }`)\n * would otherwise have that constraint silently overwritten by the cursor, and\n * the walk would return rows the caller excluded. `$and` composes instead of\n * colliding, and every driver executes it.\n */\nfunction withCursor(where: unknown, key: string, cursor: unknown): unknown {\n const seek = { [key]: { $gt: cursor } };\n if (where == null) return seek;\n if (typeof where === 'object' && Object.keys(where as object).length === 0) return seek;\n return { $and: [where, seek] };\n}\n\n/**\n * Walk an object by seeking past the last key rather than counting from the\n * start. See the module comment for why every batch scan should.\n *\n * `read` receives a {@link KeysetPageQuery} and returns the page; the caller\n * owns everything else about the query (projection, context, object name).\n */\nexport function keysetWalk<T extends Record<string, unknown>>(\n read: (query: KeysetPageQuery) => Promise<T[]>,\n options: KeysetWalkOptions,\n): KeysetWalk<T> {\n const key = options.key ?? 'id';\n const pageSize = options.pageSize;\n let scanned = 0;\n let truncated = false;\n\n async function* pages(): AsyncGenerator<T[]> {\n let cursor: unknown = undefined;\n for (;;) {\n const want = options.max == null ? pageSize : Math.min(pageSize, options.max - scanned);\n if (want <= 0) {\n truncated = true;\n return;\n }\n\n // When `max` clips this page, ask for ONE more row than we will yield.\n // That extra row is the difference between \"the cap stopped us\" and \"the\n // source ended at exactly the cap\" — without it a walk that read\n // everything still reports `truncated`, and a caller acting on that goes\n // looking for rows that were never withheld.\n const clipped = options.max != null && want < pageSize;\n const page = await read({\n where: cursor === undefined ? options.where : withCursor(options.where, key, cursor),\n orderBy: [{ field: key, order: 'asc' }],\n limit: clipped ? want + 1 : want,\n });\n if (!Array.isArray(page) || page.length === 0) return;\n\n const overflow = clipped && page.length > want;\n const emit = overflow ? page.slice(0, want) : page;\n scanned += emit.length;\n yield emit;\n\n if (overflow) {\n truncated = true;\n return;\n }\n\n const last = emit[emit.length - 1]?.[key];\n // A row without the key column cannot advance the cursor, and continuing\n // would re-read the same page forever. Stop and report it as truncation\n // rather than spin: a walk that cannot seek is not a walk that finished.\n if (last === undefined || last === null) {\n truncated = true;\n return;\n }\n // The same stop for a reader that did not APPLY the seek — the cursor\n // comes back no further along than it went in, so the next page would be\n // this page again, forever. Production drivers execute the predicate;\n // a test double or a future reader that quietly drops it would otherwise\n // hang rather than fail, and a hang is the one failure nobody can read.\n if (cursor !== undefined && !(String(last) > String(cursor))) {\n truncated = true;\n return;\n }\n cursor = last;\n\n // A short page means the source is exhausted.\n if (emit.length < want) return;\n // Reaching the cap on a full, unclipped page: more rows may remain, and\n // the next iteration's `want <= 0` reports that as truncation.\n if (options.max != null && scanned >= options.max && !clipped) continue;\n if (options.max != null && scanned >= options.max) return;\n }\n }\n\n return {\n pages,\n get scanned() {\n return scanned;\n },\n get truncated() {\n return truncated;\n },\n };\n}\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * True when a dynamic `import()` / `require.resolve()` failed because the\n * module is simply NOT INSTALLED — as opposed to the module being present but\n * throwing while it loads (a real crash). Checking `err.code` FIRST matters:\n * ESM reports a missing package as `err.code === 'ERR_MODULE_NOT_FOUND'` with\n * the human message `Cannot find package '...'`; matching only the older\n * `Cannot find module` string mis-classifies that as a crash (framework#1595).\n *\n * Single shared owner for this classification (framework#3265): the CLI's\n * optional-plugin guards and `requires` capability resolver delegate here, and\n * cloud's `objectos-runtime` capability loader is expected to adopt it at its\n * next framework pin bump — so the parallel loaders cannot drift apart and\n * re-introduce the #1595 false-alarm class.\n */\nexport function isModuleNotFoundError(err: unknown): boolean {\n const code = (err as { code?: string } | null | undefined)?.code;\n if (code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND') return true;\n const msg = err instanceof Error ? err.message : String(err);\n return msg.includes('Cannot find module') || msg.includes('Cannot find package');\n}\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * The ONE writer for the declared REST response envelope (#3973).\n *\n * `BaseResponseSchema` (`packages/spec/src/api/contract.zod.ts`) declares one\n * envelope for every REST body the platform emits:\n *\n * { success: true, data }\n * { success: false, error: { code, message } }\n *\n * The schema declares it once. Until this file, the code that *wrote* it was\n * copied per route module — seven `sendOk` / `sendError` pairs after #3843 and\n * #3983 converted the last drifting one, so the envelope's shape lived in\n * fourteen places rather than one.\n *\n * ## Why a shared builder rather than seven agreeing copies\n *\n * `scripts/check-route-envelope.mjs` proves the copies agree today, and that is\n * exactly why this is a cleanup and not a bug fix. But a guard proves agreement;\n * it does not create it. An eighth module starts by copying the pair again —\n * which is not hypothetical, it is the observed history: `share-link-routes.ts`\n * was found by the repo-wide scan already drifting, and its drift had broken\n * `client.shareLinks.create()` / `.list()` through `unwrapResponse` (#3983).\n *\n * ## Why here\n *\n * Placement was the open question in #3973, not design. `packages/spec` is\n * schemas-only (Prime Directive #2), and the callers span `packages/rest`, four\n * `services/*` and one `plugins/*`, which rules out anything that depends on\n * them. `@objectstack/types` depends on nothing but `@objectstack/spec`, so\n * every caller can reach it, and it is where the repo already puts a helper the\n * HTTP boundaries share: {@link looksLikeInternalErrorLeak} lives one file over\n * for the same reason, and made the same argument first — \"do not ship driver\n * internals to clients\" is a property of the boundary, not of one router.\n *\n * Writing the declared envelope is the same kind of property.\n *\n * ## What this does NOT change\n *\n * Every byte on the wire. The seven pairs were already identical modulo the\n * optional `status` and `extra` parameters unioned below; this file is their\n * union, and each module's driven conformance suite still parses its real\n * bodies against the real spec schemas.\n *\n * The dispatcher surface (`packages/runtime/src/domains/*`) is deliberately not\n * a caller: those handlers RETURN `{ status, body }` for a central sender rather\n * than writing to a response, so they are already consolidated behind their own\n * `deps.success` / `deps.error` helpers and audited by the other half of\n * `check-route-envelope.mjs`.\n */\n\nimport type { ApiError, ErrorCode } from '@objectstack/spec/api';\n\n/**\n * The only thing an envelope builder needs from a response object.\n *\n * Structural on purpose, so this file depends on no HTTP contract at all:\n * `IHttpResponse` (`@objectstack/spec/contracts`) satisfies it, and so does the\n * `any`-typed `res` the three older route modules still carry. That is what lets\n * a package import the builders without also importing a server abstraction.\n */\nexport interface EnvelopeResponse {\n status(code: number): EnvelopeResponse;\n json(body: unknown): unknown;\n}\n\n/**\n * Emit a success body in the DECLARED envelope — `{ success: true, data }`.\n *\n * `data` carries the route's payload; it is not spread. A payload duplicated\n * into a stray top-level key (`{ success: true, data: link, link }`) parses\n * clean against `BaseResponseSchema` and is still drift — that shipped on\n * `/share-links` for as long as nobody looked (#4038), which is why\n * `envelopeViolations` exists beside the schema and why there is one `data`\n * slot here rather than a spread.\n *\n * `status` defaults to 200 and is set explicitly even then. Five of the seven\n * modules already did that; the two that called `res.json(...)` bare are\n * unaffected, because the default they were relying on is the value now passed.\n */\nexport function sendOk(res: EnvelopeResponse, data: unknown, status = 200): void {\n res.status(status).json({ success: true, data });\n}\n\n/**\n * Emit an error in the DECLARED envelope — `{ success: false, error: { code,\n * message } }`, with `code` a semantic STRING and `message` a field OF `error`\n * rather than a sibling of it.\n *\n * Both halves of that sentence were once wrong somewhere: `error` was a bare\n * string in `service-storage` and `admin-routes` (so `body.error.message` read\n * `undefined`), and `code` was the human message in `package-routes` (#3675 →\n * #3689 → #3843).\n *\n * ## `code` is the closed ADR-0112 vocabulary, not `string`\n *\n * All seven copies typed this parameter `string`, so an invented code was caught\n * only at runtime, by a conformance suite parsing a driven body against\n * `ApiErrorSchema` — i.e. only on the routes a test happened to drive. `ErrorCode`\n * is `StandardErrorCode ∪ ERROR_CODE_LEDGER` (`error-code-ledger.zod.ts`), the\n * same union that schema validates against, so consolidating here moves the check\n * to compile time for every call site at once. It cost no call-site churn: every\n * code the seven modules emit was already registered.\n *\n * A new code is registered in `ERROR_CODE_LEDGER` under its owning package —\n * and if the condition is generic (not found / permission / validation), the\n * standard catalog is used instead of registering a synonym for it.\n *\n * ## `extra` is `ApiError`'s own optional fields, not a `Record`\n *\n * Merged into `error`, and typed as exactly what `ApiErrorSchema` declares\n * beside `code` and `message` — `details`, `category`, `requestId`, `httpStatus`.\n * `details` is the slot for structured context: `package-routes` puts a partial\n * delete's per-item failures there, `settings-routes` the whole\n * `SettingsActionResult`.\n *\n * This started as `Record<string, unknown>`, because `settings-routes` also hung\n * `namespace` / `key` / `reason` / `fields` beside `code`, which the schema does\n * not declare. Those bodies passed every gate anyway — `ApiErrorSchema` is a\n * plain `z.object`, so unknown keys were STRIPPED rather than rejected, and\n * `envelopeViolations` inspects only the body's top level — making them\n * conformant *by stripping* rather than by declaration. #4224 moved that module's\n * four branches onto `details`, which is what lets the parameter close here.\n *\n * Closing it at the shared builder is the part that lasts: an undeclared sibling\n * is now a compile error in every module at once, rather than a key that quietly\n * evaporates at the schema boundary in whichever module reintroduces it.\n */\nexport function sendError(\n res: EnvelopeResponse,\n status: number,\n code: ErrorCode,\n message: string,\n extra?: Pick<ApiError, 'category' | 'httpStatus' | 'details' | 'requestId'>,\n): void {\n res.status(status).json({ success: false, error: { code, message, ...extra } });\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/degraded-boot.ts","../src/env.ts","../src/error-leak.ts","../src/keyset-walk.ts","../src/module-not-found.ts","../src/response-envelope.ts","../src/unique-scope-install-gate.ts"],"sourcesContent":["// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nexport * from './degraded-boot.js';\nexport * from './env.js';\nexport * from './error-leak.js';\n// Seek-based pagination for batch walks — the offset alternative that neither\n// skips rows when the walk mutates as it goes, nor costs O(n²/p) (#4363).\nexport * from './keyset-walk.js';\nexport * from './module-not-found.js';\nexport * from './response-envelope.js';\n// [ADR-0120 D5e] The `isolated`-posture install gate for `'global'` uniques —\n// the pure enumerator both the hard stop (install seam) and the advisories\n// (`os doctor` / `os migrate plan`) read, so the three cannot drift apart.\nexport * from './unique-scope-install-gate.js';\n\n// Placeholder for Kernel interface to avoid circular dependency\n// The actual Kernel implementation will satisfy this interface.\nexport interface IKernel {\n // We can add specific methods here that plugins are allowed to call\n // forcing a stricter contract than exposing the whole class.\n ql?: any; // ObjectQL instance (optional to support initialization phase)\n start(): Promise<void>;\n // ... expose other needed public methods\n [key: string]: any; \n}\n\nexport interface RuntimeContext {\n engine: IKernel;\n}\n\nexport interface RuntimePlugin {\n name: string;\n install?: (ctx: RuntimeContext) => void | Promise<void>;\n onStart?: (ctx: RuntimeContext) => void | Promise<void>;\n}\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * Degraded-boot reporting, shared by every subsystem that can be told to boot\n * without a datasource it needs.\n *\n * Two of them exist today and they opt in through the *same* operator flag\n * (`OS_ALLOW_DRIVER_CONNECT_FAILURE`, see {@link resolveAllowDriverConnectFailure}):\n *\n * - `ObjectQLEngine.init()` — a boot-registered driver whose `connect()`\n * rejected (framework#3741).\n * - `DatasourceConnectionService` — a declared datasource that objects bind to\n * explicitly, or an `external` one with `validation.onMismatch:'fail'`,\n * that could not be connected (framework#3758).\n *\n * They live in different packages but owe the operator the same thing: the\n * degraded state must be impossible to miss.\n */\n\n/**\n * Emit the degraded-boot banner on a channel the host cannot accidentally\n * silence.\n *\n * `OS_ALLOW_DRIVER_CONNECT_FAILURE` only justifies itself if the state it opts\n * into is impossible to miss — and a logger-only banner is missable, because\n * the logger answers to a level the operator sets. `Logger.write()` returns\n * before emitting anything when the record is below `config.level`, so at\n * `--log-level error`, `fatal`, or `silent` this `warn` never reaches ANY\n * stream. A production host running at `error` is exactly the deployment this\n * flag exists for, and is exactly where the banner would vanish. Writing to\n * stderr as well is the same belt-and-braces the kernel already uses for\n * plugin startup failures.\n *\n * A second reason used to be load-bearing and no longer is: `os serve` blanked\n * ALL of stdout while the kernel booted, and `Logger` routes `warn` to stdout,\n * so a boot-phase banner was swallowed at every level. That was framework#4012\n * and is fixed — the boot window buffers and replays `warn`-and-above instead\n * of discarding it. Do not re-derive this helper's necessity from the\n * boot-quiet capture; the level filter is what keeps it alive.\n *\n * Best-effort and never throws: falls back to `console.error`, then to silence\n * on runtimes that have neither (the logger still carries the structured\n * record either way).\n */\nexport function emitDegradedBootBanner(message: string): void {\n const proc = (globalThis as {\n process?: { stderr?: { write?: (chunk: string) => unknown } };\n }).process;\n try {\n if (typeof proc?.stderr?.write === 'function') {\n proc.stderr.write(`${message}\\n`);\n return;\n }\n } catch {\n /* stderr unavailable / closed — fall through to console */\n }\n try {\n (globalThis as { console?: { error?: (msg: string) => void } }).console?.error?.(message);\n } catch {\n /* no output channel at all — the logger record is the remaining trace */\n }\n}\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * Environment-variable helpers shared across `@objectstack/*` packages.\n *\n * The framework standardises on `OS_*` prefixed env vars (see AGENTS.md\n * \"Environment Variables\" section). Some historical names predate this\n * convention — `AUTH_SECRET`, `ROOT_DOMAIN`, `OBJECTSTACK_*`, …\n *\n * To migrate without breaking user `.env` files mid-release, call\n * {@link readEnvWithDeprecation} at every legacy read site:\n *\n * const v = readEnvWithDeprecation('OS_AUTH_SECRET', 'AUTH_SECRET');\n *\n * If only the legacy name is set, the value is still returned but a\n * one-shot `console.warn` fires (per-process per-variable) telling\n * operators to rename it.\n */\n\nimport {\n normalizeTenancyPosture,\n TENANCY_POSTURES,\n type TenancyPosture,\n} from '@objectstack/spec/security';\n\nconst _warnedKeys = new Set<string>();\n\n/**\n * Read an env var, preferring the canonical `OS_*` name and falling\n * back to one or more legacy aliases.\n *\n * When only a legacy alias is set, emits a one-shot deprecation warning.\n * The warning is process-wide deduplicated: identical (preferred, legacy)\n * pairs will only warn once even if read from multiple call sites.\n *\n * Legacy aliases are checked in order; the first one with a defined\n * value wins (and triggers the warning for that specific alias).\n *\n * Safe to call from environments where `process` is unavailable (returns\n * `undefined`); the warning is suppressed when running outside Node-like\n * runtimes that lack `console.warn`.\n *\n * @param preferred Canonical OS_*-prefixed env var name.\n * @param legacy Older name (or array of older names) to fall back on.\n * @param options Optional behaviour flags. Set `silent: true` for aliases\n * that remain accepted conventions rather than true legacy\n * names — e.g. `PORT`, which PaaS platforms (Render, Railway,\n * Heroku, Fly, …) inject automatically. Warning on those\n * would nag operators about env they never set.\n * @returns The resolved value, or `undefined` if neither is set.\n */\nexport function readEnvWithDeprecation(\n preferred: string,\n legacy: string | readonly string[],\n options?: { silent?: boolean },\n): string | undefined {\n const env = (globalThis as { process?: { env?: Record<string, string | undefined> } })\n .process?.env;\n if (!env) return undefined;\n\n const preferredValue = env[preferred];\n if (preferredValue !== undefined) return preferredValue;\n\n const legacyList = typeof legacy === 'string' ? [legacy] : legacy;\n for (const legacyName of legacyList) {\n const legacyValue = env[legacyName];\n if (legacyValue !== undefined) {\n const dedupeKey = `${preferred}|${legacyName}`;\n if (!options?.silent && !_warnedKeys.has(dedupeKey)) {\n _warnedKeys.add(dedupeKey);\n const consoleRef = (globalThis as { console?: { warn?: (msg: string) => void } }).console;\n try {\n consoleRef?.warn?.(\n `[ObjectStack] Env var \\`${legacyName}\\` is deprecated; rename it to \\`${preferred}\\`. ` +\n `The legacy name still works for now but will be removed in a future major release.`,\n );\n } catch {\n /* `console.warn` unavailable (exotic runtime) — ignore */\n }\n }\n return legacyValue;\n }\n }\n\n return undefined;\n}\n\n/**\n * Read the LEGACY `OS_MULTI_ORG_ENABLED` boolean.\n *\n * ⚠️ **[ADR-0105 D1] DEMOTED — not the knob to gate on.** `OS_TENANCY_POSTURE`\n * superseded this flag and is the authoritative one;\n * {@link resolveTenancyPosture} is where the two are reconciled (posture when\n * set, else this boolean). This function only reports the legacy input, so a\n * deployment that sets ONLY the canonical `OS_TENANCY_POSTURE` reads `false`\n * here while genuinely running a walled multi-organization posture.\n *\n * **Answering \"is this deployment multi-org?\" with this function is a bug.**\n * Ask the posture instead — `postureEnforcesWall(resolveTenancyPosture())`\n * (`@objectstack/spec/security`) — or, inside a running kernel, the `tenancy`\n * service, which additionally knows whether the requested wall is actually\n * ENFORCED (ADR-0093 D4/D5). Two shipped defects came from gating on this\n * boolean after the demotion: cloud#1020 (the EE licence gate) and #5233\n * (`organization/create` 403'd on a posture-only deployment whose organization\n * wall was fully mounted — the guided \"create your workspace\" path dead-ended).\n * The sentence this paragraph replaced actively instructed both.\n *\n * Legitimate remaining callers are the ones that specifically mean *the legacy\n * input*: {@link resolveTenancyPosture}'s own back-compat fallback, and\n * back-compat/reporting surfaces that must echo what the operator typed.\n *\n * Resolution: `OS_MULTI_ORG_ENABLED`; else `false`. Any value other than a\n * case-insensitive `'false'` enables it. (The legacy `OS_MULTI_TENANT` alias was\n * removed in 11.0.)\n *\n * Reads `process.env` live on each call; memoise at the call site if the\n * result must be stable for the process lifetime.\n */\nexport function resolveMultiOrgEnabled(): boolean {\n const raw = readEnvWithDeprecation('OS_MULTI_ORG_ENABLED', []);\n return String(raw ?? 'false').toLowerCase() !== 'false';\n}\n\n/**\n * [ADR-0105 D1] Resolve the deployment's REQUESTED tenancy posture —\n * `single` | `group` | `isolated`.\n *\n * `OS_TENANCY_POSTURE` is the canonical knob and generalizes the boolean\n * `OS_MULTI_ORG_ENABLED` it supersedes:\n *\n * - set → that posture (the legacy spelling `multi` normalizes to `isolated`)\n * - unset → derived from `OS_MULTI_ORG_ENABLED`: `true` ⇒ `isolated`, else `single`\n *\n * so every existing deployment keeps its current posture with no config change.\n *\n * An unrecognized value THROWS rather than falling back. A typo'd posture that\n * quietly resolved to `single` would silently remove the organization wall —\n * the deployment-layer form of the \"declared but unenforced\" defect ADR-0049\n * forbids, and the same reasoning behind ADR-0093 D5's refusal to boot into\n * undeclared degradation.\n *\n * This resolves what the operator ASKED FOR. Whether the posture is actually\n * enforced is the `tenancy` service's answer (`isolationActive` / `degraded`).\n */\nexport function resolveTenancyPosture(): TenancyPosture {\n // Read through `globalThis` like `readEnvWithDeprecation` does — this package\n // targets non-Node runtimes too, where a bare `process` reference throws.\n const raw = (globalThis as { process?: { env?: Record<string, string | undefined> } })\n .process?.env?.OS_TENANCY_POSTURE;\n if (raw != null && String(raw).trim() !== '') {\n const posture = normalizeTenancyPosture(raw);\n if (!posture) {\n throw new Error(\n `Invalid OS_TENANCY_POSTURE=${JSON.stringify(String(raw))}. ` +\n `Expected one of: ${TENANCY_POSTURES.join(', ')} (or the legacy alias 'multi' = 'isolated'). ` +\n 'Refusing to boot rather than silently falling back to a posture with no organization wall.',\n );\n }\n return posture;\n }\n return resolveMultiOrgEnabled() ? 'isolated' : 'single';\n}\n\n/**\n * Escape hatch for the degraded-tenancy boot guard (ADR-0093 D5).\n *\n * When `OS_MULTI_ORG_ENABLED=true` but the enterprise `@objectstack/organizations`\n * package cannot provide tenant isolation, the platform refuses to boot — a\n * deployment that asked for tenant isolation must not serve traffic pretending\n * to have it (ADR-0049 at the deployment layer). Setting this to a truthy value\n * (`true`/`1`/`on`/`yes`, case-insensitive) boots anyway in an explicitly\n * *degraded* state that is branded everywhere an operator looks. Defaults OFF —\n * an unset flag means \"fail fast\".\n */\nexport function resolveAllowDegradedTenancy(): boolean {\n const raw = readEnvWithDeprecation('OS_ALLOW_DEGRADED_TENANCY', [], { silent: true });\n if (raw == null) return false;\n return ['1', 'true', 'on', 'yes'].includes(String(raw).trim().toLowerCase());\n}\n\n/**\n * Escape hatch for the driver-connect boot guard (framework#3741).\n *\n * `ObjectQLEngine.init()` connects every boot-registered driver and, by\n * default, refuses to boot when any of them fails — a server whose database is\n * unreachable must not report itself started and then 500 every request with an\n * error that reads nothing like \"the database is down\". Failing there is also\n * what gives a driver the ability to REFUSE STARTUP at all: any fatal startup\n * check a driver wants to run (licence, server version, incompatible\n * configuration, missing capability) can simply throw from `connect()`.\n *\n * Setting this to a truthy value (`true`/`1`/`on`/`yes`, case-insensitive)\n * boots anyway, in an explicitly degraded state that is logged loudly at\n * startup. Every query routed to a failed driver fails until the datasource\n * becomes reachable — the underlying clients do re-establish connections on\n * their own (framework#3759) — but the boot-time schema sync those drivers\n * missed is never re-run, so their tables may simply not exist afterwards.\n * Defaults OFF — an unset flag means \"fail fast\".\n */\nexport function resolveAllowDriverConnectFailure(): boolean {\n const raw = readEnvWithDeprecation('OS_ALLOW_DRIVER_CONNECT_FAILURE', [], { silent: true });\n if (raw == null) return false;\n return ['1', 'true', 'on', 'yes'].includes(String(raw).trim().toLowerCase());\n}\n\n/**\n * Escape hatch for plugin-dev's production boot guard (ADR-0115 D6, #3900).\n *\n * `DevPlugin.init()` refuses to run under `NODE_ENV=production`: the stack it\n * assembles is built around an auth secret published inside the npm package and\n * an in-memory driver with persistence off, neither of which a production\n * deployment should acquire by accident. Setting this to a truthy value\n * (`true`/`1`/`on`/`yes`, case-insensitive) boots anyway, in an explicitly\n * degraded state that is branded in the boot log and on the ready banner.\n * Defaults OFF — an unset flag means \"fail fast\".\n *\n * Lives here rather than as a bare `process.env[…] === '1'` inside plugin-dev so\n * that the whole `OS_ALLOW_*` family answers to one truthy vocabulary: the\n * strict `=== '1'` it replaced fails CLOSED on `OS_ALLOW_DEV_PLUGIN=true`, which\n * is safe but reads to an operator as the flag being broken.\n */\nexport function resolveAllowDevPlugin(): boolean {\n const raw = readEnvWithDeprecation('OS_ALLOW_DEV_PLUGIN', [], { silent: true });\n if (raw == null) return false;\n return ['1', 'true', 'on', 'yes'].includes(String(raw).trim().toLowerCase());\n}\n\n/**\n * SINGLE decision point for \"is the MCP HTTP surface (`/api/v1/mcp`) on?\".\n *\n * MCP is a core platform capability and defaults ON: an unset\n * `OS_MCP_SERVER_ENABLED` means the surface is served. Operators opt OUT with\n * an explicit falsy value (`false`/`0`/`off`/`no`, case-insensitive); any\n * other value — including the historical `true` — keeps it on.\n *\n * Every consumer of the flag — the runtime dispatcher's `/mcp` route gate,\n * the CLI's MCP plugin auto-load, the REST `/discovery` advertisement, and\n * the auth service's OAuth/DCR follow-defaults — MUST call this instead of\n * re-reading the env, so the served route, the advertised route, and the\n * authorization track can never disagree.\n *\n * Note the asymmetry with the MCP plugin's *stdio* auto-start\n * ({@link resolveMcpStdioAutoStart}), which stays opt-in and is gated by a\n * SEPARATE switch: attaching a long-lived stdio transport to every process is\n * a side effect no default should impose, while the HTTP surface is served\n * statelessly per-request.\n */\nexport function isMcpServerEnabled(): boolean {\n const raw = readEnvWithDeprecation('OS_MCP_SERVER_ENABLED', 'MCP_SERVER_ENABLED', {\n silent: true,\n });\n if (raw == null) return true;\n return !['0', 'false', 'off', 'no'].includes(raw.trim().toLowerCase());\n}\n\n/**\n * SINGLE decision point for \"should the MCP plugin auto-start a long-lived\n * (stdio) transport?\" — distinct from {@link isMcpServerEnabled}, which governs\n * the stateless HTTP surface.\n *\n * The stdio transport is a different, stricter posture: the plugin bridges the\n * RAW metadata service + data engine onto the long-lived server with NO\n * per-request principal (unscoped — see the `mcp-stdio-authority` conformance\n * row), so it is safe only as a single-operator LOCAL tool and MUST stay\n * opt-in. It defaults OFF.\n *\n * Canonical switch: `OS_MCP_STDIO_ENABLED` (truthy). The plugin also starts it\n * when constructed with `{ autoStart: true }` (that path is checked by the\n * caller, not here).\n *\n * DEPRECATED alias: `OS_MCP_SERVER_ENABLED=true` historically ALSO started\n * stdio — overloading the very var that gates the HTTP surface, so an operator\n * setting it to \"make sure MCP is on\" silently attached an unscoped transport.\n * That trigger still works (with a one-time warning from the caller) for one\n * release; prefer the dedicated var. Note `OS_MCP_SERVER_ENABLED=false` only\n * ever gated the HTTP surface and never started stdio, so it is unaffected.\n *\n * @returns `enabled` — whether stdio auto-start is requested by the env; and\n * `viaDeprecatedAlias` — whether it came through the legacy\n * `OS_MCP_SERVER_ENABLED=true` trigger (so the caller can warn once).\n */\nexport function resolveMcpStdioAutoStart(): { enabled: boolean; viaDeprecatedAlias: boolean } {\n const stdio = readEnvWithDeprecation('OS_MCP_STDIO_ENABLED', [], { silent: true });\n if (stdio != null && ['1', 'true', 'on', 'yes'].includes(stdio.trim().toLowerCase())) {\n return { enabled: true, viaDeprecatedAlias: false };\n }\n // Legacy trigger: only the literal `true` ever started stdio (preserved\n // exactly). `OS_MCP_SERVER_ENABLED=false`/other values never did.\n const legacy = readEnvWithDeprecation('OS_MCP_SERVER_ENABLED', 'MCP_SERVER_ENABLED', { silent: true });\n if (legacy != null && legacy.trim().toLowerCase() === 'true') {\n return { enabled: true, viaDeprecatedAlias: true };\n }\n return { enabled: false, viaDeprecatedAlias: false };\n}\n\n/**\n * Maximum number of organizations a single user may CREATE, from `OS_ORG_LIMIT`.\n * The auth plugin forwards this as better-auth's `organizationLimit` in function\n * form, counting only the caller's `role=owner` memberships — so it caps\n * self-created orgs (each of which can auto-provision a free environment on the\n * cloud control plane) without penalising a user invited into many orgs.\n *\n * Only meaningful under a posture that enforces an organization wall, i.e.\n * `postureEnforcesWall({@link resolveTenancyPosture}())` — NOT the demoted\n * `resolveMultiOrgEnabled()` boolean (ADR-0105 D1, #5233).\n * Returns `undefined` when unset or non-positive → no limit (better-auth treats\n * an absent `organizationLimit` as unlimited), preserving self-host behaviour.\n * Deployments that let users self-create orgs SHOULD set a generous cap.\n */\nexport function resolveOrgLimit(): number | undefined {\n const raw = readEnvWithDeprecation('OS_ORG_LIMIT', [], { silent: true });\n if (raw == null || String(raw).trim() === '') return undefined;\n const n = Number.parseInt(String(raw), 10);\n return Number.isFinite(n) && n > 0 ? n : undefined;\n}\n\n/**\n * SINGLE decision point for \"is pinyin search recall on?\" (#2486).\n *\n * Pinyin search is a deployment/locale-level capability, not field metadata:\n * Chinese deployments want it, pure-Japanese/English deployments don't. The\n * flag gates the whole feature end-to-end — the SchemaRegistry's compile-time\n * `__search` companion-column seam AND the `plugin-pinyin-search` populate\n * hooks — so there is no half-state where a column exists but nobody fills it\n * (ADR-0049: no declared-but-unenforced capability).\n *\n * Resolution:\n * 1. An explicit `OS_SEARCH_PINYIN_ENABLED` always wins — truthy\n * (`1`/`true`/`on`/`yes`) enables, anything else disables.\n * 2. When unset, the default derives from the deployment's configured\n * locales (`opts.locales`, e.g. the stack's `i18n.defaultLocale` +\n * `supportedLocales`): any `zh-*` locale turns it on.\n * 3. No env var and no `zh-*` locale → off. OSS / non-Chinese deployments\n * never load `pinyin-pro` and pay zero compute cost.\n *\n * Hosts that know the stack's i18n config — the CLI `serve` boot path AND the\n * standalone artifact boot (`createStandaloneStack`, which `os migrate`\n * plan/apply and embedders go through) — resolve once with locales and stamp\n * the decision back into the env via {@link stampSearchPinyinEnabled}, so\n * downstream consumers constructed without config access (per-engine\n * SchemaRegistry) read the same answer via the no-arg form (#3955).\n */\nexport function resolveSearchPinyinEnabled(opts?: { locales?: readonly string[] }): boolean {\n const raw = readEnvWithDeprecation('OS_SEARCH_PINYIN_ENABLED', [], { silent: true });\n if (raw != null && String(raw).trim() !== '') {\n return ['1', 'true', 'on', 'yes'].includes(String(raw).trim().toLowerCase());\n }\n return (opts?.locales ?? []).some((l) => /^zh([-_]|$)/i.test(String(l ?? '').trim()));\n}\n\n/**\n * The locales a stack's `i18n` config declares — `defaultLocale`,\n * `fallbackLocale`, then `supportedLocales`. Accepts the config loosely typed\n * (`unknown`) so any boot path can pass whatever its stack config or compiled\n * artifact carries without importing spec schemas; non-string entries and a\n * non-object config collapse to `[]`.\n */\nexport function collectConfiguredLocales(i18n: unknown): string[] {\n const cfg = (i18n && typeof i18n === 'object' ? i18n : {}) as {\n defaultLocale?: unknown;\n fallbackLocale?: unknown;\n supportedLocales?: unknown;\n };\n return [\n cfg.defaultLocale,\n cfg.fallbackLocale,\n ...(Array.isArray(cfg.supportedLocales) ? cfg.supportedLocales : []),\n ].filter((l): l is string => typeof l === 'string');\n}\n\n/**\n * Resolve the pinyin-search decision from a stack's `i18n` config and stamp a\n * positive result back into `OS_SEARCH_PINYIN_ENABLED` (#2486, #3955).\n *\n * Every boot path that SEES the stack config must stamp, because consumers\n * constructed later without config access (each engine's `SchemaRegistry`\n * provisioning the `__search` companion column, the `plugin-pinyin-search`\n * gate) read the decision through the no-arg\n * {@link resolveSearchPinyinEnabled}. A boot path that skips the stamp\n * computes a schema view WITHOUT the companion columns — which is how\n * `os migrate` came to flag the dev runtime's live `__search` columns as\n * destructive orphans (#3955). Call sites: the CLI `serve`/`dev` boot\n * (`objectstack.config.ts`) and `createStandaloneStack` (compiled artifact —\n * `os migrate plan`/`apply`, embedders).\n *\n * An explicit `OS_SEARCH_PINYIN_ENABLED` always wins — the resolver reads it\n * before consulting locales, so the stamp only materializes the\n * locale-derived default. Only a positive decision is written: \"unset\" and\n * \"off\" read identically through the no-arg resolver, and leaving the var\n * untouched keeps a later boot free to re-derive from ITS config.\n */\nexport function stampSearchPinyinEnabled(i18n: unknown): boolean {\n const enabled = resolveSearchPinyinEnabled({ locales: collectConfiguredLocales(i18n) });\n // Write through `globalThis` like `readEnvWithDeprecation` reads — this\n // package has no Node type dependency (edge-safe); no env object → no stamp.\n const env = (globalThis as { process?: { env?: Record<string, string | undefined> } })\n .process?.env;\n if (enabled && env) env.OS_SEARCH_PINYIN_ENABLED = 'true';\n return enabled;\n}\n\n/**\n * SINGLE decision point for a sandbox script-runner DEFAULT (ms), resolved from\n * the environment (framework#3259 / ADR-0102).\n *\n * The QuickJS sandbox meters each hook/action invocation against a per-invocation\n * budget. Two dimensions are env-tunable:\n * - the **CPU-time budget** for hooks / actions — how much *VM-active* time a\n * body may burn (built-in 250ms hooks / 5000ms actions); and\n * - the **wall-clock ceiling** — the backstop bounding a body parked forever on\n * a host call that never settles (built-in 30_000ms).\n *\n * The built-in defaults suit a warm, idle host; a heavily loaded or slow host\n * (an oversubscribed CI runner, constrained production hardware) may need a\n * higher floor. This lets an operator raise it once, deployment-wide, instead of\n * re-tuning every call site.\n *\n * Canonical vars (OS_{DOMAIN}_{NAME}, DOMAIN=SANDBOX):\n * - hook → `OS_SANDBOX_HOOK_TIMEOUT_MS`\n * - action → `OS_SANDBOX_ACTION_TIMEOUT_MS`\n * - wallCeiling → `OS_SANDBOX_WALL_CEILING_MS`\n *\n * Only a positive integer is honored; unset / empty / non-numeric / non-positive\n * falls back to `fallback`, so behaviour is byte-for-byte unchanged when the var\n * is absent. This is a FALLBACK default ONLY: an explicit constructor option\n * still wins over it, and (for the CPU budget) a body's own declared `timeoutMs`\n * still wins over the resolved default per the runner's resolution rule.\n */\nexport function resolveSandboxTimeoutMs(\n kind: 'hook' | 'action' | 'wallCeiling',\n fallback: number,\n): number {\n const name =\n kind === 'hook'\n ? 'OS_SANDBOX_HOOK_TIMEOUT_MS'\n : kind === 'action'\n ? 'OS_SANDBOX_ACTION_TIMEOUT_MS'\n : 'OS_SANDBOX_WALL_CEILING_MS';\n const raw = readEnvWithDeprecation(name, [], { silent: true });\n if (raw == null || String(raw).trim() === '') return fallback;\n const n = Number.parseInt(String(raw).trim(), 10);\n return Number.isFinite(n) && n > 0 ? n : fallback;\n}\n\n/**\n * Internal: clear the dedupe set. Test-only; exposed so suite-wide\n * deprecation warnings don't bleed between tests.\n *\n * @internal\n */\nexport function _resetEnvDeprecationWarnings(): void {\n _warnedKeys.clear();\n}\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * Shared \"does this error message leak server internals?\" heuristic (#3867).\n *\n * ObjectStack has more than one HTTP boundary. `@objectstack/rest` guards the\n * REST data routes inside `mapDataError`; the dispatcher-plugin routes\n * (`/analytics`, `/packages`, `/i18n`, `/automation`, …) exit\n * through `errorResponseBase`. Before #3867 only the first of those sanitised\n * anything, so a driver error raised under `/analytics/query` reached the\n * client verbatim — a real SQL statement in the response body:\n *\n * ```\n * {\"success\":false,\"error\":{\"message\":\"SELECT FROM \\\"sqlite_sequence\\\" - near \\\"FROM\\\": syntax error\",\"code\":500}}\n * ```\n *\n * \"Do not ship driver internals to clients\" is a property of the HTTP\n * boundary, not of one router, so the predicate lives here — the package both\n * `@objectstack/rest` and `@objectstack/runtime` already depend on — and each\n * boundary applies it in its own envelope. One heuristic, one place to widen\n * when a new dialect's phrasing shows up.\n *\n * Deliberately a *heuristic over the message*, not a driver taxonomy: these\n * errors arrive as plain `Error`s from a half-dozen dialects with no shared\n * shape. It is applied only where the outcome is already a 5xx, so a false\n * positive costs a caller nothing but detail on a response that was a server\n * fault anyway — while the full text still reaches server logs and the\n * error reporter.\n *\n * [#5811] {@link declaresServerFault} joins it here for the same reason and\n * answers the other half of the question: the heuristic asks whether a message\n * *sounds* internal, the declaration asks whether the producer *said so*.\n */\n\n/** Generic replacement text for a message that trips {@link looksLikeInternalErrorLeak}. */\nexport const INTERNAL_ERROR_MESSAGE = 'Internal server error';\n\n/**\n * Whether `message` looks like a raw SQL statement or driver/engine dump that\n * must not be returned to an API client.\n *\n * Matches: dialect error codes (`SQLSTATE`, `sqlite_*`), bare statements\n * (a message that *starts* as `SELECT`/`INSERT INTO`/`UPDATE`/`DELETE FROM` —\n * drivers prefix the offending SQL to their message), and constraint-violation\n * dumps, which name physical tables and columns.\n *\n * Does NOT match ordinary business or validation messages, which is why the\n * statement forms are anchored with `startsWith`: a legitimate message may\n * *mention* \"update\" without being one.\n */\nexport function looksLikeInternalErrorLeak(message: string | undefined | null): boolean {\n if (!message) return false;\n const lower = String(message).toLowerCase();\n return (\n lower.includes('sqlite_') ||\n lower.includes('sqlstate') ||\n lower.startsWith('insert into ') ||\n lower.startsWith('update ') ||\n lower.startsWith('select ') ||\n lower.startsWith('delete from ') ||\n lower.includes('constraint failed') ||\n lower.includes('unique constraint') ||\n lower.includes('foreign key')\n );\n}\n\n/**\n * Whether the thrown error **declares a server fault** in the ADR-0112 envelope:\n * `status >= 500` *and* a non-empty `code`.\n *\n * The counterpart to {@link looksLikeInternalErrorLeak}, and deliberately not a\n * message test at all. Some server faults are dangerous to echo while saying\n * nothing a phrasing heuristic can recognise — the motivating family is\n * `service-analytics`' `read-scope-sql.ts`, whose ten fail-closed RLS lowering\n * refusals name the FIELD NAMES AND COMPARANDS OF THE RLS POLICY:\n *\n * ```\n * [read-scope-sql] unsafe field identifier \"secret_policy_field\" — refusing to\n * build read scope (fail-closed).\n * ```\n *\n * That text comes from an administrator's sharing rule compiled by the security\n * service; the tenant who receives it never wrote it and must not be able to read\n * it out of an error body. Measured, all eleven of its message shapes return\n * FALSE from `looksLikeInternalErrorLeak` — they look nothing like a driver dump —\n * so a boundary that only ran the heuristic echoed every one of them verbatim\n * (#5811 measured 11/11 through `errorResponseBase`). Teaching the heuristic to\n * recognise `[read-scope-sql]` would have been *more* message sniffing, which is\n * the mechanism #5352/#5367 exist to remove. So the withhold keys on the\n * DECLARATION instead: a producer that says `status >= 500` with a `code` has\n * declared that this is the server's fault, and a server fault's detail belongs in\n * the operator's log, not in the caller's body.\n *\n * **Both halves are required, and it is deliberately NOT \"any 5xx\".** #5667 kept\n * UNDECLARED 5xx errors legible on purpose — a bare `Error` from our own code\n * (\"no strategy can handle query …\") is the operator's own bug report, carries\n * nothing tenant-sensitive, and still falls to `looksLikeInternalErrorLeak`.\n * Widening this to every 500 would delete that decision.\n *\n * **Reads `status`, not `statusCode`.** `status` is the channel ADR-0112 declares;\n * `statusCode` is an alternate spelling some boundaries tolerate when *deriving*\n * an HTTP status. Accepting it here would make the disclosure rule depend on which\n * spelling a producer happened to use — consumer-side leniency of exactly the kind\n * Prime Directive #12 removes. A producer that wants its detail withheld declares\n * the envelope.\n *\n * Costs no diagnostics: every boundary that applies this still logs the untouched\n * error and hands it to the error reporter.\n *\n * @param err - the thrown value, of any shape (a non-object is simply not a\n * declaration).\n */\nexport function declaresServerFault(err: unknown): boolean {\n if (typeof err !== 'object' || err === null) return false;\n const { status, code } = err as { status?: unknown; code?: unknown };\n return typeof status === 'number' && status >= 500 && typeof code === 'string' && code.length > 0;\n}\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * Seek-based (keyset) pagination for the batch walks that read a whole object.\n *\n * # Why this exists rather than `limit`/`offset`\n *\n * A background walk that pages with a growing `offset` — rebuild an index,\n * verify file references, backfill a projection — is wrong in two ways that a\n * seek fixes at once.\n *\n * **It can skip rows.** `LIMIT n OFFSET k` is a slice of an arrangement, and\n * the arrangement has to be the *same* one on every page for the slices to\n * partition the set. Drivers now guarantee that for a single read\n * (objectstack#4363), but not across a walk that *mutates as it goes*: a\n * backfill that updates each page, or a rebuild that deletes, changes the very\n * set the next offset counts into. Rows shift past the cursor and are never\n * visited. For a verifier that decides which files are still referenced, or an\n * index rebuild that deletes what it did not see, a skipped row is not a slow\n * page — it is a wrong answer that looks like a clean run. A seek predicate\n * carries the position *in the data* instead of counting from the start, so an\n * update cannot move a row past it and a delete cannot shift one under it.\n *\n * **It is quadratic.** The database must produce and discard every skipped row\n * to honor an offset, so walking n rows in pages of p costs O(n²/p). On a\n * 2M-row table the last pages were measured at ~1.1 s each against ~0.09 s for\n * the first. A seek starts each page at the cursor, so every page costs the\n * same: O(n) for the walk, and index-served throughout.\n *\n * # What it requires\n *\n * A column that is **unique and orderable** — `id` by default, which every\n * object this driver-managed platform creates carries. An object without one\n * (a federated table, ADR-0015) cannot be walked this way; callers that scan\n * arbitrary registry objects already skip what they cannot read, and that is\n * the correct outcome here too rather than a silent partial scan.\n *\n * # Shape\n *\n * `read` is the caller's own query — this owns the loop, the cursor and the\n * `where` merge, and nothing else. Deliberately one implementation rather than\n * the six hand-rolled copies it replaces: the cursor merge is the part that is\n * easy to get subtly wrong (an object whose own `where` already constrains the\n * key), and six copies of it drift silently.\n *\n * @example\n * const walk = keysetWalk<Row>(\n * (q) => engine.find('sys_approval_request', { ...q, fields: ['id'], context: SYSTEM_CTX }),\n * { where: { status: 'pending' }, pageSize: 500 },\n * );\n * for await (const page of walk.pages()) { … }\n * if (walk.truncated) { … }\n */\n\n/** The query a {@link keysetWalk} hands its reader: the caller's `where`, narrowed by the cursor. */\nexport interface KeysetPageQuery {\n /** The caller's `where`, AND-ed with the seek predicate once the walk has a cursor. */\n where?: unknown;\n /** Always ascending on the key column — the walk's order IS the seek order. */\n orderBy: Array<{ field: string; order: 'asc' }>;\n /** Page size. */\n limit: number;\n}\n\nexport interface KeysetWalkOptions {\n /** The caller's filter, applied to every page. */\n where?: unknown;\n /** Rows per page. */\n pageSize: number;\n /**\n * Stop after this many rows and set {@link KeysetWalk.truncated}. Omit for an\n * unbounded walk. A cap is not a failure — it is how a scan bounds its own\n * cost — but it must be reported, or a partial scan reads as a complete one.\n */\n max?: number;\n /** Unique, orderable column to seek on. Defaults to `id`. */\n key?: string;\n}\n\nexport interface KeysetWalk<T> {\n /** Pages, in key order, until the source is exhausted or `max` is reached. */\n pages(): AsyncGenerator<T[]>;\n /** Rows yielded so far. */\n readonly scanned: number;\n /** True when `max` stopped the walk before the source was exhausted. */\n readonly truncated: boolean;\n}\n\n/**\n * AND the seek predicate onto the caller's filter.\n *\n * Uses `$and` rather than spreading the key into the same object: a caller\n * whose own `where` already constrains the key column (`{ id: { $in: [...] } }`)\n * would otherwise have that constraint silently overwritten by the cursor, and\n * the walk would return rows the caller excluded. `$and` composes instead of\n * colliding, and every driver executes it.\n */\nfunction withCursor(where: unknown, key: string, cursor: unknown): unknown {\n const seek = { [key]: { $gt: cursor } };\n if (where == null) return seek;\n if (typeof where === 'object' && Object.keys(where as object).length === 0) return seek;\n return { $and: [where, seek] };\n}\n\n/**\n * Walk an object by seeking past the last key rather than counting from the\n * start. See the module comment for why every batch scan should.\n *\n * `read` receives a {@link KeysetPageQuery} and returns the page; the caller\n * owns everything else about the query (projection, context, object name).\n */\nexport function keysetWalk<T extends Record<string, unknown>>(\n read: (query: KeysetPageQuery) => Promise<T[]>,\n options: KeysetWalkOptions,\n): KeysetWalk<T> {\n const key = options.key ?? 'id';\n const pageSize = options.pageSize;\n let scanned = 0;\n let truncated = false;\n\n async function* pages(): AsyncGenerator<T[]> {\n let cursor: unknown = undefined;\n for (;;) {\n const want = options.max == null ? pageSize : Math.min(pageSize, options.max - scanned);\n if (want <= 0) {\n truncated = true;\n return;\n }\n\n // When `max` clips this page, ask for ONE more row than we will yield.\n // That extra row is the difference between \"the cap stopped us\" and \"the\n // source ended at exactly the cap\" — without it a walk that read\n // everything still reports `truncated`, and a caller acting on that goes\n // looking for rows that were never withheld.\n const clipped = options.max != null && want < pageSize;\n const page = await read({\n where: cursor === undefined ? options.where : withCursor(options.where, key, cursor),\n orderBy: [{ field: key, order: 'asc' }],\n limit: clipped ? want + 1 : want,\n });\n if (!Array.isArray(page) || page.length === 0) return;\n\n const overflow = clipped && page.length > want;\n const emit = overflow ? page.slice(0, want) : page;\n scanned += emit.length;\n yield emit;\n\n if (overflow) {\n truncated = true;\n return;\n }\n\n const last = emit[emit.length - 1]?.[key];\n // A row without the key column cannot advance the cursor, and continuing\n // would re-read the same page forever. Stop and report it as truncation\n // rather than spin: a walk that cannot seek is not a walk that finished.\n if (last === undefined || last === null) {\n truncated = true;\n return;\n }\n // The same stop for a reader that did not APPLY the seek — the cursor\n // comes back no further along than it went in, so the next page would be\n // this page again, forever. Production drivers execute the predicate;\n // a test double or a future reader that quietly drops it would otherwise\n // hang rather than fail, and a hang is the one failure nobody can read.\n if (cursor !== undefined && !(String(last) > String(cursor))) {\n truncated = true;\n return;\n }\n cursor = last;\n\n // A short page means the source is exhausted.\n if (emit.length < want) return;\n // Reaching the cap on a full, unclipped page: more rows may remain, and\n // the next iteration's `want <= 0` reports that as truncation.\n if (options.max != null && scanned >= options.max && !clipped) continue;\n if (options.max != null && scanned >= options.max) return;\n }\n }\n\n return {\n pages,\n get scanned() {\n return scanned;\n },\n get truncated() {\n return truncated;\n },\n };\n}\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * True when a dynamic `import()` / `require.resolve()` failed because the\n * module is simply NOT INSTALLED — as opposed to the module being present but\n * throwing while it loads (a real crash). Checking `err.code` FIRST matters:\n * ESM reports a missing package as `err.code === 'ERR_MODULE_NOT_FOUND'` with\n * the human message `Cannot find package '...'`; matching only the older\n * `Cannot find module` string mis-classifies that as a crash (framework#1595).\n *\n * Single shared owner for this classification (framework#3265): the CLI's\n * optional-plugin guards and `requires` capability resolver delegate here, and\n * cloud's `objectos-runtime` capability loader is expected to adopt it at its\n * next framework pin bump — so the parallel loaders cannot drift apart and\n * re-introduce the #1595 false-alarm class.\n */\nexport function isModuleNotFoundError(err: unknown): boolean {\n const code = (err as { code?: string } | null | undefined)?.code;\n if (code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND') return true;\n const msg = err instanceof Error ? err.message : String(err);\n return msg.includes('Cannot find module') || msg.includes('Cannot find package');\n}\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * The ONE writer for the declared REST response envelope (#3973).\n *\n * `BaseResponseSchema` (`packages/spec/src/api/contract.zod.ts`) declares one\n * envelope for every REST body the platform emits:\n *\n * { success: true, data }\n * { success: false, error: { code, message } }\n *\n * The schema declares it once. Until this file, the code that *wrote* it was\n * copied per route module — seven `sendOk` / `sendError` pairs after #3843 and\n * #3983 converted the last drifting one, so the envelope's shape lived in\n * fourteen places rather than one.\n *\n * ## Why a shared builder rather than seven agreeing copies\n *\n * `scripts/check-route-envelope.mjs` proves the copies agree today, and that is\n * exactly why this is a cleanup and not a bug fix. But a guard proves agreement;\n * it does not create it. An eighth module starts by copying the pair again —\n * which is not hypothetical, it is the observed history: `share-link-routes.ts`\n * was found by the repo-wide scan already drifting, and its drift had broken\n * `client.shareLinks.create()` / `.list()` through `unwrapResponse` (#3983).\n *\n * ## Why here\n *\n * Placement was the open question in #3973, not design. `packages/spec` is\n * schemas-only (Prime Directive #2), and the callers span `packages/rest`, four\n * `services/*` and one `plugins/*`, which rules out anything that depends on\n * them. `@objectstack/types` depends on nothing but `@objectstack/spec`, so\n * every caller can reach it, and it is where the repo already puts a helper the\n * HTTP boundaries share: {@link looksLikeInternalErrorLeak} lives one file over\n * for the same reason, and made the same argument first — \"do not ship driver\n * internals to clients\" is a property of the boundary, not of one router.\n *\n * Writing the declared envelope is the same kind of property.\n *\n * ## What this does NOT change\n *\n * Every byte on the wire. The seven pairs were already identical modulo the\n * optional `status` and `extra` parameters unioned below; this file is their\n * union, and each module's driven conformance suite still parses its real\n * bodies against the real spec schemas.\n *\n * The dispatcher surface (`packages/runtime/src/domains/*`) is deliberately not\n * a caller: those handlers RETURN `{ status, body }` for a central sender rather\n * than writing to a response, so they are already consolidated behind their own\n * `deps.success` / `deps.error` helpers and audited by the other half of\n * `check-route-envelope.mjs`.\n */\n\nimport type { ApiError, ErrorCode } from '@objectstack/spec/api';\n\n/**\n * The only thing an envelope builder needs from a response object.\n *\n * Structural on purpose, so this file depends on no HTTP contract at all:\n * `IHttpResponse` (`@objectstack/spec/contracts`) satisfies it, and so does the\n * `any`-typed `res` the three older route modules still carry. That is what lets\n * a package import the builders without also importing a server abstraction.\n */\nexport interface EnvelopeResponse {\n status(code: number): EnvelopeResponse;\n json(body: unknown): unknown;\n}\n\n/**\n * Emit a success body in the DECLARED envelope — `{ success: true, data }`.\n *\n * `data` carries the route's payload; it is not spread. A payload duplicated\n * into a stray top-level key (`{ success: true, data: link, link }`) parses\n * clean against `BaseResponseSchema` and is still drift — that shipped on\n * `/share-links` for as long as nobody looked (#4038), which is why\n * `envelopeViolations` exists beside the schema and why there is one `data`\n * slot here rather than a spread.\n *\n * `status` defaults to 200 and is set explicitly even then. Five of the seven\n * modules already did that; the two that called `res.json(...)` bare are\n * unaffected, because the default they were relying on is the value now passed.\n */\nexport function sendOk(res: EnvelopeResponse, data: unknown, status = 200): void {\n res.status(status).json({ success: true, data });\n}\n\n/**\n * Emit an error in the DECLARED envelope — `{ success: false, error: { code,\n * message } }`, with `code` a semantic STRING and `message` a field OF `error`\n * rather than a sibling of it.\n *\n * Both halves of that sentence were once wrong somewhere: `error` was a bare\n * string in `service-storage` and `admin-routes` (so `body.error.message` read\n * `undefined`), and `code` was the human message in `package-routes` (#3675 →\n * #3689 → #3843).\n *\n * ## `code` is the closed ADR-0112 vocabulary, not `string`\n *\n * All seven copies typed this parameter `string`, so an invented code was caught\n * only at runtime, by a conformance suite parsing a driven body against\n * `ApiErrorSchema` — i.e. only on the routes a test happened to drive. `ErrorCode`\n * is `StandardErrorCode ∪ ERROR_CODE_LEDGER` (`error-code-ledger.zod.ts`), the\n * same union that schema validates against, so consolidating here moves the check\n * to compile time for every call site at once. It cost no call-site churn: every\n * code the seven modules emit was already registered.\n *\n * A new code is registered in `ERROR_CODE_LEDGER` under its owning package —\n * and if the condition is generic (not found / permission / validation), the\n * standard catalog is used instead of registering a synonym for it.\n *\n * ## `extra` is `ApiError`'s own optional fields, not a `Record`\n *\n * Merged into `error`, and typed as exactly what `ApiErrorSchema` declares\n * beside `code` and `message` — `details`, `category`, `requestId`, `httpStatus`.\n * `details` is the slot for structured context: `package-routes` puts a partial\n * delete's per-item failures there, `settings-routes` the whole\n * `SettingsActionResult`.\n *\n * This started as `Record<string, unknown>`, because `settings-routes` also hung\n * `namespace` / `key` / `reason` / `fields` beside `code`, which the schema does\n * not declare. Those bodies passed every gate anyway — `ApiErrorSchema` is a\n * plain `z.object`, so unknown keys were STRIPPED rather than rejected, and\n * `envelopeViolations` inspects only the body's top level — making them\n * conformant *by stripping* rather than by declaration. #4224 moved that module's\n * four branches onto `details`, which is what lets the parameter close here.\n *\n * Closing it at the shared builder is the part that lasts: an undeclared sibling\n * is now a compile error in every module at once, rather than a key that quietly\n * evaporates at the schema boundary in whichever module reintroduces it.\n */\nexport function sendError(\n res: EnvelopeResponse,\n status: number,\n code: ErrorCode,\n message: string,\n extra?: Pick<ApiError, 'category' | 'httpStatus' | 'details' | 'requestId'>,\n): void {\n res.status(status).json({ success: false, error: { code, message, ...extra } });\n}\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * [ADR-0120 D5e] The `isolated`-posture install gate for `'global'` uniqueness.\n *\n * ## Why a gate exists at all\n *\n * ADR-0120's scope vocabulary is deliberately **posture-invariant**: the author\n * states a business boundary (`'organization'` = one holder per organization,\n * `'global'` = one holder across the whole installation) and the same app\n * package runs unmodified under every tenancy posture (ADR-0105 D1\n * `single | group | isolated`). No index shape reads the posture — a posture\n * flip has zero automatic schema consequences, which is exactly what makes one\n * app package serve all three.\n *\n * One residual survives that invariance, and only in one direction\n * (ADR-0120 §Posture portability, Resolved question #4):\n *\n * - Under `single` / `group`, `'global'` means \"the installation\" — which for a\n * `group` deployment IS the customer company (集团). An app business rule\n * spelled `'global'` is correct there.\n * - Under `isolated`, organizations are **separate customers**. The identical\n * declaration now crosses customers: it over-constrains (customer B cannot\n * reuse customer A's material code) and it becomes a cross-tenant existence\n * oracle — the very leak #3696 closed for field-level uniques (S10).\n *\n * `'global'` is therefore physically posture-invariant but not *safety*-invariant,\n * and the ADR's S14 row records the honest cost: \"unique across the whole\n * company\" is not expressible in metadata alone, because it means the\n * installation under `group` and one organization under `isolated`. A third,\n * posture-resolved word (`'company'`) was designed and **rejected** — it is the\n * one token that cannot be used without first understanding the posture\n * spectrum, exactly the cognitive load an AI-authored vocabulary must not carry.\n * The scenario is handled **here**, at the deployment seam, instead.\n *\n * ## Why a HARD stop and not an advisory\n *\n * Maintainer decision, 2026-08-04 (ADR-0120 Resolved #4). An advisory that\n * nobody reads leaves a cross-customer constraint enforced in production — the\n * ADR-0049/0078 class this whole ADR exists to close. So installing an app that\n * carries `'global'` uniques on non-`sys` objects into an `isolated` environment\n * **stops**, lists each index, and asks the installer (typically an AI agent) to\n * either confirm it as genuinely platform-wide or rewrite it to\n * `'organization'`. The confirmation is recorded in the install manifest\n * (ADR-0104 attestation style) so it is **never re-asked**.\n *\n * ⛔ **Never a boot-time warning** (#4884 discipline). A deployment whose apps\n * were installed before this gate existed, or whose posture changed after\n * install, is reached by the ADVISORY form in `os doctor` / `os migrate plan` —\n * the two cases a gate at the install seam structurally cannot see. Turning\n * this into a startup diagnostic would fire on every boot of every deployment\n * forever, which is the false-alarm class #4884 retired.\n *\n * ## What counts as a finding\n *\n * | Declaration | Finding? | Why |\n * |:---|:---|:---|\n * | field `unique: 'global'` | ✅ | one holder across the installation — crosses customers under `isolated` |\n * | declared index `unique: 'global'` | ✅ | same boundary, spelled on the index |\n * | declared index `unique: true` | ✅ | ADR-0120 D1: bare `true` **is** the deprecated positional spelling of `'global'`; identical physical shape, identical hazard. Excluding it would leave the gate bypassable by spelling for the whole of 17.x |\n * | field `unique: true` / `'organization'` | ❌ | per-organization — correct under every posture |\n * | declared index `unique: 'organization'` | ❌ | per-organization (D3 NULL-safe key part) |\n * | anything on a `sys_*` object | ❌ | engine idempotency / dedup keys (the ADR's S5 inventory) are platform-wide **by construction**; asking about them on every install is the false-alarm class again |\n *\n * The enumeration is a pure projection of declared metadata — no tenancy\n * inference, no database access — which is what lets the identical function\n * serve the hard gate, `os doctor` and `os migrate plan`.\n */\n\nimport { normalizeTenancyPosture, type TenancyPosture } from '@objectstack/spec/security';\n\n/** Objects owned by the platform itself never raise a finding. */\nconst SYS_OBJECT_PREFIXES = ['sys_', 'base_'] as const;\n\n/**\n * Is this object platform-owned (the ADR's \"`sys` objects\")?\n *\n * The ADR scopes the gate to **non-`sys`** objects because the platform's own\n * `'global'` uniques are the S5 inventory — `sys_job.name`,\n * `sys_notification.dedup_key`, `http_delivery (source, dedup_key)` and the rest\n * — engine idempotency keys that are platform-wide on purpose and identical\n * under every posture. Re-confirming them on every app install would be the\n * #4884 false-alarm class with extra steps.\n *\n * `base_` is included alongside `sys_`: it is the platform's other reserved\n * object prefix, carrying the same \"owned by the framework, not the app\"\n * meaning. An app object can never legitimately claim either.\n */\nexport function isPlatformOwnedObject(objectName: unknown): boolean {\n const name = typeof objectName === 'string' ? objectName.trim().toLowerCase() : '';\n if (!name) return false;\n return SYS_OBJECT_PREFIXES.some((prefix) => name.startsWith(prefix));\n}\n\n/**\n * Does a FIELD-level `unique` value ask for the installation-wide boundary?\n *\n * Only the explicit `'global'` does. Bare `true` at field level is the\n * documented, unambiguous synonym of `'organization'` (ADR-0120 D1 —\n * \"field-level bare `true` stays valid indefinitely\", Resolved #2), so it is\n * never a finding.\n */\nexport function fieldUniqueIsGlobal(unique: unknown): boolean {\n return unique === 'global';\n}\n\n/**\n * Does a DECLARED-INDEX `unique` value ask for the installation-wide boundary?\n *\n * `'global'` and bare `true` both do. Per ADR-0120 D1 the bare spelling **is**\n * `'global'` — \"today's verbatim semantics, materialized over exactly the listed\n * columns\" — deprecated (lint `unique/unscoped-declared-index` warns in 17.x,\n * protocol 18 rejects it, #5082) but physically identical while it lasts. A gate\n * that judged only the explicit word would be bypassable by writing the\n * deprecated one, which is the #4986 trap wearing the gate's own uniform.\n */\nexport function declaredIndexUniqueIsGlobal(unique: unknown): boolean {\n return unique === 'global' || unique === true;\n}\n\n/** One installation-wide unique declaration found on an app (non-`sys`) object. */\nexport interface GlobalUniqueFinding {\n /** Stable identity for the attestation record — see {@link globalUniqueFindingId}. */\n readonly id: string;\n /** Object (and therefore table) the declaration sits on. */\n readonly object: string;\n /** Which spelling carried it. */\n readonly kind: 'field' | 'index';\n /** Field name for `kind: 'field'`; the index's declared name (when it has one) otherwise. */\n readonly name?: string;\n /** The columns the constraint spans, in declaration order. */\n readonly columns: readonly string[];\n /** The exact authored value (`true` | `'global'`) — quoted back in the stop message. */\n readonly spelling: true | 'global';\n}\n\n/**\n * Stable id for one finding, used as the attestation key.\n *\n * Keyed by object + kind + **columns**, deliberately NOT by the index's optional\n * `name`: a declared index may be anonymous, and renaming an index does not\n * change which constraint the installer confirmed. Two indexes on the same\n * object spanning the same columns are the same constraint by any physical\n * reading, so collapsing them is correct rather than lossy.\n */\nexport function globalUniqueFindingId(\n objectName: string,\n kind: 'field' | 'index',\n columns: readonly string[],\n): string {\n return `${objectName}:${kind}:${columns.join('+')}`;\n}\n\n/** Field map or field array — both authoring shapes are accepted. */\nfunction fieldEntriesOf(fields: unknown): Array<{ name: string; def: any }> {\n if (!fields) return [];\n if (Array.isArray(fields)) {\n return fields\n .filter((f: any) => f && f.name != null)\n .map((f: any) => ({ name: String(f.name), def: f }));\n }\n if (typeof fields !== 'object') return [];\n return Object.entries(fields as Record<string, any>).map(([name, def]) => ({ name, def }));\n}\n\n/**\n * Enumerate every installation-wide unique declared on an app's non-`sys`\n * objects (ADR-0120 D5e).\n *\n * Pure and posture-agnostic on purpose: the CALLER decides whether the posture\n * makes these findings a hard stop (`isolated`, at install) or an advisory\n * (`os doctor` / `os migrate plan`). Deterministic order — objects as supplied,\n * fields before indexes within an object — so the stop message and the\n * attestation record are reproducible across runs.\n */\nexport function collectGlobalUniques(objects: unknown): GlobalUniqueFinding[] {\n if (!Array.isArray(objects)) return [];\n const findings: GlobalUniqueFinding[] = [];\n\n for (const obj of objects as any[]) {\n const objectName = typeof obj?.name === 'string' ? obj.name.trim() : '';\n if (!objectName) continue;\n if (isPlatformOwnedObject(objectName)) continue;\n\n for (const { name, def } of fieldEntriesOf(obj?.fields)) {\n if (!fieldUniqueIsGlobal(def?.unique)) continue;\n findings.push({\n id: globalUniqueFindingId(objectName, 'field', [name]),\n object: objectName,\n kind: 'field',\n name,\n columns: [name],\n spelling: 'global',\n });\n }\n\n const declaredIndexes = Array.isArray(obj?.indexes) ? obj.indexes : [];\n for (const idx of declaredIndexes as any[]) {\n if (!declaredIndexUniqueIsGlobal(idx?.unique)) continue;\n const columns = Array.isArray(idx?.fields)\n ? idx.fields.filter((f: unknown) => typeof f === 'string').map((f: string) => f)\n : [];\n if (columns.length === 0) continue;\n const indexName = typeof idx?.name === 'string' && idx.name.trim() ? idx.name.trim() : undefined;\n findings.push({\n id: globalUniqueFindingId(objectName, 'index', columns),\n object: objectName,\n kind: 'index',\n ...(indexName ? { name: indexName } : {}),\n columns,\n spelling: idx.unique === true ? true : 'global',\n });\n }\n }\n\n return findings;\n}\n\n/**\n * The attestation recorded in the install manifest once an installer has\n * confirmed a set of findings as genuinely platform-wide (ADR-0104 style).\n *\n * Shape follows the ADR-0104 precedent rather than inventing one: the FACT\n * observed (which constraint ids a human/agent affirmed), WHO affirmed it, WHEN,\n * and under WHICH posture the question was asked. That last field is what keeps\n * the record honest — an attestation given under `isolated` is evidence about\n * `isolated`, and nothing else.\n *\n * Never rewritten in place: confirmations ACCUMULATE. A later install of a newer\n * version that adds a new `'global'` index asks about the new one only — the\n * earlier answers stand, which is the \"之后不复问\" half of the decision.\n */\nexport interface GlobalUniqueAttestation {\n /** Posture the confirmation was given under. */\n readonly posture: TenancyPosture;\n /** Finding ids affirmed as genuinely platform-wide. */\n readonly confirmed: readonly string[];\n /** ISO timestamp of the most recent confirmation. */\n readonly attestedAt: string;\n /** Identity of the confirming installer, when the seam knows one. */\n readonly attestedBy?: string | null;\n}\n\n/**\n * Which findings still need an answer, given an existing attestation.\n *\n * Returns the findings NOT covered by `attestation.confirmed`. An empty result\n * means the install proceeds silently — this is the mechanism behind \"never\n * re-asked\".\n *\n * An attestation recorded under a DIFFERENT posture does not carry over: the\n * question \"is this genuinely platform-wide, knowing organizations here are\n * separate customers?\" was never asked. Confirmations made under `isolated` are\n * the only ones that answer it, so a `single`-posture record is treated as\n * absent rather than as consent — the conservative direction, and the only one\n * that cannot silently admit a cross-customer constraint.\n */\nexport function unconfirmedGlobalUniques(\n findings: readonly GlobalUniqueFinding[],\n attestation: GlobalUniqueAttestation | undefined | null,\n posture: TenancyPosture,\n): GlobalUniqueFinding[] {\n if (!attestation || attestation.posture !== posture) return [...findings];\n const confirmed = new Set(attestation.confirmed ?? []);\n return findings.filter((f) => !confirmed.has(f.id));\n}\n\n/**\n * Merge a new set of confirmations into an existing attestation.\n *\n * Additive by construction — see {@link GlobalUniqueAttestation}. A record from\n * another posture is replaced rather than merged: its `confirmed` ids answered a\n * different question.\n */\nexport function recordGlobalUniqueAttestation(\n previous: GlobalUniqueAttestation | undefined | null,\n confirmedIds: readonly string[],\n posture: TenancyPosture,\n attestedBy?: string | null,\n now: string = new Date().toISOString(),\n): GlobalUniqueAttestation {\n const carried = previous && previous.posture === posture ? previous.confirmed ?? [] : [];\n const merged = Array.from(new Set([...carried, ...confirmedIds])).sort();\n return {\n posture,\n confirmed: merged,\n attestedAt: now,\n ...(attestedBy !== undefined ? { attestedBy } : {}),\n };\n}\n\n/** Render one finding the way both the hard stop and the advisory quote it. */\nexport function describeGlobalUniqueFinding(finding: GlobalUniqueFinding): string {\n const spelling = finding.spelling === true ? '`unique: true`' : \"`unique: 'global'`\";\n const deprecated = finding.spelling === true ? ' [deprecated bare spelling of \\'global\\']' : '';\n if (finding.kind === 'field') {\n return `${finding.object}.${finding.name} — field-level ${spelling}`;\n }\n const label = finding.name ? ` '${finding.name}'` : '';\n return `${finding.object} — declared index${label} [${finding.columns.join(', ')}] ${spelling}${deprecated}`;\n}\n\n/**\n * The prescription every surface repeats verbatim, so the hard stop and the two\n * advisories cannot drift into three different pieces of advice.\n */\nexport const GLOBAL_UNIQUE_ISOLATED_PRESCRIPTION =\n \"Under the 'isolated' posture organizations are separate CUSTOMERS, so an installation-wide unique \" +\n 'constrains across customers and can reveal that another customer already holds a value (ADR-0120 S10/S14). ' +\n 'For each index above, either (a) confirm it is genuinely platform-wide — an infrastructure/dedup key, a DNS ' +\n 'hostname, an external provider id — or (b) rewrite it to `unique: \\'organization\\'` so it is one holder per ' +\n 'organization. See ADR-0120 §Posture portability.';\n\n/**\n * The full hard-stop message for an install into an `isolated` environment.\n *\n * Built here rather than at the install seam so the CLI, the HTTP surface and\n * the tests all quote one text.\n */\nexport function buildGlobalUniqueStopMessage(\n appLabel: string,\n findings: readonly GlobalUniqueFinding[],\n): string {\n const lines = findings.map((f) => ` • ${describeGlobalUniqueFinding(f)}`);\n return (\n `'${appLabel}' declares ${findings.length} installation-wide unique constraint(s) on its own objects, and this ` +\n \"environment runs the 'isolated' tenancy posture (ADR-0120 D5e):\\n\" +\n `${lines.join('\\n')}\\n` +\n `${GLOBAL_UNIQUE_ISOLATED_PRESCRIPTION}\\n` +\n 'Re-run the install with the confirmation to record it in the install manifest — it is asked once, ' +\n 'never again for the same constraints.'\n );\n}\n\n/** Error code the install seam returns when the gate stops an install. */\nexport const GLOBAL_UNIQUE_CONFIRMATION_REQUIRED = 'UNIQUE_SCOPE_CONFIRMATION_REQUIRED';\n\n/**\n * Does this posture make `'global'` uniques a decision point at all?\n *\n * `isolated` only. Under `single` there is one customer; under `group` the\n * installation IS the customer company, which is what `'global'` means there —\n * both are the benign direction the ADR leaves to the app's install notes.\n */\nexport function postureGatesGlobalUniques(posture: unknown): boolean {\n return normalizeTenancyPosture(posture) === 'isolated';\n}\n"],"mappings":"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","names":["import_security"]}
|
package/dist/index.mjs
CHANGED
|
@@ -141,6 +141,11 @@ function looksLikeInternalErrorLeak(message) {
|
|
|
141
141
|
const lower = String(message).toLowerCase();
|
|
142
142
|
return lower.includes("sqlite_") || lower.includes("sqlstate") || lower.startsWith("insert into ") || lower.startsWith("update ") || lower.startsWith("select ") || lower.startsWith("delete from ") || lower.includes("constraint failed") || lower.includes("unique constraint") || lower.includes("foreign key");
|
|
143
143
|
}
|
|
144
|
+
function declaresServerFault(err) {
|
|
145
|
+
if (typeof err !== "object" || err === null) return false;
|
|
146
|
+
const { status, code } = err;
|
|
147
|
+
return typeof status === "number" && status >= 500 && typeof code === "string" && code.length > 0;
|
|
148
|
+
}
|
|
144
149
|
|
|
145
150
|
// src/keyset-walk.ts
|
|
146
151
|
function withCursor(where, key, cursor) {
|
|
@@ -218,16 +223,126 @@ function sendOk(res, data, status = 200) {
|
|
|
218
223
|
function sendError(res, status, code, message, extra) {
|
|
219
224
|
res.status(status).json({ success: false, error: { code, message, ...extra } });
|
|
220
225
|
}
|
|
226
|
+
|
|
227
|
+
// src/unique-scope-install-gate.ts
|
|
228
|
+
import { normalizeTenancyPosture as normalizeTenancyPosture2 } from "@objectstack/spec/security";
|
|
229
|
+
var SYS_OBJECT_PREFIXES = ["sys_", "base_"];
|
|
230
|
+
function isPlatformOwnedObject(objectName) {
|
|
231
|
+
const name = typeof objectName === "string" ? objectName.trim().toLowerCase() : "";
|
|
232
|
+
if (!name) return false;
|
|
233
|
+
return SYS_OBJECT_PREFIXES.some((prefix) => name.startsWith(prefix));
|
|
234
|
+
}
|
|
235
|
+
function fieldUniqueIsGlobal(unique) {
|
|
236
|
+
return unique === "global";
|
|
237
|
+
}
|
|
238
|
+
function declaredIndexUniqueIsGlobal(unique) {
|
|
239
|
+
return unique === "global" || unique === true;
|
|
240
|
+
}
|
|
241
|
+
function globalUniqueFindingId(objectName, kind, columns) {
|
|
242
|
+
return `${objectName}:${kind}:${columns.join("+")}`;
|
|
243
|
+
}
|
|
244
|
+
function fieldEntriesOf(fields) {
|
|
245
|
+
if (!fields) return [];
|
|
246
|
+
if (Array.isArray(fields)) {
|
|
247
|
+
return fields.filter((f) => f && f.name != null).map((f) => ({ name: String(f.name), def: f }));
|
|
248
|
+
}
|
|
249
|
+
if (typeof fields !== "object") return [];
|
|
250
|
+
return Object.entries(fields).map(([name, def]) => ({ name, def }));
|
|
251
|
+
}
|
|
252
|
+
function collectGlobalUniques(objects) {
|
|
253
|
+
if (!Array.isArray(objects)) return [];
|
|
254
|
+
const findings = [];
|
|
255
|
+
for (const obj of objects) {
|
|
256
|
+
const objectName = typeof obj?.name === "string" ? obj.name.trim() : "";
|
|
257
|
+
if (!objectName) continue;
|
|
258
|
+
if (isPlatformOwnedObject(objectName)) continue;
|
|
259
|
+
for (const { name, def } of fieldEntriesOf(obj?.fields)) {
|
|
260
|
+
if (!fieldUniqueIsGlobal(def?.unique)) continue;
|
|
261
|
+
findings.push({
|
|
262
|
+
id: globalUniqueFindingId(objectName, "field", [name]),
|
|
263
|
+
object: objectName,
|
|
264
|
+
kind: "field",
|
|
265
|
+
name,
|
|
266
|
+
columns: [name],
|
|
267
|
+
spelling: "global"
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
const declaredIndexes = Array.isArray(obj?.indexes) ? obj.indexes : [];
|
|
271
|
+
for (const idx of declaredIndexes) {
|
|
272
|
+
if (!declaredIndexUniqueIsGlobal(idx?.unique)) continue;
|
|
273
|
+
const columns = Array.isArray(idx?.fields) ? idx.fields.filter((f) => typeof f === "string").map((f) => f) : [];
|
|
274
|
+
if (columns.length === 0) continue;
|
|
275
|
+
const indexName = typeof idx?.name === "string" && idx.name.trim() ? idx.name.trim() : void 0;
|
|
276
|
+
findings.push({
|
|
277
|
+
id: globalUniqueFindingId(objectName, "index", columns),
|
|
278
|
+
object: objectName,
|
|
279
|
+
kind: "index",
|
|
280
|
+
...indexName ? { name: indexName } : {},
|
|
281
|
+
columns,
|
|
282
|
+
spelling: idx.unique === true ? true : "global"
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return findings;
|
|
287
|
+
}
|
|
288
|
+
function unconfirmedGlobalUniques(findings, attestation, posture) {
|
|
289
|
+
if (!attestation || attestation.posture !== posture) return [...findings];
|
|
290
|
+
const confirmed = new Set(attestation.confirmed ?? []);
|
|
291
|
+
return findings.filter((f) => !confirmed.has(f.id));
|
|
292
|
+
}
|
|
293
|
+
function recordGlobalUniqueAttestation(previous, confirmedIds, posture, attestedBy, now = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
294
|
+
const carried = previous && previous.posture === posture ? previous.confirmed ?? [] : [];
|
|
295
|
+
const merged = Array.from(/* @__PURE__ */ new Set([...carried, ...confirmedIds])).sort();
|
|
296
|
+
return {
|
|
297
|
+
posture,
|
|
298
|
+
confirmed: merged,
|
|
299
|
+
attestedAt: now,
|
|
300
|
+
...attestedBy !== void 0 ? { attestedBy } : {}
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
function describeGlobalUniqueFinding(finding) {
|
|
304
|
+
const spelling = finding.spelling === true ? "`unique: true`" : "`unique: 'global'`";
|
|
305
|
+
const deprecated = finding.spelling === true ? " [deprecated bare spelling of 'global']" : "";
|
|
306
|
+
if (finding.kind === "field") {
|
|
307
|
+
return `${finding.object}.${finding.name} \u2014 field-level ${spelling}`;
|
|
308
|
+
}
|
|
309
|
+
const label = finding.name ? ` '${finding.name}'` : "";
|
|
310
|
+
return `${finding.object} \u2014 declared index${label} [${finding.columns.join(", ")}] ${spelling}${deprecated}`;
|
|
311
|
+
}
|
|
312
|
+
var GLOBAL_UNIQUE_ISOLATED_PRESCRIPTION = "Under the 'isolated' posture organizations are separate CUSTOMERS, so an installation-wide unique constrains across customers and can reveal that another customer already holds a value (ADR-0120 S10/S14). For each index above, either (a) confirm it is genuinely platform-wide \u2014 an infrastructure/dedup key, a DNS hostname, an external provider id \u2014 or (b) rewrite it to `unique: 'organization'` so it is one holder per organization. See ADR-0120 \xA7Posture portability.";
|
|
313
|
+
function buildGlobalUniqueStopMessage(appLabel, findings) {
|
|
314
|
+
const lines = findings.map((f) => ` \u2022 ${describeGlobalUniqueFinding(f)}`);
|
|
315
|
+
return `'${appLabel}' declares ${findings.length} installation-wide unique constraint(s) on its own objects, and this environment runs the 'isolated' tenancy posture (ADR-0120 D5e):
|
|
316
|
+
${lines.join("\n")}
|
|
317
|
+
${GLOBAL_UNIQUE_ISOLATED_PRESCRIPTION}
|
|
318
|
+
Re-run the install with the confirmation to record it in the install manifest \u2014 it is asked once, never again for the same constraints.`;
|
|
319
|
+
}
|
|
320
|
+
var GLOBAL_UNIQUE_CONFIRMATION_REQUIRED = "UNIQUE_SCOPE_CONFIRMATION_REQUIRED";
|
|
321
|
+
function postureGatesGlobalUniques(posture) {
|
|
322
|
+
return normalizeTenancyPosture2(posture) === "isolated";
|
|
323
|
+
}
|
|
221
324
|
export {
|
|
325
|
+
GLOBAL_UNIQUE_CONFIRMATION_REQUIRED,
|
|
326
|
+
GLOBAL_UNIQUE_ISOLATED_PRESCRIPTION,
|
|
222
327
|
INTERNAL_ERROR_MESSAGE,
|
|
223
328
|
_resetEnvDeprecationWarnings,
|
|
329
|
+
buildGlobalUniqueStopMessage,
|
|
224
330
|
collectConfiguredLocales,
|
|
331
|
+
collectGlobalUniques,
|
|
332
|
+
declaredIndexUniqueIsGlobal,
|
|
333
|
+
declaresServerFault,
|
|
334
|
+
describeGlobalUniqueFinding,
|
|
225
335
|
emitDegradedBootBanner,
|
|
336
|
+
fieldUniqueIsGlobal,
|
|
337
|
+
globalUniqueFindingId,
|
|
226
338
|
isMcpServerEnabled,
|
|
227
339
|
isModuleNotFoundError,
|
|
340
|
+
isPlatformOwnedObject,
|
|
228
341
|
keysetWalk,
|
|
229
342
|
looksLikeInternalErrorLeak,
|
|
343
|
+
postureGatesGlobalUniques,
|
|
230
344
|
readEnvWithDeprecation,
|
|
345
|
+
recordGlobalUniqueAttestation,
|
|
231
346
|
resolveAllowDegradedTenancy,
|
|
232
347
|
resolveAllowDevPlugin,
|
|
233
348
|
resolveAllowDriverConnectFailure,
|
|
@@ -239,6 +354,7 @@ export {
|
|
|
239
354
|
resolveTenancyPosture,
|
|
240
355
|
sendError,
|
|
241
356
|
sendOk,
|
|
242
|
-
stampSearchPinyinEnabled
|
|
357
|
+
stampSearchPinyinEnabled,
|
|
358
|
+
unconfirmedGlobalUniques
|
|
243
359
|
};
|
|
244
360
|
//# sourceMappingURL=index.mjs.map
|