@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.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
|
|
|
@@ -1484,35 +1494,6 @@ function createMemoryQueue() {
|
|
|
1484
1494
|
};
|
|
1485
1495
|
}
|
|
1486
1496
|
|
|
1487
|
-
// src/fallbacks/memory-job.ts
|
|
1488
|
-
function createMemoryJob() {
|
|
1489
|
-
const jobs = /* @__PURE__ */ new Map();
|
|
1490
|
-
return {
|
|
1491
|
-
__serviceInfo: {
|
|
1492
|
-
status: "degraded",
|
|
1493
|
-
handlerReady: false,
|
|
1494
|
-
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."
|
|
1495
|
-
},
|
|
1496
|
-
_serviceName: "job",
|
|
1497
|
-
async schedule(name, schedule, handler) {
|
|
1498
|
-
jobs.set(name, { schedule, handler });
|
|
1499
|
-
},
|
|
1500
|
-
async cancel(name) {
|
|
1501
|
-
jobs.delete(name);
|
|
1502
|
-
},
|
|
1503
|
-
async trigger(name, data) {
|
|
1504
|
-
const job = jobs.get(name);
|
|
1505
|
-
if (job?.handler) await job.handler({ jobId: name, data });
|
|
1506
|
-
},
|
|
1507
|
-
async getExecutions() {
|
|
1508
|
-
return [];
|
|
1509
|
-
},
|
|
1510
|
-
async listJobs() {
|
|
1511
|
-
return [...jobs.keys()];
|
|
1512
|
-
}
|
|
1513
|
-
};
|
|
1514
|
-
}
|
|
1515
|
-
|
|
1516
1497
|
// src/fallbacks/memory-i18n.ts
|
|
1517
1498
|
var import_system = require("@objectstack/spec/system");
|
|
1518
1499
|
function deepMerge(target, source) {
|
|
@@ -1740,6 +1721,35 @@ function createMemoryMetadata() {
|
|
|
1740
1721
|
};
|
|
1741
1722
|
}
|
|
1742
1723
|
|
|
1724
|
+
// src/fallbacks/memory-job.ts
|
|
1725
|
+
function createMemoryJob() {
|
|
1726
|
+
const jobs = /* @__PURE__ */ new Map();
|
|
1727
|
+
return {
|
|
1728
|
+
__serviceInfo: {
|
|
1729
|
+
status: "degraded",
|
|
1730
|
+
handlerReady: false,
|
|
1731
|
+
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."
|
|
1732
|
+
},
|
|
1733
|
+
_serviceName: "job",
|
|
1734
|
+
async schedule(name, schedule, handler) {
|
|
1735
|
+
jobs.set(name, { schedule, handler });
|
|
1736
|
+
},
|
|
1737
|
+
async cancel(name) {
|
|
1738
|
+
jobs.delete(name);
|
|
1739
|
+
},
|
|
1740
|
+
async trigger(name, data) {
|
|
1741
|
+
const job = jobs.get(name);
|
|
1742
|
+
if (job?.handler) await job.handler({ jobId: name, data });
|
|
1743
|
+
},
|
|
1744
|
+
async getExecutions() {
|
|
1745
|
+
return [];
|
|
1746
|
+
},
|
|
1747
|
+
async listJobs() {
|
|
1748
|
+
return [...jobs.keys()];
|
|
1749
|
+
}
|
|
1750
|
+
};
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1743
1753
|
// src/fallbacks/authored-translation-sync.ts
|
|
1744
1754
|
var import_system2 = require("@objectstack/spec/system");
|
|
1745
1755
|
var OWNER_PROP = "__authoredTranslationSyncOwner";
|
|
@@ -1872,10 +1882,71 @@ var CORE_FALLBACK_FACTORIES = {
|
|
|
1872
1882
|
metadata: createMemoryMetadata,
|
|
1873
1883
|
cache: createMemoryCache,
|
|
1874
1884
|
queue: createMemoryQueue,
|
|
1875
|
-
job: createMemoryJob,
|
|
1876
1885
|
i18n: createMemoryI18n
|
|
1877
1886
|
};
|
|
1878
1887
|
|
|
1888
|
+
// src/plugin-registration.ts
|
|
1889
|
+
function versionLabel(plugin) {
|
|
1890
|
+
return plugin.version ? `v${plugin.version}` : "unversioned";
|
|
1891
|
+
}
|
|
1892
|
+
function describeSupersededRegistration(previous, next) {
|
|
1893
|
+
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.`;
|
|
1894
|
+
}
|
|
1895
|
+
function registerPluginByName(registry, plugin, logger) {
|
|
1896
|
+
const previous = registry.get(plugin.name);
|
|
1897
|
+
if (previous !== void 0) {
|
|
1898
|
+
logger.warn(describeSupersededRegistration(previous, plugin), {
|
|
1899
|
+
plugin: plugin.name,
|
|
1900
|
+
supersededVersion: previous.version,
|
|
1901
|
+
supersedingVersion: plugin.version
|
|
1902
|
+
});
|
|
1903
|
+
}
|
|
1904
|
+
registry.set(plugin.name, plugin);
|
|
1905
|
+
return previous;
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
// src/timeout-guard.ts
|
|
1909
|
+
var TimeoutGuard = class {
|
|
1910
|
+
constructor(timeoutMs, createTimeoutError) {
|
|
1911
|
+
/**
|
|
1912
|
+
* Settles `expiry` without a value. `Promise<never>` has no resolvable
|
|
1913
|
+
* value in the type system, but settling it is the entire point: it is
|
|
1914
|
+
* only ever called from `reclaim()`, i.e. after the race it guarded has
|
|
1915
|
+
* already been decided, so the resolution is discarded by construction and
|
|
1916
|
+
* can never become a race winner. The cast localises that argument here
|
|
1917
|
+
* rather than pushing a lie into every caller's return type.
|
|
1918
|
+
*/
|
|
1919
|
+
this.settleExpiry = () => {
|
|
1920
|
+
};
|
|
1921
|
+
this.expiry = new Promise((resolve, reject) => {
|
|
1922
|
+
this.settleExpiry = resolve;
|
|
1923
|
+
this.timer = setTimeout(() => reject(createTimeoutError()), timeoutMs);
|
|
1924
|
+
});
|
|
1925
|
+
}
|
|
1926
|
+
/**
|
|
1927
|
+
* Reclaim the guard once the race it protects has been decided. Both
|
|
1928
|
+
* halves, always: the timer is cleared so it cannot fire against a
|
|
1929
|
+
* lifecycle phase that is already over, and `expiry` is settled so neither
|
|
1930
|
+
* it nor the race's reaction on it is retained.
|
|
1931
|
+
*
|
|
1932
|
+
* Idempotent — `clearTimeout` on a cleared handle and a second resolve on
|
|
1933
|
+
* a settled promise are both no-ops.
|
|
1934
|
+
*/
|
|
1935
|
+
reclaim() {
|
|
1936
|
+
clearTimeout(this.timer);
|
|
1937
|
+
this.timer = void 0;
|
|
1938
|
+
this.settleExpiry();
|
|
1939
|
+
}
|
|
1940
|
+
};
|
|
1941
|
+
async function raceWithTimeout(operation, timeoutMs, createTimeoutError) {
|
|
1942
|
+
const guard = new TimeoutGuard(timeoutMs, createTimeoutError);
|
|
1943
|
+
try {
|
|
1944
|
+
return await Promise.race([operation, guard.expiry]);
|
|
1945
|
+
} finally {
|
|
1946
|
+
guard.reclaim();
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1879
1950
|
// src/kernel.ts
|
|
1880
1951
|
var ObjectKernel = class {
|
|
1881
1952
|
constructor(config = {}) {
|
|
@@ -1960,6 +2031,14 @@ var ObjectKernel = class {
|
|
|
1960
2031
|
}
|
|
1961
2032
|
/**
|
|
1962
2033
|
* Register a plugin with enhanced validation
|
|
2034
|
+
*
|
|
2035
|
+
* Duplicate names OVERWRITE, with one `warn` naming both versions — the
|
|
2036
|
+
* declared contract in `plugin-registration.ts`, applied identically by
|
|
2037
|
+
* `LiteKernel.use()` (#9864, maintainer ruling 2026-08-19). The overwrite
|
|
2038
|
+
* itself is unchanged: it is what lets an app config's `plugins` entry
|
|
2039
|
+
* supersede a plugin the CLI auto-registered earlier in the same boot
|
|
2040
|
+
* (#9863). What changes is that it is no longer silent, and no longer
|
|
2041
|
+
* disagrees with the other kernel.
|
|
1963
2042
|
*/
|
|
1964
2043
|
async use(plugin) {
|
|
1965
2044
|
if (this.state !== "idle") {
|
|
@@ -1970,11 +2049,13 @@ var ObjectKernel = class {
|
|
|
1970
2049
|
throw new Error(`Failed to load plugin: ${plugin.name} - ${result.error?.message}`);
|
|
1971
2050
|
}
|
|
1972
2051
|
const pluginMeta = result.plugin;
|
|
1973
|
-
this.plugins
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
2052
|
+
const superseded = registerPluginByName(this.plugins, pluginMeta, this.logger);
|
|
2053
|
+
if (superseded === void 0) {
|
|
2054
|
+
this.logger.info(`Plugin registered: ${pluginMeta.name}@${pluginMeta.version}`, {
|
|
2055
|
+
plugin: pluginMeta.name,
|
|
2056
|
+
version: pluginMeta.version
|
|
2057
|
+
});
|
|
2058
|
+
}
|
|
1978
2059
|
return this;
|
|
1979
2060
|
}
|
|
1980
2061
|
/**
|
|
@@ -2136,14 +2217,11 @@ var ObjectKernel = class {
|
|
|
2136
2217
|
this.logger.info("Graceful shutdown started");
|
|
2137
2218
|
const shutdownTimeoutError = new Error("Shutdown timeout exceeded");
|
|
2138
2219
|
try {
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
if (t.unref) t.unref();
|
|
2145
|
-
});
|
|
2146
|
-
await Promise.race([shutdownPromise, timeoutPromise]);
|
|
2220
|
+
await raceWithTimeout(
|
|
2221
|
+
this.performShutdown(),
|
|
2222
|
+
this.config.shutdownTimeout,
|
|
2223
|
+
() => shutdownTimeoutError
|
|
2224
|
+
);
|
|
2147
2225
|
this.state = "stopped";
|
|
2148
2226
|
this.logger.info("\u2705 Graceful shutdown complete");
|
|
2149
2227
|
} catch (error) {
|
|
@@ -2261,25 +2339,17 @@ var ObjectKernel = class {
|
|
|
2261
2339
|
* as well: if the hook never settles and nothing else keeps the loop alive,
|
|
2262
2340
|
* Node exits before the timer can fire and the timeout is never reported.
|
|
2263
2341
|
* The guard has to stay ref'd exactly as long as the race is undecided,
|
|
2264
|
-
* which is what
|
|
2342
|
+
* which is what clearing on settle expresses.
|
|
2265
2343
|
*
|
|
2266
|
-
*
|
|
2267
|
-
*
|
|
2268
|
-
*
|
|
2269
|
-
*
|
|
2344
|
+
* Clearing the timer was only half of it, though (#10604): the promise the
|
|
2345
|
+
* race still holds a reaction on has to SETTLE, or it and that reaction are
|
|
2346
|
+
* retained past the end of the run — two leaking promises per boot, which
|
|
2347
|
+
* is what `vitest --detectAsyncLeaks` names here. Both halves now live in
|
|
2348
|
+
* `TimeoutGuard.reclaim()`, shared with `shutdown()`, so the two sites
|
|
2349
|
+
* cannot drift into doing one half each again.
|
|
2270
2350
|
*/
|
|
2271
2351
|
async raceStartupTimeout(operation, timeout, message) {
|
|
2272
|
-
|
|
2273
|
-
const timeoutPromise = new Promise((_, reject) => {
|
|
2274
|
-
guard = setTimeout(() => {
|
|
2275
|
-
reject(new Error(message));
|
|
2276
|
-
}, timeout);
|
|
2277
|
-
});
|
|
2278
|
-
try {
|
|
2279
|
-
return await Promise.race([operation, timeoutPromise]);
|
|
2280
|
-
} finally {
|
|
2281
|
-
clearTimeout(guard);
|
|
2282
|
-
}
|
|
2352
|
+
return raceWithTimeout(operation, timeout, () => new Error(message));
|
|
2283
2353
|
}
|
|
2284
2354
|
/**
|
|
2285
2355
|
* Whether a service is resolvable on this kernel right now — direct
|
|
@@ -2452,14 +2522,21 @@ var LiteKernel = class extends ObjectKernelBase {
|
|
|
2452
2522
|
/**
|
|
2453
2523
|
* Register a plugin
|
|
2454
2524
|
* @param plugin - Plugin instance
|
|
2525
|
+
*
|
|
2526
|
+
* Duplicate names OVERWRITE, with one `warn` naming both versions — the
|
|
2527
|
+
* declared contract in `plugin-registration.ts`, applied identically by
|
|
2528
|
+
* `ObjectKernel.use()` (#9864, maintainer ruling 2026-08-19).
|
|
2529
|
+
*
|
|
2530
|
+
* This method used to `throw` `[Kernel] Plugin '<name>' already
|
|
2531
|
+
* registered` here while `ObjectKernel` overwrote silently, so one input
|
|
2532
|
+
* had two meanings depending on which kernel was running — and the kernel
|
|
2533
|
+
* that runs in production was the silent one. The ruling converged them on
|
|
2534
|
+
* the behaviour that already works (an app config superseding a plugin the
|
|
2535
|
+
* CLI auto-registered, #9863) and made it audible rather than removing it.
|
|
2455
2536
|
*/
|
|
2456
2537
|
use(plugin) {
|
|
2457
2538
|
this.validateIdle();
|
|
2458
|
-
|
|
2459
|
-
if (this.plugins.has(pluginName)) {
|
|
2460
|
-
throw new Error(`[Kernel] Plugin '${pluginName}' already registered`);
|
|
2461
|
-
}
|
|
2462
|
-
this.plugins.set(pluginName, plugin);
|
|
2539
|
+
registerPluginByName(this.plugins, plugin, this.logger);
|
|
2463
2540
|
return this;
|
|
2464
2541
|
}
|
|
2465
2542
|
/**
|
|
@@ -2709,27 +2786,117 @@ var TestRunner = class {
|
|
|
2709
2786
|
|
|
2710
2787
|
// src/qa/http-adapter.ts
|
|
2711
2788
|
var import_api = require("@objectstack/spec/api");
|
|
2712
|
-
var
|
|
2713
|
-
function
|
|
2714
|
-
if (
|
|
2789
|
+
var conventionCache;
|
|
2790
|
+
function conventionMounts() {
|
|
2791
|
+
if (conventionCache === void 0) {
|
|
2715
2792
|
const api = import_api.RestApiConfigSchema.parse({});
|
|
2716
2793
|
const crud = import_api.CrudEndpointsConfigSchema.parse({});
|
|
2717
|
-
|
|
2794
|
+
const apiBase = api.apiPath ?? `${api.basePath}/${api.version}`;
|
|
2795
|
+
conventionCache = { apiBase, dataPath: `${apiBase}${crud.dataPrefix}` };
|
|
2718
2796
|
}
|
|
2719
|
-
return
|
|
2797
|
+
return conventionCache;
|
|
2720
2798
|
}
|
|
2721
2799
|
var HttpTestAdapter = class {
|
|
2722
2800
|
constructor(baseUrl, authToken) {
|
|
2723
2801
|
this.baseUrl = baseUrl;
|
|
2724
2802
|
this.authToken = authToken;
|
|
2725
2803
|
}
|
|
2726
|
-
/**
|
|
2727
|
-
|
|
2728
|
-
|
|
2804
|
+
/** The resolved data mount; probes at most once per adapter. */
|
|
2805
|
+
dataMount() {
|
|
2806
|
+
if (this.mountPromise === void 0) {
|
|
2807
|
+
this.mountPromise = this.resolveDataMount();
|
|
2808
|
+
}
|
|
2809
|
+
return this.mountPromise;
|
|
2810
|
+
}
|
|
2811
|
+
/**
|
|
2812
|
+
* Ask the server where it serves the Data Protocol, and fall back to the
|
|
2813
|
+
* convention — loudly — when it cannot say.
|
|
2814
|
+
*
|
|
2815
|
+
* ## [#7983] What the probe recovers, measured rather than assumed
|
|
2816
|
+
*
|
|
2817
|
+
* `@objectstack/client` answers the same question through discovery
|
|
2818
|
+
* (`getRoute`, `packages/client/src/index.ts`), and this follows it: prefer
|
|
2819
|
+
* the server's own `routes.data`, fall back to the convention. Measured on a
|
|
2820
|
+
* booted stack (REST generator + dispatcher bridge, three configs):
|
|
2821
|
+
*
|
|
2822
|
+
* | deployment | `{apiBase}/discovery` | serves |
|
|
2823
|
+
* |--------------------------------|-----------------------|---------------|
|
|
2824
|
+
* | stock | 200 `/api/v1/data` | `/api/v1/data`|
|
|
2825
|
+
* | `crud.dataPrefix: '/objects'` | 200 `/api/v1/objects` | `/api/v1/objects` |
|
|
2826
|
+
* | `api.apiPath: '/api/2026-01'` | **404** | `/api/2026-01/data` |
|
|
2827
|
+
*
|
|
2828
|
+
* So the probe closes the `dataPrefix` row exactly: `RestServer`'s discovery
|
|
2829
|
+
* handler substitutes the configured prefix into `routes.data`, and reading
|
|
2830
|
+
* it is strictly better than recomputing it here. The `apiPath` row it cannot
|
|
2831
|
+
* close, and the reason is structural rather than an oversight — `apiPath`
|
|
2832
|
+
* moves the base that discovery itself is mounted under, so the document that
|
|
2833
|
+
* would name the new mount is behind the very prefix we are missing.
|
|
2834
|
+
*
|
|
2835
|
+
* ⛔ And the one discovery document at a FIXED path does not rescue it:
|
|
2836
|
+
* `/.well-known/objectstack` is mounted at the site root by the dispatcher
|
|
2837
|
+
* bridge, but its `routes.data` is the DISPATCHER's own `${prefix}/data` —
|
|
2838
|
+
* measured as `/api/v1/data` under all three configs above, including the two
|
|
2839
|
+
* where the server serves elsewhere. Falling back to it would turn "we could
|
|
2840
|
+
* not resolve the mount" into "discovery told us `/api/v1/data`": the same
|
|
2841
|
+
* 404, now with a false provenance attached. Not probed, deliberately.
|
|
2842
|
+
*
|
|
2843
|
+
* Hence: one probe, then a diagnostic that NAMES the mount, the evidence and
|
|
2844
|
+
* the remedy. `api_call` takes the path it is given and is unaffected either
|
|
2845
|
+
* way — it stays the escape hatch for a host this cannot reach.
|
|
2846
|
+
*/
|
|
2847
|
+
async resolveDataMount() {
|
|
2848
|
+
const { apiBase, dataPath } = conventionMounts();
|
|
2849
|
+
const probeUrl = `${this.baseUrl}${apiBase}/discovery`;
|
|
2850
|
+
let why;
|
|
2851
|
+
try {
|
|
2852
|
+
const headers = {};
|
|
2853
|
+
if (this.authToken) {
|
|
2854
|
+
headers["Authorization"] = `Bearer ${this.authToken}`;
|
|
2855
|
+
}
|
|
2856
|
+
const response = await fetch(probeUrl, { method: "GET", headers });
|
|
2857
|
+
if (response.ok) {
|
|
2858
|
+
const body = await response.json();
|
|
2859
|
+
const doc = body && typeof body === "object" && "routes" in body ? body : body?.data;
|
|
2860
|
+
const advertised = doc?.routes?.data;
|
|
2861
|
+
if (typeof advertised === "string" && advertised.length > 0) {
|
|
2862
|
+
return {
|
|
2863
|
+
path: advertised,
|
|
2864
|
+
source: "discovery",
|
|
2865
|
+
why: `GET ${probeUrl} advertised routes.data`
|
|
2866
|
+
};
|
|
2867
|
+
}
|
|
2868
|
+
why = `GET ${probeUrl} answered ${response.status} but carried no routes.data`;
|
|
2869
|
+
} else {
|
|
2870
|
+
why = `GET ${probeUrl} answered ${response.status}`;
|
|
2871
|
+
}
|
|
2872
|
+
} catch (error) {
|
|
2873
|
+
why = `GET ${probeUrl} could not be reached (${error.message})`;
|
|
2874
|
+
}
|
|
2875
|
+
const mount = { path: dataPath, source: "convention", why };
|
|
2876
|
+
console.warn(
|
|
2877
|
+
`[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.`
|
|
2878
|
+
);
|
|
2879
|
+
return mount;
|
|
2880
|
+
}
|
|
2881
|
+
/** `{baseUrl}{dataMount}/{object}` — the collection URL. */
|
|
2882
|
+
collectionUrl(mount, objectName) {
|
|
2883
|
+
return `${this.baseUrl}${mount.path}/${encodeURIComponent(objectName)}`;
|
|
2729
2884
|
}
|
|
2730
2885
|
/** `{collection}/{id}` — the single-record URL. */
|
|
2731
|
-
recordUrl(objectName, id) {
|
|
2732
|
-
return `${this.collectionUrl(objectName)}/${encodeURIComponent(String(id))}`;
|
|
2886
|
+
recordUrl(mount, objectName, id) {
|
|
2887
|
+
return `${this.collectionUrl(mount, objectName)}/${encodeURIComponent(String(id))}`;
|
|
2888
|
+
}
|
|
2889
|
+
/**
|
|
2890
|
+
* The provenance clause appended to a failed record action's error.
|
|
2891
|
+
*
|
|
2892
|
+
* The card this closes is about a 404 that reads like the author's own URL
|
|
2893
|
+
* mistake; the mount is the one fact that distinguishes the two, so it rides
|
|
2894
|
+
* on the failure itself rather than only on a warning printed earlier in the
|
|
2895
|
+
* transcript.
|
|
2896
|
+
*/
|
|
2897
|
+
mountNote(mount) {
|
|
2898
|
+
const how = mount.source === "discovery" ? "resolved from discovery" : "assumed by convention \u2014 the server was not able to state it";
|
|
2899
|
+
return `record actions addressed ${this.baseUrl}${mount.path} (data mount ${how}: ${mount.why})`;
|
|
2733
2900
|
}
|
|
2734
2901
|
async execute(action, _context) {
|
|
2735
2902
|
const headers = {
|
|
@@ -2762,48 +2929,53 @@ var HttpTestAdapter = class {
|
|
|
2762
2929
|
}
|
|
2763
2930
|
}
|
|
2764
2931
|
async createRecord(objectName, data, headers) {
|
|
2765
|
-
const
|
|
2932
|
+
const mount = await this.dataMount();
|
|
2933
|
+
const response = await fetch(this.collectionUrl(mount, objectName), {
|
|
2766
2934
|
method: "POST",
|
|
2767
2935
|
headers,
|
|
2768
2936
|
body: JSON.stringify(data)
|
|
2769
2937
|
});
|
|
2770
|
-
return this.handleResponse(response);
|
|
2938
|
+
return this.handleResponse(response, this.mountNote(mount));
|
|
2771
2939
|
}
|
|
2772
2940
|
async updateRecord(objectName, data, headers) {
|
|
2773
2941
|
const { id, ...fields } = data;
|
|
2774
2942
|
if (!id) throw new Error("Update record requires id in payload");
|
|
2775
|
-
const
|
|
2943
|
+
const mount = await this.dataMount();
|
|
2944
|
+
const response = await fetch(this.recordUrl(mount, objectName, id), {
|
|
2776
2945
|
method: "PATCH",
|
|
2777
2946
|
headers,
|
|
2778
2947
|
body: JSON.stringify(fields)
|
|
2779
2948
|
});
|
|
2780
|
-
return this.handleResponse(response);
|
|
2949
|
+
return this.handleResponse(response, this.mountNote(mount));
|
|
2781
2950
|
}
|
|
2782
2951
|
async deleteRecord(objectName, data, headers) {
|
|
2783
2952
|
const id = data.id;
|
|
2784
2953
|
if (!id) throw new Error("Delete record requires id in payload");
|
|
2785
|
-
const
|
|
2954
|
+
const mount = await this.dataMount();
|
|
2955
|
+
const response = await fetch(this.recordUrl(mount, objectName, id), {
|
|
2786
2956
|
method: "DELETE",
|
|
2787
2957
|
headers
|
|
2788
2958
|
});
|
|
2789
|
-
return this.handleResponse(response);
|
|
2959
|
+
return this.handleResponse(response, this.mountNote(mount));
|
|
2790
2960
|
}
|
|
2791
2961
|
async readRecord(objectName, data, headers) {
|
|
2792
2962
|
const id = data.id;
|
|
2793
2963
|
if (!id) throw new Error("Read record requires id in payload");
|
|
2794
|
-
const
|
|
2964
|
+
const mount = await this.dataMount();
|
|
2965
|
+
const response = await fetch(this.recordUrl(mount, objectName, id), {
|
|
2795
2966
|
method: "GET",
|
|
2796
2967
|
headers
|
|
2797
2968
|
});
|
|
2798
|
-
return this.handleResponse(response);
|
|
2969
|
+
return this.handleResponse(response, this.mountNote(mount));
|
|
2799
2970
|
}
|
|
2800
2971
|
async queryRecords(objectName, data, headers) {
|
|
2801
|
-
const
|
|
2972
|
+
const mount = await this.dataMount();
|
|
2973
|
+
const response = await fetch(`${this.collectionUrl(mount, objectName)}/query`, {
|
|
2802
2974
|
method: "POST",
|
|
2803
2975
|
headers,
|
|
2804
2976
|
body: JSON.stringify(data)
|
|
2805
2977
|
});
|
|
2806
|
-
return this.handleResponse(response);
|
|
2978
|
+
return this.handleResponse(response, this.mountNote(mount));
|
|
2807
2979
|
}
|
|
2808
2980
|
async rawApiCall(endpoint, data, headers) {
|
|
2809
2981
|
const method = data.method || "GET";
|
|
@@ -2816,10 +2988,13 @@ var HttpTestAdapter = class {
|
|
|
2816
2988
|
});
|
|
2817
2989
|
return this.handleResponse(response);
|
|
2818
2990
|
}
|
|
2819
|
-
async handleResponse(response) {
|
|
2991
|
+
async handleResponse(response, mountNote) {
|
|
2820
2992
|
if (!response.ok) {
|
|
2821
2993
|
const text = await response.text();
|
|
2822
|
-
|
|
2994
|
+
const wrongUrlShaped = response.status === 404 || response.status === 405;
|
|
2995
|
+
throw new Error(
|
|
2996
|
+
`HTTP Error ${response.status}: ${text}${wrongUrlShaped && mountNote ? ` \u2014 ${mountNote}` : ""}`
|
|
2997
|
+
);
|
|
2823
2998
|
}
|
|
2824
2999
|
const contentType = response.headers.get("content-type");
|
|
2825
3000
|
if (contentType && contentType.includes("application/json")) {
|
|
@@ -4161,6 +4336,7 @@ var PluginSecurityScanner = class {
|
|
|
4161
4336
|
|
|
4162
4337
|
// src/security/api-key.ts
|
|
4163
4338
|
var import_node_crypto2 = require("crypto");
|
|
4339
|
+
var import_security = require("@objectstack/spec/security");
|
|
4164
4340
|
var API_KEY_PREFIX = "osk_";
|
|
4165
4341
|
var API_KEY_ENTROPY_BYTES = 32;
|
|
4166
4342
|
var VISIBLE_PREFIX_LEN = 12;
|
|
@@ -4214,10 +4390,18 @@ function isExpired(value, nowMs) {
|
|
|
4214
4390
|
if (Number.isNaN(ms)) return false;
|
|
4215
4391
|
return ms <= nowMs;
|
|
4216
4392
|
}
|
|
4217
|
-
|
|
4393
|
+
function effectiveTenancyPosture(tenancy) {
|
|
4394
|
+
if (!tenancy) return void 0;
|
|
4395
|
+
return (0, import_security.normalizeTenancyPosture)(tenancy.posture) ?? (tenancy.isolationActive ? "isolated" : "single");
|
|
4396
|
+
}
|
|
4397
|
+
async function resolveApiKeyPrincipal(ql, headers, nowMs = Date.now(), tenancyPosture) {
|
|
4398
|
+
const admission = await resolveApiKeyAdmission(ql, headers, nowMs, tenancyPosture);
|
|
4399
|
+
return admission.outcome === "admitted" ? admission.principal : void 0;
|
|
4400
|
+
}
|
|
4401
|
+
async function resolveApiKeyAdmission(ql, headers, nowMs = Date.now(), tenancyPosture) {
|
|
4218
4402
|
const apiKey = extractApiKey(headers);
|
|
4219
|
-
if (!apiKey) return
|
|
4220
|
-
if (!ql || typeof ql.find !== "function") return
|
|
4403
|
+
if (!apiKey) return { outcome: "none" };
|
|
4404
|
+
if (!ql || typeof ql.find !== "function") return { outcome: "none" };
|
|
4221
4405
|
let rows;
|
|
4222
4406
|
try {
|
|
4223
4407
|
rows = await ql.find("sys_api_key", {
|
|
@@ -4226,19 +4410,29 @@ async function resolveApiKeyPrincipal(ql, headers, nowMs = Date.now()) {
|
|
|
4226
4410
|
context: { isSystem: true }
|
|
4227
4411
|
});
|
|
4228
4412
|
} catch {
|
|
4229
|
-
return
|
|
4413
|
+
return { outcome: "none" };
|
|
4230
4414
|
}
|
|
4231
4415
|
if (rows && rows.value) rows = rows.value;
|
|
4232
4416
|
const row = Array.isArray(rows) ? rows[0] : void 0;
|
|
4233
|
-
if (!row || row.revoked === true) return
|
|
4417
|
+
if (!row || row.revoked === true) return { outcome: "none" };
|
|
4234
4418
|
const expiresAt = row.expires_at ?? row.expiresAt;
|
|
4235
|
-
if (isExpired(expiresAt, nowMs)) return
|
|
4419
|
+
if (isExpired(expiresAt, nowMs)) return { outcome: "none" };
|
|
4236
4420
|
const userId = row.user_id ?? row.userId;
|
|
4237
|
-
if (!userId || typeof userId !== "string") return
|
|
4421
|
+
if (!userId || typeof userId !== "string") return { outcome: "none" };
|
|
4422
|
+
const tenantId = typeof row.active_organization_id === "string" && row.active_organization_id ? row.active_organization_id : void 0;
|
|
4423
|
+
if (!tenantId && tenancyPosture) {
|
|
4424
|
+
const posture = tenancyPosture;
|
|
4425
|
+
if ((0, import_security.postureEnforcesWall)(posture) && !(0, import_security.postureUsesUnionScope)(posture)) {
|
|
4426
|
+
return {
|
|
4427
|
+
outcome: "refused",
|
|
4428
|
+
reason: "organization_required",
|
|
4429
|
+
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."
|
|
4430
|
+
};
|
|
4431
|
+
}
|
|
4432
|
+
}
|
|
4238
4433
|
return {
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
scopes: parseScopes(row.scopes)
|
|
4434
|
+
outcome: "admitted",
|
|
4435
|
+
principal: { userId, tenantId, scopes: parseScopes(row.scopes) }
|
|
4242
4436
|
};
|
|
4243
4437
|
}
|
|
4244
4438
|
function readHeader(headers, name) {
|
|
@@ -4266,6 +4460,7 @@ function safeJsonParse(s, fallback) {
|
|
|
4266
4460
|
|
|
4267
4461
|
// src/security/resolve-authz-context.ts
|
|
4268
4462
|
var import_spec = require("@objectstack/spec");
|
|
4463
|
+
var import_security2 = require("@objectstack/spec/security");
|
|
4269
4464
|
|
|
4270
4465
|
// src/security/grant-validity.ts
|
|
4271
4466
|
function toEpochMs(value) {
|
|
@@ -4348,6 +4543,15 @@ function postureVisibleRows(posture, rows, principal) {
|
|
|
4348
4543
|
}
|
|
4349
4544
|
}
|
|
4350
4545
|
|
|
4546
|
+
// src/security/row-active.ts
|
|
4547
|
+
var DEACTIVATED_VALUES = [false, 0, "0", "false"];
|
|
4548
|
+
function isRowActive(row) {
|
|
4549
|
+
if (!row) return false;
|
|
4550
|
+
const value = row.active;
|
|
4551
|
+
if (value === void 0 || value === null) return true;
|
|
4552
|
+
return !DEACTIVATED_VALUES.includes(value);
|
|
4553
|
+
}
|
|
4554
|
+
|
|
4351
4555
|
// src/security/resolve-authz-context.ts
|
|
4352
4556
|
function safeJsonParse2(s, fallback) {
|
|
4353
4557
|
try {
|
|
@@ -4356,10 +4560,11 @@ function safeJsonParse2(s, fallback) {
|
|
|
4356
4560
|
return fallback;
|
|
4357
4561
|
}
|
|
4358
4562
|
}
|
|
4359
|
-
async function tryFind(ql, object, where, limit = 100) {
|
|
4563
|
+
async function tryFind(ql, object, where, limit = 100, organizationId) {
|
|
4360
4564
|
if (!ql || typeof ql.find !== "function") return [];
|
|
4361
4565
|
try {
|
|
4362
|
-
|
|
4566
|
+
const context = organizationId ? { isSystem: true, tenantId: organizationId } : { isSystem: true };
|
|
4567
|
+
let rows = await ql.find(object, { where, limit, context });
|
|
4363
4568
|
if (rows && rows.value) rows = rows.value;
|
|
4364
4569
|
return Array.isArray(rows) ? rows : [];
|
|
4365
4570
|
} catch {
|
|
@@ -4377,7 +4582,12 @@ async function resolveAuthzContext(input) {
|
|
|
4377
4582
|
};
|
|
4378
4583
|
let userId;
|
|
4379
4584
|
let tenantId;
|
|
4380
|
-
const
|
|
4585
|
+
const admission = await resolveApiKeyAdmission(ql, headers, input.nowMs, input.tenancyPosture);
|
|
4586
|
+
if (admission.outcome === "refused") {
|
|
4587
|
+
ctx.authRefusal = { reason: admission.reason, message: admission.message };
|
|
4588
|
+
return ctx;
|
|
4589
|
+
}
|
|
4590
|
+
const keyPrincipal = admission.outcome === "admitted" ? admission.principal : void 0;
|
|
4381
4591
|
if (keyPrincipal) {
|
|
4382
4592
|
userId = keyPrincipal.userId;
|
|
4383
4593
|
tenantId = keyPrincipal.tenantId;
|
|
@@ -4405,6 +4615,22 @@ async function resolveAuthzContext(input) {
|
|
|
4405
4615
|
seedPermissions: ctx.permissions,
|
|
4406
4616
|
seedEmail: ctx.email
|
|
4407
4617
|
});
|
|
4618
|
+
if (keyPrincipal?.tenantId && input.tenancyPosture) {
|
|
4619
|
+
const posture = input.tenancyPosture;
|
|
4620
|
+
if ((0, import_security2.postureEnforcesWall)(posture) && !grants.accessible_org_ids.includes(keyPrincipal.tenantId)) {
|
|
4621
|
+
return {
|
|
4622
|
+
positions: [],
|
|
4623
|
+
permissions: [],
|
|
4624
|
+
systemPermissions: [],
|
|
4625
|
+
org_user_ids: [],
|
|
4626
|
+
accessible_org_ids: [],
|
|
4627
|
+
authRefusal: {
|
|
4628
|
+
reason: "organization_membership_ended",
|
|
4629
|
+
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."
|
|
4630
|
+
}
|
|
4631
|
+
};
|
|
4632
|
+
}
|
|
4633
|
+
}
|
|
4408
4634
|
ctx.positions = grants.positions;
|
|
4409
4635
|
ctx.permissions = grants.permissions;
|
|
4410
4636
|
ctx.systemPermissions = grants.systemPermissions;
|
|
@@ -4436,12 +4662,19 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
|
|
|
4436
4662
|
}
|
|
4437
4663
|
return userRow;
|
|
4438
4664
|
};
|
|
4665
|
+
const needsUserRow = !grants.email || !grants.permissions.includes("ai_seat");
|
|
4666
|
+
const [, members, userPositionRows, orgMembersLeg, upsRowsAll] = await Promise.all([
|
|
4667
|
+
needsUserRow ? getUserRow() : Promise.resolve(void 0),
|
|
4668
|
+
tryFind(ql, "sys_member", { user_id: userId }, 200),
|
|
4669
|
+
tryFind(ql, "sys_user_position", { user_id: userId }, 200),
|
|
4670
|
+
tenantId ? tryFind(ql, "sys_member", { organization_id: tenantId }, 1e3) : Promise.resolve([]),
|
|
4671
|
+
tryFind(ql, "sys_user_permission_set", { user_id: userId }, 100)
|
|
4672
|
+
]);
|
|
4439
4673
|
if (!grants.email) {
|
|
4440
4674
|
const u = await getUserRow();
|
|
4441
4675
|
if (u?.email) grants.email = String(u.email);
|
|
4442
4676
|
}
|
|
4443
4677
|
const nowMs = opts.nowMs ?? Date.now();
|
|
4444
|
-
const members = await tryFind(ql, "sys_member", { user_id: userId }, 200);
|
|
4445
4678
|
const accessibleOrgIds = /* @__PURE__ */ new Set();
|
|
4446
4679
|
for (const m of members) {
|
|
4447
4680
|
if (!isGrantActive(m, nowMs)) continue;
|
|
@@ -4449,7 +4682,9 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
|
|
|
4449
4682
|
if (typeof org === "string" && org) accessibleOrgIds.add(org);
|
|
4450
4683
|
}
|
|
4451
4684
|
grants.accessible_org_ids = Array.from(accessibleOrgIds);
|
|
4452
|
-
const activeMembers =
|
|
4685
|
+
const activeMembers = members.filter(
|
|
4686
|
+
(m) => isGrantActive(m, nowMs) && (!tenantId || (m.organization_id ?? m.organizationId) === tenantId)
|
|
4687
|
+
);
|
|
4453
4688
|
for (const m of activeMembers) {
|
|
4454
4689
|
if (m.role && typeof m.role === "string") {
|
|
4455
4690
|
for (const raw of m.role.split(",").map((s) => s.trim()).filter(Boolean)) {
|
|
@@ -4458,7 +4693,6 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
|
|
|
4458
4693
|
}
|
|
4459
4694
|
}
|
|
4460
4695
|
}
|
|
4461
|
-
const userPositionRows = await tryFind(ql, "sys_user_position", { user_id: userId }, 200);
|
|
4462
4696
|
for (const ur of userPositionRows) {
|
|
4463
4697
|
const org = ur.organization_id ?? null;
|
|
4464
4698
|
if (org && tenantId && org !== tenantId) continue;
|
|
@@ -4467,14 +4701,13 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
|
|
|
4467
4701
|
if (typeof r === "string" && r && !grants.positions.includes(r)) grants.positions.push(r);
|
|
4468
4702
|
}
|
|
4469
4703
|
if (tenantId) {
|
|
4470
|
-
const orgMembers =
|
|
4704
|
+
const orgMembers = orgMembersLeg;
|
|
4471
4705
|
const ids = new Set(
|
|
4472
4706
|
orgMembers.map((m) => m.user_id ?? m.userId).filter((v) => typeof v === "string" && v.length > 0)
|
|
4473
4707
|
);
|
|
4474
4708
|
ids.add(userId);
|
|
4475
4709
|
grants.org_user_ids = Array.from(ids);
|
|
4476
4710
|
}
|
|
4477
|
-
const upsRowsAll = await tryFind(ql, "sys_user_permission_set", { user_id: userId }, 100);
|
|
4478
4711
|
const upsRows = upsRowsAll.filter((r) => isGrantActive(r, nowMs));
|
|
4479
4712
|
const psIds = new Set(
|
|
4480
4713
|
upsRows.filter((r) => {
|
|
@@ -4488,8 +4721,14 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
|
|
|
4488
4721
|
let hasPlatformAdminGrant = false;
|
|
4489
4722
|
if (!grants.positions.includes("everyone")) grants.positions.push("everyone");
|
|
4490
4723
|
if (grants.positions.length > 0) {
|
|
4491
|
-
const positionRows = await tryFind(ql, "sys_position", { name: { $in: grants.positions } },
|
|
4492
|
-
const
|
|
4724
|
+
const positionRows = await tryFind(ql, "sys_position", { name: { $in: grants.positions } }, 200, tenantId);
|
|
4725
|
+
const deactivatedNames = new Set(
|
|
4726
|
+
positionRows.filter((r) => !isRowActive(r)).map((r) => r.name).filter(Boolean)
|
|
4727
|
+
);
|
|
4728
|
+
if (deactivatedNames.size > 0) {
|
|
4729
|
+
grants.positions = grants.positions.filter((n) => !deactivatedNames.has(n));
|
|
4730
|
+
}
|
|
4731
|
+
const positionIds = positionRows.filter((r) => isRowActive(r)).map((r) => r.id).filter(Boolean);
|
|
4493
4732
|
if (positionIds.length > 0) {
|
|
4494
4733
|
const rpsRows = await tryFind(ql, "sys_position_permission_set", { position_id: { $in: positionIds } }, 500);
|
|
4495
4734
|
for (const r of rpsRows) {
|
|
@@ -4499,7 +4738,8 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
|
|
|
4499
4738
|
}
|
|
4500
4739
|
}
|
|
4501
4740
|
if (psIds.size > 0) {
|
|
4502
|
-
const
|
|
4741
|
+
const psRowsAll = await tryFind(ql, "sys_permission_set", { id: { $in: Array.from(psIds) } }, 500);
|
|
4742
|
+
const psRows = psRowsAll.filter((r) => isRowActive(r));
|
|
4503
4743
|
const tabRank = { hidden: 0, default_off: 1, default_on: 2, visible: 3 };
|
|
4504
4744
|
const mergedTabs = {};
|
|
4505
4745
|
for (const ps of psRows) {
|
|
@@ -4559,34 +4799,89 @@ function coerceCurrency(value) {
|
|
|
4559
4799
|
const s = typeof value === "string" ? value.trim().toUpperCase() : "";
|
|
4560
4800
|
return /^[A-Z]{3}$/.test(s) ? s : void 0;
|
|
4561
4801
|
}
|
|
4802
|
+
var LOCALIZATION_FAILURE_CACHE_TTL_MS = 3e4;
|
|
4803
|
+
var localizationFailureCache = /* @__PURE__ */ new WeakMap();
|
|
4562
4804
|
async function resolveLocalizationContext(input) {
|
|
4805
|
+
const { ql, tenantId, userId } = input;
|
|
4806
|
+
const cacheKey = `${tenantId ?? ""}|${userId ?? ""}`;
|
|
4807
|
+
if (ql && typeof ql === "object") {
|
|
4808
|
+
const hit = localizationFailureCache.get(ql)?.get(cacheKey);
|
|
4809
|
+
if (hit && hit.expiresAt > Date.now()) return hit.value;
|
|
4810
|
+
}
|
|
4811
|
+
const { value, failed } = await resolveLocalizationContextUncached(input);
|
|
4812
|
+
if (failed && ql && typeof ql === "object") {
|
|
4813
|
+
const bucket = localizationFailureCache.get(ql) ?? /* @__PURE__ */ new Map();
|
|
4814
|
+
bucket.set(cacheKey, { value, expiresAt: Date.now() + LOCALIZATION_FAILURE_CACHE_TTL_MS });
|
|
4815
|
+
localizationFailureCache.set(ql, bucket);
|
|
4816
|
+
}
|
|
4817
|
+
return value;
|
|
4818
|
+
}
|
|
4819
|
+
async function resolveLocalizationContextUncached(input) {
|
|
4563
4820
|
const { ql, settings, tenantId, userId } = input;
|
|
4821
|
+
let failed = false;
|
|
4564
4822
|
try {
|
|
4565
4823
|
if (settings && typeof settings.get === "function") {
|
|
4566
4824
|
const sctx = { tenantId, userId };
|
|
4567
|
-
|
|
4568
|
-
|
|
4569
|
-
|
|
4570
|
-
|
|
4571
|
-
|
|
4825
|
+
let tzRes;
|
|
4826
|
+
let localeRes;
|
|
4827
|
+
let currencyRes;
|
|
4828
|
+
if (typeof settings.getMany === "function") {
|
|
4829
|
+
try {
|
|
4830
|
+
const many = await settings.getMany("localization", ["timezone", "locale", "currency"], sctx);
|
|
4831
|
+
tzRes = many.timezone;
|
|
4832
|
+
localeRes = many.locale;
|
|
4833
|
+
currencyRes = many.currency;
|
|
4834
|
+
} catch {
|
|
4835
|
+
failed = true;
|
|
4836
|
+
}
|
|
4837
|
+
} else {
|
|
4838
|
+
[tzRes, localeRes, currencyRes] = await Promise.all([
|
|
4839
|
+
settings.get("localization", "timezone", sctx).catch(() => {
|
|
4840
|
+
failed = true;
|
|
4841
|
+
return void 0;
|
|
4842
|
+
}),
|
|
4843
|
+
settings.get("localization", "locale", sctx).catch(() => {
|
|
4844
|
+
failed = true;
|
|
4845
|
+
return void 0;
|
|
4846
|
+
}),
|
|
4847
|
+
settings.get("localization", "currency", sctx).catch(() => {
|
|
4848
|
+
failed = true;
|
|
4849
|
+
return void 0;
|
|
4850
|
+
})
|
|
4851
|
+
]);
|
|
4852
|
+
}
|
|
4572
4853
|
const tz = coerceTimeZone(tzRes?.value);
|
|
4573
4854
|
const locale = coerceLocale(localeRes?.value);
|
|
4574
4855
|
const currency = coerceCurrency(currencyRes?.value);
|
|
4575
|
-
if (tz || locale || currency)
|
|
4856
|
+
if (tz || locale || currency) {
|
|
4857
|
+
return { value: { timezone: tz ?? "UTC", locale: locale ?? "en-US", currency }, failed: false };
|
|
4858
|
+
}
|
|
4576
4859
|
}
|
|
4577
4860
|
} catch {
|
|
4861
|
+
failed = true;
|
|
4862
|
+
}
|
|
4863
|
+
let rows = [];
|
|
4864
|
+
if (ql && typeof ql.find === "function") {
|
|
4865
|
+
try {
|
|
4866
|
+
let result = await ql.find("sys_setting", {
|
|
4867
|
+
where: { namespace: "localization", key: { $in: ["timezone", "locale", "currency"] }, scope: "tenant" },
|
|
4868
|
+
limit: 10,
|
|
4869
|
+
context: { isSystem: true }
|
|
4870
|
+
});
|
|
4871
|
+
if (result && result.value) result = result.value;
|
|
4872
|
+
rows = Array.isArray(result) ? result : [];
|
|
4873
|
+
} catch {
|
|
4874
|
+
failed = true;
|
|
4875
|
+
}
|
|
4578
4876
|
}
|
|
4579
|
-
const rows = await tryFind(
|
|
4580
|
-
ql,
|
|
4581
|
-
"sys_setting",
|
|
4582
|
-
{ namespace: "localization", key: { $in: ["timezone", "locale", "currency"] }, scope: "tenant" },
|
|
4583
|
-
10
|
|
4584
|
-
);
|
|
4585
4877
|
const valueOf = (k) => rows.find((r) => r.key === k)?.value;
|
|
4586
4878
|
return {
|
|
4587
|
-
|
|
4588
|
-
|
|
4589
|
-
|
|
4879
|
+
value: {
|
|
4880
|
+
timezone: coerceTimeZone(valueOf("timezone")) ?? "UTC",
|
|
4881
|
+
locale: coerceLocale(valueOf("locale")) ?? "en-US",
|
|
4882
|
+
currency: coerceCurrency(valueOf("currency"))
|
|
4883
|
+
},
|
|
4884
|
+
failed
|
|
4590
4885
|
};
|
|
4591
4886
|
}
|
|
4592
4887
|
|
|
@@ -4730,6 +5025,7 @@ var ANONYMOUS_DENY_CODE = "UNAUTHENTICATED";
|
|
|
4730
5025
|
var ANONYMOUS_DENY_MESSAGE = "Authentication is required to access this endpoint.";
|
|
4731
5026
|
var ANONYMOUS_DENY_BODY = {
|
|
4732
5027
|
error: ANONYMOUS_DENY_CODE,
|
|
5028
|
+
code: ANONYMOUS_DENY_CODE,
|
|
4733
5029
|
message: ANONYMOUS_DENY_MESSAGE
|
|
4734
5030
|
};
|
|
4735
5031
|
function shouldDenyAnonymous(input) {
|
|
@@ -4743,6 +5039,76 @@ function shouldDenyAnonymous(input) {
|
|
|
4743
5039
|
return true;
|
|
4744
5040
|
}
|
|
4745
5041
|
|
|
5042
|
+
// src/security/admin-standing-surface.ts
|
|
5043
|
+
var ADMIN_STANDING_SURFACE = {
|
|
5044
|
+
sys_permission_set: {
|
|
5045
|
+
role: "derives",
|
|
5046
|
+
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.",
|
|
5047
|
+
columns: [
|
|
5048
|
+
"id",
|
|
5049
|
+
"name",
|
|
5050
|
+
"active",
|
|
5051
|
+
"system_permissions",
|
|
5052
|
+
"systemPermissions",
|
|
5053
|
+
"tab_permissions",
|
|
5054
|
+
"tabPermissions"
|
|
5055
|
+
]
|
|
5056
|
+
},
|
|
5057
|
+
sys_user_permission_set: {
|
|
5058
|
+
role: "derives",
|
|
5059
|
+
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.",
|
|
5060
|
+
columns: [
|
|
5061
|
+
"user_id",
|
|
5062
|
+
"permission_set_id",
|
|
5063
|
+
"permissionSetId",
|
|
5064
|
+
"organization_id",
|
|
5065
|
+
"organizationId",
|
|
5066
|
+
"valid_from",
|
|
5067
|
+
"validFrom",
|
|
5068
|
+
"valid_until",
|
|
5069
|
+
"validUntil"
|
|
5070
|
+
]
|
|
5071
|
+
},
|
|
5072
|
+
sys_member: {
|
|
5073
|
+
role: "derives",
|
|
5074
|
+
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.",
|
|
5075
|
+
columns: [
|
|
5076
|
+
"user_id",
|
|
5077
|
+
"userId",
|
|
5078
|
+
"organization_id",
|
|
5079
|
+
"organizationId",
|
|
5080
|
+
"role",
|
|
5081
|
+
"valid_from",
|
|
5082
|
+
"validFrom",
|
|
5083
|
+
"valid_until",
|
|
5084
|
+
"validUntil"
|
|
5085
|
+
]
|
|
5086
|
+
},
|
|
5087
|
+
sys_user: {
|
|
5088
|
+
role: "reads-only",
|
|
5089
|
+
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."
|
|
5090
|
+
},
|
|
5091
|
+
sys_user_position: {
|
|
5092
|
+
role: "reads-only",
|
|
5093
|
+
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."
|
|
5094
|
+
},
|
|
5095
|
+
sys_position: {
|
|
5096
|
+
role: "reads-only",
|
|
5097
|
+
reason: "Read to drop DEACTIVATED positions (ADR-0049, \xA76a). Same reason as `sys_user_position`: the position path cannot reach `hasPlatformAdminGrant`."
|
|
5098
|
+
},
|
|
5099
|
+
sys_position_permission_set: {
|
|
5100
|
+
role: "reads-only",
|
|
5101
|
+
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."
|
|
5102
|
+
}
|
|
5103
|
+
};
|
|
5104
|
+
function adminStandingTables() {
|
|
5105
|
+
return Object.entries(ADMIN_STANDING_SURFACE).filter(([, t]) => t.role === "derives").map(([name]) => name).sort();
|
|
5106
|
+
}
|
|
5107
|
+
function adminStandingColumns(table) {
|
|
5108
|
+
const entry = ADMIN_STANDING_SURFACE[table];
|
|
5109
|
+
return entry?.role === "derives" ? entry.columns : void 0;
|
|
5110
|
+
}
|
|
5111
|
+
|
|
4746
5112
|
// src/security/audience-binding-suggestion-status.ts
|
|
4747
5113
|
var AUDIENCE_BINDING_SUGGESTION_STATUSES = {
|
|
4748
5114
|
pending: true,
|
|
@@ -4793,10 +5159,26 @@ function calendarPartsInTzOrUtc(d, tz) {
|
|
|
4793
5159
|
}
|
|
4794
5160
|
function zonedDateStartToUtcMs(ymd2, tz) {
|
|
4795
5161
|
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(ymd2);
|
|
4796
|
-
|
|
5162
|
+
if (!m) return NaN;
|
|
5163
|
+
return zonedWallClockToUtcMs(
|
|
5164
|
+
{ year: Number(m[1]), month: Number(m[2]), day: Number(m[3]) },
|
|
5165
|
+
tz
|
|
5166
|
+
);
|
|
5167
|
+
}
|
|
5168
|
+
function zonedWallClockToUtcMs(parts, tz) {
|
|
5169
|
+
const wallAsUtc = Date.UTC(
|
|
5170
|
+
parts.year,
|
|
5171
|
+
parts.month - 1,
|
|
5172
|
+
parts.day,
|
|
5173
|
+
parts.hour ?? 0,
|
|
5174
|
+
parts.minute ?? 0,
|
|
5175
|
+
parts.second ?? 0,
|
|
5176
|
+
parts.millisecond ?? 0
|
|
5177
|
+
);
|
|
4797
5178
|
if (!tz || tz === "UTC" || Number.isNaN(wallAsUtc)) return wallAsUtc;
|
|
4798
5179
|
try {
|
|
4799
5180
|
const offsetAt = (t) => {
|
|
5181
|
+
const whole = Math.floor(t / 1e3) * 1e3;
|
|
4800
5182
|
const p = new Intl.DateTimeFormat("en-US", {
|
|
4801
5183
|
timeZone: tz,
|
|
4802
5184
|
hourCycle: "h23",
|
|
@@ -4806,9 +5188,9 @@ function zonedDateStartToUtcMs(ymd2, tz) {
|
|
|
4806
5188
|
hour: "2-digit",
|
|
4807
5189
|
minute: "2-digit",
|
|
4808
5190
|
second: "2-digit"
|
|
4809
|
-
}).formatToParts(new Date(
|
|
5191
|
+
}).formatToParts(new Date(whole));
|
|
4810
5192
|
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")) -
|
|
5193
|
+
return Date.UTC(g("year"), g("month") - 1, g("day"), g("hour"), g("minute"), g("second")) - whole;
|
|
4812
5194
|
};
|
|
4813
5195
|
const off1 = offsetAt(wallAsUtc - offsetAt(wallAsUtc));
|
|
4814
5196
|
return wallAsUtc - off1;
|
|
@@ -5619,6 +6001,37 @@ function filterTokenContextFrom(execCtx, now) {
|
|
|
5619
6001
|
};
|
|
5620
6002
|
}
|
|
5621
6003
|
|
|
6004
|
+
// src/utils/temporal-comparand.ts
|
|
6005
|
+
var import_data3 = require("@objectstack/spec/data");
|
|
6006
|
+
function temporalComparandKind(fieldType) {
|
|
6007
|
+
if (fieldType === "datetime") return "datetime";
|
|
6008
|
+
if (fieldType === "date") return "date";
|
|
6009
|
+
if (fieldType === "time") return "time";
|
|
6010
|
+
return null;
|
|
6011
|
+
}
|
|
6012
|
+
function readsAsInstant(s) {
|
|
6013
|
+
if (/^-?\d+$/.test(s)) return Number.isFinite(new Date(Number(s)).getTime());
|
|
6014
|
+
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;
|
|
6015
|
+
return Number.isFinite(Date.parse(iso));
|
|
6016
|
+
}
|
|
6017
|
+
function readsAsCalendarDay(s) {
|
|
6018
|
+
return /^\d{4}-\d{2}-\d{2}/.test(s);
|
|
6019
|
+
}
|
|
6020
|
+
function readsAsWallClock(s) {
|
|
6021
|
+
const m = /^(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?$/.exec(s);
|
|
6022
|
+
if (!m) return false;
|
|
6023
|
+
return Number(m[1]) <= 23 && Number(m[2]) <= 59 && Number(m[3] ?? "0") <= 59;
|
|
6024
|
+
}
|
|
6025
|
+
function isUninterpretableTemporalComparand(kind, value) {
|
|
6026
|
+
if (typeof value !== "string") return false;
|
|
6027
|
+
const s = value.trim();
|
|
6028
|
+
if (s === "") return false;
|
|
6029
|
+
if ((0, import_data3.classifyFilterToken)(value) !== null) return false;
|
|
6030
|
+
if (kind === "datetime") return !readsAsInstant(s);
|
|
6031
|
+
if (kind === "date") return !readsAsCalendarDay(s);
|
|
6032
|
+
return !(readsAsWallClock(s) || readsAsInstant(s));
|
|
6033
|
+
}
|
|
6034
|
+
|
|
5622
6035
|
// src/utils/record-not-found.ts
|
|
5623
6036
|
function recordNotFoundError(object, id) {
|
|
5624
6037
|
const err = new Error(`Record ${id} not found in ${object}`);
|
|
@@ -6604,8 +7017,12 @@ var NamespaceResolver = class {
|
|
|
6604
7017
|
return `${shortName}_${ns}`;
|
|
6605
7018
|
}
|
|
6606
7019
|
};
|
|
7020
|
+
|
|
7021
|
+
// src/index.ts
|
|
7022
|
+
var import_contracts = require("@objectstack/spec/contracts");
|
|
6607
7023
|
// Annotate the CommonJS export names for ESM import in node:
|
|
6608
7024
|
0 && (module.exports = {
|
|
7025
|
+
ADMIN_STANDING_SURFACE,
|
|
6609
7026
|
ANONYMOUS_DENY_BODY,
|
|
6610
7027
|
ANONYMOUS_DENY_CODE,
|
|
6611
7028
|
ANONYMOUS_DENY_MESSAGE,
|
|
@@ -6641,8 +7058,11 @@ var NamespaceResolver = class {
|
|
|
6641
7058
|
SecurePluginContext,
|
|
6642
7059
|
SemanticVersionManager,
|
|
6643
7060
|
ServiceLifecycle,
|
|
7061
|
+
UNMATCHED_ROUTE_PATTERN,
|
|
6644
7062
|
UnknownFilterTokenError,
|
|
6645
7063
|
UnresolvedFilterTokenError,
|
|
7064
|
+
adminStandingColumns,
|
|
7065
|
+
adminStandingTables,
|
|
6646
7066
|
assembleExecutionContext,
|
|
6647
7067
|
assembleExecutionContextOrGuest,
|
|
6648
7068
|
assertInitServiceRequirements,
|
|
@@ -6667,6 +7087,7 @@ var NamespaceResolver = class {
|
|
|
6667
7087
|
defaultIsTransientError,
|
|
6668
7088
|
derivePosture,
|
|
6669
7089
|
describeInitOrderFault,
|
|
7090
|
+
effectiveTenancyPosture,
|
|
6670
7091
|
engineCanRollBack,
|
|
6671
7092
|
evaluateAuthGate,
|
|
6672
7093
|
extractApiKey,
|
|
@@ -6684,6 +7105,8 @@ var NamespaceResolver = class {
|
|
|
6684
7105
|
isGrantActive,
|
|
6685
7106
|
isGrantExpired,
|
|
6686
7107
|
isNode,
|
|
7108
|
+
isRowActive,
|
|
7109
|
+
isUninterpretableTemporalComparand,
|
|
6687
7110
|
nextUtcCalendarDay,
|
|
6688
7111
|
normalizeAuthGate,
|
|
6689
7112
|
omitInternalFieldsFromWriteResponse,
|
|
@@ -6694,6 +7117,7 @@ var NamespaceResolver = class {
|
|
|
6694
7117
|
readAuthoredTranslationLayer,
|
|
6695
7118
|
readRunJournal,
|
|
6696
7119
|
recordNotFoundError,
|
|
7120
|
+
resolveApiKeyAdmission,
|
|
6697
7121
|
resolveApiKeyPrincipal,
|
|
6698
7122
|
resolveAuthzContext,
|
|
6699
7123
|
resolveFilterToken,
|
|
@@ -6707,6 +7131,7 @@ var NamespaceResolver = class {
|
|
|
6707
7131
|
safeExit,
|
|
6708
7132
|
shouldDenyAnonymous,
|
|
6709
7133
|
signPayload,
|
|
7134
|
+
temporalComparandKind,
|
|
6710
7135
|
unknownAudienceBindingSuggestionStatusMessage,
|
|
6711
7136
|
utcInstantMs,
|
|
6712
7137
|
validateInitServiceContract,
|
|
@@ -6717,6 +7142,7 @@ var NamespaceResolver = class {
|
|
|
6717
7142
|
wireAuthoredTranslationSync,
|
|
6718
7143
|
withTransientRetry,
|
|
6719
7144
|
withoutOperationPrivateKeys,
|
|
6720
|
-
zonedDateStartToUtcMs
|
|
7145
|
+
zonedDateStartToUtcMs,
|
|
7146
|
+
zonedWallClockToUtcMs
|
|
6721
7147
|
});
|
|
6722
7148
|
//# sourceMappingURL=index.cjs.map
|