@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.cjs CHANGED
@@ -30,6 +30,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
+ ADMIN_STANDING_SURFACE: () => ADMIN_STANDING_SURFACE,
33
34
  ANONYMOUS_DENY_BODY: () => ANONYMOUS_DENY_BODY,
34
35
  ANONYMOUS_DENY_CODE: () => ANONYMOUS_DENY_CODE,
35
36
  ANONYMOUS_DENY_MESSAGE: () => ANONYMOUS_DENY_MESSAGE,
@@ -65,8 +66,11 @@ __export(index_exports, {
65
66
  SecurePluginContext: () => SecurePluginContext,
66
67
  SemanticVersionManager: () => SemanticVersionManager,
67
68
  ServiceLifecycle: () => ServiceLifecycle,
69
+ UNMATCHED_ROUTE_PATTERN: () => import_contracts.UNMATCHED_ROUTE_PATTERN,
68
70
  UnknownFilterTokenError: () => UnknownFilterTokenError,
69
71
  UnresolvedFilterTokenError: () => UnresolvedFilterTokenError,
72
+ adminStandingColumns: () => adminStandingColumns,
73
+ adminStandingTables: () => adminStandingTables,
70
74
  assembleExecutionContext: () => assembleExecutionContext,
71
75
  assembleExecutionContextOrGuest: () => assembleExecutionContextOrGuest,
72
76
  assertInitServiceRequirements: () => assertInitServiceRequirements,
@@ -91,6 +95,7 @@ __export(index_exports, {
91
95
  defaultIsTransientError: () => defaultIsTransientError,
92
96
  derivePosture: () => derivePosture,
93
97
  describeInitOrderFault: () => describeInitOrderFault,
98
+ effectiveTenancyPosture: () => effectiveTenancyPosture,
94
99
  engineCanRollBack: () => engineCanRollBack,
95
100
  evaluateAuthGate: () => evaluateAuthGate,
96
101
  extractApiKey: () => extractApiKey,
@@ -108,6 +113,8 @@ __export(index_exports, {
108
113
  isGrantActive: () => isGrantActive,
109
114
  isGrantExpired: () => isGrantExpired,
110
115
  isNode: () => isNode,
116
+ isRowActive: () => isRowActive,
117
+ isUninterpretableTemporalComparand: () => isUninterpretableTemporalComparand,
111
118
  nextUtcCalendarDay: () => import_data.nextUtcCalendarDay,
112
119
  normalizeAuthGate: () => normalizeAuthGate,
113
120
  omitInternalFieldsFromWriteResponse: () => omitInternalFieldsFromWriteResponse,
@@ -118,6 +125,7 @@ __export(index_exports, {
118
125
  readAuthoredTranslationLayer: () => readAuthoredTranslationLayer,
119
126
  readRunJournal: () => readRunJournal,
120
127
  recordNotFoundError: () => recordNotFoundError,
128
+ resolveApiKeyAdmission: () => resolveApiKeyAdmission,
121
129
  resolveApiKeyPrincipal: () => resolveApiKeyPrincipal,
122
130
  resolveAuthzContext: () => resolveAuthzContext,
123
131
  resolveFilterToken: () => resolveFilterToken,
@@ -131,6 +139,7 @@ __export(index_exports, {
131
139
  safeExit: () => safeExit,
132
140
  shouldDenyAnonymous: () => shouldDenyAnonymous,
133
141
  signPayload: () => signPayload,
142
+ temporalComparandKind: () => temporalComparandKind,
134
143
  unknownAudienceBindingSuggestionStatusMessage: () => unknownAudienceBindingSuggestionStatusMessage,
135
144
  utcInstantMs: () => import_data.utcInstantMs,
136
145
  validateInitServiceContract: () => validateInitServiceContract,
@@ -141,7 +150,8 @@ __export(index_exports, {
141
150
  wireAuthoredTranslationSync: () => wireAuthoredTranslationSync,
142
151
  withTransientRetry: () => withTransientRetry,
143
152
  withoutOperationPrivateKeys: () => withoutOperationPrivateKeys,
144
- zonedDateStartToUtcMs: () => zonedDateStartToUtcMs
153
+ zonedDateStartToUtcMs: () => zonedDateStartToUtcMs,
154
+ zonedWallClockToUtcMs: () => zonedWallClockToUtcMs
145
155
  });
146
156
  module.exports = __toCommonJS(index_exports);
147
157
 
@@ -1876,6 +1886,26 @@ var CORE_FALLBACK_FACTORIES = {
1876
1886
  i18n: createMemoryI18n
1877
1887
  };
1878
1888
 
1889
+ // src/plugin-registration.ts
1890
+ function versionLabel(plugin) {
1891
+ return plugin.version ? `v${plugin.version}` : "unversioned";
1892
+ }
1893
+ function describeSupersededRegistration(previous, next) {
1894
+ 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.`;
1895
+ }
1896
+ function registerPluginByName(registry, plugin, logger) {
1897
+ const previous = registry.get(plugin.name);
1898
+ if (previous !== void 0) {
1899
+ logger.warn(describeSupersededRegistration(previous, plugin), {
1900
+ plugin: plugin.name,
1901
+ supersededVersion: previous.version,
1902
+ supersedingVersion: plugin.version
1903
+ });
1904
+ }
1905
+ registry.set(plugin.name, plugin);
1906
+ return previous;
1907
+ }
1908
+
1879
1909
  // src/kernel.ts
1880
1910
  var ObjectKernel = class {
1881
1911
  constructor(config = {}) {
@@ -1960,6 +1990,14 @@ var ObjectKernel = class {
1960
1990
  }
1961
1991
  /**
1962
1992
  * Register a plugin with enhanced validation
1993
+ *
1994
+ * Duplicate names OVERWRITE, with one `warn` naming both versions — the
1995
+ * declared contract in `plugin-registration.ts`, applied identically by
1996
+ * `LiteKernel.use()` (#9864, maintainer ruling 2026-08-19). The overwrite
1997
+ * itself is unchanged: it is what lets an app config's `plugins` entry
1998
+ * supersede a plugin the CLI auto-registered earlier in the same boot
1999
+ * (#9863). What changes is that it is no longer silent, and no longer
2000
+ * disagrees with the other kernel.
1963
2001
  */
1964
2002
  async use(plugin) {
1965
2003
  if (this.state !== "idle") {
@@ -1970,11 +2008,13 @@ var ObjectKernel = class {
1970
2008
  throw new Error(`Failed to load plugin: ${plugin.name} - ${result.error?.message}`);
1971
2009
  }
1972
2010
  const pluginMeta = result.plugin;
1973
- this.plugins.set(pluginMeta.name, pluginMeta);
1974
- this.logger.info(`Plugin registered: ${pluginMeta.name}@${pluginMeta.version}`, {
1975
- plugin: pluginMeta.name,
1976
- version: pluginMeta.version
1977
- });
2011
+ const superseded = registerPluginByName(this.plugins, pluginMeta, this.logger);
2012
+ if (superseded === void 0) {
2013
+ this.logger.info(`Plugin registered: ${pluginMeta.name}@${pluginMeta.version}`, {
2014
+ plugin: pluginMeta.name,
2015
+ version: pluginMeta.version
2016
+ });
2017
+ }
1978
2018
  return this;
1979
2019
  }
1980
2020
  /**
@@ -2452,14 +2492,21 @@ var LiteKernel = class extends ObjectKernelBase {
2452
2492
  /**
2453
2493
  * Register a plugin
2454
2494
  * @param plugin - Plugin instance
2495
+ *
2496
+ * Duplicate names OVERWRITE, with one `warn` naming both versions — the
2497
+ * declared contract in `plugin-registration.ts`, applied identically by
2498
+ * `ObjectKernel.use()` (#9864, maintainer ruling 2026-08-19).
2499
+ *
2500
+ * This method used to `throw` `[Kernel] Plugin '<name>' already
2501
+ * registered` here while `ObjectKernel` overwrote silently, so one input
2502
+ * had two meanings depending on which kernel was running — and the kernel
2503
+ * that runs in production was the silent one. The ruling converged them on
2504
+ * the behaviour that already works (an app config superseding a plugin the
2505
+ * CLI auto-registered, #9863) and made it audible rather than removing it.
2455
2506
  */
2456
2507
  use(plugin) {
2457
2508
  this.validateIdle();
2458
- const pluginName = plugin.name;
2459
- if (this.plugins.has(pluginName)) {
2460
- throw new Error(`[Kernel] Plugin '${pluginName}' already registered`);
2461
- }
2462
- this.plugins.set(pluginName, plugin);
2509
+ registerPluginByName(this.plugins, plugin, this.logger);
2463
2510
  return this;
2464
2511
  }
2465
2512
  /**
@@ -2709,27 +2756,117 @@ var TestRunner = class {
2709
2756
 
2710
2757
  // src/qa/http-adapter.ts
2711
2758
  var import_api = require("@objectstack/spec/api");
2712
- var dataPathCache;
2713
- function defaultDataPath() {
2714
- if (dataPathCache === void 0) {
2759
+ var conventionCache;
2760
+ function conventionMounts() {
2761
+ if (conventionCache === void 0) {
2715
2762
  const api = import_api.RestApiConfigSchema.parse({});
2716
2763
  const crud = import_api.CrudEndpointsConfigSchema.parse({});
2717
- dataPathCache = `${api.apiPath ?? `${api.basePath}/${api.version}`}${crud.dataPrefix}`;
2764
+ const apiBase = api.apiPath ?? `${api.basePath}/${api.version}`;
2765
+ conventionCache = { apiBase, dataPath: `${apiBase}${crud.dataPrefix}` };
2718
2766
  }
2719
- return dataPathCache;
2767
+ return conventionCache;
2720
2768
  }
2721
2769
  var HttpTestAdapter = class {
2722
2770
  constructor(baseUrl, authToken) {
2723
2771
  this.baseUrl = baseUrl;
2724
2772
  this.authToken = authToken;
2725
2773
  }
2726
- /** `{baseUrl}{apiBasePath}{dataPrefix}/{object}` the collection URL. */
2727
- collectionUrl(objectName) {
2728
- return `${this.baseUrl}${defaultDataPath()}/${encodeURIComponent(objectName)}`;
2774
+ /** The resolved data mount; probes at most once per adapter. */
2775
+ dataMount() {
2776
+ if (this.mountPromise === void 0) {
2777
+ this.mountPromise = this.resolveDataMount();
2778
+ }
2779
+ return this.mountPromise;
2780
+ }
2781
+ /**
2782
+ * Ask the server where it serves the Data Protocol, and fall back to the
2783
+ * convention — loudly — when it cannot say.
2784
+ *
2785
+ * ## [#7983] What the probe recovers, measured rather than assumed
2786
+ *
2787
+ * `@objectstack/client` answers the same question through discovery
2788
+ * (`getRoute`, `packages/client/src/index.ts`), and this follows it: prefer
2789
+ * the server's own `routes.data`, fall back to the convention. Measured on a
2790
+ * booted stack (REST generator + dispatcher bridge, three configs):
2791
+ *
2792
+ * | deployment | `{apiBase}/discovery` | serves |
2793
+ * |--------------------------------|-----------------------|---------------|
2794
+ * | stock | 200 `/api/v1/data` | `/api/v1/data`|
2795
+ * | `crud.dataPrefix: '/objects'` | 200 `/api/v1/objects` | `/api/v1/objects` |
2796
+ * | `api.apiPath: '/api/2026-01'` | **404** | `/api/2026-01/data` |
2797
+ *
2798
+ * So the probe closes the `dataPrefix` row exactly: `RestServer`'s discovery
2799
+ * handler substitutes the configured prefix into `routes.data`, and reading
2800
+ * it is strictly better than recomputing it here. The `apiPath` row it cannot
2801
+ * close, and the reason is structural rather than an oversight — `apiPath`
2802
+ * moves the base that discovery itself is mounted under, so the document that
2803
+ * would name the new mount is behind the very prefix we are missing.
2804
+ *
2805
+ * ⛔ And the one discovery document at a FIXED path does not rescue it:
2806
+ * `/.well-known/objectstack` is mounted at the site root by the dispatcher
2807
+ * bridge, but its `routes.data` is the DISPATCHER's own `${prefix}/data` —
2808
+ * measured as `/api/v1/data` under all three configs above, including the two
2809
+ * where the server serves elsewhere. Falling back to it would turn "we could
2810
+ * not resolve the mount" into "discovery told us `/api/v1/data`": the same
2811
+ * 404, now with a false provenance attached. Not probed, deliberately.
2812
+ *
2813
+ * Hence: one probe, then a diagnostic that NAMES the mount, the evidence and
2814
+ * the remedy. `api_call` takes the path it is given and is unaffected either
2815
+ * way — it stays the escape hatch for a host this cannot reach.
2816
+ */
2817
+ async resolveDataMount() {
2818
+ const { apiBase, dataPath } = conventionMounts();
2819
+ const probeUrl = `${this.baseUrl}${apiBase}/discovery`;
2820
+ let why;
2821
+ try {
2822
+ const headers = {};
2823
+ if (this.authToken) {
2824
+ headers["Authorization"] = `Bearer ${this.authToken}`;
2825
+ }
2826
+ const response = await fetch(probeUrl, { method: "GET", headers });
2827
+ if (response.ok) {
2828
+ const body = await response.json();
2829
+ const doc = body && typeof body === "object" && "routes" in body ? body : body?.data;
2830
+ const advertised = doc?.routes?.data;
2831
+ if (typeof advertised === "string" && advertised.length > 0) {
2832
+ return {
2833
+ path: advertised,
2834
+ source: "discovery",
2835
+ why: `GET ${probeUrl} advertised routes.data`
2836
+ };
2837
+ }
2838
+ why = `GET ${probeUrl} answered ${response.status} but carried no routes.data`;
2839
+ } else {
2840
+ why = `GET ${probeUrl} answered ${response.status}`;
2841
+ }
2842
+ } catch (error) {
2843
+ why = `GET ${probeUrl} could not be reached (${error.message})`;
2844
+ }
2845
+ const mount = { path: dataPath, source: "convention", why };
2846
+ console.warn(
2847
+ `[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.`
2848
+ );
2849
+ return mount;
2850
+ }
2851
+ /** `{baseUrl}{dataMount}/{object}` — the collection URL. */
2852
+ collectionUrl(mount, objectName) {
2853
+ return `${this.baseUrl}${mount.path}/${encodeURIComponent(objectName)}`;
2729
2854
  }
2730
2855
  /** `{collection}/{id}` — the single-record URL. */
2731
- recordUrl(objectName, id) {
2732
- return `${this.collectionUrl(objectName)}/${encodeURIComponent(String(id))}`;
2856
+ recordUrl(mount, objectName, id) {
2857
+ return `${this.collectionUrl(mount, objectName)}/${encodeURIComponent(String(id))}`;
2858
+ }
2859
+ /**
2860
+ * The provenance clause appended to a failed record action's error.
2861
+ *
2862
+ * The card this closes is about a 404 that reads like the author's own URL
2863
+ * mistake; the mount is the one fact that distinguishes the two, so it rides
2864
+ * on the failure itself rather than only on a warning printed earlier in the
2865
+ * transcript.
2866
+ */
2867
+ mountNote(mount) {
2868
+ const how = mount.source === "discovery" ? "resolved from discovery" : "assumed by convention \u2014 the server was not able to state it";
2869
+ return `record actions addressed ${this.baseUrl}${mount.path} (data mount ${how}: ${mount.why})`;
2733
2870
  }
2734
2871
  async execute(action, _context) {
2735
2872
  const headers = {
@@ -2762,48 +2899,53 @@ var HttpTestAdapter = class {
2762
2899
  }
2763
2900
  }
2764
2901
  async createRecord(objectName, data, headers) {
2765
- const response = await fetch(this.collectionUrl(objectName), {
2902
+ const mount = await this.dataMount();
2903
+ const response = await fetch(this.collectionUrl(mount, objectName), {
2766
2904
  method: "POST",
2767
2905
  headers,
2768
2906
  body: JSON.stringify(data)
2769
2907
  });
2770
- return this.handleResponse(response);
2908
+ return this.handleResponse(response, this.mountNote(mount));
2771
2909
  }
2772
2910
  async updateRecord(objectName, data, headers) {
2773
2911
  const { id, ...fields } = data;
2774
2912
  if (!id) throw new Error("Update record requires id in payload");
2775
- const response = await fetch(this.recordUrl(objectName, id), {
2913
+ const mount = await this.dataMount();
2914
+ const response = await fetch(this.recordUrl(mount, objectName, id), {
2776
2915
  method: "PATCH",
2777
2916
  headers,
2778
2917
  body: JSON.stringify(fields)
2779
2918
  });
2780
- return this.handleResponse(response);
2919
+ return this.handleResponse(response, this.mountNote(mount));
2781
2920
  }
2782
2921
  async deleteRecord(objectName, data, headers) {
2783
2922
  const id = data.id;
2784
2923
  if (!id) throw new Error("Delete record requires id in payload");
2785
- const response = await fetch(this.recordUrl(objectName, id), {
2924
+ const mount = await this.dataMount();
2925
+ const response = await fetch(this.recordUrl(mount, objectName, id), {
2786
2926
  method: "DELETE",
2787
2927
  headers
2788
2928
  });
2789
- return this.handleResponse(response);
2929
+ return this.handleResponse(response, this.mountNote(mount));
2790
2930
  }
2791
2931
  async readRecord(objectName, data, headers) {
2792
2932
  const id = data.id;
2793
2933
  if (!id) throw new Error("Read record requires id in payload");
2794
- const response = await fetch(this.recordUrl(objectName, id), {
2934
+ const mount = await this.dataMount();
2935
+ const response = await fetch(this.recordUrl(mount, objectName, id), {
2795
2936
  method: "GET",
2796
2937
  headers
2797
2938
  });
2798
- return this.handleResponse(response);
2939
+ return this.handleResponse(response, this.mountNote(mount));
2799
2940
  }
2800
2941
  async queryRecords(objectName, data, headers) {
2801
- const response = await fetch(`${this.collectionUrl(objectName)}/query`, {
2942
+ const mount = await this.dataMount();
2943
+ const response = await fetch(`${this.collectionUrl(mount, objectName)}/query`, {
2802
2944
  method: "POST",
2803
2945
  headers,
2804
2946
  body: JSON.stringify(data)
2805
2947
  });
2806
- return this.handleResponse(response);
2948
+ return this.handleResponse(response, this.mountNote(mount));
2807
2949
  }
2808
2950
  async rawApiCall(endpoint, data, headers) {
2809
2951
  const method = data.method || "GET";
@@ -2816,10 +2958,13 @@ var HttpTestAdapter = class {
2816
2958
  });
2817
2959
  return this.handleResponse(response);
2818
2960
  }
2819
- async handleResponse(response) {
2961
+ async handleResponse(response, mountNote) {
2820
2962
  if (!response.ok) {
2821
2963
  const text = await response.text();
2822
- throw new Error(`HTTP Error ${response.status}: ${text}`);
2964
+ const wrongUrlShaped = response.status === 404 || response.status === 405;
2965
+ throw new Error(
2966
+ `HTTP Error ${response.status}: ${text}${wrongUrlShaped && mountNote ? ` \u2014 ${mountNote}` : ""}`
2967
+ );
2823
2968
  }
2824
2969
  const contentType = response.headers.get("content-type");
2825
2970
  if (contentType && contentType.includes("application/json")) {
@@ -4161,6 +4306,7 @@ var PluginSecurityScanner = class {
4161
4306
 
4162
4307
  // src/security/api-key.ts
4163
4308
  var import_node_crypto2 = require("crypto");
4309
+ var import_security = require("@objectstack/spec/security");
4164
4310
  var API_KEY_PREFIX = "osk_";
4165
4311
  var API_KEY_ENTROPY_BYTES = 32;
4166
4312
  var VISIBLE_PREFIX_LEN = 12;
@@ -4214,10 +4360,18 @@ function isExpired(value, nowMs) {
4214
4360
  if (Number.isNaN(ms)) return false;
4215
4361
  return ms <= nowMs;
4216
4362
  }
4217
- async function resolveApiKeyPrincipal(ql, headers, nowMs = Date.now()) {
4363
+ function effectiveTenancyPosture(tenancy) {
4364
+ if (!tenancy) return void 0;
4365
+ return (0, import_security.normalizeTenancyPosture)(tenancy.posture) ?? (tenancy.isolationActive ? "isolated" : "single");
4366
+ }
4367
+ async function resolveApiKeyPrincipal(ql, headers, nowMs = Date.now(), tenancyPosture) {
4368
+ const admission = await resolveApiKeyAdmission(ql, headers, nowMs, tenancyPosture);
4369
+ return admission.outcome === "admitted" ? admission.principal : void 0;
4370
+ }
4371
+ async function resolveApiKeyAdmission(ql, headers, nowMs = Date.now(), tenancyPosture) {
4218
4372
  const apiKey = extractApiKey(headers);
4219
- if (!apiKey) return void 0;
4220
- if (!ql || typeof ql.find !== "function") return void 0;
4373
+ if (!apiKey) return { outcome: "none" };
4374
+ if (!ql || typeof ql.find !== "function") return { outcome: "none" };
4221
4375
  let rows;
4222
4376
  try {
4223
4377
  rows = await ql.find("sys_api_key", {
@@ -4226,19 +4380,29 @@ async function resolveApiKeyPrincipal(ql, headers, nowMs = Date.now()) {
4226
4380
  context: { isSystem: true }
4227
4381
  });
4228
4382
  } catch {
4229
- return void 0;
4383
+ return { outcome: "none" };
4230
4384
  }
4231
4385
  if (rows && rows.value) rows = rows.value;
4232
4386
  const row = Array.isArray(rows) ? rows[0] : void 0;
4233
- if (!row || row.revoked === true) return void 0;
4387
+ if (!row || row.revoked === true) return { outcome: "none" };
4234
4388
  const expiresAt = row.expires_at ?? row.expiresAt;
4235
- if (isExpired(expiresAt, nowMs)) return void 0;
4389
+ if (isExpired(expiresAt, nowMs)) return { outcome: "none" };
4236
4390
  const userId = row.user_id ?? row.userId;
4237
- if (!userId || typeof userId !== "string") return void 0;
4391
+ if (!userId || typeof userId !== "string") return { outcome: "none" };
4392
+ const tenantId = typeof row.active_organization_id === "string" && row.active_organization_id ? row.active_organization_id : void 0;
4393
+ if (!tenantId && tenancyPosture) {
4394
+ const posture = tenancyPosture;
4395
+ if ((0, import_security.postureEnforcesWall)(posture) && !(0, import_security.postureUsesUnionScope)(posture)) {
4396
+ return {
4397
+ outcome: "refused",
4398
+ reason: "organization_required",
4399
+ 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."
4400
+ };
4401
+ }
4402
+ }
4238
4403
  return {
4239
- userId,
4240
- tenantId: row.organization_id ?? row.organizationId ?? void 0,
4241
- scopes: parseScopes(row.scopes)
4404
+ outcome: "admitted",
4405
+ principal: { userId, tenantId, scopes: parseScopes(row.scopes) }
4242
4406
  };
4243
4407
  }
4244
4408
  function readHeader(headers, name) {
@@ -4266,6 +4430,7 @@ function safeJsonParse(s, fallback) {
4266
4430
 
4267
4431
  // src/security/resolve-authz-context.ts
4268
4432
  var import_spec = require("@objectstack/spec");
4433
+ var import_security2 = require("@objectstack/spec/security");
4269
4434
 
4270
4435
  // src/security/grant-validity.ts
4271
4436
  function toEpochMs(value) {
@@ -4348,6 +4513,15 @@ function postureVisibleRows(posture, rows, principal) {
4348
4513
  }
4349
4514
  }
4350
4515
 
4516
+ // src/security/row-active.ts
4517
+ var DEACTIVATED_VALUES = [false, 0, "0", "false"];
4518
+ function isRowActive(row) {
4519
+ if (!row) return false;
4520
+ const value = row.active;
4521
+ if (value === void 0 || value === null) return true;
4522
+ return !DEACTIVATED_VALUES.includes(value);
4523
+ }
4524
+
4351
4525
  // src/security/resolve-authz-context.ts
4352
4526
  function safeJsonParse2(s, fallback) {
4353
4527
  try {
@@ -4377,7 +4551,12 @@ async function resolveAuthzContext(input) {
4377
4551
  };
4378
4552
  let userId;
4379
4553
  let tenantId;
4380
- const keyPrincipal = await resolveApiKeyPrincipal(ql, headers, input.nowMs);
4554
+ const admission = await resolveApiKeyAdmission(ql, headers, input.nowMs, input.tenancyPosture);
4555
+ if (admission.outcome === "refused") {
4556
+ ctx.authRefusal = { reason: admission.reason, message: admission.message };
4557
+ return ctx;
4558
+ }
4559
+ const keyPrincipal = admission.outcome === "admitted" ? admission.principal : void 0;
4381
4560
  if (keyPrincipal) {
4382
4561
  userId = keyPrincipal.userId;
4383
4562
  tenantId = keyPrincipal.tenantId;
@@ -4405,6 +4584,22 @@ async function resolveAuthzContext(input) {
4405
4584
  seedPermissions: ctx.permissions,
4406
4585
  seedEmail: ctx.email
4407
4586
  });
4587
+ if (keyPrincipal?.tenantId && input.tenancyPosture) {
4588
+ const posture = input.tenancyPosture;
4589
+ if ((0, import_security2.postureEnforcesWall)(posture) && !grants.accessible_org_ids.includes(keyPrincipal.tenantId)) {
4590
+ return {
4591
+ positions: [],
4592
+ permissions: [],
4593
+ systemPermissions: [],
4594
+ org_user_ids: [],
4595
+ accessible_org_ids: [],
4596
+ authRefusal: {
4597
+ reason: "organization_membership_ended",
4598
+ 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."
4599
+ }
4600
+ };
4601
+ }
4602
+ }
4408
4603
  ctx.positions = grants.positions;
4409
4604
  ctx.permissions = grants.permissions;
4410
4605
  ctx.systemPermissions = grants.systemPermissions;
@@ -4489,7 +4684,13 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
4489
4684
  if (!grants.positions.includes("everyone")) grants.positions.push("everyone");
4490
4685
  if (grants.positions.length > 0) {
4491
4686
  const positionRows = await tryFind(ql, "sys_position", { name: { $in: grants.positions } }, 100);
4492
- const positionIds = positionRows.map((r) => r.id).filter(Boolean);
4687
+ const deactivatedNames = new Set(
4688
+ positionRows.filter((r) => !isRowActive(r)).map((r) => r.name).filter(Boolean)
4689
+ );
4690
+ if (deactivatedNames.size > 0) {
4691
+ grants.positions = grants.positions.filter((n) => !deactivatedNames.has(n));
4692
+ }
4693
+ const positionIds = positionRows.filter((r) => isRowActive(r)).map((r) => r.id).filter(Boolean);
4493
4694
  if (positionIds.length > 0) {
4494
4695
  const rpsRows = await tryFind(ql, "sys_position_permission_set", { position_id: { $in: positionIds } }, 500);
4495
4696
  for (const r of rpsRows) {
@@ -4499,7 +4700,8 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
4499
4700
  }
4500
4701
  }
4501
4702
  if (psIds.size > 0) {
4502
- const psRows = await tryFind(ql, "sys_permission_set", { id: { $in: Array.from(psIds) } }, 500);
4703
+ const psRowsAll = await tryFind(ql, "sys_permission_set", { id: { $in: Array.from(psIds) } }, 500);
4704
+ const psRows = psRowsAll.filter((r) => isRowActive(r));
4503
4705
  const tabRank = { hidden: 0, default_off: 1, default_on: 2, visible: 3 };
4504
4706
  const mergedTabs = {};
4505
4707
  for (const ps of psRows) {
@@ -4730,6 +4932,7 @@ var ANONYMOUS_DENY_CODE = "UNAUTHENTICATED";
4730
4932
  var ANONYMOUS_DENY_MESSAGE = "Authentication is required to access this endpoint.";
4731
4933
  var ANONYMOUS_DENY_BODY = {
4732
4934
  error: ANONYMOUS_DENY_CODE,
4935
+ code: ANONYMOUS_DENY_CODE,
4733
4936
  message: ANONYMOUS_DENY_MESSAGE
4734
4937
  };
4735
4938
  function shouldDenyAnonymous(input) {
@@ -4743,6 +4946,76 @@ function shouldDenyAnonymous(input) {
4743
4946
  return true;
4744
4947
  }
4745
4948
 
4949
+ // src/security/admin-standing-surface.ts
4950
+ var ADMIN_STANDING_SURFACE = {
4951
+ sys_permission_set: {
4952
+ role: "derives",
4953
+ 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.",
4954
+ columns: [
4955
+ "id",
4956
+ "name",
4957
+ "active",
4958
+ "system_permissions",
4959
+ "systemPermissions",
4960
+ "tab_permissions",
4961
+ "tabPermissions"
4962
+ ]
4963
+ },
4964
+ sys_user_permission_set: {
4965
+ role: "derives",
4966
+ 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.",
4967
+ columns: [
4968
+ "user_id",
4969
+ "permission_set_id",
4970
+ "permissionSetId",
4971
+ "organization_id",
4972
+ "organizationId",
4973
+ "valid_from",
4974
+ "validFrom",
4975
+ "valid_until",
4976
+ "validUntil"
4977
+ ]
4978
+ },
4979
+ sys_member: {
4980
+ role: "derives",
4981
+ 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.",
4982
+ columns: [
4983
+ "user_id",
4984
+ "userId",
4985
+ "organization_id",
4986
+ "organizationId",
4987
+ "role",
4988
+ "valid_from",
4989
+ "validFrom",
4990
+ "valid_until",
4991
+ "validUntil"
4992
+ ]
4993
+ },
4994
+ sys_user: {
4995
+ role: "reads-only",
4996
+ 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."
4997
+ },
4998
+ sys_user_position: {
4999
+ role: "reads-only",
5000
+ 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."
5001
+ },
5002
+ sys_position: {
5003
+ role: "reads-only",
5004
+ reason: "Read to drop DEACTIVATED positions (ADR-0049, \xA76a). Same reason as `sys_user_position`: the position path cannot reach `hasPlatformAdminGrant`."
5005
+ },
5006
+ sys_position_permission_set: {
5007
+ role: "reads-only",
5008
+ 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."
5009
+ }
5010
+ };
5011
+ function adminStandingTables() {
5012
+ return Object.entries(ADMIN_STANDING_SURFACE).filter(([, t]) => t.role === "derives").map(([name]) => name).sort();
5013
+ }
5014
+ function adminStandingColumns(table) {
5015
+ const entry = ADMIN_STANDING_SURFACE[table];
5016
+ return entry?.role === "derives" ? entry.columns : void 0;
5017
+ }
5018
+
4746
5019
  // src/security/audience-binding-suggestion-status.ts
4747
5020
  var AUDIENCE_BINDING_SUGGESTION_STATUSES = {
4748
5021
  pending: true,
@@ -4793,10 +5066,26 @@ function calendarPartsInTzOrUtc(d, tz) {
4793
5066
  }
4794
5067
  function zonedDateStartToUtcMs(ymd2, tz) {
4795
5068
  const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(ymd2);
4796
- const wallAsUtc = m ? Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])) : NaN;
5069
+ if (!m) return NaN;
5070
+ return zonedWallClockToUtcMs(
5071
+ { year: Number(m[1]), month: Number(m[2]), day: Number(m[3]) },
5072
+ tz
5073
+ );
5074
+ }
5075
+ function zonedWallClockToUtcMs(parts, tz) {
5076
+ const wallAsUtc = Date.UTC(
5077
+ parts.year,
5078
+ parts.month - 1,
5079
+ parts.day,
5080
+ parts.hour ?? 0,
5081
+ parts.minute ?? 0,
5082
+ parts.second ?? 0,
5083
+ parts.millisecond ?? 0
5084
+ );
4797
5085
  if (!tz || tz === "UTC" || Number.isNaN(wallAsUtc)) return wallAsUtc;
4798
5086
  try {
4799
5087
  const offsetAt = (t) => {
5088
+ const whole = Math.floor(t / 1e3) * 1e3;
4800
5089
  const p = new Intl.DateTimeFormat("en-US", {
4801
5090
  timeZone: tz,
4802
5091
  hourCycle: "h23",
@@ -4806,9 +5095,9 @@ function zonedDateStartToUtcMs(ymd2, tz) {
4806
5095
  hour: "2-digit",
4807
5096
  minute: "2-digit",
4808
5097
  second: "2-digit"
4809
- }).formatToParts(new Date(t));
5098
+ }).formatToParts(new Date(whole));
4810
5099
  const g = (k) => Number(p.find((x) => x.type === k)?.value);
4811
- return Date.UTC(g("year"), g("month") - 1, g("day"), g("hour"), g("minute"), g("second")) - t;
5100
+ return Date.UTC(g("year"), g("month") - 1, g("day"), g("hour"), g("minute"), g("second")) - whole;
4812
5101
  };
4813
5102
  const off1 = offsetAt(wallAsUtc - offsetAt(wallAsUtc));
4814
5103
  return wallAsUtc - off1;
@@ -5619,6 +5908,37 @@ function filterTokenContextFrom(execCtx, now) {
5619
5908
  };
5620
5909
  }
5621
5910
 
5911
+ // src/utils/temporal-comparand.ts
5912
+ var import_data3 = require("@objectstack/spec/data");
5913
+ function temporalComparandKind(fieldType) {
5914
+ if (fieldType === "datetime") return "datetime";
5915
+ if (fieldType === "date") return "date";
5916
+ if (fieldType === "time") return "time";
5917
+ return null;
5918
+ }
5919
+ function readsAsInstant(s) {
5920
+ if (/^-?\d+$/.test(s)) return Number.isFinite(new Date(Number(s)).getTime());
5921
+ 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;
5922
+ return Number.isFinite(Date.parse(iso));
5923
+ }
5924
+ function readsAsCalendarDay(s) {
5925
+ return /^\d{4}-\d{2}-\d{2}/.test(s);
5926
+ }
5927
+ function readsAsWallClock(s) {
5928
+ const m = /^(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?$/.exec(s);
5929
+ if (!m) return false;
5930
+ return Number(m[1]) <= 23 && Number(m[2]) <= 59 && Number(m[3] ?? "0") <= 59;
5931
+ }
5932
+ function isUninterpretableTemporalComparand(kind, value) {
5933
+ if (typeof value !== "string") return false;
5934
+ const s = value.trim();
5935
+ if (s === "") return false;
5936
+ if ((0, import_data3.classifyFilterToken)(value) !== null) return false;
5937
+ if (kind === "datetime") return !readsAsInstant(s);
5938
+ if (kind === "date") return !readsAsCalendarDay(s);
5939
+ return !(readsAsWallClock(s) || readsAsInstant(s));
5940
+ }
5941
+
5622
5942
  // src/utils/record-not-found.ts
5623
5943
  function recordNotFoundError(object, id) {
5624
5944
  const err = new Error(`Record ${id} not found in ${object}`);
@@ -6604,8 +6924,12 @@ var NamespaceResolver = class {
6604
6924
  return `${shortName}_${ns}`;
6605
6925
  }
6606
6926
  };
6927
+
6928
+ // src/index.ts
6929
+ var import_contracts = require("@objectstack/spec/contracts");
6607
6930
  // Annotate the CommonJS export names for ESM import in node:
6608
6931
  0 && (module.exports = {
6932
+ ADMIN_STANDING_SURFACE,
6609
6933
  ANONYMOUS_DENY_BODY,
6610
6934
  ANONYMOUS_DENY_CODE,
6611
6935
  ANONYMOUS_DENY_MESSAGE,
@@ -6641,8 +6965,11 @@ var NamespaceResolver = class {
6641
6965
  SecurePluginContext,
6642
6966
  SemanticVersionManager,
6643
6967
  ServiceLifecycle,
6968
+ UNMATCHED_ROUTE_PATTERN,
6644
6969
  UnknownFilterTokenError,
6645
6970
  UnresolvedFilterTokenError,
6971
+ adminStandingColumns,
6972
+ adminStandingTables,
6646
6973
  assembleExecutionContext,
6647
6974
  assembleExecutionContextOrGuest,
6648
6975
  assertInitServiceRequirements,
@@ -6667,6 +6994,7 @@ var NamespaceResolver = class {
6667
6994
  defaultIsTransientError,
6668
6995
  derivePosture,
6669
6996
  describeInitOrderFault,
6997
+ effectiveTenancyPosture,
6670
6998
  engineCanRollBack,
6671
6999
  evaluateAuthGate,
6672
7000
  extractApiKey,
@@ -6684,6 +7012,8 @@ var NamespaceResolver = class {
6684
7012
  isGrantActive,
6685
7013
  isGrantExpired,
6686
7014
  isNode,
7015
+ isRowActive,
7016
+ isUninterpretableTemporalComparand,
6687
7017
  nextUtcCalendarDay,
6688
7018
  normalizeAuthGate,
6689
7019
  omitInternalFieldsFromWriteResponse,
@@ -6694,6 +7024,7 @@ var NamespaceResolver = class {
6694
7024
  readAuthoredTranslationLayer,
6695
7025
  readRunJournal,
6696
7026
  recordNotFoundError,
7027
+ resolveApiKeyAdmission,
6697
7028
  resolveApiKeyPrincipal,
6698
7029
  resolveAuthzContext,
6699
7030
  resolveFilterToken,
@@ -6707,6 +7038,7 @@ var NamespaceResolver = class {
6707
7038
  safeExit,
6708
7039
  shouldDenyAnonymous,
6709
7040
  signPayload,
7041
+ temporalComparandKind,
6710
7042
  unknownAudienceBindingSuggestionStatusMessage,
6711
7043
  utcInstantMs,
6712
7044
  validateInitServiceContract,
@@ -6717,6 +7049,7 @@ var NamespaceResolver = class {
6717
7049
  wireAuthoredTranslationSync,
6718
7050
  withTransientRetry,
6719
7051
  withoutOperationPrivateKeys,
6720
- zonedDateStartToUtcMs
7052
+ zonedDateStartToUtcMs,
7053
+ zonedWallClockToUtcMs
6721
7054
  });
6722
7055
  //# sourceMappingURL=index.cjs.map