@objectstack/core 17.0.0 → 17.2.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 +734 -0
- package/dist/index.cjs +553 -127
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +452 -29
- package/dist/index.d.ts +452 -29
- package/dist/index.js +542 -126
- package/dist/index.js.map +1 -1
- package/package.json +6 -5
package/dist/index.js
CHANGED
|
@@ -1355,35 +1355,6 @@ function createMemoryQueue() {
|
|
|
1355
1355
|
};
|
|
1356
1356
|
}
|
|
1357
1357
|
|
|
1358
|
-
// src/fallbacks/memory-job.ts
|
|
1359
|
-
function createMemoryJob() {
|
|
1360
|
-
const jobs = /* @__PURE__ */ new Map();
|
|
1361
|
-
return {
|
|
1362
|
-
__serviceInfo: {
|
|
1363
|
-
status: "degraded",
|
|
1364
|
-
handlerReady: false,
|
|
1365
|
-
message: "In-process job registry \u2014 trigger() runs handlers, but scheduled jobs never fire on their own (no timer). Register a job plugin (e.g. Agenda) for real scheduling."
|
|
1366
|
-
},
|
|
1367
|
-
_serviceName: "job",
|
|
1368
|
-
async schedule(name, schedule, handler) {
|
|
1369
|
-
jobs.set(name, { schedule, handler });
|
|
1370
|
-
},
|
|
1371
|
-
async cancel(name) {
|
|
1372
|
-
jobs.delete(name);
|
|
1373
|
-
},
|
|
1374
|
-
async trigger(name, data) {
|
|
1375
|
-
const job = jobs.get(name);
|
|
1376
|
-
if (job?.handler) await job.handler({ jobId: name, data });
|
|
1377
|
-
},
|
|
1378
|
-
async getExecutions() {
|
|
1379
|
-
return [];
|
|
1380
|
-
},
|
|
1381
|
-
async listJobs() {
|
|
1382
|
-
return [...jobs.keys()];
|
|
1383
|
-
}
|
|
1384
|
-
};
|
|
1385
|
-
}
|
|
1386
|
-
|
|
1387
1358
|
// src/fallbacks/memory-i18n.ts
|
|
1388
1359
|
import { normalizeSupportedLocales } from "@objectstack/spec/system";
|
|
1389
1360
|
function deepMerge(target, source) {
|
|
@@ -1611,6 +1582,35 @@ function createMemoryMetadata() {
|
|
|
1611
1582
|
};
|
|
1612
1583
|
}
|
|
1613
1584
|
|
|
1585
|
+
// src/fallbacks/memory-job.ts
|
|
1586
|
+
function createMemoryJob() {
|
|
1587
|
+
const jobs = /* @__PURE__ */ new Map();
|
|
1588
|
+
return {
|
|
1589
|
+
__serviceInfo: {
|
|
1590
|
+
status: "degraded",
|
|
1591
|
+
handlerReady: false,
|
|
1592
|
+
message: "In-process job registry \u2014 trigger() runs handlers, but scheduled jobs never fire on their own (no timer). Register a job plugin (e.g. Agenda) for real scheduling."
|
|
1593
|
+
},
|
|
1594
|
+
_serviceName: "job",
|
|
1595
|
+
async schedule(name, schedule, handler) {
|
|
1596
|
+
jobs.set(name, { schedule, handler });
|
|
1597
|
+
},
|
|
1598
|
+
async cancel(name) {
|
|
1599
|
+
jobs.delete(name);
|
|
1600
|
+
},
|
|
1601
|
+
async trigger(name, data) {
|
|
1602
|
+
const job = jobs.get(name);
|
|
1603
|
+
if (job?.handler) await job.handler({ jobId: name, data });
|
|
1604
|
+
},
|
|
1605
|
+
async getExecutions() {
|
|
1606
|
+
return [];
|
|
1607
|
+
},
|
|
1608
|
+
async listJobs() {
|
|
1609
|
+
return [...jobs.keys()];
|
|
1610
|
+
}
|
|
1611
|
+
};
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
1614
|
// src/fallbacks/authored-translation-sync.ts
|
|
1615
1615
|
import { LEGACY_OBJECT_FIRST_KEYS } from "@objectstack/spec/system";
|
|
1616
1616
|
var OWNER_PROP = "__authoredTranslationSyncOwner";
|
|
@@ -1743,10 +1743,71 @@ var CORE_FALLBACK_FACTORIES = {
|
|
|
1743
1743
|
metadata: createMemoryMetadata,
|
|
1744
1744
|
cache: createMemoryCache,
|
|
1745
1745
|
queue: createMemoryQueue,
|
|
1746
|
-
job: createMemoryJob,
|
|
1747
1746
|
i18n: createMemoryI18n
|
|
1748
1747
|
};
|
|
1749
1748
|
|
|
1749
|
+
// src/plugin-registration.ts
|
|
1750
|
+
function versionLabel(plugin) {
|
|
1751
|
+
return plugin.version ? `v${plugin.version}` : "unversioned";
|
|
1752
|
+
}
|
|
1753
|
+
function describeSupersededRegistration(previous, next) {
|
|
1754
|
+
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.`;
|
|
1755
|
+
}
|
|
1756
|
+
function registerPluginByName(registry, plugin, logger) {
|
|
1757
|
+
const previous = registry.get(plugin.name);
|
|
1758
|
+
if (previous !== void 0) {
|
|
1759
|
+
logger.warn(describeSupersededRegistration(previous, plugin), {
|
|
1760
|
+
plugin: plugin.name,
|
|
1761
|
+
supersededVersion: previous.version,
|
|
1762
|
+
supersedingVersion: plugin.version
|
|
1763
|
+
});
|
|
1764
|
+
}
|
|
1765
|
+
registry.set(plugin.name, plugin);
|
|
1766
|
+
return previous;
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
// src/timeout-guard.ts
|
|
1770
|
+
var TimeoutGuard = class {
|
|
1771
|
+
constructor(timeoutMs, createTimeoutError) {
|
|
1772
|
+
/**
|
|
1773
|
+
* Settles `expiry` without a value. `Promise<never>` has no resolvable
|
|
1774
|
+
* value in the type system, but settling it is the entire point: it is
|
|
1775
|
+
* only ever called from `reclaim()`, i.e. after the race it guarded has
|
|
1776
|
+
* already been decided, so the resolution is discarded by construction and
|
|
1777
|
+
* can never become a race winner. The cast localises that argument here
|
|
1778
|
+
* rather than pushing a lie into every caller's return type.
|
|
1779
|
+
*/
|
|
1780
|
+
this.settleExpiry = () => {
|
|
1781
|
+
};
|
|
1782
|
+
this.expiry = new Promise((resolve, reject) => {
|
|
1783
|
+
this.settleExpiry = resolve;
|
|
1784
|
+
this.timer = setTimeout(() => reject(createTimeoutError()), timeoutMs);
|
|
1785
|
+
});
|
|
1786
|
+
}
|
|
1787
|
+
/**
|
|
1788
|
+
* Reclaim the guard once the race it protects has been decided. Both
|
|
1789
|
+
* halves, always: the timer is cleared so it cannot fire against a
|
|
1790
|
+
* lifecycle phase that is already over, and `expiry` is settled so neither
|
|
1791
|
+
* it nor the race's reaction on it is retained.
|
|
1792
|
+
*
|
|
1793
|
+
* Idempotent — `clearTimeout` on a cleared handle and a second resolve on
|
|
1794
|
+
* a settled promise are both no-ops.
|
|
1795
|
+
*/
|
|
1796
|
+
reclaim() {
|
|
1797
|
+
clearTimeout(this.timer);
|
|
1798
|
+
this.timer = void 0;
|
|
1799
|
+
this.settleExpiry();
|
|
1800
|
+
}
|
|
1801
|
+
};
|
|
1802
|
+
async function raceWithTimeout(operation, timeoutMs, createTimeoutError) {
|
|
1803
|
+
const guard = new TimeoutGuard(timeoutMs, createTimeoutError);
|
|
1804
|
+
try {
|
|
1805
|
+
return await Promise.race([operation, guard.expiry]);
|
|
1806
|
+
} finally {
|
|
1807
|
+
guard.reclaim();
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1750
1811
|
// src/kernel.ts
|
|
1751
1812
|
var ObjectKernel = class {
|
|
1752
1813
|
constructor(config = {}) {
|
|
@@ -1831,6 +1892,14 @@ var ObjectKernel = class {
|
|
|
1831
1892
|
}
|
|
1832
1893
|
/**
|
|
1833
1894
|
* Register a plugin with enhanced validation
|
|
1895
|
+
*
|
|
1896
|
+
* Duplicate names OVERWRITE, with one `warn` naming both versions — the
|
|
1897
|
+
* declared contract in `plugin-registration.ts`, applied identically by
|
|
1898
|
+
* `LiteKernel.use()` (#9864, maintainer ruling 2026-08-19). The overwrite
|
|
1899
|
+
* itself is unchanged: it is what lets an app config's `plugins` entry
|
|
1900
|
+
* supersede a plugin the CLI auto-registered earlier in the same boot
|
|
1901
|
+
* (#9863). What changes is that it is no longer silent, and no longer
|
|
1902
|
+
* disagrees with the other kernel.
|
|
1834
1903
|
*/
|
|
1835
1904
|
async use(plugin) {
|
|
1836
1905
|
if (this.state !== "idle") {
|
|
@@ -1841,11 +1910,13 @@ var ObjectKernel = class {
|
|
|
1841
1910
|
throw new Error(`Failed to load plugin: ${plugin.name} - ${result.error?.message}`);
|
|
1842
1911
|
}
|
|
1843
1912
|
const pluginMeta = result.plugin;
|
|
1844
|
-
this.plugins
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1913
|
+
const superseded = registerPluginByName(this.plugins, pluginMeta, this.logger);
|
|
1914
|
+
if (superseded === void 0) {
|
|
1915
|
+
this.logger.info(`Plugin registered: ${pluginMeta.name}@${pluginMeta.version}`, {
|
|
1916
|
+
plugin: pluginMeta.name,
|
|
1917
|
+
version: pluginMeta.version
|
|
1918
|
+
});
|
|
1919
|
+
}
|
|
1849
1920
|
return this;
|
|
1850
1921
|
}
|
|
1851
1922
|
/**
|
|
@@ -2007,14 +2078,11 @@ var ObjectKernel = class {
|
|
|
2007
2078
|
this.logger.info("Graceful shutdown started");
|
|
2008
2079
|
const shutdownTimeoutError = new Error("Shutdown timeout exceeded");
|
|
2009
2080
|
try {
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
if (t.unref) t.unref();
|
|
2016
|
-
});
|
|
2017
|
-
await Promise.race([shutdownPromise, timeoutPromise]);
|
|
2081
|
+
await raceWithTimeout(
|
|
2082
|
+
this.performShutdown(),
|
|
2083
|
+
this.config.shutdownTimeout,
|
|
2084
|
+
() => shutdownTimeoutError
|
|
2085
|
+
);
|
|
2018
2086
|
this.state = "stopped";
|
|
2019
2087
|
this.logger.info("\u2705 Graceful shutdown complete");
|
|
2020
2088
|
} catch (error) {
|
|
@@ -2132,25 +2200,17 @@ var ObjectKernel = class {
|
|
|
2132
2200
|
* as well: if the hook never settles and nothing else keeps the loop alive,
|
|
2133
2201
|
* Node exits before the timer can fire and the timeout is never reported.
|
|
2134
2202
|
* The guard has to stay ref'd exactly as long as the race is undecided,
|
|
2135
|
-
* which is what
|
|
2203
|
+
* which is what clearing on settle expresses.
|
|
2136
2204
|
*
|
|
2137
|
-
*
|
|
2138
|
-
*
|
|
2139
|
-
*
|
|
2140
|
-
*
|
|
2205
|
+
* Clearing the timer was only half of it, though (#10604): the promise the
|
|
2206
|
+
* race still holds a reaction on has to SETTLE, or it and that reaction are
|
|
2207
|
+
* retained past the end of the run — two leaking promises per boot, which
|
|
2208
|
+
* is what `vitest --detectAsyncLeaks` names here. Both halves now live in
|
|
2209
|
+
* `TimeoutGuard.reclaim()`, shared with `shutdown()`, so the two sites
|
|
2210
|
+
* cannot drift into doing one half each again.
|
|
2141
2211
|
*/
|
|
2142
2212
|
async raceStartupTimeout(operation, timeout, message) {
|
|
2143
|
-
|
|
2144
|
-
const timeoutPromise = new Promise((_, reject) => {
|
|
2145
|
-
guard = setTimeout(() => {
|
|
2146
|
-
reject(new Error(message));
|
|
2147
|
-
}, timeout);
|
|
2148
|
-
});
|
|
2149
|
-
try {
|
|
2150
|
-
return await Promise.race([operation, timeoutPromise]);
|
|
2151
|
-
} finally {
|
|
2152
|
-
clearTimeout(guard);
|
|
2153
|
-
}
|
|
2213
|
+
return raceWithTimeout(operation, timeout, () => new Error(message));
|
|
2154
2214
|
}
|
|
2155
2215
|
/**
|
|
2156
2216
|
* Whether a service is resolvable on this kernel right now — direct
|
|
@@ -2323,14 +2383,21 @@ var LiteKernel = class extends ObjectKernelBase {
|
|
|
2323
2383
|
/**
|
|
2324
2384
|
* Register a plugin
|
|
2325
2385
|
* @param plugin - Plugin instance
|
|
2386
|
+
*
|
|
2387
|
+
* Duplicate names OVERWRITE, with one `warn` naming both versions — the
|
|
2388
|
+
* declared contract in `plugin-registration.ts`, applied identically by
|
|
2389
|
+
* `ObjectKernel.use()` (#9864, maintainer ruling 2026-08-19).
|
|
2390
|
+
*
|
|
2391
|
+
* This method used to `throw` `[Kernel] Plugin '<name>' already
|
|
2392
|
+
* registered` here while `ObjectKernel` overwrote silently, so one input
|
|
2393
|
+
* had two meanings depending on which kernel was running — and the kernel
|
|
2394
|
+
* that runs in production was the silent one. The ruling converged them on
|
|
2395
|
+
* the behaviour that already works (an app config superseding a plugin the
|
|
2396
|
+
* CLI auto-registered, #9863) and made it audible rather than removing it.
|
|
2326
2397
|
*/
|
|
2327
2398
|
use(plugin) {
|
|
2328
2399
|
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);
|
|
2400
|
+
registerPluginByName(this.plugins, plugin, this.logger);
|
|
2334
2401
|
return this;
|
|
2335
2402
|
}
|
|
2336
2403
|
/**
|
|
@@ -2580,27 +2647,117 @@ var TestRunner = class {
|
|
|
2580
2647
|
|
|
2581
2648
|
// src/qa/http-adapter.ts
|
|
2582
2649
|
import { RestApiConfigSchema, CrudEndpointsConfigSchema } from "@objectstack/spec/api";
|
|
2583
|
-
var
|
|
2584
|
-
function
|
|
2585
|
-
if (
|
|
2650
|
+
var conventionCache;
|
|
2651
|
+
function conventionMounts() {
|
|
2652
|
+
if (conventionCache === void 0) {
|
|
2586
2653
|
const api = RestApiConfigSchema.parse({});
|
|
2587
2654
|
const crud = CrudEndpointsConfigSchema.parse({});
|
|
2588
|
-
|
|
2655
|
+
const apiBase = api.apiPath ?? `${api.basePath}/${api.version}`;
|
|
2656
|
+
conventionCache = { apiBase, dataPath: `${apiBase}${crud.dataPrefix}` };
|
|
2589
2657
|
}
|
|
2590
|
-
return
|
|
2658
|
+
return conventionCache;
|
|
2591
2659
|
}
|
|
2592
2660
|
var HttpTestAdapter = class {
|
|
2593
2661
|
constructor(baseUrl, authToken) {
|
|
2594
2662
|
this.baseUrl = baseUrl;
|
|
2595
2663
|
this.authToken = authToken;
|
|
2596
2664
|
}
|
|
2597
|
-
/**
|
|
2598
|
-
|
|
2599
|
-
|
|
2665
|
+
/** The resolved data mount; probes at most once per adapter. */
|
|
2666
|
+
dataMount() {
|
|
2667
|
+
if (this.mountPromise === void 0) {
|
|
2668
|
+
this.mountPromise = this.resolveDataMount();
|
|
2669
|
+
}
|
|
2670
|
+
return this.mountPromise;
|
|
2671
|
+
}
|
|
2672
|
+
/**
|
|
2673
|
+
* Ask the server where it serves the Data Protocol, and fall back to the
|
|
2674
|
+
* convention — loudly — when it cannot say.
|
|
2675
|
+
*
|
|
2676
|
+
* ## [#7983] What the probe recovers, measured rather than assumed
|
|
2677
|
+
*
|
|
2678
|
+
* `@objectstack/client` answers the same question through discovery
|
|
2679
|
+
* (`getRoute`, `packages/client/src/index.ts`), and this follows it: prefer
|
|
2680
|
+
* the server's own `routes.data`, fall back to the convention. Measured on a
|
|
2681
|
+
* booted stack (REST generator + dispatcher bridge, three configs):
|
|
2682
|
+
*
|
|
2683
|
+
* | deployment | `{apiBase}/discovery` | serves |
|
|
2684
|
+
* |--------------------------------|-----------------------|---------------|
|
|
2685
|
+
* | stock | 200 `/api/v1/data` | `/api/v1/data`|
|
|
2686
|
+
* | `crud.dataPrefix: '/objects'` | 200 `/api/v1/objects` | `/api/v1/objects` |
|
|
2687
|
+
* | `api.apiPath: '/api/2026-01'` | **404** | `/api/2026-01/data` |
|
|
2688
|
+
*
|
|
2689
|
+
* So the probe closes the `dataPrefix` row exactly: `RestServer`'s discovery
|
|
2690
|
+
* handler substitutes the configured prefix into `routes.data`, and reading
|
|
2691
|
+
* it is strictly better than recomputing it here. The `apiPath` row it cannot
|
|
2692
|
+
* close, and the reason is structural rather than an oversight — `apiPath`
|
|
2693
|
+
* moves the base that discovery itself is mounted under, so the document that
|
|
2694
|
+
* would name the new mount is behind the very prefix we are missing.
|
|
2695
|
+
*
|
|
2696
|
+
* ⛔ And the one discovery document at a FIXED path does not rescue it:
|
|
2697
|
+
* `/.well-known/objectstack` is mounted at the site root by the dispatcher
|
|
2698
|
+
* bridge, but its `routes.data` is the DISPATCHER's own `${prefix}/data` —
|
|
2699
|
+
* measured as `/api/v1/data` under all three configs above, including the two
|
|
2700
|
+
* where the server serves elsewhere. Falling back to it would turn "we could
|
|
2701
|
+
* not resolve the mount" into "discovery told us `/api/v1/data`": the same
|
|
2702
|
+
* 404, now with a false provenance attached. Not probed, deliberately.
|
|
2703
|
+
*
|
|
2704
|
+
* Hence: one probe, then a diagnostic that NAMES the mount, the evidence and
|
|
2705
|
+
* the remedy. `api_call` takes the path it is given and is unaffected either
|
|
2706
|
+
* way — it stays the escape hatch for a host this cannot reach.
|
|
2707
|
+
*/
|
|
2708
|
+
async resolveDataMount() {
|
|
2709
|
+
const { apiBase, dataPath } = conventionMounts();
|
|
2710
|
+
const probeUrl = `${this.baseUrl}${apiBase}/discovery`;
|
|
2711
|
+
let why;
|
|
2712
|
+
try {
|
|
2713
|
+
const headers = {};
|
|
2714
|
+
if (this.authToken) {
|
|
2715
|
+
headers["Authorization"] = `Bearer ${this.authToken}`;
|
|
2716
|
+
}
|
|
2717
|
+
const response = await fetch(probeUrl, { method: "GET", headers });
|
|
2718
|
+
if (response.ok) {
|
|
2719
|
+
const body = await response.json();
|
|
2720
|
+
const doc = body && typeof body === "object" && "routes" in body ? body : body?.data;
|
|
2721
|
+
const advertised = doc?.routes?.data;
|
|
2722
|
+
if (typeof advertised === "string" && advertised.length > 0) {
|
|
2723
|
+
return {
|
|
2724
|
+
path: advertised,
|
|
2725
|
+
source: "discovery",
|
|
2726
|
+
why: `GET ${probeUrl} advertised routes.data`
|
|
2727
|
+
};
|
|
2728
|
+
}
|
|
2729
|
+
why = `GET ${probeUrl} answered ${response.status} but carried no routes.data`;
|
|
2730
|
+
} else {
|
|
2731
|
+
why = `GET ${probeUrl} answered ${response.status}`;
|
|
2732
|
+
}
|
|
2733
|
+
} catch (error) {
|
|
2734
|
+
why = `GET ${probeUrl} could not be reached (${error.message})`;
|
|
2735
|
+
}
|
|
2736
|
+
const mount = { path: dataPath, source: "convention", why };
|
|
2737
|
+
console.warn(
|
|
2738
|
+
`[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.`
|
|
2739
|
+
);
|
|
2740
|
+
return mount;
|
|
2741
|
+
}
|
|
2742
|
+
/** `{baseUrl}{dataMount}/{object}` — the collection URL. */
|
|
2743
|
+
collectionUrl(mount, objectName) {
|
|
2744
|
+
return `${this.baseUrl}${mount.path}/${encodeURIComponent(objectName)}`;
|
|
2600
2745
|
}
|
|
2601
2746
|
/** `{collection}/{id}` — the single-record URL. */
|
|
2602
|
-
recordUrl(objectName, id) {
|
|
2603
|
-
return `${this.collectionUrl(objectName)}/${encodeURIComponent(String(id))}`;
|
|
2747
|
+
recordUrl(mount, objectName, id) {
|
|
2748
|
+
return `${this.collectionUrl(mount, objectName)}/${encodeURIComponent(String(id))}`;
|
|
2749
|
+
}
|
|
2750
|
+
/**
|
|
2751
|
+
* The provenance clause appended to a failed record action's error.
|
|
2752
|
+
*
|
|
2753
|
+
* The card this closes is about a 404 that reads like the author's own URL
|
|
2754
|
+
* mistake; the mount is the one fact that distinguishes the two, so it rides
|
|
2755
|
+
* on the failure itself rather than only on a warning printed earlier in the
|
|
2756
|
+
* transcript.
|
|
2757
|
+
*/
|
|
2758
|
+
mountNote(mount) {
|
|
2759
|
+
const how = mount.source === "discovery" ? "resolved from discovery" : "assumed by convention \u2014 the server was not able to state it";
|
|
2760
|
+
return `record actions addressed ${this.baseUrl}${mount.path} (data mount ${how}: ${mount.why})`;
|
|
2604
2761
|
}
|
|
2605
2762
|
async execute(action, _context) {
|
|
2606
2763
|
const headers = {
|
|
@@ -2633,48 +2790,53 @@ var HttpTestAdapter = class {
|
|
|
2633
2790
|
}
|
|
2634
2791
|
}
|
|
2635
2792
|
async createRecord(objectName, data, headers) {
|
|
2636
|
-
const
|
|
2793
|
+
const mount = await this.dataMount();
|
|
2794
|
+
const response = await fetch(this.collectionUrl(mount, objectName), {
|
|
2637
2795
|
method: "POST",
|
|
2638
2796
|
headers,
|
|
2639
2797
|
body: JSON.stringify(data)
|
|
2640
2798
|
});
|
|
2641
|
-
return this.handleResponse(response);
|
|
2799
|
+
return this.handleResponse(response, this.mountNote(mount));
|
|
2642
2800
|
}
|
|
2643
2801
|
async updateRecord(objectName, data, headers) {
|
|
2644
2802
|
const { id, ...fields } = data;
|
|
2645
2803
|
if (!id) throw new Error("Update record requires id in payload");
|
|
2646
|
-
const
|
|
2804
|
+
const mount = await this.dataMount();
|
|
2805
|
+
const response = await fetch(this.recordUrl(mount, objectName, id), {
|
|
2647
2806
|
method: "PATCH",
|
|
2648
2807
|
headers,
|
|
2649
2808
|
body: JSON.stringify(fields)
|
|
2650
2809
|
});
|
|
2651
|
-
return this.handleResponse(response);
|
|
2810
|
+
return this.handleResponse(response, this.mountNote(mount));
|
|
2652
2811
|
}
|
|
2653
2812
|
async deleteRecord(objectName, data, headers) {
|
|
2654
2813
|
const id = data.id;
|
|
2655
2814
|
if (!id) throw new Error("Delete record requires id in payload");
|
|
2656
|
-
const
|
|
2815
|
+
const mount = await this.dataMount();
|
|
2816
|
+
const response = await fetch(this.recordUrl(mount, objectName, id), {
|
|
2657
2817
|
method: "DELETE",
|
|
2658
2818
|
headers
|
|
2659
2819
|
});
|
|
2660
|
-
return this.handleResponse(response);
|
|
2820
|
+
return this.handleResponse(response, this.mountNote(mount));
|
|
2661
2821
|
}
|
|
2662
2822
|
async readRecord(objectName, data, headers) {
|
|
2663
2823
|
const id = data.id;
|
|
2664
2824
|
if (!id) throw new Error("Read record requires id in payload");
|
|
2665
|
-
const
|
|
2825
|
+
const mount = await this.dataMount();
|
|
2826
|
+
const response = await fetch(this.recordUrl(mount, objectName, id), {
|
|
2666
2827
|
method: "GET",
|
|
2667
2828
|
headers
|
|
2668
2829
|
});
|
|
2669
|
-
return this.handleResponse(response);
|
|
2830
|
+
return this.handleResponse(response, this.mountNote(mount));
|
|
2670
2831
|
}
|
|
2671
2832
|
async queryRecords(objectName, data, headers) {
|
|
2672
|
-
const
|
|
2833
|
+
const mount = await this.dataMount();
|
|
2834
|
+
const response = await fetch(`${this.collectionUrl(mount, objectName)}/query`, {
|
|
2673
2835
|
method: "POST",
|
|
2674
2836
|
headers,
|
|
2675
2837
|
body: JSON.stringify(data)
|
|
2676
2838
|
});
|
|
2677
|
-
return this.handleResponse(response);
|
|
2839
|
+
return this.handleResponse(response, this.mountNote(mount));
|
|
2678
2840
|
}
|
|
2679
2841
|
async rawApiCall(endpoint, data, headers) {
|
|
2680
2842
|
const method = data.method || "GET";
|
|
@@ -2687,10 +2849,13 @@ var HttpTestAdapter = class {
|
|
|
2687
2849
|
});
|
|
2688
2850
|
return this.handleResponse(response);
|
|
2689
2851
|
}
|
|
2690
|
-
async handleResponse(response) {
|
|
2852
|
+
async handleResponse(response, mountNote) {
|
|
2691
2853
|
if (!response.ok) {
|
|
2692
2854
|
const text = await response.text();
|
|
2693
|
-
|
|
2855
|
+
const wrongUrlShaped = response.status === 404 || response.status === 405;
|
|
2856
|
+
throw new Error(
|
|
2857
|
+
`HTTP Error ${response.status}: ${text}${wrongUrlShaped && mountNote ? ` \u2014 ${mountNote}` : ""}`
|
|
2858
|
+
);
|
|
2694
2859
|
}
|
|
2695
2860
|
const contentType = response.headers.get("content-type");
|
|
2696
2861
|
if (contentType && contentType.includes("application/json")) {
|
|
@@ -4032,6 +4197,7 @@ var PluginSecurityScanner = class {
|
|
|
4032
4197
|
|
|
4033
4198
|
// src/security/api-key.ts
|
|
4034
4199
|
import { createHash, randomBytes } from "crypto";
|
|
4200
|
+
import { postureEnforcesWall, postureUsesUnionScope, normalizeTenancyPosture } from "@objectstack/spec/security";
|
|
4035
4201
|
var API_KEY_PREFIX = "osk_";
|
|
4036
4202
|
var API_KEY_ENTROPY_BYTES = 32;
|
|
4037
4203
|
var VISIBLE_PREFIX_LEN = 12;
|
|
@@ -4085,10 +4251,18 @@ function isExpired(value, nowMs) {
|
|
|
4085
4251
|
if (Number.isNaN(ms)) return false;
|
|
4086
4252
|
return ms <= nowMs;
|
|
4087
4253
|
}
|
|
4088
|
-
|
|
4254
|
+
function effectiveTenancyPosture(tenancy) {
|
|
4255
|
+
if (!tenancy) return void 0;
|
|
4256
|
+
return normalizeTenancyPosture(tenancy.posture) ?? (tenancy.isolationActive ? "isolated" : "single");
|
|
4257
|
+
}
|
|
4258
|
+
async function resolveApiKeyPrincipal(ql, headers, nowMs = Date.now(), tenancyPosture) {
|
|
4259
|
+
const admission = await resolveApiKeyAdmission(ql, headers, nowMs, tenancyPosture);
|
|
4260
|
+
return admission.outcome === "admitted" ? admission.principal : void 0;
|
|
4261
|
+
}
|
|
4262
|
+
async function resolveApiKeyAdmission(ql, headers, nowMs = Date.now(), tenancyPosture) {
|
|
4089
4263
|
const apiKey = extractApiKey(headers);
|
|
4090
|
-
if (!apiKey) return
|
|
4091
|
-
if (!ql || typeof ql.find !== "function") return
|
|
4264
|
+
if (!apiKey) return { outcome: "none" };
|
|
4265
|
+
if (!ql || typeof ql.find !== "function") return { outcome: "none" };
|
|
4092
4266
|
let rows;
|
|
4093
4267
|
try {
|
|
4094
4268
|
rows = await ql.find("sys_api_key", {
|
|
@@ -4097,19 +4271,29 @@ async function resolveApiKeyPrincipal(ql, headers, nowMs = Date.now()) {
|
|
|
4097
4271
|
context: { isSystem: true }
|
|
4098
4272
|
});
|
|
4099
4273
|
} catch {
|
|
4100
|
-
return
|
|
4274
|
+
return { outcome: "none" };
|
|
4101
4275
|
}
|
|
4102
4276
|
if (rows && rows.value) rows = rows.value;
|
|
4103
4277
|
const row = Array.isArray(rows) ? rows[0] : void 0;
|
|
4104
|
-
if (!row || row.revoked === true) return
|
|
4278
|
+
if (!row || row.revoked === true) return { outcome: "none" };
|
|
4105
4279
|
const expiresAt = row.expires_at ?? row.expiresAt;
|
|
4106
|
-
if (isExpired(expiresAt, nowMs)) return
|
|
4280
|
+
if (isExpired(expiresAt, nowMs)) return { outcome: "none" };
|
|
4107
4281
|
const userId = row.user_id ?? row.userId;
|
|
4108
|
-
if (!userId || typeof userId !== "string") return
|
|
4282
|
+
if (!userId || typeof userId !== "string") return { outcome: "none" };
|
|
4283
|
+
const tenantId = typeof row.active_organization_id === "string" && row.active_organization_id ? row.active_organization_id : void 0;
|
|
4284
|
+
if (!tenantId && tenancyPosture) {
|
|
4285
|
+
const posture = tenancyPosture;
|
|
4286
|
+
if (postureEnforcesWall(posture) && !postureUsesUnionScope(posture)) {
|
|
4287
|
+
return {
|
|
4288
|
+
outcome: "refused",
|
|
4289
|
+
reason: "organization_required",
|
|
4290
|
+
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."
|
|
4291
|
+
};
|
|
4292
|
+
}
|
|
4293
|
+
}
|
|
4109
4294
|
return {
|
|
4110
|
-
|
|
4111
|
-
|
|
4112
|
-
scopes: parseScopes(row.scopes)
|
|
4295
|
+
outcome: "admitted",
|
|
4296
|
+
principal: { userId, tenantId, scopes: parseScopes(row.scopes) }
|
|
4113
4297
|
};
|
|
4114
4298
|
}
|
|
4115
4299
|
function readHeader(headers, name) {
|
|
@@ -4142,6 +4326,7 @@ import {
|
|
|
4142
4326
|
ADMIN_FULL_ACCESS,
|
|
4143
4327
|
ORGANIZATION_ADMIN_GRANTS
|
|
4144
4328
|
} from "@objectstack/spec";
|
|
4329
|
+
import { postureEnforcesWall as postureEnforcesWall2 } from "@objectstack/spec/security";
|
|
4145
4330
|
|
|
4146
4331
|
// src/security/grant-validity.ts
|
|
4147
4332
|
function toEpochMs(value) {
|
|
@@ -4224,6 +4409,15 @@ function postureVisibleRows(posture, rows, principal) {
|
|
|
4224
4409
|
}
|
|
4225
4410
|
}
|
|
4226
4411
|
|
|
4412
|
+
// src/security/row-active.ts
|
|
4413
|
+
var DEACTIVATED_VALUES = [false, 0, "0", "false"];
|
|
4414
|
+
function isRowActive(row) {
|
|
4415
|
+
if (!row) return false;
|
|
4416
|
+
const value = row.active;
|
|
4417
|
+
if (value === void 0 || value === null) return true;
|
|
4418
|
+
return !DEACTIVATED_VALUES.includes(value);
|
|
4419
|
+
}
|
|
4420
|
+
|
|
4227
4421
|
// src/security/resolve-authz-context.ts
|
|
4228
4422
|
function safeJsonParse2(s, fallback) {
|
|
4229
4423
|
try {
|
|
@@ -4232,10 +4426,11 @@ function safeJsonParse2(s, fallback) {
|
|
|
4232
4426
|
return fallback;
|
|
4233
4427
|
}
|
|
4234
4428
|
}
|
|
4235
|
-
async function tryFind(ql, object, where, limit = 100) {
|
|
4429
|
+
async function tryFind(ql, object, where, limit = 100, organizationId) {
|
|
4236
4430
|
if (!ql || typeof ql.find !== "function") return [];
|
|
4237
4431
|
try {
|
|
4238
|
-
|
|
4432
|
+
const context = organizationId ? { isSystem: true, tenantId: organizationId } : { isSystem: true };
|
|
4433
|
+
let rows = await ql.find(object, { where, limit, context });
|
|
4239
4434
|
if (rows && rows.value) rows = rows.value;
|
|
4240
4435
|
return Array.isArray(rows) ? rows : [];
|
|
4241
4436
|
} catch {
|
|
@@ -4253,7 +4448,12 @@ async function resolveAuthzContext(input) {
|
|
|
4253
4448
|
};
|
|
4254
4449
|
let userId;
|
|
4255
4450
|
let tenantId;
|
|
4256
|
-
const
|
|
4451
|
+
const admission = await resolveApiKeyAdmission(ql, headers, input.nowMs, input.tenancyPosture);
|
|
4452
|
+
if (admission.outcome === "refused") {
|
|
4453
|
+
ctx.authRefusal = { reason: admission.reason, message: admission.message };
|
|
4454
|
+
return ctx;
|
|
4455
|
+
}
|
|
4456
|
+
const keyPrincipal = admission.outcome === "admitted" ? admission.principal : void 0;
|
|
4257
4457
|
if (keyPrincipal) {
|
|
4258
4458
|
userId = keyPrincipal.userId;
|
|
4259
4459
|
tenantId = keyPrincipal.tenantId;
|
|
@@ -4281,6 +4481,22 @@ async function resolveAuthzContext(input) {
|
|
|
4281
4481
|
seedPermissions: ctx.permissions,
|
|
4282
4482
|
seedEmail: ctx.email
|
|
4283
4483
|
});
|
|
4484
|
+
if (keyPrincipal?.tenantId && input.tenancyPosture) {
|
|
4485
|
+
const posture = input.tenancyPosture;
|
|
4486
|
+
if (postureEnforcesWall2(posture) && !grants.accessible_org_ids.includes(keyPrincipal.tenantId)) {
|
|
4487
|
+
return {
|
|
4488
|
+
positions: [],
|
|
4489
|
+
permissions: [],
|
|
4490
|
+
systemPermissions: [],
|
|
4491
|
+
org_user_ids: [],
|
|
4492
|
+
accessible_org_ids: [],
|
|
4493
|
+
authRefusal: {
|
|
4494
|
+
reason: "organization_membership_ended",
|
|
4495
|
+
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."
|
|
4496
|
+
}
|
|
4497
|
+
};
|
|
4498
|
+
}
|
|
4499
|
+
}
|
|
4284
4500
|
ctx.positions = grants.positions;
|
|
4285
4501
|
ctx.permissions = grants.permissions;
|
|
4286
4502
|
ctx.systemPermissions = grants.systemPermissions;
|
|
@@ -4312,12 +4528,19 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
|
|
|
4312
4528
|
}
|
|
4313
4529
|
return userRow;
|
|
4314
4530
|
};
|
|
4531
|
+
const needsUserRow = !grants.email || !grants.permissions.includes("ai_seat");
|
|
4532
|
+
const [, members, userPositionRows, orgMembersLeg, upsRowsAll] = await Promise.all([
|
|
4533
|
+
needsUserRow ? getUserRow() : Promise.resolve(void 0),
|
|
4534
|
+
tryFind(ql, "sys_member", { user_id: userId }, 200),
|
|
4535
|
+
tryFind(ql, "sys_user_position", { user_id: userId }, 200),
|
|
4536
|
+
tenantId ? tryFind(ql, "sys_member", { organization_id: tenantId }, 1e3) : Promise.resolve([]),
|
|
4537
|
+
tryFind(ql, "sys_user_permission_set", { user_id: userId }, 100)
|
|
4538
|
+
]);
|
|
4315
4539
|
if (!grants.email) {
|
|
4316
4540
|
const u = await getUserRow();
|
|
4317
4541
|
if (u?.email) grants.email = String(u.email);
|
|
4318
4542
|
}
|
|
4319
4543
|
const nowMs = opts.nowMs ?? Date.now();
|
|
4320
|
-
const members = await tryFind(ql, "sys_member", { user_id: userId }, 200);
|
|
4321
4544
|
const accessibleOrgIds = /* @__PURE__ */ new Set();
|
|
4322
4545
|
for (const m of members) {
|
|
4323
4546
|
if (!isGrantActive(m, nowMs)) continue;
|
|
@@ -4325,7 +4548,9 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
|
|
|
4325
4548
|
if (typeof org === "string" && org) accessibleOrgIds.add(org);
|
|
4326
4549
|
}
|
|
4327
4550
|
grants.accessible_org_ids = Array.from(accessibleOrgIds);
|
|
4328
|
-
const activeMembers =
|
|
4551
|
+
const activeMembers = members.filter(
|
|
4552
|
+
(m) => isGrantActive(m, nowMs) && (!tenantId || (m.organization_id ?? m.organizationId) === tenantId)
|
|
4553
|
+
);
|
|
4329
4554
|
for (const m of activeMembers) {
|
|
4330
4555
|
if (m.role && typeof m.role === "string") {
|
|
4331
4556
|
for (const raw of m.role.split(",").map((s) => s.trim()).filter(Boolean)) {
|
|
@@ -4334,7 +4559,6 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
|
|
|
4334
4559
|
}
|
|
4335
4560
|
}
|
|
4336
4561
|
}
|
|
4337
|
-
const userPositionRows = await tryFind(ql, "sys_user_position", { user_id: userId }, 200);
|
|
4338
4562
|
for (const ur of userPositionRows) {
|
|
4339
4563
|
const org = ur.organization_id ?? null;
|
|
4340
4564
|
if (org && tenantId && org !== tenantId) continue;
|
|
@@ -4343,14 +4567,13 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
|
|
|
4343
4567
|
if (typeof r === "string" && r && !grants.positions.includes(r)) grants.positions.push(r);
|
|
4344
4568
|
}
|
|
4345
4569
|
if (tenantId) {
|
|
4346
|
-
const orgMembers =
|
|
4570
|
+
const orgMembers = orgMembersLeg;
|
|
4347
4571
|
const ids = new Set(
|
|
4348
4572
|
orgMembers.map((m) => m.user_id ?? m.userId).filter((v) => typeof v === "string" && v.length > 0)
|
|
4349
4573
|
);
|
|
4350
4574
|
ids.add(userId);
|
|
4351
4575
|
grants.org_user_ids = Array.from(ids);
|
|
4352
4576
|
}
|
|
4353
|
-
const upsRowsAll = await tryFind(ql, "sys_user_permission_set", { user_id: userId }, 100);
|
|
4354
4577
|
const upsRows = upsRowsAll.filter((r) => isGrantActive(r, nowMs));
|
|
4355
4578
|
const psIds = new Set(
|
|
4356
4579
|
upsRows.filter((r) => {
|
|
@@ -4364,8 +4587,14 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
|
|
|
4364
4587
|
let hasPlatformAdminGrant = false;
|
|
4365
4588
|
if (!grants.positions.includes("everyone")) grants.positions.push("everyone");
|
|
4366
4589
|
if (grants.positions.length > 0) {
|
|
4367
|
-
const positionRows = await tryFind(ql, "sys_position", { name: { $in: grants.positions } },
|
|
4368
|
-
const
|
|
4590
|
+
const positionRows = await tryFind(ql, "sys_position", { name: { $in: grants.positions } }, 200, tenantId);
|
|
4591
|
+
const deactivatedNames = new Set(
|
|
4592
|
+
positionRows.filter((r) => !isRowActive(r)).map((r) => r.name).filter(Boolean)
|
|
4593
|
+
);
|
|
4594
|
+
if (deactivatedNames.size > 0) {
|
|
4595
|
+
grants.positions = grants.positions.filter((n) => !deactivatedNames.has(n));
|
|
4596
|
+
}
|
|
4597
|
+
const positionIds = positionRows.filter((r) => isRowActive(r)).map((r) => r.id).filter(Boolean);
|
|
4369
4598
|
if (positionIds.length > 0) {
|
|
4370
4599
|
const rpsRows = await tryFind(ql, "sys_position_permission_set", { position_id: { $in: positionIds } }, 500);
|
|
4371
4600
|
for (const r of rpsRows) {
|
|
@@ -4375,7 +4604,8 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
|
|
|
4375
4604
|
}
|
|
4376
4605
|
}
|
|
4377
4606
|
if (psIds.size > 0) {
|
|
4378
|
-
const
|
|
4607
|
+
const psRowsAll = await tryFind(ql, "sys_permission_set", { id: { $in: Array.from(psIds) } }, 500);
|
|
4608
|
+
const psRows = psRowsAll.filter((r) => isRowActive(r));
|
|
4379
4609
|
const tabRank = { hidden: 0, default_off: 1, default_on: 2, visible: 3 };
|
|
4380
4610
|
const mergedTabs = {};
|
|
4381
4611
|
for (const ps of psRows) {
|
|
@@ -4435,34 +4665,89 @@ function coerceCurrency(value) {
|
|
|
4435
4665
|
const s = typeof value === "string" ? value.trim().toUpperCase() : "";
|
|
4436
4666
|
return /^[A-Z]{3}$/.test(s) ? s : void 0;
|
|
4437
4667
|
}
|
|
4668
|
+
var LOCALIZATION_FAILURE_CACHE_TTL_MS = 3e4;
|
|
4669
|
+
var localizationFailureCache = /* @__PURE__ */ new WeakMap();
|
|
4438
4670
|
async function resolveLocalizationContext(input) {
|
|
4671
|
+
const { ql, tenantId, userId } = input;
|
|
4672
|
+
const cacheKey = `${tenantId ?? ""}|${userId ?? ""}`;
|
|
4673
|
+
if (ql && typeof ql === "object") {
|
|
4674
|
+
const hit = localizationFailureCache.get(ql)?.get(cacheKey);
|
|
4675
|
+
if (hit && hit.expiresAt > Date.now()) return hit.value;
|
|
4676
|
+
}
|
|
4677
|
+
const { value, failed } = await resolveLocalizationContextUncached(input);
|
|
4678
|
+
if (failed && ql && typeof ql === "object") {
|
|
4679
|
+
const bucket = localizationFailureCache.get(ql) ?? /* @__PURE__ */ new Map();
|
|
4680
|
+
bucket.set(cacheKey, { value, expiresAt: Date.now() + LOCALIZATION_FAILURE_CACHE_TTL_MS });
|
|
4681
|
+
localizationFailureCache.set(ql, bucket);
|
|
4682
|
+
}
|
|
4683
|
+
return value;
|
|
4684
|
+
}
|
|
4685
|
+
async function resolveLocalizationContextUncached(input) {
|
|
4439
4686
|
const { ql, settings, tenantId, userId } = input;
|
|
4687
|
+
let failed = false;
|
|
4440
4688
|
try {
|
|
4441
4689
|
if (settings && typeof settings.get === "function") {
|
|
4442
4690
|
const sctx = { tenantId, userId };
|
|
4443
|
-
|
|
4444
|
-
|
|
4445
|
-
|
|
4446
|
-
|
|
4447
|
-
|
|
4691
|
+
let tzRes;
|
|
4692
|
+
let localeRes;
|
|
4693
|
+
let currencyRes;
|
|
4694
|
+
if (typeof settings.getMany === "function") {
|
|
4695
|
+
try {
|
|
4696
|
+
const many = await settings.getMany("localization", ["timezone", "locale", "currency"], sctx);
|
|
4697
|
+
tzRes = many.timezone;
|
|
4698
|
+
localeRes = many.locale;
|
|
4699
|
+
currencyRes = many.currency;
|
|
4700
|
+
} catch {
|
|
4701
|
+
failed = true;
|
|
4702
|
+
}
|
|
4703
|
+
} else {
|
|
4704
|
+
[tzRes, localeRes, currencyRes] = await Promise.all([
|
|
4705
|
+
settings.get("localization", "timezone", sctx).catch(() => {
|
|
4706
|
+
failed = true;
|
|
4707
|
+
return void 0;
|
|
4708
|
+
}),
|
|
4709
|
+
settings.get("localization", "locale", sctx).catch(() => {
|
|
4710
|
+
failed = true;
|
|
4711
|
+
return void 0;
|
|
4712
|
+
}),
|
|
4713
|
+
settings.get("localization", "currency", sctx).catch(() => {
|
|
4714
|
+
failed = true;
|
|
4715
|
+
return void 0;
|
|
4716
|
+
})
|
|
4717
|
+
]);
|
|
4718
|
+
}
|
|
4448
4719
|
const tz = coerceTimeZone(tzRes?.value);
|
|
4449
4720
|
const locale = coerceLocale(localeRes?.value);
|
|
4450
4721
|
const currency = coerceCurrency(currencyRes?.value);
|
|
4451
|
-
if (tz || locale || currency)
|
|
4722
|
+
if (tz || locale || currency) {
|
|
4723
|
+
return { value: { timezone: tz ?? "UTC", locale: locale ?? "en-US", currency }, failed: false };
|
|
4724
|
+
}
|
|
4452
4725
|
}
|
|
4453
4726
|
} catch {
|
|
4727
|
+
failed = true;
|
|
4728
|
+
}
|
|
4729
|
+
let rows = [];
|
|
4730
|
+
if (ql && typeof ql.find === "function") {
|
|
4731
|
+
try {
|
|
4732
|
+
let result = await ql.find("sys_setting", {
|
|
4733
|
+
where: { namespace: "localization", key: { $in: ["timezone", "locale", "currency"] }, scope: "tenant" },
|
|
4734
|
+
limit: 10,
|
|
4735
|
+
context: { isSystem: true }
|
|
4736
|
+
});
|
|
4737
|
+
if (result && result.value) result = result.value;
|
|
4738
|
+
rows = Array.isArray(result) ? result : [];
|
|
4739
|
+
} catch {
|
|
4740
|
+
failed = true;
|
|
4741
|
+
}
|
|
4454
4742
|
}
|
|
4455
|
-
const rows = await tryFind(
|
|
4456
|
-
ql,
|
|
4457
|
-
"sys_setting",
|
|
4458
|
-
{ namespace: "localization", key: { $in: ["timezone", "locale", "currency"] }, scope: "tenant" },
|
|
4459
|
-
10
|
|
4460
|
-
);
|
|
4461
4743
|
const valueOf = (k) => rows.find((r) => r.key === k)?.value;
|
|
4462
4744
|
return {
|
|
4463
|
-
|
|
4464
|
-
|
|
4465
|
-
|
|
4745
|
+
value: {
|
|
4746
|
+
timezone: coerceTimeZone(valueOf("timezone")) ?? "UTC",
|
|
4747
|
+
locale: coerceLocale(valueOf("locale")) ?? "en-US",
|
|
4748
|
+
currency: coerceCurrency(valueOf("currency"))
|
|
4749
|
+
},
|
|
4750
|
+
failed
|
|
4466
4751
|
};
|
|
4467
4752
|
}
|
|
4468
4753
|
|
|
@@ -4606,6 +4891,7 @@ var ANONYMOUS_DENY_CODE = "UNAUTHENTICATED";
|
|
|
4606
4891
|
var ANONYMOUS_DENY_MESSAGE = "Authentication is required to access this endpoint.";
|
|
4607
4892
|
var ANONYMOUS_DENY_BODY = {
|
|
4608
4893
|
error: ANONYMOUS_DENY_CODE,
|
|
4894
|
+
code: ANONYMOUS_DENY_CODE,
|
|
4609
4895
|
message: ANONYMOUS_DENY_MESSAGE
|
|
4610
4896
|
};
|
|
4611
4897
|
function shouldDenyAnonymous(input) {
|
|
@@ -4619,6 +4905,76 @@ function shouldDenyAnonymous(input) {
|
|
|
4619
4905
|
return true;
|
|
4620
4906
|
}
|
|
4621
4907
|
|
|
4908
|
+
// src/security/admin-standing-surface.ts
|
|
4909
|
+
var ADMIN_STANDING_SURFACE = {
|
|
4910
|
+
sys_permission_set: {
|
|
4911
|
+
role: "derives",
|
|
4912
|
+
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.",
|
|
4913
|
+
columns: [
|
|
4914
|
+
"id",
|
|
4915
|
+
"name",
|
|
4916
|
+
"active",
|
|
4917
|
+
"system_permissions",
|
|
4918
|
+
"systemPermissions",
|
|
4919
|
+
"tab_permissions",
|
|
4920
|
+
"tabPermissions"
|
|
4921
|
+
]
|
|
4922
|
+
},
|
|
4923
|
+
sys_user_permission_set: {
|
|
4924
|
+
role: "derives",
|
|
4925
|
+
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.",
|
|
4926
|
+
columns: [
|
|
4927
|
+
"user_id",
|
|
4928
|
+
"permission_set_id",
|
|
4929
|
+
"permissionSetId",
|
|
4930
|
+
"organization_id",
|
|
4931
|
+
"organizationId",
|
|
4932
|
+
"valid_from",
|
|
4933
|
+
"validFrom",
|
|
4934
|
+
"valid_until",
|
|
4935
|
+
"validUntil"
|
|
4936
|
+
]
|
|
4937
|
+
},
|
|
4938
|
+
sys_member: {
|
|
4939
|
+
role: "derives",
|
|
4940
|
+
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.",
|
|
4941
|
+
columns: [
|
|
4942
|
+
"user_id",
|
|
4943
|
+
"userId",
|
|
4944
|
+
"organization_id",
|
|
4945
|
+
"organizationId",
|
|
4946
|
+
"role",
|
|
4947
|
+
"valid_from",
|
|
4948
|
+
"validFrom",
|
|
4949
|
+
"valid_until",
|
|
4950
|
+
"validUntil"
|
|
4951
|
+
]
|
|
4952
|
+
},
|
|
4953
|
+
sys_user: {
|
|
4954
|
+
role: "reads-only",
|
|
4955
|
+
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."
|
|
4956
|
+
},
|
|
4957
|
+
sys_user_position: {
|
|
4958
|
+
role: "reads-only",
|
|
4959
|
+
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."
|
|
4960
|
+
},
|
|
4961
|
+
sys_position: {
|
|
4962
|
+
role: "reads-only",
|
|
4963
|
+
reason: "Read to drop DEACTIVATED positions (ADR-0049, \xA76a). Same reason as `sys_user_position`: the position path cannot reach `hasPlatformAdminGrant`."
|
|
4964
|
+
},
|
|
4965
|
+
sys_position_permission_set: {
|
|
4966
|
+
role: "reads-only",
|
|
4967
|
+
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."
|
|
4968
|
+
}
|
|
4969
|
+
};
|
|
4970
|
+
function adminStandingTables() {
|
|
4971
|
+
return Object.entries(ADMIN_STANDING_SURFACE).filter(([, t]) => t.role === "derives").map(([name]) => name).sort();
|
|
4972
|
+
}
|
|
4973
|
+
function adminStandingColumns(table) {
|
|
4974
|
+
const entry = ADMIN_STANDING_SURFACE[table];
|
|
4975
|
+
return entry?.role === "derives" ? entry.columns : void 0;
|
|
4976
|
+
}
|
|
4977
|
+
|
|
4622
4978
|
// src/security/audience-binding-suggestion-status.ts
|
|
4623
4979
|
var AUDIENCE_BINDING_SUGGESTION_STATUSES = {
|
|
4624
4980
|
pending: true,
|
|
@@ -4669,10 +5025,26 @@ function calendarPartsInTzOrUtc(d, tz) {
|
|
|
4669
5025
|
}
|
|
4670
5026
|
function zonedDateStartToUtcMs(ymd2, tz) {
|
|
4671
5027
|
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(ymd2);
|
|
4672
|
-
|
|
5028
|
+
if (!m) return NaN;
|
|
5029
|
+
return zonedWallClockToUtcMs(
|
|
5030
|
+
{ year: Number(m[1]), month: Number(m[2]), day: Number(m[3]) },
|
|
5031
|
+
tz
|
|
5032
|
+
);
|
|
5033
|
+
}
|
|
5034
|
+
function zonedWallClockToUtcMs(parts, tz) {
|
|
5035
|
+
const wallAsUtc = Date.UTC(
|
|
5036
|
+
parts.year,
|
|
5037
|
+
parts.month - 1,
|
|
5038
|
+
parts.day,
|
|
5039
|
+
parts.hour ?? 0,
|
|
5040
|
+
parts.minute ?? 0,
|
|
5041
|
+
parts.second ?? 0,
|
|
5042
|
+
parts.millisecond ?? 0
|
|
5043
|
+
);
|
|
4673
5044
|
if (!tz || tz === "UTC" || Number.isNaN(wallAsUtc)) return wallAsUtc;
|
|
4674
5045
|
try {
|
|
4675
5046
|
const offsetAt = (t) => {
|
|
5047
|
+
const whole = Math.floor(t / 1e3) * 1e3;
|
|
4676
5048
|
const p = new Intl.DateTimeFormat("en-US", {
|
|
4677
5049
|
timeZone: tz,
|
|
4678
5050
|
hourCycle: "h23",
|
|
@@ -4682,9 +5054,9 @@ function zonedDateStartToUtcMs(ymd2, tz) {
|
|
|
4682
5054
|
hour: "2-digit",
|
|
4683
5055
|
minute: "2-digit",
|
|
4684
5056
|
second: "2-digit"
|
|
4685
|
-
}).formatToParts(new Date(
|
|
5057
|
+
}).formatToParts(new Date(whole));
|
|
4686
5058
|
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")) -
|
|
5059
|
+
return Date.UTC(g("year"), g("month") - 1, g("day"), g("hour"), g("minute"), g("second")) - whole;
|
|
4688
5060
|
};
|
|
4689
5061
|
const off1 = offsetAt(wallAsUtc - offsetAt(wallAsUtc));
|
|
4690
5062
|
return wallAsUtc - off1;
|
|
@@ -5500,6 +5872,37 @@ function filterTokenContextFrom(execCtx, now) {
|
|
|
5500
5872
|
};
|
|
5501
5873
|
}
|
|
5502
5874
|
|
|
5875
|
+
// src/utils/temporal-comparand.ts
|
|
5876
|
+
import { classifyFilterToken as classifyFilterToken2 } from "@objectstack/spec/data";
|
|
5877
|
+
function temporalComparandKind(fieldType) {
|
|
5878
|
+
if (fieldType === "datetime") return "datetime";
|
|
5879
|
+
if (fieldType === "date") return "date";
|
|
5880
|
+
if (fieldType === "time") return "time";
|
|
5881
|
+
return null;
|
|
5882
|
+
}
|
|
5883
|
+
function readsAsInstant(s) {
|
|
5884
|
+
if (/^-?\d+$/.test(s)) return Number.isFinite(new Date(Number(s)).getTime());
|
|
5885
|
+
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;
|
|
5886
|
+
return Number.isFinite(Date.parse(iso));
|
|
5887
|
+
}
|
|
5888
|
+
function readsAsCalendarDay(s) {
|
|
5889
|
+
return /^\d{4}-\d{2}-\d{2}/.test(s);
|
|
5890
|
+
}
|
|
5891
|
+
function readsAsWallClock(s) {
|
|
5892
|
+
const m = /^(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?$/.exec(s);
|
|
5893
|
+
if (!m) return false;
|
|
5894
|
+
return Number(m[1]) <= 23 && Number(m[2]) <= 59 && Number(m[3] ?? "0") <= 59;
|
|
5895
|
+
}
|
|
5896
|
+
function isUninterpretableTemporalComparand(kind, value) {
|
|
5897
|
+
if (typeof value !== "string") return false;
|
|
5898
|
+
const s = value.trim();
|
|
5899
|
+
if (s === "") return false;
|
|
5900
|
+
if (classifyFilterToken2(value) !== null) return false;
|
|
5901
|
+
if (kind === "datetime") return !readsAsInstant(s);
|
|
5902
|
+
if (kind === "date") return !readsAsCalendarDay(s);
|
|
5903
|
+
return !(readsAsWallClock(s) || readsAsInstant(s));
|
|
5904
|
+
}
|
|
5905
|
+
|
|
5503
5906
|
// src/utils/record-not-found.ts
|
|
5504
5907
|
function recordNotFoundError(object, id) {
|
|
5505
5908
|
const err = new Error(`Record ${id} not found in ${object}`);
|
|
@@ -6485,7 +6888,11 @@ var NamespaceResolver = class {
|
|
|
6485
6888
|
return `${shortName}_${ns}`;
|
|
6486
6889
|
}
|
|
6487
6890
|
};
|
|
6891
|
+
|
|
6892
|
+
// src/index.ts
|
|
6893
|
+
import { UNMATCHED_ROUTE_PATTERN } from "@objectstack/spec/contracts";
|
|
6488
6894
|
export {
|
|
6895
|
+
ADMIN_STANDING_SURFACE,
|
|
6489
6896
|
ANONYMOUS_DENY_BODY,
|
|
6490
6897
|
ANONYMOUS_DENY_CODE,
|
|
6491
6898
|
ANONYMOUS_DENY_MESSAGE,
|
|
@@ -6521,8 +6928,11 @@ export {
|
|
|
6521
6928
|
SecurePluginContext,
|
|
6522
6929
|
SemanticVersionManager,
|
|
6523
6930
|
ServiceLifecycle,
|
|
6931
|
+
UNMATCHED_ROUTE_PATTERN,
|
|
6524
6932
|
UnknownFilterTokenError,
|
|
6525
6933
|
UnresolvedFilterTokenError,
|
|
6934
|
+
adminStandingColumns,
|
|
6935
|
+
adminStandingTables,
|
|
6526
6936
|
assembleExecutionContext,
|
|
6527
6937
|
assembleExecutionContextOrGuest,
|
|
6528
6938
|
assertInitServiceRequirements,
|
|
@@ -6547,6 +6957,7 @@ export {
|
|
|
6547
6957
|
defaultIsTransientError,
|
|
6548
6958
|
derivePosture,
|
|
6549
6959
|
describeInitOrderFault,
|
|
6960
|
+
effectiveTenancyPosture,
|
|
6550
6961
|
engineCanRollBack,
|
|
6551
6962
|
evaluateAuthGate,
|
|
6552
6963
|
extractApiKey,
|
|
@@ -6564,6 +6975,8 @@ export {
|
|
|
6564
6975
|
isGrantActive,
|
|
6565
6976
|
isGrantExpired,
|
|
6566
6977
|
isNode,
|
|
6978
|
+
isRowActive,
|
|
6979
|
+
isUninterpretableTemporalComparand,
|
|
6567
6980
|
nextUtcCalendarDay,
|
|
6568
6981
|
normalizeAuthGate,
|
|
6569
6982
|
omitInternalFieldsFromWriteResponse,
|
|
@@ -6574,6 +6987,7 @@ export {
|
|
|
6574
6987
|
readAuthoredTranslationLayer,
|
|
6575
6988
|
readRunJournal,
|
|
6576
6989
|
recordNotFoundError,
|
|
6990
|
+
resolveApiKeyAdmission,
|
|
6577
6991
|
resolveApiKeyPrincipal,
|
|
6578
6992
|
resolveAuthzContext,
|
|
6579
6993
|
resolveFilterToken,
|
|
@@ -6587,6 +7001,7 @@ export {
|
|
|
6587
7001
|
safeExit,
|
|
6588
7002
|
shouldDenyAnonymous,
|
|
6589
7003
|
signPayload,
|
|
7004
|
+
temporalComparandKind,
|
|
6590
7005
|
unknownAudienceBindingSuggestionStatusMessage,
|
|
6591
7006
|
utcInstantMs,
|
|
6592
7007
|
validateInitServiceContract,
|
|
@@ -6597,6 +7012,7 @@ export {
|
|
|
6597
7012
|
wireAuthoredTranslationSync,
|
|
6598
7013
|
withTransientRetry,
|
|
6599
7014
|
withoutOperationPrivateKeys,
|
|
6600
|
-
zonedDateStartToUtcMs
|
|
7015
|
+
zonedDateStartToUtcMs,
|
|
7016
|
+
zonedWallClockToUtcMs
|
|
6601
7017
|
};
|
|
6602
7018
|
//# sourceMappingURL=index.js.map
|