@objectstack/core 17.0.0-rc.6 → 17.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1385,6 +1385,7 @@ function createMemoryJob() {
1385
1385
  }
1386
1386
 
1387
1387
  // src/fallbacks/memory-i18n.ts
1388
+ import { normalizeSupportedLocales } from "@objectstack/spec/system";
1388
1389
  function deepMerge(target, source) {
1389
1390
  const result = { ...target };
1390
1391
  for (const key of Object.keys(source)) {
@@ -1418,6 +1419,7 @@ function createMemoryI18n() {
1418
1419
  const translations = /* @__PURE__ */ new Map();
1419
1420
  const authored = /* @__PURE__ */ new Map();
1420
1421
  let defaultLocale = "en";
1422
+ let supportedLocales;
1421
1423
  function resolveKey(data, key) {
1422
1424
  const parts = key.split(".");
1423
1425
  let current = data;
@@ -1483,9 +1485,29 @@ function createMemoryI18n() {
1483
1485
  authored.set(locale, { ...data });
1484
1486
  }
1485
1487
  },
1488
+ /**
1489
+ * Report the locales this stack offers.
1490
+ *
1491
+ * [#7679] When the app declared `i18n.supportedLocales`, that declaration
1492
+ * IS the answer — in declared order, and including a declared locale no
1493
+ * bundle was ever loaded for (declared-but-unserved). Reporting the
1494
+ * declaration rather than an intersection is what gives a client the
1495
+ * signal that the locale it is being offered has nothing behind it yet;
1496
+ * quietly dropping it would leave the gap invisible on both sides. It is
1497
+ * also the only answer that does not depend on how much had loaded by the
1498
+ * time this was called.
1499
+ *
1500
+ * With nothing declared, the loaded set — the behaviour every app that
1501
+ * never opted in already has.
1502
+ */
1486
1503
  getLocales() {
1504
+ if (supportedLocales) return [...supportedLocales];
1487
1505
  return [.../* @__PURE__ */ new Set([...translations.keys(), ...authored.keys()])];
1488
1506
  },
1507
+ /** @see II18nService.setSupportedLocales — [#7679] */
1508
+ setSupportedLocales(locales) {
1509
+ supportedLocales = normalizeSupportedLocales(locales);
1510
+ },
1489
1511
  getDefaultLocale() {
1490
1512
  return defaultLocale;
1491
1513
  },
@@ -1495,14 +1517,42 @@ function createMemoryI18n() {
1495
1517
  };
1496
1518
  }
1497
1519
 
1520
+ // src/metadata-service-contract.ts
1521
+ import { pluralToSingular } from "@objectstack/spec/shared";
1522
+ var REGISTER_REFUSAL_CODE = "VALIDATION_ERROR";
1523
+ function canonicalMetadataServiceType(type) {
1524
+ return pluralToSingular(type);
1525
+ }
1526
+ function registerRefusal(message) {
1527
+ const err = new Error(message);
1528
+ err.code = REGISTER_REFUSAL_CODE;
1529
+ err.status = 400;
1530
+ return err;
1531
+ }
1532
+ function assertMetadataRegisterContract(type, name, data) {
1533
+ if (typeof data !== "object" || data === null || Array.isArray(data)) {
1534
+ const shape = data === null ? "null" : Array.isArray(data) ? "an array" : `a ${typeof data}`;
1535
+ throw registerRefusal(
1536
+ `IMetadataService.register('${type}', '${name}'): data is ${shape}, not a metadata document. register() stores plain-object documents only \u2014 accepting a value the service cannot key was measured as accept-then-drop on document-keyed stores (#7378 row 3: refuse loudly, never coerce into storability). Wrap the value in a document object whose shape the '${type}' type's schema accepts, or store it under a type that declares one.`
1537
+ );
1538
+ }
1539
+ const documentName = data.name;
1540
+ if (documentName !== void 0 && documentName !== name) {
1541
+ throw registerRefusal(
1542
+ `IMetadataService.register('${type}', '${name}'): data.name is '${String(documentName)}', which disagrees with the name argument '${name}'. A disagreement is almost always an authoring bug, and resolving it silently in either direction can file the item under a key the caller never wrote (#7378 row 1: refuse loudly, locate the mismatch). Register under one name: pass the intended key as the argument and make data.name match it, or omit data.name.`
1543
+ );
1544
+ }
1545
+ }
1546
+
1498
1547
  // src/fallbacks/memory-metadata.ts
1499
1548
  function createMemoryMetadata() {
1500
1549
  const store = /* @__PURE__ */ new Map();
1501
1550
  function getTypeMap(type) {
1502
- let map = store.get(type);
1551
+ const canonical = canonicalMetadataServiceType(type);
1552
+ let map = store.get(canonical);
1503
1553
  if (!map) {
1504
1554
  map = /* @__PURE__ */ new Map();
1505
- store.set(type, map);
1555
+ store.set(canonical, map);
1506
1556
  }
1507
1557
  return map;
1508
1558
  }
@@ -1517,6 +1567,7 @@ function createMemoryMetadata() {
1517
1567
  },
1518
1568
  _serviceName: "metadata",
1519
1569
  async register(type, name, data) {
1570
+ assertMetadataRegisterContract(type, name, data);
1520
1571
  getTypeMap(type).set(name, data);
1521
1572
  },
1522
1573
  // Mirror MetadataManager.registerInMemory (synchronous, no persistence).
@@ -1528,7 +1579,11 @@ function createMemoryMetadata() {
1528
1579
  // so `defineStack({ datasources })` entries silently never reached the
1529
1580
  // registry and were absent from GET /api/v1/datasources and
1530
1581
  // GET /api/v1/meta/datasource (ADR-0015 §18). This store is already
1531
- // in-memory only, so registerInMemory and register share an implementation.
1582
+ // in-memory only, so registerInMemory and register share a store — but
1583
+ // NOT the [#7378] refusals: the ruling names `register`, and this member
1584
+ // is a boot-time seeding primitive for source-control-owned artefacts
1585
+ // (see assertMetadataRegisterContract's header for the boundary). It does
1586
+ // share the row-2 canonical type fold, via getTypeMap.
1532
1587
  registerInMemory(type, name, data) {
1533
1588
  getTypeMap(type).set(name, data);
1534
1589
  },
@@ -1692,6 +1747,26 @@ var CORE_FALLBACK_FACTORIES = {
1692
1747
  i18n: createMemoryI18n
1693
1748
  };
1694
1749
 
1750
+ // src/plugin-registration.ts
1751
+ function versionLabel(plugin) {
1752
+ return plugin.version ? `v${plugin.version}` : "unversioned";
1753
+ }
1754
+ function describeSupersededRegistration(previous, next) {
1755
+ return `Plugin superseded: '${next.name}' \u2014 the later registration (${versionLabel(next)}) REPLACED the earlier one (${versionLabel(previous)}). Only the later instance is initialized and started; the earlier one is discarded without ever running init(). Duplicate registration by name is last-one-wins on both kernels by declared contract (#9864) \u2014 register the plugin once if that is not what you meant.`;
1756
+ }
1757
+ function registerPluginByName(registry, plugin, logger) {
1758
+ const previous = registry.get(plugin.name);
1759
+ if (previous !== void 0) {
1760
+ logger.warn(describeSupersededRegistration(previous, plugin), {
1761
+ plugin: plugin.name,
1762
+ supersededVersion: previous.version,
1763
+ supersedingVersion: plugin.version
1764
+ });
1765
+ }
1766
+ registry.set(plugin.name, plugin);
1767
+ return previous;
1768
+ }
1769
+
1695
1770
  // src/kernel.ts
1696
1771
  var ObjectKernel = class {
1697
1772
  constructor(config = {}) {
@@ -1776,6 +1851,14 @@ var ObjectKernel = class {
1776
1851
  }
1777
1852
  /**
1778
1853
  * Register a plugin with enhanced validation
1854
+ *
1855
+ * Duplicate names OVERWRITE, with one `warn` naming both versions — the
1856
+ * declared contract in `plugin-registration.ts`, applied identically by
1857
+ * `LiteKernel.use()` (#9864, maintainer ruling 2026-08-19). The overwrite
1858
+ * itself is unchanged: it is what lets an app config's `plugins` entry
1859
+ * supersede a plugin the CLI auto-registered earlier in the same boot
1860
+ * (#9863). What changes is that it is no longer silent, and no longer
1861
+ * disagrees with the other kernel.
1779
1862
  */
1780
1863
  async use(plugin) {
1781
1864
  if (this.state !== "idle") {
@@ -1786,11 +1869,13 @@ var ObjectKernel = class {
1786
1869
  throw new Error(`Failed to load plugin: ${plugin.name} - ${result.error?.message}`);
1787
1870
  }
1788
1871
  const pluginMeta = result.plugin;
1789
- this.plugins.set(pluginMeta.name, pluginMeta);
1790
- this.logger.info(`Plugin registered: ${pluginMeta.name}@${pluginMeta.version}`, {
1791
- plugin: pluginMeta.name,
1792
- version: pluginMeta.version
1793
- });
1872
+ const superseded = registerPluginByName(this.plugins, pluginMeta, this.logger);
1873
+ if (superseded === void 0) {
1874
+ this.logger.info(`Plugin registered: ${pluginMeta.name}@${pluginMeta.version}`, {
1875
+ plugin: pluginMeta.name,
1876
+ version: pluginMeta.version
1877
+ });
1878
+ }
1794
1879
  return this;
1795
1880
  }
1796
1881
  /**
@@ -2268,14 +2353,21 @@ var LiteKernel = class extends ObjectKernelBase {
2268
2353
  /**
2269
2354
  * Register a plugin
2270
2355
  * @param plugin - Plugin instance
2356
+ *
2357
+ * Duplicate names OVERWRITE, with one `warn` naming both versions — the
2358
+ * declared contract in `plugin-registration.ts`, applied identically by
2359
+ * `ObjectKernel.use()` (#9864, maintainer ruling 2026-08-19).
2360
+ *
2361
+ * This method used to `throw` `[Kernel] Plugin '<name>' already
2362
+ * registered` here while `ObjectKernel` overwrote silently, so one input
2363
+ * had two meanings depending on which kernel was running — and the kernel
2364
+ * that runs in production was the silent one. The ruling converged them on
2365
+ * the behaviour that already works (an app config superseding a plugin the
2366
+ * CLI auto-registered, #9863) and made it audible rather than removing it.
2271
2367
  */
2272
2368
  use(plugin) {
2273
2369
  this.validateIdle();
2274
- const pluginName = plugin.name;
2275
- if (this.plugins.has(pluginName)) {
2276
- throw new Error(`[Kernel] Plugin '${pluginName}' already registered`);
2277
- }
2278
- this.plugins.set(pluginName, plugin);
2370
+ registerPluginByName(this.plugins, plugin, this.logger);
2279
2371
  return this;
2280
2372
  }
2281
2373
  /**
@@ -2524,11 +2616,119 @@ var TestRunner = class {
2524
2616
  };
2525
2617
 
2526
2618
  // src/qa/http-adapter.ts
2619
+ import { RestApiConfigSchema, CrudEndpointsConfigSchema } from "@objectstack/spec/api";
2620
+ var conventionCache;
2621
+ function conventionMounts() {
2622
+ if (conventionCache === void 0) {
2623
+ const api = RestApiConfigSchema.parse({});
2624
+ const crud = CrudEndpointsConfigSchema.parse({});
2625
+ const apiBase = api.apiPath ?? `${api.basePath}/${api.version}`;
2626
+ conventionCache = { apiBase, dataPath: `${apiBase}${crud.dataPrefix}` };
2627
+ }
2628
+ return conventionCache;
2629
+ }
2527
2630
  var HttpTestAdapter = class {
2528
2631
  constructor(baseUrl, authToken) {
2529
2632
  this.baseUrl = baseUrl;
2530
2633
  this.authToken = authToken;
2531
2634
  }
2635
+ /** The resolved data mount; probes at most once per adapter. */
2636
+ dataMount() {
2637
+ if (this.mountPromise === void 0) {
2638
+ this.mountPromise = this.resolveDataMount();
2639
+ }
2640
+ return this.mountPromise;
2641
+ }
2642
+ /**
2643
+ * Ask the server where it serves the Data Protocol, and fall back to the
2644
+ * convention — loudly — when it cannot say.
2645
+ *
2646
+ * ## [#7983] What the probe recovers, measured rather than assumed
2647
+ *
2648
+ * `@objectstack/client` answers the same question through discovery
2649
+ * (`getRoute`, `packages/client/src/index.ts`), and this follows it: prefer
2650
+ * the server's own `routes.data`, fall back to the convention. Measured on a
2651
+ * booted stack (REST generator + dispatcher bridge, three configs):
2652
+ *
2653
+ * | deployment | `{apiBase}/discovery` | serves |
2654
+ * |--------------------------------|-----------------------|---------------|
2655
+ * | stock | 200 `/api/v1/data` | `/api/v1/data`|
2656
+ * | `crud.dataPrefix: '/objects'` | 200 `/api/v1/objects` | `/api/v1/objects` |
2657
+ * | `api.apiPath: '/api/2026-01'` | **404** | `/api/2026-01/data` |
2658
+ *
2659
+ * So the probe closes the `dataPrefix` row exactly: `RestServer`'s discovery
2660
+ * handler substitutes the configured prefix into `routes.data`, and reading
2661
+ * it is strictly better than recomputing it here. The `apiPath` row it cannot
2662
+ * close, and the reason is structural rather than an oversight — `apiPath`
2663
+ * moves the base that discovery itself is mounted under, so the document that
2664
+ * would name the new mount is behind the very prefix we are missing.
2665
+ *
2666
+ * ⛔ And the one discovery document at a FIXED path does not rescue it:
2667
+ * `/.well-known/objectstack` is mounted at the site root by the dispatcher
2668
+ * bridge, but its `routes.data` is the DISPATCHER's own `${prefix}/data` —
2669
+ * measured as `/api/v1/data` under all three configs above, including the two
2670
+ * where the server serves elsewhere. Falling back to it would turn "we could
2671
+ * not resolve the mount" into "discovery told us `/api/v1/data`": the same
2672
+ * 404, now with a false provenance attached. Not probed, deliberately.
2673
+ *
2674
+ * Hence: one probe, then a diagnostic that NAMES the mount, the evidence and
2675
+ * the remedy. `api_call` takes the path it is given and is unaffected either
2676
+ * way — it stays the escape hatch for a host this cannot reach.
2677
+ */
2678
+ async resolveDataMount() {
2679
+ const { apiBase, dataPath } = conventionMounts();
2680
+ const probeUrl = `${this.baseUrl}${apiBase}/discovery`;
2681
+ let why;
2682
+ try {
2683
+ const headers = {};
2684
+ if (this.authToken) {
2685
+ headers["Authorization"] = `Bearer ${this.authToken}`;
2686
+ }
2687
+ const response = await fetch(probeUrl, { method: "GET", headers });
2688
+ if (response.ok) {
2689
+ const body = await response.json();
2690
+ const doc = body && typeof body === "object" && "routes" in body ? body : body?.data;
2691
+ const advertised = doc?.routes?.data;
2692
+ if (typeof advertised === "string" && advertised.length > 0) {
2693
+ return {
2694
+ path: advertised,
2695
+ source: "discovery",
2696
+ why: `GET ${probeUrl} advertised routes.data`
2697
+ };
2698
+ }
2699
+ why = `GET ${probeUrl} answered ${response.status} but carried no routes.data`;
2700
+ } else {
2701
+ why = `GET ${probeUrl} answered ${response.status}`;
2702
+ }
2703
+ } catch (error) {
2704
+ why = `GET ${probeUrl} could not be reached (${error.message})`;
2705
+ }
2706
+ const mount = { path: dataPath, source: "convention", why };
2707
+ console.warn(
2708
+ `[HttpTestAdapter] Data Protocol mount NOT resolved from discovery. Record actions (create_record, read_record, update_record, delete_record, query_records) will address ${this.baseUrl}${dataPath}, the convention declared by RestApiConfigSchema (apiPath ?? basePath/version) + CrudEndpointsConfigSchema.dataPrefix. Evidence: ${why}. A deployment that sets crud.dataPrefix is normally recovered by this probe; one that sets api.apiPath moves discovery itself out from under it, and no fixed-path document reports the REST mount \u2014 write those steps as api_call, which takes the path you give it.`
2709
+ );
2710
+ return mount;
2711
+ }
2712
+ /** `{baseUrl}{dataMount}/{object}` — the collection URL. */
2713
+ collectionUrl(mount, objectName) {
2714
+ return `${this.baseUrl}${mount.path}/${encodeURIComponent(objectName)}`;
2715
+ }
2716
+ /** `{collection}/{id}` — the single-record URL. */
2717
+ recordUrl(mount, objectName, id) {
2718
+ return `${this.collectionUrl(mount, objectName)}/${encodeURIComponent(String(id))}`;
2719
+ }
2720
+ /**
2721
+ * The provenance clause appended to a failed record action's error.
2722
+ *
2723
+ * The card this closes is about a 404 that reads like the author's own URL
2724
+ * mistake; the mount is the one fact that distinguishes the two, so it rides
2725
+ * on the failure itself rather than only on a warning printed earlier in the
2726
+ * transcript.
2727
+ */
2728
+ mountNote(mount) {
2729
+ const how = mount.source === "discovery" ? "resolved from discovery" : "assumed by convention \u2014 the server was not able to state it";
2730
+ return `record actions addressed ${this.baseUrl}${mount.path} (data mount ${how}: ${mount.why})`;
2731
+ }
2532
2732
  async execute(action, _context) {
2533
2733
  const headers = {
2534
2734
  "Content-Type": "application/json"
@@ -2560,48 +2760,53 @@ var HttpTestAdapter = class {
2560
2760
  }
2561
2761
  }
2562
2762
  async createRecord(objectName, data, headers) {
2563
- const response = await fetch(`${this.baseUrl}/api/data/${objectName}`, {
2763
+ const mount = await this.dataMount();
2764
+ const response = await fetch(this.collectionUrl(mount, objectName), {
2564
2765
  method: "POST",
2565
2766
  headers,
2566
2767
  body: JSON.stringify(data)
2567
2768
  });
2568
- return this.handleResponse(response);
2769
+ return this.handleResponse(response, this.mountNote(mount));
2569
2770
  }
2570
2771
  async updateRecord(objectName, data, headers) {
2571
- const id = data.id;
2772
+ const { id, ...fields } = data;
2572
2773
  if (!id) throw new Error("Update record requires id in payload");
2573
- const response = await fetch(`${this.baseUrl}/api/data/${objectName}/${id}`, {
2574
- method: "PUT",
2774
+ const mount = await this.dataMount();
2775
+ const response = await fetch(this.recordUrl(mount, objectName, id), {
2776
+ method: "PATCH",
2575
2777
  headers,
2576
- body: JSON.stringify(data)
2778
+ body: JSON.stringify(fields)
2577
2779
  });
2578
- return this.handleResponse(response);
2780
+ return this.handleResponse(response, this.mountNote(mount));
2579
2781
  }
2580
2782
  async deleteRecord(objectName, data, headers) {
2581
2783
  const id = data.id;
2582
2784
  if (!id) throw new Error("Delete record requires id in payload");
2583
- const response = await fetch(`${this.baseUrl}/api/data/${objectName}/${id}`, {
2785
+ const mount = await this.dataMount();
2786
+ const response = await fetch(this.recordUrl(mount, objectName, id), {
2584
2787
  method: "DELETE",
2585
2788
  headers
2586
2789
  });
2587
- return this.handleResponse(response);
2790
+ return this.handleResponse(response, this.mountNote(mount));
2588
2791
  }
2589
2792
  async readRecord(objectName, data, headers) {
2590
2793
  const id = data.id;
2591
2794
  if (!id) throw new Error("Read record requires id in payload");
2592
- const response = await fetch(`${this.baseUrl}/api/data/${objectName}/${id}`, {
2795
+ const mount = await this.dataMount();
2796
+ const response = await fetch(this.recordUrl(mount, objectName, id), {
2593
2797
  method: "GET",
2594
2798
  headers
2595
2799
  });
2596
- return this.handleResponse(response);
2800
+ return this.handleResponse(response, this.mountNote(mount));
2597
2801
  }
2598
2802
  async queryRecords(objectName, data, headers) {
2599
- const response = await fetch(`${this.baseUrl}/api/data/${objectName}/query`, {
2803
+ const mount = await this.dataMount();
2804
+ const response = await fetch(`${this.collectionUrl(mount, objectName)}/query`, {
2600
2805
  method: "POST",
2601
2806
  headers,
2602
2807
  body: JSON.stringify(data)
2603
2808
  });
2604
- return this.handleResponse(response);
2809
+ return this.handleResponse(response, this.mountNote(mount));
2605
2810
  }
2606
2811
  async rawApiCall(endpoint, data, headers) {
2607
2812
  const method = data.method || "GET";
@@ -2614,10 +2819,13 @@ var HttpTestAdapter = class {
2614
2819
  });
2615
2820
  return this.handleResponse(response);
2616
2821
  }
2617
- async handleResponse(response) {
2822
+ async handleResponse(response, mountNote) {
2618
2823
  if (!response.ok) {
2619
2824
  const text = await response.text();
2620
- throw new Error(`HTTP Error ${response.status}: ${text}`);
2825
+ const wrongUrlShaped = response.status === 404 || response.status === 405;
2826
+ throw new Error(
2827
+ `HTTP Error ${response.status}: ${text}${wrongUrlShaped && mountNote ? ` \u2014 ${mountNote}` : ""}`
2828
+ );
2621
2829
  }
2622
2830
  const contentType = response.headers.get("content-type");
2623
2831
  if (contentType && contentType.includes("application/json")) {
@@ -3959,6 +4167,7 @@ var PluginSecurityScanner = class {
3959
4167
 
3960
4168
  // src/security/api-key.ts
3961
4169
  import { createHash, randomBytes } from "crypto";
4170
+ import { postureEnforcesWall, postureUsesUnionScope, normalizeTenancyPosture } from "@objectstack/spec/security";
3962
4171
  var API_KEY_PREFIX = "osk_";
3963
4172
  var API_KEY_ENTROPY_BYTES = 32;
3964
4173
  var VISIBLE_PREFIX_LEN = 12;
@@ -4012,10 +4221,18 @@ function isExpired(value, nowMs) {
4012
4221
  if (Number.isNaN(ms)) return false;
4013
4222
  return ms <= nowMs;
4014
4223
  }
4015
- async function resolveApiKeyPrincipal(ql, headers, nowMs = Date.now()) {
4224
+ function effectiveTenancyPosture(tenancy) {
4225
+ if (!tenancy) return void 0;
4226
+ return normalizeTenancyPosture(tenancy.posture) ?? (tenancy.isolationActive ? "isolated" : "single");
4227
+ }
4228
+ async function resolveApiKeyPrincipal(ql, headers, nowMs = Date.now(), tenancyPosture) {
4229
+ const admission = await resolveApiKeyAdmission(ql, headers, nowMs, tenancyPosture);
4230
+ return admission.outcome === "admitted" ? admission.principal : void 0;
4231
+ }
4232
+ async function resolveApiKeyAdmission(ql, headers, nowMs = Date.now(), tenancyPosture) {
4016
4233
  const apiKey = extractApiKey(headers);
4017
- if (!apiKey) return void 0;
4018
- if (!ql || typeof ql.find !== "function") return void 0;
4234
+ if (!apiKey) return { outcome: "none" };
4235
+ if (!ql || typeof ql.find !== "function") return { outcome: "none" };
4019
4236
  let rows;
4020
4237
  try {
4021
4238
  rows = await ql.find("sys_api_key", {
@@ -4024,19 +4241,29 @@ async function resolveApiKeyPrincipal(ql, headers, nowMs = Date.now()) {
4024
4241
  context: { isSystem: true }
4025
4242
  });
4026
4243
  } catch {
4027
- return void 0;
4244
+ return { outcome: "none" };
4028
4245
  }
4029
4246
  if (rows && rows.value) rows = rows.value;
4030
4247
  const row = Array.isArray(rows) ? rows[0] : void 0;
4031
- if (!row || row.revoked === true) return void 0;
4248
+ if (!row || row.revoked === true) return { outcome: "none" };
4032
4249
  const expiresAt = row.expires_at ?? row.expiresAt;
4033
- if (isExpired(expiresAt, nowMs)) return void 0;
4250
+ if (isExpired(expiresAt, nowMs)) return { outcome: "none" };
4034
4251
  const userId = row.user_id ?? row.userId;
4035
- if (!userId || typeof userId !== "string") return void 0;
4252
+ if (!userId || typeof userId !== "string") return { outcome: "none" };
4253
+ const tenantId = typeof row.active_organization_id === "string" && row.active_organization_id ? row.active_organization_id : void 0;
4254
+ if (!tenantId && tenancyPosture) {
4255
+ const posture = tenancyPosture;
4256
+ if (postureEnforcesWall(posture) && !postureUsesUnionScope(posture)) {
4257
+ return {
4258
+ outcome: "refused",
4259
+ reason: "organization_required",
4260
+ message: "This API key carries no organization and cannot be used under the `isolated` tenancy posture, where every organization-scoped read is walled to an active organization. Mint a replacement key \u2014 new keys inherit the minter\u2019s active organization."
4261
+ };
4262
+ }
4263
+ }
4036
4264
  return {
4037
- userId,
4038
- tenantId: row.organization_id ?? row.organizationId ?? void 0,
4039
- scopes: parseScopes(row.scopes)
4265
+ outcome: "admitted",
4266
+ principal: { userId, tenantId, scopes: parseScopes(row.scopes) }
4040
4267
  };
4041
4268
  }
4042
4269
  function readHeader(headers, name) {
@@ -4069,6 +4296,7 @@ import {
4069
4296
  ADMIN_FULL_ACCESS,
4070
4297
  ORGANIZATION_ADMIN_GRANTS
4071
4298
  } from "@objectstack/spec";
4299
+ import { postureEnforcesWall as postureEnforcesWall2 } from "@objectstack/spec/security";
4072
4300
 
4073
4301
  // src/security/grant-validity.ts
4074
4302
  function toEpochMs(value) {
@@ -4151,6 +4379,15 @@ function postureVisibleRows(posture, rows, principal) {
4151
4379
  }
4152
4380
  }
4153
4381
 
4382
+ // src/security/row-active.ts
4383
+ var DEACTIVATED_VALUES = [false, 0, "0", "false"];
4384
+ function isRowActive(row) {
4385
+ if (!row) return false;
4386
+ const value = row.active;
4387
+ if (value === void 0 || value === null) return true;
4388
+ return !DEACTIVATED_VALUES.includes(value);
4389
+ }
4390
+
4154
4391
  // src/security/resolve-authz-context.ts
4155
4392
  function safeJsonParse2(s, fallback) {
4156
4393
  try {
@@ -4180,7 +4417,12 @@ async function resolveAuthzContext(input) {
4180
4417
  };
4181
4418
  let userId;
4182
4419
  let tenantId;
4183
- const keyPrincipal = await resolveApiKeyPrincipal(ql, headers, input.nowMs);
4420
+ const admission = await resolveApiKeyAdmission(ql, headers, input.nowMs, input.tenancyPosture);
4421
+ if (admission.outcome === "refused") {
4422
+ ctx.authRefusal = { reason: admission.reason, message: admission.message };
4423
+ return ctx;
4424
+ }
4425
+ const keyPrincipal = admission.outcome === "admitted" ? admission.principal : void 0;
4184
4426
  if (keyPrincipal) {
4185
4427
  userId = keyPrincipal.userId;
4186
4428
  tenantId = keyPrincipal.tenantId;
@@ -4208,6 +4450,22 @@ async function resolveAuthzContext(input) {
4208
4450
  seedPermissions: ctx.permissions,
4209
4451
  seedEmail: ctx.email
4210
4452
  });
4453
+ if (keyPrincipal?.tenantId && input.tenancyPosture) {
4454
+ const posture = input.tenancyPosture;
4455
+ if (postureEnforcesWall2(posture) && !grants.accessible_org_ids.includes(keyPrincipal.tenantId)) {
4456
+ return {
4457
+ positions: [],
4458
+ permissions: [],
4459
+ systemPermissions: [],
4460
+ org_user_ids: [],
4461
+ accessible_org_ids: [],
4462
+ authRefusal: {
4463
+ reason: "organization_membership_ended",
4464
+ message: "This API key authenticates into an organization its owner is no longer a member of. The key was not revoked \u2014 the membership that backed it ended."
4465
+ }
4466
+ };
4467
+ }
4468
+ }
4211
4469
  ctx.positions = grants.positions;
4212
4470
  ctx.permissions = grants.permissions;
4213
4471
  ctx.systemPermissions = grants.systemPermissions;
@@ -4292,7 +4550,13 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
4292
4550
  if (!grants.positions.includes("everyone")) grants.positions.push("everyone");
4293
4551
  if (grants.positions.length > 0) {
4294
4552
  const positionRows = await tryFind(ql, "sys_position", { name: { $in: grants.positions } }, 100);
4295
- const positionIds = positionRows.map((r) => r.id).filter(Boolean);
4553
+ const deactivatedNames = new Set(
4554
+ positionRows.filter((r) => !isRowActive(r)).map((r) => r.name).filter(Boolean)
4555
+ );
4556
+ if (deactivatedNames.size > 0) {
4557
+ grants.positions = grants.positions.filter((n) => !deactivatedNames.has(n));
4558
+ }
4559
+ const positionIds = positionRows.filter((r) => isRowActive(r)).map((r) => r.id).filter(Boolean);
4296
4560
  if (positionIds.length > 0) {
4297
4561
  const rpsRows = await tryFind(ql, "sys_position_permission_set", { position_id: { $in: positionIds } }, 500);
4298
4562
  for (const r of rpsRows) {
@@ -4302,7 +4566,8 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
4302
4566
  }
4303
4567
  }
4304
4568
  if (psIds.size > 0) {
4305
- const psRows = await tryFind(ql, "sys_permission_set", { id: { $in: Array.from(psIds) } }, 500);
4569
+ const psRowsAll = await tryFind(ql, "sys_permission_set", { id: { $in: Array.from(psIds) } }, 500);
4570
+ const psRows = psRowsAll.filter((r) => isRowActive(r));
4306
4571
  const tabRank = { hidden: 0, default_off: 1, default_on: 2, visible: 3 };
4307
4572
  const mergedTabs = {};
4308
4573
  for (const ps of psRows) {
@@ -4533,6 +4798,7 @@ var ANONYMOUS_DENY_CODE = "UNAUTHENTICATED";
4533
4798
  var ANONYMOUS_DENY_MESSAGE = "Authentication is required to access this endpoint.";
4534
4799
  var ANONYMOUS_DENY_BODY = {
4535
4800
  error: ANONYMOUS_DENY_CODE,
4801
+ code: ANONYMOUS_DENY_CODE,
4536
4802
  message: ANONYMOUS_DENY_MESSAGE
4537
4803
  };
4538
4804
  function shouldDenyAnonymous(input) {
@@ -4546,6 +4812,88 @@ function shouldDenyAnonymous(input) {
4546
4812
  return true;
4547
4813
  }
4548
4814
 
4815
+ // src/security/admin-standing-surface.ts
4816
+ var ADMIN_STANDING_SURFACE = {
4817
+ sys_permission_set: {
4818
+ role: "derives",
4819
+ reason: "The row `platform_admin` is resolved BY NAME from (\xA76b). Renaming it, deleting it or switching it off (ADR-0049 `active`, read here since #8613) un-makes every platform admin at once, with no identity table touched.",
4820
+ columns: [
4821
+ "id",
4822
+ "name",
4823
+ "active",
4824
+ "system_permissions",
4825
+ "systemPermissions",
4826
+ "tab_permissions",
4827
+ "tabPermissions"
4828
+ ]
4829
+ },
4830
+ sys_user_permission_set: {
4831
+ role: "derives",
4832
+ reason: "The grant that makes a user a platform admin: an UNSCOPED, in-window (ADR-0091) grant of `admin_full_access` (\xA76). Re-pointing it, scoping it to an organization or moving it out of its window revokes the standing while leaving the row in place.",
4833
+ columns: [
4834
+ "user_id",
4835
+ "permission_set_id",
4836
+ "permissionSetId",
4837
+ "organization_id",
4838
+ "organizationId",
4839
+ "valid_from",
4840
+ "validFrom",
4841
+ "valid_until",
4842
+ "validUntil"
4843
+ ]
4844
+ },
4845
+ sys_member: {
4846
+ role: "derives",
4847
+ reason: "Organization owner/admin standing (\xA73). The graded `role` is projected into `positions` here and separately drives the `organization_admin` capability grant, which is what the posture ladder reads; the break-glass guard counts the same rows one step earlier, by grade (ADR-0108). Either way a downgrade of the last graded membership is a write that can empty the administrator population.",
4848
+ columns: [
4849
+ "user_id",
4850
+ "userId",
4851
+ "organization_id",
4852
+ "organizationId",
4853
+ "role",
4854
+ "valid_from",
4855
+ "validFrom",
4856
+ "valid_until",
4857
+ "validUntil"
4858
+ ]
4859
+ },
4860
+ sys_user: {
4861
+ role: "reads-only",
4862
+ reason: "Read for the `current_user.email` RLS fallback and the ADR-0024 `ai_seat` synthesis (\xA77). Neither confers administrator standing. The guard does watch this table, but for the ban/delete WRITE SHAPES \u2014 `banned` is never read here, so it is not a derivation column and carries no standing-key list."
4863
+ },
4864
+ sys_user_position: {
4865
+ role: "reads-only",
4866
+ reason: "ADR-0057 D4 platform-RBAC position assignments (\xA74). A position can carry permission sets (see `sys_position_permission_set`) but never platform-admin standing \u2014 \xA76b requires the set to be reached through an unscoped USER grant (`unscopedUserPsIds`), so a position-bound `admin_full_access` resolves the set name into `permissions` and leaves `hasPlatformAdminGrant` false."
4867
+ },
4868
+ sys_position: {
4869
+ role: "reads-only",
4870
+ reason: "Read to drop DEACTIVATED positions (ADR-0049, \xA76a). Same reason as `sys_user_position`: the position path cannot reach `hasPlatformAdminGrant`."
4871
+ },
4872
+ sys_position_permission_set: {
4873
+ role: "reads-only",
4874
+ reason: "Position-bound permission sets (\xA76a). Contributes ids to `psIds` \u2014 and therefore names to `permissions` \u2014 but not to `unscopedUserPsIds`, which is the set \xA76b tests for platform-admin standing."
4875
+ }
4876
+ };
4877
+ function adminStandingTables() {
4878
+ return Object.entries(ADMIN_STANDING_SURFACE).filter(([, t]) => t.role === "derives").map(([name]) => name).sort();
4879
+ }
4880
+ function adminStandingColumns(table) {
4881
+ const entry = ADMIN_STANDING_SURFACE[table];
4882
+ return entry?.role === "derives" ? entry.columns : void 0;
4883
+ }
4884
+
4885
+ // src/security/audience-binding-suggestion-status.ts
4886
+ var AUDIENCE_BINDING_SUGGESTION_STATUSES = {
4887
+ pending: true,
4888
+ confirmed: true,
4889
+ dismissed: true
4890
+ };
4891
+ var AUDIENCE_BINDING_SUGGESTION_STATUS_VALUES = Object.keys(
4892
+ AUDIENCE_BINDING_SUGGESTION_STATUSES
4893
+ );
4894
+ var isAudienceBindingSuggestionStatus = (value) => Object.prototype.hasOwnProperty.call(AUDIENCE_BINDING_SUGGESTION_STATUSES, value);
4895
+ var unknownAudienceBindingSuggestionStatusMessage = (value) => `Unknown status filter '${value}' \u2014 expected one of: ${AUDIENCE_BINDING_SUGGESTION_STATUS_VALUES.join(", ")}`;
4896
+
4549
4897
  // src/security/operation-private-keys.ts
4550
4898
  var OPERATION_PRIVATE_KEY_PREFIX = "__";
4551
4899
  function withoutOperationPrivateKeys(exec) {
@@ -4584,10 +4932,26 @@ function calendarPartsInTzOrUtc(d, tz) {
4584
4932
  }
4585
4933
  function zonedDateStartToUtcMs(ymd2, tz) {
4586
4934
  const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(ymd2);
4587
- const wallAsUtc = m ? Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])) : NaN;
4935
+ if (!m) return NaN;
4936
+ return zonedWallClockToUtcMs(
4937
+ { year: Number(m[1]), month: Number(m[2]), day: Number(m[3]) },
4938
+ tz
4939
+ );
4940
+ }
4941
+ function zonedWallClockToUtcMs(parts, tz) {
4942
+ const wallAsUtc = Date.UTC(
4943
+ parts.year,
4944
+ parts.month - 1,
4945
+ parts.day,
4946
+ parts.hour ?? 0,
4947
+ parts.minute ?? 0,
4948
+ parts.second ?? 0,
4949
+ parts.millisecond ?? 0
4950
+ );
4588
4951
  if (!tz || tz === "UTC" || Number.isNaN(wallAsUtc)) return wallAsUtc;
4589
4952
  try {
4590
4953
  const offsetAt = (t) => {
4954
+ const whole = Math.floor(t / 1e3) * 1e3;
4591
4955
  const p = new Intl.DateTimeFormat("en-US", {
4592
4956
  timeZone: tz,
4593
4957
  hourCycle: "h23",
@@ -4597,9 +4961,9 @@ function zonedDateStartToUtcMs(ymd2, tz) {
4597
4961
  hour: "2-digit",
4598
4962
  minute: "2-digit",
4599
4963
  second: "2-digit"
4600
- }).formatToParts(new Date(t));
4964
+ }).formatToParts(new Date(whole));
4601
4965
  const g = (k) => Number(p.find((x) => x.type === k)?.value);
4602
- return Date.UTC(g("year"), g("month") - 1, g("day"), g("hour"), g("minute"), g("second")) - t;
4966
+ return Date.UTC(g("year"), g("month") - 1, g("day"), g("hour"), g("minute"), g("second")) - whole;
4603
4967
  };
4604
4968
  const off1 = offsetAt(wallAsUtc - offsetAt(wallAsUtc));
4605
4969
  return wallAsUtc - off1;
@@ -4799,6 +5163,27 @@ async function bulkWrite(rows, opts) {
4799
5163
  return results;
4800
5164
  }
4801
5165
 
5166
+ // src/utils/internal-write-response.ts
5167
+ function collectInternalWriteResponseFields(schema) {
5168
+ const fields = schema?.fields;
5169
+ if (!fields || typeof fields !== "object") return [];
5170
+ const out = [];
5171
+ for (const [name, def] of Object.entries(fields)) {
5172
+ if (def && def.internal === true) out.push(name);
5173
+ }
5174
+ return out;
5175
+ }
5176
+ function omitInternalFieldsFromWriteResponse(schema, records) {
5177
+ if (!records) return;
5178
+ const internalFields = collectInternalWriteResponseFields(schema);
5179
+ if (internalFields.length === 0) return;
5180
+ const list = Array.isArray(records) ? records : [records];
5181
+ for (const row of list) {
5182
+ if (!row || typeof row !== "object") continue;
5183
+ for (const field of internalFields) delete row[field];
5184
+ }
5185
+ }
5186
+
4802
5187
  // src/utils/migration-journal.ts
4803
5188
  import { createHash as createHash2, randomUUID } from "crypto";
4804
5189
  import {
@@ -5394,6 +5779,46 @@ function filterTokenContextFrom(execCtx, now) {
5394
5779
  };
5395
5780
  }
5396
5781
 
5782
+ // src/utils/temporal-comparand.ts
5783
+ import { classifyFilterToken as classifyFilterToken2 } from "@objectstack/spec/data";
5784
+ function temporalComparandKind(fieldType) {
5785
+ if (fieldType === "datetime") return "datetime";
5786
+ if (fieldType === "date") return "date";
5787
+ if (fieldType === "time") return "time";
5788
+ return null;
5789
+ }
5790
+ function readsAsInstant(s) {
5791
+ if (/^-?\d+$/.test(s)) return Number.isFinite(new Date(Number(s)).getTime());
5792
+ const iso = /^\d{4}-\d{2}-\d{2}$/.test(s) ? `${s}T00:00:00.000Z` : /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/.test(s) ? `${s.replace(" ", "T")}Z` : s;
5793
+ return Number.isFinite(Date.parse(iso));
5794
+ }
5795
+ function readsAsCalendarDay(s) {
5796
+ return /^\d{4}-\d{2}-\d{2}/.test(s);
5797
+ }
5798
+ function readsAsWallClock(s) {
5799
+ const m = /^(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?$/.exec(s);
5800
+ if (!m) return false;
5801
+ return Number(m[1]) <= 23 && Number(m[2]) <= 59 && Number(m[3] ?? "0") <= 59;
5802
+ }
5803
+ function isUninterpretableTemporalComparand(kind, value) {
5804
+ if (typeof value !== "string") return false;
5805
+ const s = value.trim();
5806
+ if (s === "") return false;
5807
+ if (classifyFilterToken2(value) !== null) return false;
5808
+ if (kind === "datetime") return !readsAsInstant(s);
5809
+ if (kind === "date") return !readsAsCalendarDay(s);
5810
+ return !(readsAsWallClock(s) || readsAsInstant(s));
5811
+ }
5812
+
5813
+ // src/utils/record-not-found.ts
5814
+ function recordNotFoundError(object, id) {
5815
+ const err = new Error(`Record ${id} not found in ${object}`);
5816
+ err.code = "RECORD_NOT_FOUND";
5817
+ err.status = 404;
5818
+ err.object = object;
5819
+ return err;
5820
+ }
5821
+
5397
5822
  // src/health-monitor.ts
5398
5823
  var PluginHealthMonitor = class {
5399
5824
  constructor(logger) {
@@ -6370,12 +6795,18 @@ var NamespaceResolver = class {
6370
6795
  return `${shortName}_${ns}`;
6371
6796
  }
6372
6797
  };
6798
+
6799
+ // src/index.ts
6800
+ import { UNMATCHED_ROUTE_PATTERN } from "@objectstack/spec/contracts";
6373
6801
  export {
6802
+ ADMIN_STANDING_SURFACE,
6374
6803
  ANONYMOUS_DENY_BODY,
6375
6804
  ANONYMOUS_DENY_CODE,
6376
6805
  ANONYMOUS_DENY_MESSAGE,
6377
6806
  ANONYMOUS_DENY_STATUS,
6378
6807
  API_KEY_PREFIX,
6808
+ AUDIENCE_BINDING_SUGGESTION_STATUSES,
6809
+ AUDIENCE_BINDING_SUGGESTION_STATUS_VALUES,
6379
6810
  CORE_FALLBACK_FACTORIES,
6380
6811
  DependencyResolver,
6381
6812
  ENTRY_EXECUTION_CONTEXT_FIELDS,
@@ -6404,16 +6835,22 @@ export {
6404
6835
  SecurePluginContext,
6405
6836
  SemanticVersionManager,
6406
6837
  ServiceLifecycle,
6838
+ UNMATCHED_ROUTE_PATTERN,
6407
6839
  UnknownFilterTokenError,
6408
6840
  UnresolvedFilterTokenError,
6841
+ adminStandingColumns,
6842
+ adminStandingTables,
6409
6843
  assembleExecutionContext,
6410
6844
  assembleExecutionContextOrGuest,
6411
6845
  assertInitServiceRequirements,
6846
+ assertMetadataRegisterContract,
6412
6847
  bucketKeyToCalendarRange,
6413
6848
  buildPermissionsFromGrants,
6414
6849
  bulkWrite,
6415
6850
  calendarPartsInTz,
6416
6851
  calendarPartsInTzOrUtc,
6852
+ canonicalMetadataServiceType,
6853
+ collectInternalWriteResponseFields,
6417
6854
  counterSignPayload,
6418
6855
  createLogger,
6419
6856
  createMemoryCache,
@@ -6427,6 +6864,7 @@ export {
6427
6864
  defaultIsTransientError,
6428
6865
  derivePosture,
6429
6866
  describeInitOrderFault,
6867
+ effectiveTenancyPosture,
6430
6868
  engineCanRollBack,
6431
6869
  evaluateAuthGate,
6432
6870
  extractApiKey,
@@ -6438,19 +6876,25 @@ export {
6438
6876
  getMemoryUsage,
6439
6877
  hashApiKey,
6440
6878
  hashMigrationPlan,
6879
+ isAudienceBindingSuggestionStatus,
6441
6880
  isAuthGateAllowlisted,
6442
6881
  isExpired,
6443
6882
  isGrantActive,
6444
6883
  isGrantExpired,
6445
6884
  isNode,
6885
+ isRowActive,
6886
+ isUninterpretableTemporalComparand,
6446
6887
  nextUtcCalendarDay,
6447
6888
  normalizeAuthGate,
6889
+ omitInternalFieldsFromWriteResponse,
6448
6890
  parseScopes,
6449
6891
  parseSignature,
6450
6892
  planChunks,
6451
6893
  postureVisibleRows,
6452
6894
  readAuthoredTranslationLayer,
6453
6895
  readRunJournal,
6896
+ recordNotFoundError,
6897
+ resolveApiKeyAdmission,
6454
6898
  resolveApiKeyPrincipal,
6455
6899
  resolveAuthzContext,
6456
6900
  resolveFilterToken,
@@ -6464,6 +6908,8 @@ export {
6464
6908
  safeExit,
6465
6909
  shouldDenyAnonymous,
6466
6910
  signPayload,
6911
+ temporalComparandKind,
6912
+ unknownAudienceBindingSuggestionStatusMessage,
6467
6913
  utcInstantMs,
6468
6914
  validateInitServiceContract,
6469
6915
  verifyPayload,
@@ -6473,6 +6919,7 @@ export {
6473
6919
  wireAuthoredTranslationSync,
6474
6920
  withTransientRetry,
6475
6921
  withoutOperationPrivateKeys,
6476
- zonedDateStartToUtcMs
6922
+ zonedDateStartToUtcMs,
6923
+ zonedWallClockToUtcMs
6477
6924
  };
6478
6925
  //# sourceMappingURL=index.js.map