@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/CHANGELOG.md +538 -0
- package/dist/index.cjs +383 -50
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +402 -13
- package/dist/index.d.ts +402 -13
- package/dist/index.js +372 -49
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
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
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
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
|
-
|
|
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
|
|
2584
|
-
function
|
|
2585
|
-
if (
|
|
2620
|
+
var conventionCache;
|
|
2621
|
+
function conventionMounts() {
|
|
2622
|
+
if (conventionCache === void 0) {
|
|
2586
2623
|
const api = RestApiConfigSchema.parse({});
|
|
2587
2624
|
const crud = CrudEndpointsConfigSchema.parse({});
|
|
2588
|
-
|
|
2625
|
+
const apiBase = api.apiPath ?? `${api.basePath}/${api.version}`;
|
|
2626
|
+
conventionCache = { apiBase, dataPath: `${apiBase}${crud.dataPrefix}` };
|
|
2589
2627
|
}
|
|
2590
|
-
return
|
|
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
|
-
/**
|
|
2598
|
-
|
|
2599
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
4091
|
-
if (!ql || typeof ql.find !== "function") return
|
|
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
|
|
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
|
|
4248
|
+
if (!row || row.revoked === true) return { outcome: "none" };
|
|
4105
4249
|
const expiresAt = row.expires_at ?? row.expiresAt;
|
|
4106
|
-
if (isExpired(expiresAt, nowMs)) return
|
|
4250
|
+
if (isExpired(expiresAt, nowMs)) return { outcome: "none" };
|
|
4107
4251
|
const userId = row.user_id ?? row.userId;
|
|
4108
|
-
if (!userId || typeof userId !== "string") return
|
|
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
|
-
|
|
4111
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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")) -
|
|
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
|