@objectstack/core 17.0.0 → 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
@@ -1747,6 +1747,26 @@ var CORE_FALLBACK_FACTORIES = {
1747
1747
  i18n: createMemoryI18n
1748
1748
  };
1749
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
+
1750
1770
  // src/kernel.ts
1751
1771
  var ObjectKernel = class {
1752
1772
  constructor(config = {}) {
@@ -1831,6 +1851,14 @@ var ObjectKernel = class {
1831
1851
  }
1832
1852
  /**
1833
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.
1834
1862
  */
1835
1863
  async use(plugin) {
1836
1864
  if (this.state !== "idle") {
@@ -1841,11 +1869,13 @@ var ObjectKernel = class {
1841
1869
  throw new Error(`Failed to load plugin: ${plugin.name} - ${result.error?.message}`);
1842
1870
  }
1843
1871
  const pluginMeta = result.plugin;
1844
- this.plugins.set(pluginMeta.name, pluginMeta);
1845
- this.logger.info(`Plugin registered: ${pluginMeta.name}@${pluginMeta.version}`, {
1846
- plugin: pluginMeta.name,
1847
- version: pluginMeta.version
1848
- });
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
+ }
1849
1879
  return this;
1850
1880
  }
1851
1881
  /**
@@ -2323,14 +2353,21 @@ var LiteKernel = class extends ObjectKernelBase {
2323
2353
  /**
2324
2354
  * Register a plugin
2325
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.
2326
2367
  */
2327
2368
  use(plugin) {
2328
2369
  this.validateIdle();
2329
- const pluginName = plugin.name;
2330
- if (this.plugins.has(pluginName)) {
2331
- throw new Error(`[Kernel] Plugin '${pluginName}' already registered`);
2332
- }
2333
- this.plugins.set(pluginName, plugin);
2370
+ registerPluginByName(this.plugins, plugin, this.logger);
2334
2371
  return this;
2335
2372
  }
2336
2373
  /**
@@ -2580,27 +2617,117 @@ var TestRunner = class {
2580
2617
 
2581
2618
  // src/qa/http-adapter.ts
2582
2619
  import { RestApiConfigSchema, CrudEndpointsConfigSchema } from "@objectstack/spec/api";
2583
- var dataPathCache;
2584
- function defaultDataPath() {
2585
- if (dataPathCache === void 0) {
2620
+ var conventionCache;
2621
+ function conventionMounts() {
2622
+ if (conventionCache === void 0) {
2586
2623
  const api = RestApiConfigSchema.parse({});
2587
2624
  const crud = CrudEndpointsConfigSchema.parse({});
2588
- dataPathCache = `${api.apiPath ?? `${api.basePath}/${api.version}`}${crud.dataPrefix}`;
2625
+ const apiBase = api.apiPath ?? `${api.basePath}/${api.version}`;
2626
+ conventionCache = { apiBase, dataPath: `${apiBase}${crud.dataPrefix}` };
2589
2627
  }
2590
- return dataPathCache;
2628
+ return conventionCache;
2591
2629
  }
2592
2630
  var HttpTestAdapter = class {
2593
2631
  constructor(baseUrl, authToken) {
2594
2632
  this.baseUrl = baseUrl;
2595
2633
  this.authToken = authToken;
2596
2634
  }
2597
- /** `{baseUrl}{apiBasePath}{dataPrefix}/{object}` the collection URL. */
2598
- collectionUrl(objectName) {
2599
- return `${this.baseUrl}${defaultDataPath()}/${encodeURIComponent(objectName)}`;
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)}`;
2600
2715
  }
2601
2716
  /** `{collection}/{id}` — the single-record URL. */
2602
- recordUrl(objectName, id) {
2603
- return `${this.collectionUrl(objectName)}/${encodeURIComponent(String(id))}`;
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})`;
2604
2731
  }
2605
2732
  async execute(action, _context) {
2606
2733
  const headers = {
@@ -2633,48 +2760,53 @@ var HttpTestAdapter = class {
2633
2760
  }
2634
2761
  }
2635
2762
  async createRecord(objectName, data, headers) {
2636
- const response = await fetch(this.collectionUrl(objectName), {
2763
+ const mount = await this.dataMount();
2764
+ const response = await fetch(this.collectionUrl(mount, objectName), {
2637
2765
  method: "POST",
2638
2766
  headers,
2639
2767
  body: JSON.stringify(data)
2640
2768
  });
2641
- return this.handleResponse(response);
2769
+ return this.handleResponse(response, this.mountNote(mount));
2642
2770
  }
2643
2771
  async updateRecord(objectName, data, headers) {
2644
2772
  const { id, ...fields } = data;
2645
2773
  if (!id) throw new Error("Update record requires id in payload");
2646
- const response = await fetch(this.recordUrl(objectName, id), {
2774
+ const mount = await this.dataMount();
2775
+ const response = await fetch(this.recordUrl(mount, objectName, id), {
2647
2776
  method: "PATCH",
2648
2777
  headers,
2649
2778
  body: JSON.stringify(fields)
2650
2779
  });
2651
- return this.handleResponse(response);
2780
+ return this.handleResponse(response, this.mountNote(mount));
2652
2781
  }
2653
2782
  async deleteRecord(objectName, data, headers) {
2654
2783
  const id = data.id;
2655
2784
  if (!id) throw new Error("Delete record requires id in payload");
2656
- const response = await fetch(this.recordUrl(objectName, id), {
2785
+ const mount = await this.dataMount();
2786
+ const response = await fetch(this.recordUrl(mount, objectName, id), {
2657
2787
  method: "DELETE",
2658
2788
  headers
2659
2789
  });
2660
- return this.handleResponse(response);
2790
+ return this.handleResponse(response, this.mountNote(mount));
2661
2791
  }
2662
2792
  async readRecord(objectName, data, headers) {
2663
2793
  const id = data.id;
2664
2794
  if (!id) throw new Error("Read record requires id in payload");
2665
- const response = await fetch(this.recordUrl(objectName, id), {
2795
+ const mount = await this.dataMount();
2796
+ const response = await fetch(this.recordUrl(mount, objectName, id), {
2666
2797
  method: "GET",
2667
2798
  headers
2668
2799
  });
2669
- return this.handleResponse(response);
2800
+ return this.handleResponse(response, this.mountNote(mount));
2670
2801
  }
2671
2802
  async queryRecords(objectName, data, headers) {
2672
- const response = await fetch(`${this.collectionUrl(objectName)}/query`, {
2803
+ const mount = await this.dataMount();
2804
+ const response = await fetch(`${this.collectionUrl(mount, objectName)}/query`, {
2673
2805
  method: "POST",
2674
2806
  headers,
2675
2807
  body: JSON.stringify(data)
2676
2808
  });
2677
- return this.handleResponse(response);
2809
+ return this.handleResponse(response, this.mountNote(mount));
2678
2810
  }
2679
2811
  async rawApiCall(endpoint, data, headers) {
2680
2812
  const method = data.method || "GET";
@@ -2687,10 +2819,13 @@ var HttpTestAdapter = class {
2687
2819
  });
2688
2820
  return this.handleResponse(response);
2689
2821
  }
2690
- async handleResponse(response) {
2822
+ async handleResponse(response, mountNote) {
2691
2823
  if (!response.ok) {
2692
2824
  const text = await response.text();
2693
- 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
+ );
2694
2829
  }
2695
2830
  const contentType = response.headers.get("content-type");
2696
2831
  if (contentType && contentType.includes("application/json")) {
@@ -4032,6 +4167,7 @@ var PluginSecurityScanner = class {
4032
4167
 
4033
4168
  // src/security/api-key.ts
4034
4169
  import { createHash, randomBytes } from "crypto";
4170
+ import { postureEnforcesWall, postureUsesUnionScope, normalizeTenancyPosture } from "@objectstack/spec/security";
4035
4171
  var API_KEY_PREFIX = "osk_";
4036
4172
  var API_KEY_ENTROPY_BYTES = 32;
4037
4173
  var VISIBLE_PREFIX_LEN = 12;
@@ -4085,10 +4221,18 @@ function isExpired(value, nowMs) {
4085
4221
  if (Number.isNaN(ms)) return false;
4086
4222
  return ms <= nowMs;
4087
4223
  }
4088
- 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) {
4089
4233
  const apiKey = extractApiKey(headers);
4090
- if (!apiKey) return void 0;
4091
- 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" };
4092
4236
  let rows;
4093
4237
  try {
4094
4238
  rows = await ql.find("sys_api_key", {
@@ -4097,19 +4241,29 @@ async function resolveApiKeyPrincipal(ql, headers, nowMs = Date.now()) {
4097
4241
  context: { isSystem: true }
4098
4242
  });
4099
4243
  } catch {
4100
- return void 0;
4244
+ return { outcome: "none" };
4101
4245
  }
4102
4246
  if (rows && rows.value) rows = rows.value;
4103
4247
  const row = Array.isArray(rows) ? rows[0] : void 0;
4104
- if (!row || row.revoked === true) return void 0;
4248
+ if (!row || row.revoked === true) return { outcome: "none" };
4105
4249
  const expiresAt = row.expires_at ?? row.expiresAt;
4106
- if (isExpired(expiresAt, nowMs)) return void 0;
4250
+ if (isExpired(expiresAt, nowMs)) return { outcome: "none" };
4107
4251
  const userId = row.user_id ?? row.userId;
4108
- 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
+ }
4109
4264
  return {
4110
- userId,
4111
- tenantId: row.organization_id ?? row.organizationId ?? void 0,
4112
- scopes: parseScopes(row.scopes)
4265
+ outcome: "admitted",
4266
+ principal: { userId, tenantId, scopes: parseScopes(row.scopes) }
4113
4267
  };
4114
4268
  }
4115
4269
  function readHeader(headers, name) {
@@ -4142,6 +4296,7 @@ import {
4142
4296
  ADMIN_FULL_ACCESS,
4143
4297
  ORGANIZATION_ADMIN_GRANTS
4144
4298
  } from "@objectstack/spec";
4299
+ import { postureEnforcesWall as postureEnforcesWall2 } from "@objectstack/spec/security";
4145
4300
 
4146
4301
  // src/security/grant-validity.ts
4147
4302
  function toEpochMs(value) {
@@ -4224,6 +4379,15 @@ function postureVisibleRows(posture, rows, principal) {
4224
4379
  }
4225
4380
  }
4226
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
+
4227
4391
  // src/security/resolve-authz-context.ts
4228
4392
  function safeJsonParse2(s, fallback) {
4229
4393
  try {
@@ -4253,7 +4417,12 @@ async function resolveAuthzContext(input) {
4253
4417
  };
4254
4418
  let userId;
4255
4419
  let tenantId;
4256
- 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;
4257
4426
  if (keyPrincipal) {
4258
4427
  userId = keyPrincipal.userId;
4259
4428
  tenantId = keyPrincipal.tenantId;
@@ -4281,6 +4450,22 @@ async function resolveAuthzContext(input) {
4281
4450
  seedPermissions: ctx.permissions,
4282
4451
  seedEmail: ctx.email
4283
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
+ }
4284
4469
  ctx.positions = grants.positions;
4285
4470
  ctx.permissions = grants.permissions;
4286
4471
  ctx.systemPermissions = grants.systemPermissions;
@@ -4365,7 +4550,13 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
4365
4550
  if (!grants.positions.includes("everyone")) grants.positions.push("everyone");
4366
4551
  if (grants.positions.length > 0) {
4367
4552
  const positionRows = await tryFind(ql, "sys_position", { name: { $in: grants.positions } }, 100);
4368
- 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);
4369
4560
  if (positionIds.length > 0) {
4370
4561
  const rpsRows = await tryFind(ql, "sys_position_permission_set", { position_id: { $in: positionIds } }, 500);
4371
4562
  for (const r of rpsRows) {
@@ -4375,7 +4566,8 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
4375
4566
  }
4376
4567
  }
4377
4568
  if (psIds.size > 0) {
4378
- 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));
4379
4571
  const tabRank = { hidden: 0, default_off: 1, default_on: 2, visible: 3 };
4380
4572
  const mergedTabs = {};
4381
4573
  for (const ps of psRows) {
@@ -4606,6 +4798,7 @@ var ANONYMOUS_DENY_CODE = "UNAUTHENTICATED";
4606
4798
  var ANONYMOUS_DENY_MESSAGE = "Authentication is required to access this endpoint.";
4607
4799
  var ANONYMOUS_DENY_BODY = {
4608
4800
  error: ANONYMOUS_DENY_CODE,
4801
+ code: ANONYMOUS_DENY_CODE,
4609
4802
  message: ANONYMOUS_DENY_MESSAGE
4610
4803
  };
4611
4804
  function shouldDenyAnonymous(input) {
@@ -4619,6 +4812,76 @@ function shouldDenyAnonymous(input) {
4619
4812
  return true;
4620
4813
  }
4621
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
+
4622
4885
  // src/security/audience-binding-suggestion-status.ts
4623
4886
  var AUDIENCE_BINDING_SUGGESTION_STATUSES = {
4624
4887
  pending: true,
@@ -4669,10 +4932,26 @@ function calendarPartsInTzOrUtc(d, tz) {
4669
4932
  }
4670
4933
  function zonedDateStartToUtcMs(ymd2, tz) {
4671
4934
  const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(ymd2);
4672
- 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
+ );
4673
4951
  if (!tz || tz === "UTC" || Number.isNaN(wallAsUtc)) return wallAsUtc;
4674
4952
  try {
4675
4953
  const offsetAt = (t) => {
4954
+ const whole = Math.floor(t / 1e3) * 1e3;
4676
4955
  const p = new Intl.DateTimeFormat("en-US", {
4677
4956
  timeZone: tz,
4678
4957
  hourCycle: "h23",
@@ -4682,9 +4961,9 @@ function zonedDateStartToUtcMs(ymd2, tz) {
4682
4961
  hour: "2-digit",
4683
4962
  minute: "2-digit",
4684
4963
  second: "2-digit"
4685
- }).formatToParts(new Date(t));
4964
+ }).formatToParts(new Date(whole));
4686
4965
  const g = (k) => Number(p.find((x) => x.type === k)?.value);
4687
- 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;
4688
4967
  };
4689
4968
  const off1 = offsetAt(wallAsUtc - offsetAt(wallAsUtc));
4690
4969
  return wallAsUtc - off1;
@@ -5500,6 +5779,37 @@ function filterTokenContextFrom(execCtx, now) {
5500
5779
  };
5501
5780
  }
5502
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
+
5503
5813
  // src/utils/record-not-found.ts
5504
5814
  function recordNotFoundError(object, id) {
5505
5815
  const err = new Error(`Record ${id} not found in ${object}`);
@@ -6485,7 +6795,11 @@ var NamespaceResolver = class {
6485
6795
  return `${shortName}_${ns}`;
6486
6796
  }
6487
6797
  };
6798
+
6799
+ // src/index.ts
6800
+ import { UNMATCHED_ROUTE_PATTERN } from "@objectstack/spec/contracts";
6488
6801
  export {
6802
+ ADMIN_STANDING_SURFACE,
6489
6803
  ANONYMOUS_DENY_BODY,
6490
6804
  ANONYMOUS_DENY_CODE,
6491
6805
  ANONYMOUS_DENY_MESSAGE,
@@ -6521,8 +6835,11 @@ export {
6521
6835
  SecurePluginContext,
6522
6836
  SemanticVersionManager,
6523
6837
  ServiceLifecycle,
6838
+ UNMATCHED_ROUTE_PATTERN,
6524
6839
  UnknownFilterTokenError,
6525
6840
  UnresolvedFilterTokenError,
6841
+ adminStandingColumns,
6842
+ adminStandingTables,
6526
6843
  assembleExecutionContext,
6527
6844
  assembleExecutionContextOrGuest,
6528
6845
  assertInitServiceRequirements,
@@ -6547,6 +6864,7 @@ export {
6547
6864
  defaultIsTransientError,
6548
6865
  derivePosture,
6549
6866
  describeInitOrderFault,
6867
+ effectiveTenancyPosture,
6550
6868
  engineCanRollBack,
6551
6869
  evaluateAuthGate,
6552
6870
  extractApiKey,
@@ -6564,6 +6882,8 @@ export {
6564
6882
  isGrantActive,
6565
6883
  isGrantExpired,
6566
6884
  isNode,
6885
+ isRowActive,
6886
+ isUninterpretableTemporalComparand,
6567
6887
  nextUtcCalendarDay,
6568
6888
  normalizeAuthGate,
6569
6889
  omitInternalFieldsFromWriteResponse,
@@ -6574,6 +6894,7 @@ export {
6574
6894
  readAuthoredTranslationLayer,
6575
6895
  readRunJournal,
6576
6896
  recordNotFoundError,
6897
+ resolveApiKeyAdmission,
6577
6898
  resolveApiKeyPrincipal,
6578
6899
  resolveAuthzContext,
6579
6900
  resolveFilterToken,
@@ -6587,6 +6908,7 @@ export {
6587
6908
  safeExit,
6588
6909
  shouldDenyAnonymous,
6589
6910
  signPayload,
6911
+ temporalComparandKind,
6590
6912
  unknownAudienceBindingSuggestionStatusMessage,
6591
6913
  utcInstantMs,
6592
6914
  validateInitServiceContract,
@@ -6597,6 +6919,7 @@ export {
6597
6919
  wireAuthoredTranslationSync,
6598
6920
  withTransientRetry,
6599
6921
  withoutOperationPrivateKeys,
6600
- zonedDateStartToUtcMs
6922
+ zonedDateStartToUtcMs,
6923
+ zonedWallClockToUtcMs
6601
6924
  };
6602
6925
  //# sourceMappingURL=index.js.map