@objectstack/core 17.1.0 → 17.3.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 +1646 -0
- package/dist/index.cjs +1002 -349
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1238 -124
- package/dist/index.d.ts +1238 -124
- package/dist/index.js +962 -338
- package/dist/index.js.map +1 -1
- package/package.json +9 -4
package/dist/index.js
CHANGED
|
@@ -713,138 +713,6 @@ function createLogger(config) {
|
|
|
713
713
|
// src/kernel.ts
|
|
714
714
|
import { ServiceRequirementDef } from "@objectstack/spec/system";
|
|
715
715
|
|
|
716
|
-
// src/security/plugin-config-validator.ts
|
|
717
|
-
import { z } from "zod";
|
|
718
|
-
var PluginConfigValidator = class {
|
|
719
|
-
constructor(logger) {
|
|
720
|
-
this.logger = logger;
|
|
721
|
-
}
|
|
722
|
-
/**
|
|
723
|
-
* Validate plugin configuration against its Zod schema
|
|
724
|
-
*
|
|
725
|
-
* @param plugin - Plugin metadata with configSchema
|
|
726
|
-
* @param config - User-provided configuration
|
|
727
|
-
* @returns Validated and typed configuration
|
|
728
|
-
* @throws Error with detailed validation errors
|
|
729
|
-
*/
|
|
730
|
-
validatePluginConfig(plugin, config) {
|
|
731
|
-
if (!plugin.configSchema) {
|
|
732
|
-
this.logger.debug(`Plugin ${plugin.name} has no config schema - skipping validation`);
|
|
733
|
-
return config;
|
|
734
|
-
}
|
|
735
|
-
try {
|
|
736
|
-
const validatedConfig = plugin.configSchema.parse(config);
|
|
737
|
-
this.logger.debug(`\u2705 Plugin config validated: ${plugin.name}`, {
|
|
738
|
-
plugin: plugin.name,
|
|
739
|
-
configKeys: Object.keys(config || {}).length
|
|
740
|
-
});
|
|
741
|
-
return validatedConfig;
|
|
742
|
-
} catch (error) {
|
|
743
|
-
if (error instanceof z.ZodError) {
|
|
744
|
-
const formattedErrors = this.formatZodErrors(error);
|
|
745
|
-
const errorMessage = [
|
|
746
|
-
`Plugin ${plugin.name} configuration validation failed:`,
|
|
747
|
-
...formattedErrors.map((e) => ` - ${e.path}: ${e.message}`)
|
|
748
|
-
].join("\n");
|
|
749
|
-
this.logger.error(errorMessage, void 0, {
|
|
750
|
-
plugin: plugin.name,
|
|
751
|
-
errors: formattedErrors
|
|
752
|
-
});
|
|
753
|
-
throw new Error(errorMessage);
|
|
754
|
-
}
|
|
755
|
-
throw error;
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
/**
|
|
759
|
-
* Validate partial configuration (for incremental updates)
|
|
760
|
-
*
|
|
761
|
-
* @param plugin - Plugin metadata
|
|
762
|
-
* @param partialConfig - Partial configuration to validate
|
|
763
|
-
* @returns Validated partial configuration
|
|
764
|
-
*/
|
|
765
|
-
validatePartialConfig(plugin, partialConfig) {
|
|
766
|
-
if (!plugin.configSchema) {
|
|
767
|
-
return partialConfig;
|
|
768
|
-
}
|
|
769
|
-
try {
|
|
770
|
-
const partialSchema = plugin.configSchema.partial();
|
|
771
|
-
const validatedConfig = partialSchema.parse(partialConfig);
|
|
772
|
-
this.logger.debug(`\u2705 Partial config validated: ${plugin.name}`);
|
|
773
|
-
return validatedConfig;
|
|
774
|
-
} catch (error) {
|
|
775
|
-
if (error instanceof z.ZodError) {
|
|
776
|
-
const formattedErrors = this.formatZodErrors(error);
|
|
777
|
-
const errorMessage = [
|
|
778
|
-
`Plugin ${plugin.name} partial configuration validation failed:`,
|
|
779
|
-
...formattedErrors.map((e) => ` - ${e.path}: ${e.message}`)
|
|
780
|
-
].join("\n");
|
|
781
|
-
throw new Error(errorMessage);
|
|
782
|
-
}
|
|
783
|
-
throw error;
|
|
784
|
-
}
|
|
785
|
-
}
|
|
786
|
-
/**
|
|
787
|
-
* Get default configuration from schema
|
|
788
|
-
*
|
|
789
|
-
* @param plugin - Plugin metadata
|
|
790
|
-
* @returns Default configuration object
|
|
791
|
-
*/
|
|
792
|
-
getDefaultConfig(plugin) {
|
|
793
|
-
if (!plugin.configSchema) {
|
|
794
|
-
return void 0;
|
|
795
|
-
}
|
|
796
|
-
try {
|
|
797
|
-
const defaults = plugin.configSchema.parse({});
|
|
798
|
-
this.logger.debug(`Default config extracted: ${plugin.name}`);
|
|
799
|
-
return defaults;
|
|
800
|
-
} catch (error) {
|
|
801
|
-
this.logger.debug(`No default config available: ${plugin.name}`);
|
|
802
|
-
return void 0;
|
|
803
|
-
}
|
|
804
|
-
}
|
|
805
|
-
/**
|
|
806
|
-
* Check if configuration is valid without throwing
|
|
807
|
-
*
|
|
808
|
-
* @param plugin - Plugin metadata
|
|
809
|
-
* @param config - Configuration to check
|
|
810
|
-
* @returns True if valid, false otherwise
|
|
811
|
-
*/
|
|
812
|
-
isConfigValid(plugin, config) {
|
|
813
|
-
if (!plugin.configSchema) {
|
|
814
|
-
return true;
|
|
815
|
-
}
|
|
816
|
-
const result = plugin.configSchema.safeParse(config);
|
|
817
|
-
return result.success;
|
|
818
|
-
}
|
|
819
|
-
/**
|
|
820
|
-
* Get configuration errors without throwing
|
|
821
|
-
*
|
|
822
|
-
* @param plugin - Plugin metadata
|
|
823
|
-
* @param config - Configuration to check
|
|
824
|
-
* @returns Array of validation errors, or empty array if valid
|
|
825
|
-
*/
|
|
826
|
-
getConfigErrors(plugin, config) {
|
|
827
|
-
if (!plugin.configSchema) {
|
|
828
|
-
return [];
|
|
829
|
-
}
|
|
830
|
-
const result = plugin.configSchema.safeParse(config);
|
|
831
|
-
if (result.success) {
|
|
832
|
-
return [];
|
|
833
|
-
}
|
|
834
|
-
return this.formatZodErrors(result.error);
|
|
835
|
-
}
|
|
836
|
-
// Private methods
|
|
837
|
-
formatZodErrors(error) {
|
|
838
|
-
return error.issues.map((e) => ({
|
|
839
|
-
path: e.path.join(".") || "root",
|
|
840
|
-
message: e.message
|
|
841
|
-
}));
|
|
842
|
-
}
|
|
843
|
-
};
|
|
844
|
-
function createPluginConfigValidator(logger) {
|
|
845
|
-
return new PluginConfigValidator(logger);
|
|
846
|
-
}
|
|
847
|
-
|
|
848
716
|
// src/security/plugin-artifact-signature.ts
|
|
849
717
|
import {
|
|
850
718
|
sign as cryptoSign,
|
|
@@ -947,6 +815,20 @@ async function verifyPluginArtifact(input, keys) {
|
|
|
947
815
|
return { ok: true, publisherVerified: publisher.verified, platformVerified };
|
|
948
816
|
}
|
|
949
817
|
|
|
818
|
+
// src/service-not-registered.ts
|
|
819
|
+
var SERVICE_NOT_REGISTERED_CODE = "SERVICE_NOT_REGISTERED";
|
|
820
|
+
var SERVICE_NOT_REGISTERED_BRAND = "__objectstackServiceNotRegistered";
|
|
821
|
+
function serviceNotRegisteredError(name) {
|
|
822
|
+
const err = new Error(`Service '${name}' not found`);
|
|
823
|
+
err[SERVICE_NOT_REGISTERED_BRAND] = true;
|
|
824
|
+
err.code = SERVICE_NOT_REGISTERED_CODE;
|
|
825
|
+
err.serviceName = name;
|
|
826
|
+
return err;
|
|
827
|
+
}
|
|
828
|
+
function isServiceNotRegisteredError(err) {
|
|
829
|
+
return typeof err === "object" && err !== null && err[SERVICE_NOT_REGISTERED_BRAND] === true;
|
|
830
|
+
}
|
|
831
|
+
|
|
950
832
|
// src/plugin-loader.ts
|
|
951
833
|
var ServiceLifecycle = /* @__PURE__ */ ((ServiceLifecycle2) => {
|
|
952
834
|
ServiceLifecycle2["SINGLETON"] = "singleton";
|
|
@@ -962,7 +844,6 @@ var PluginLoader = class {
|
|
|
962
844
|
this.scopedServices = /* @__PURE__ */ new Map();
|
|
963
845
|
this.creating = /* @__PURE__ */ new Set();
|
|
964
846
|
this.logger = logger;
|
|
965
|
-
this.configValidator = new PluginConfigValidator(logger);
|
|
966
847
|
}
|
|
967
848
|
/**
|
|
968
849
|
* Set the plugin context for service factories
|
|
@@ -989,9 +870,6 @@ var PluginLoader = class {
|
|
|
989
870
|
if (!versionCheck.compatible) {
|
|
990
871
|
throw new Error(`Version incompatible: ${versionCheck.message}`);
|
|
991
872
|
}
|
|
992
|
-
if (metadata.configSchema) {
|
|
993
|
-
this.validatePluginConfig(metadata);
|
|
994
|
-
}
|
|
995
873
|
if (metadata.signature) {
|
|
996
874
|
await this.verifyPluginSignature(metadata);
|
|
997
875
|
}
|
|
@@ -1030,7 +908,7 @@ var PluginLoader = class {
|
|
|
1030
908
|
if (!registration) {
|
|
1031
909
|
const instance = this.serviceInstances.get(name);
|
|
1032
910
|
if (!instance) {
|
|
1033
|
-
throw
|
|
911
|
+
throw serviceNotRegisteredError(name);
|
|
1034
912
|
}
|
|
1035
913
|
return instance;
|
|
1036
914
|
}
|
|
@@ -1190,16 +1068,6 @@ var PluginLoader = class {
|
|
|
1190
1068
|
const semverRegex = /^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/;
|
|
1191
1069
|
return semverRegex.test(version);
|
|
1192
1070
|
}
|
|
1193
|
-
validatePluginConfig(plugin, config) {
|
|
1194
|
-
if (!plugin.configSchema) {
|
|
1195
|
-
return;
|
|
1196
|
-
}
|
|
1197
|
-
if (config === void 0) {
|
|
1198
|
-
this.logger.debug(`Plugin ${plugin.name} has configuration schema (config validation postponed)`);
|
|
1199
|
-
return;
|
|
1200
|
-
}
|
|
1201
|
-
this.configValidator.validatePluginConfig(plugin, config);
|
|
1202
|
-
}
|
|
1203
1071
|
async verifyPluginSignature(plugin) {
|
|
1204
1072
|
if (!plugin.signature) {
|
|
1205
1073
|
return;
|
|
@@ -1355,35 +1223,6 @@ function createMemoryQueue() {
|
|
|
1355
1223
|
};
|
|
1356
1224
|
}
|
|
1357
1225
|
|
|
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
1226
|
// src/fallbacks/memory-i18n.ts
|
|
1388
1227
|
import { normalizeSupportedLocales } from "@objectstack/spec/system";
|
|
1389
1228
|
function deepMerge(target, source) {
|
|
@@ -1518,7 +1357,7 @@ function createMemoryI18n() {
|
|
|
1518
1357
|
}
|
|
1519
1358
|
|
|
1520
1359
|
// src/metadata-service-contract.ts
|
|
1521
|
-
import { pluralToSingular } from "@objectstack/spec/
|
|
1360
|
+
import { pluralToSingular } from "@objectstack/spec/meta-spelling";
|
|
1522
1361
|
var REGISTER_REFUSAL_CODE = "VALIDATION_ERROR";
|
|
1523
1362
|
function canonicalMetadataServiceType(type) {
|
|
1524
1363
|
return pluralToSingular(type);
|
|
@@ -1611,6 +1450,35 @@ function createMemoryMetadata() {
|
|
|
1611
1450
|
};
|
|
1612
1451
|
}
|
|
1613
1452
|
|
|
1453
|
+
// src/fallbacks/memory-job.ts
|
|
1454
|
+
function createMemoryJob() {
|
|
1455
|
+
const jobs = /* @__PURE__ */ new Map();
|
|
1456
|
+
return {
|
|
1457
|
+
__serviceInfo: {
|
|
1458
|
+
status: "degraded",
|
|
1459
|
+
handlerReady: false,
|
|
1460
|
+
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."
|
|
1461
|
+
},
|
|
1462
|
+
_serviceName: "job",
|
|
1463
|
+
async schedule(name, schedule, handler) {
|
|
1464
|
+
jobs.set(name, { schedule, handler });
|
|
1465
|
+
},
|
|
1466
|
+
async cancel(name) {
|
|
1467
|
+
jobs.delete(name);
|
|
1468
|
+
},
|
|
1469
|
+
async trigger(name, data) {
|
|
1470
|
+
const job = jobs.get(name);
|
|
1471
|
+
if (job?.handler) await job.handler({ jobId: name, data });
|
|
1472
|
+
},
|
|
1473
|
+
async getExecutions() {
|
|
1474
|
+
return [];
|
|
1475
|
+
},
|
|
1476
|
+
async listJobs() {
|
|
1477
|
+
return [...jobs.keys()];
|
|
1478
|
+
}
|
|
1479
|
+
};
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1614
1482
|
// src/fallbacks/authored-translation-sync.ts
|
|
1615
1483
|
import { LEGACY_OBJECT_FIRST_KEYS } from "@objectstack/spec/system";
|
|
1616
1484
|
var OWNER_PROP = "__authoredTranslationSyncOwner";
|
|
@@ -1743,7 +1611,6 @@ var CORE_FALLBACK_FACTORIES = {
|
|
|
1743
1611
|
metadata: createMemoryMetadata,
|
|
1744
1612
|
cache: createMemoryCache,
|
|
1745
1613
|
queue: createMemoryQueue,
|
|
1746
|
-
job: createMemoryJob,
|
|
1747
1614
|
i18n: createMemoryI18n
|
|
1748
1615
|
};
|
|
1749
1616
|
|
|
@@ -1767,6 +1634,48 @@ function registerPluginByName(registry, plugin, logger) {
|
|
|
1767
1634
|
return previous;
|
|
1768
1635
|
}
|
|
1769
1636
|
|
|
1637
|
+
// src/timeout-guard.ts
|
|
1638
|
+
var TimeoutGuard = class {
|
|
1639
|
+
constructor(timeoutMs, createTimeoutError) {
|
|
1640
|
+
/**
|
|
1641
|
+
* Settles `expiry` without a value. `Promise<never>` has no resolvable
|
|
1642
|
+
* value in the type system, but settling it is the entire point: it is
|
|
1643
|
+
* only ever called from `reclaim()`, i.e. after the race it guarded has
|
|
1644
|
+
* already been decided, so the resolution is discarded by construction and
|
|
1645
|
+
* can never become a race winner. The cast localises that argument here
|
|
1646
|
+
* rather than pushing a lie into every caller's return type.
|
|
1647
|
+
*/
|
|
1648
|
+
this.settleExpiry = () => {
|
|
1649
|
+
};
|
|
1650
|
+
this.expiry = new Promise((resolve, reject) => {
|
|
1651
|
+
this.settleExpiry = resolve;
|
|
1652
|
+
this.timer = setTimeout(() => reject(createTimeoutError()), timeoutMs);
|
|
1653
|
+
});
|
|
1654
|
+
}
|
|
1655
|
+
/**
|
|
1656
|
+
* Reclaim the guard once the race it protects has been decided. Both
|
|
1657
|
+
* halves, always: the timer is cleared so it cannot fire against a
|
|
1658
|
+
* lifecycle phase that is already over, and `expiry` is settled so neither
|
|
1659
|
+
* it nor the race's reaction on it is retained.
|
|
1660
|
+
*
|
|
1661
|
+
* Idempotent — `clearTimeout` on a cleared handle and a second resolve on
|
|
1662
|
+
* a settled promise are both no-ops.
|
|
1663
|
+
*/
|
|
1664
|
+
reclaim() {
|
|
1665
|
+
clearTimeout(this.timer);
|
|
1666
|
+
this.timer = void 0;
|
|
1667
|
+
this.settleExpiry();
|
|
1668
|
+
}
|
|
1669
|
+
};
|
|
1670
|
+
async function raceWithTimeout(operation, timeoutMs, createTimeoutError) {
|
|
1671
|
+
const guard = new TimeoutGuard(timeoutMs, createTimeoutError);
|
|
1672
|
+
try {
|
|
1673
|
+
return await Promise.race([operation, guard.expiry]);
|
|
1674
|
+
} finally {
|
|
1675
|
+
guard.reclaim();
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1770
1679
|
// src/kernel.ts
|
|
1771
1680
|
var ObjectKernel = class {
|
|
1772
1681
|
constructor(config = {}) {
|
|
@@ -2037,14 +1946,11 @@ var ObjectKernel = class {
|
|
|
2037
1946
|
this.logger.info("Graceful shutdown started");
|
|
2038
1947
|
const shutdownTimeoutError = new Error("Shutdown timeout exceeded");
|
|
2039
1948
|
try {
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
if (t.unref) t.unref();
|
|
2046
|
-
});
|
|
2047
|
-
await Promise.race([shutdownPromise, timeoutPromise]);
|
|
1949
|
+
await raceWithTimeout(
|
|
1950
|
+
this.performShutdown(),
|
|
1951
|
+
this.config.shutdownTimeout,
|
|
1952
|
+
() => shutdownTimeoutError
|
|
1953
|
+
);
|
|
2048
1954
|
this.state = "stopped";
|
|
2049
1955
|
this.logger.info("\u2705 Graceful shutdown complete");
|
|
2050
1956
|
} catch (error) {
|
|
@@ -2162,25 +2068,17 @@ var ObjectKernel = class {
|
|
|
2162
2068
|
* as well: if the hook never settles and nothing else keeps the loop alive,
|
|
2163
2069
|
* Node exits before the timer can fire and the timeout is never reported.
|
|
2164
2070
|
* The guard has to stay ref'd exactly as long as the race is undecided,
|
|
2165
|
-
* which is what
|
|
2071
|
+
* which is what clearing on settle expresses.
|
|
2166
2072
|
*
|
|
2167
|
-
*
|
|
2168
|
-
*
|
|
2169
|
-
*
|
|
2170
|
-
*
|
|
2073
|
+
* Clearing the timer was only half of it, though (#10604): the promise the
|
|
2074
|
+
* race still holds a reaction on has to SETTLE, or it and that reaction are
|
|
2075
|
+
* retained past the end of the run — two leaking promises per boot, which
|
|
2076
|
+
* is what `vitest --detectAsyncLeaks` names here. Both halves now live in
|
|
2077
|
+
* `TimeoutGuard.reclaim()`, shared with `shutdown()`, so the two sites
|
|
2078
|
+
* cannot drift into doing one half each again.
|
|
2171
2079
|
*/
|
|
2172
2080
|
async raceStartupTimeout(operation, timeout, message) {
|
|
2173
|
-
|
|
2174
|
-
const timeoutPromise = new Promise((_, reject) => {
|
|
2175
|
-
guard = setTimeout(() => {
|
|
2176
|
-
reject(new Error(message));
|
|
2177
|
-
}, timeout);
|
|
2178
|
-
});
|
|
2179
|
-
try {
|
|
2180
|
-
return await Promise.race([operation, timeoutPromise]);
|
|
2181
|
-
} finally {
|
|
2182
|
-
clearTimeout(guard);
|
|
2183
|
-
}
|
|
2081
|
+
return raceWithTimeout(operation, timeout, () => new Error(message));
|
|
2184
2082
|
}
|
|
2185
2083
|
/**
|
|
2186
2084
|
* Whether a service is resolvable on this kernel right now — direct
|
|
@@ -2343,6 +2241,67 @@ var ObjectKernel = class {
|
|
|
2343
2241
|
}
|
|
2344
2242
|
};
|
|
2345
2243
|
|
|
2244
|
+
// src/artifact-packages.ts
|
|
2245
|
+
import { ArtifactPackageSchema } from "@objectstack/spec";
|
|
2246
|
+
var MAX_REPORTED_ENTRY_ISSUES = 5;
|
|
2247
|
+
function refuse(code, message) {
|
|
2248
|
+
const err = new Error(message);
|
|
2249
|
+
err.code = code;
|
|
2250
|
+
err.status = 422;
|
|
2251
|
+
return err;
|
|
2252
|
+
}
|
|
2253
|
+
function artifactPackageId(manifest) {
|
|
2254
|
+
const id = manifest?.id || manifest?.name;
|
|
2255
|
+
return typeof id === "string" && id !== "" ? id : void 0;
|
|
2256
|
+
}
|
|
2257
|
+
function resolveArtifactPackageOrder(artifact) {
|
|
2258
|
+
const declared = artifact?.packages;
|
|
2259
|
+
if (declared === void 0 || declared === null) return [artifact];
|
|
2260
|
+
if (!Array.isArray(declared)) {
|
|
2261
|
+
throw refuse(
|
|
2262
|
+
"INVALID_ARTIFACT_PACKAGES",
|
|
2263
|
+
`A release artifact's \`packages\` must be an array of package entries (ADR-0130 D4, \`ArtifactPackageEntrySchema\`), but this artifact carries \`packages\` of type ${typeof declared}. Omit the key entirely for a single-package artifact \u2014 \`manifest\` is retained, not replaced.`
|
|
2264
|
+
);
|
|
2265
|
+
}
|
|
2266
|
+
const nodes = /* @__PURE__ */ new Map();
|
|
2267
|
+
declared.forEach((entry, index) => {
|
|
2268
|
+
const verdict = ArtifactPackageSchema.safeParse(entry);
|
|
2269
|
+
if (!verdict.success) {
|
|
2270
|
+
const issues = verdict.error.issues;
|
|
2271
|
+
const shown = issues.slice(0, MAX_REPORTED_ENTRY_ISSUES);
|
|
2272
|
+
throw refuse(
|
|
2273
|
+
"INVALID_ARTIFACT_PACKAGE_ENTRY",
|
|
2274
|
+
`Release artifact \`packages[${index}]\` is not a package entry (ADR-0130 D4): ` + shown.map((i) => `${i.path.join(".") || "<entry>"}: ${i.message}`).join("; ") + (issues.length > shown.length ? ` (+${issues.length - shown.length} more)` : "") + ". Each entry is a WRAPPER object carrying its package under `manifest:` \u2014 wrap an inlined body as `{ manifest: { \u2026 } }`. The key position is reserved so a future external-segment form is an additive key rather than a reshape. The body under `manifest:` is the ASSEMBLED package body (`AssembledPackageBodySchema`): its `objects` / `datasources` are DEFINITIONS, not the authoring manifest's glob patterns \u2014 a compiled artifact has no files left to glob."
|
|
2275
|
+
);
|
|
2276
|
+
}
|
|
2277
|
+
const manifest = entry.manifest;
|
|
2278
|
+
const id = artifactPackageId(manifest);
|
|
2279
|
+
if (id === void 0) {
|
|
2280
|
+
throw refuse(
|
|
2281
|
+
"INVALID_ARTIFACT_PACKAGE_ENTRY",
|
|
2282
|
+
`Release artifact \`packages[${index}]\` carries a manifest with no usable package id: \`registerApp\` keys the installed package on \`id || name\`, so an entry without either cannot be ordered against its siblings or addressed after install.`
|
|
2283
|
+
);
|
|
2284
|
+
}
|
|
2285
|
+
if (nodes.has(id)) {
|
|
2286
|
+
throw refuse(
|
|
2287
|
+
"DUPLICATE_ARTIFACT_PACKAGE",
|
|
2288
|
+
`Release artifact declares package "${id}" more than once (\`packages[${index}]\` repeats an earlier entry). One artifact carries each package once \u2014 an artifact is one atomic delivery (ADR-0130 D1/D6), not a list with last-writer-wins.`
|
|
2289
|
+
);
|
|
2290
|
+
}
|
|
2291
|
+
nodes.set(id, {
|
|
2292
|
+
// `name` is what `resolvePluginOrder` puts in its diagnostics; the MAP KEY
|
|
2293
|
+
// is what its edges resolve against. Both are the package id, so an error
|
|
2294
|
+
// it raises names the same string the artifact author wrote.
|
|
2295
|
+
name: id,
|
|
2296
|
+
optionalDependencies: Object.keys(
|
|
2297
|
+
manifest.dependencies ?? {}
|
|
2298
|
+
),
|
|
2299
|
+
manifest
|
|
2300
|
+
});
|
|
2301
|
+
});
|
|
2302
|
+
return resolvePluginOrder(nodes).map((node) => node.manifest);
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2346
2305
|
// src/lite-kernel.ts
|
|
2347
2306
|
var LiteKernel = class extends ObjectKernelBase {
|
|
2348
2307
|
constructor(config) {
|
|
@@ -3072,6 +3031,55 @@ var PluginSignatureVerifier = class {
|
|
|
3072
3031
|
}
|
|
3073
3032
|
};
|
|
3074
3033
|
|
|
3034
|
+
// src/security/plugin-artifact-integrity.ts
|
|
3035
|
+
import { createHash } from "crypto";
|
|
3036
|
+
var SRI_ALGORITHMS = /* @__PURE__ */ new Set(["sha256", "sha384", "sha512"]);
|
|
3037
|
+
function sriDigestFor(declared, data) {
|
|
3038
|
+
const dash = declared.indexOf("-");
|
|
3039
|
+
const alg = dash > 0 && SRI_ALGORITHMS.has(declared.slice(0, dash)) ? declared.slice(0, dash) : "sha256";
|
|
3040
|
+
return `${alg}-${createHash(alg).update(data).digest("base64")}`;
|
|
3041
|
+
}
|
|
3042
|
+
function verifyIntegrity(files, integrity, options = {}) {
|
|
3043
|
+
if (integrity === null || integrity === void 0) {
|
|
3044
|
+
return { ok: true, skipped: true, checked: 0, violations: [] };
|
|
3045
|
+
}
|
|
3046
|
+
const exempt = new Set(options.exempt ?? []);
|
|
3047
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
3048
|
+
for (const f of files) {
|
|
3049
|
+
if (!exempt.has(f.path)) byPath.set(f.path, f.data);
|
|
3050
|
+
}
|
|
3051
|
+
const violations = [];
|
|
3052
|
+
let checked = 0;
|
|
3053
|
+
for (const [path, declaredRaw] of Object.entries(integrity)) {
|
|
3054
|
+
if (exempt.has(path)) continue;
|
|
3055
|
+
const declared = typeof declaredRaw === "string" ? declaredRaw : String(declaredRaw);
|
|
3056
|
+
const data = byPath.get(path);
|
|
3057
|
+
if (data === void 0) {
|
|
3058
|
+
violations.push({ kind: "missing_file", path, declared });
|
|
3059
|
+
continue;
|
|
3060
|
+
}
|
|
3061
|
+
checked++;
|
|
3062
|
+
const actual = sriDigestFor(declared, data);
|
|
3063
|
+
if (actual !== declared) violations.push({ kind: "digest_mismatch", path, declared, actual });
|
|
3064
|
+
}
|
|
3065
|
+
for (const path of [...byPath.keys()].sort()) {
|
|
3066
|
+
if (!Object.prototype.hasOwnProperty.call(integrity, path)) {
|
|
3067
|
+
violations.push({ kind: "extra_file", path });
|
|
3068
|
+
}
|
|
3069
|
+
}
|
|
3070
|
+
return { ok: violations.length === 0, skipped: false, checked, violations };
|
|
3071
|
+
}
|
|
3072
|
+
function formatIntegrityViolation(v) {
|
|
3073
|
+
switch (v.kind) {
|
|
3074
|
+
case "digest_mismatch":
|
|
3075
|
+
return `${v.path}: digest mismatch \u2014 manifest declares ${v.declared}, artifact bytes hash to ${v.actual}`;
|
|
3076
|
+
case "missing_file":
|
|
3077
|
+
return `${v.path}: declared in the integrity map but absent from the artifact`;
|
|
3078
|
+
case "extra_file":
|
|
3079
|
+
return `${v.path}: present in the artifact but not in the integrity map`;
|
|
3080
|
+
}
|
|
3081
|
+
}
|
|
3082
|
+
|
|
3075
3083
|
// src/security/plugin-permission-enforcer.ts
|
|
3076
3084
|
var PluginPermissionEnforcer = class {
|
|
3077
3085
|
constructor(logger) {
|
|
@@ -4166,13 +4174,13 @@ var PluginSecurityScanner = class {
|
|
|
4166
4174
|
};
|
|
4167
4175
|
|
|
4168
4176
|
// src/security/api-key.ts
|
|
4169
|
-
import { createHash, randomBytes } from "crypto";
|
|
4177
|
+
import { createHash as createHash2, randomBytes } from "crypto";
|
|
4170
4178
|
import { postureEnforcesWall, postureUsesUnionScope, normalizeTenancyPosture } from "@objectstack/spec/security";
|
|
4171
4179
|
var API_KEY_PREFIX = "osk_";
|
|
4172
4180
|
var API_KEY_ENTROPY_BYTES = 32;
|
|
4173
4181
|
var VISIBLE_PREFIX_LEN = 12;
|
|
4174
4182
|
function hashApiKey(raw) {
|
|
4175
|
-
return
|
|
4183
|
+
return createHash2("sha256").update(raw, "utf8").digest("hex");
|
|
4176
4184
|
}
|
|
4177
4185
|
function generateApiKey(prefix = API_KEY_PREFIX) {
|
|
4178
4186
|
const secret = randomBytes(API_KEY_ENTROPY_BYTES).toString("base64url");
|
|
@@ -4289,11 +4297,41 @@ function safeJsonParse(s, fallback) {
|
|
|
4289
4297
|
}
|
|
4290
4298
|
}
|
|
4291
4299
|
|
|
4300
|
+
// src/security/authz-store-unavailable.ts
|
|
4301
|
+
var AUTHZ_STORE_UNAVAILABLE_STATUS = 503;
|
|
4302
|
+
var AUTHZ_STORE_UNAVAILABLE_CODE = "SERVICE_UNAVAILABLE";
|
|
4303
|
+
var AUTHZ_STORE_UNAVAILABLE_MESSAGE = "The authorization store could not be read, so this request's permissions were never determined. This is a server-side outage, not a permission denial.";
|
|
4304
|
+
var AUTHZ_STORE_UNAVAILABLE_BRAND = "__objectstackAuthzStoreUnavailable";
|
|
4305
|
+
var _a, _b;
|
|
4306
|
+
var AuthzStoreUnavailableError = class extends (_b = Error, _a = AUTHZ_STORE_UNAVAILABLE_BRAND, _b) {
|
|
4307
|
+
constructor(object, cause) {
|
|
4308
|
+
super(`${AUTHZ_STORE_UNAVAILABLE_MESSAGE} (failed read: \`${object}\`)`);
|
|
4309
|
+
/** Brand — see the module doc on why this is not `instanceof`. */
|
|
4310
|
+
this[_a] = true;
|
|
4311
|
+
/** ADR-0112 wire code. */
|
|
4312
|
+
this.code = AUTHZ_STORE_UNAVAILABLE_CODE;
|
|
4313
|
+
/** HTTP status a transport should answer. */
|
|
4314
|
+
this.status = AUTHZ_STORE_UNAVAILABLE_STATUS;
|
|
4315
|
+
this.name = "AuthzStoreUnavailableError";
|
|
4316
|
+
this.object = object;
|
|
4317
|
+
if (cause !== void 0) this.cause = cause;
|
|
4318
|
+
}
|
|
4319
|
+
};
|
|
4320
|
+
function isAuthzStoreUnavailableError(err) {
|
|
4321
|
+
return typeof err === "object" && err !== null && err[AUTHZ_STORE_UNAVAILABLE_BRAND] === true;
|
|
4322
|
+
}
|
|
4323
|
+
function rethrowAuthzStoreUnavailable(err) {
|
|
4324
|
+
if (isAuthzStoreUnavailableError(err)) throw err;
|
|
4325
|
+
return void 0;
|
|
4326
|
+
}
|
|
4327
|
+
|
|
4292
4328
|
// src/security/resolve-authz-context.ts
|
|
4329
|
+
import { isMissingTableError, resolveTenancyPosture } from "@objectstack/types";
|
|
4293
4330
|
import {
|
|
4294
4331
|
mapMembershipRole,
|
|
4295
4332
|
BUILTIN_IDENTITY_PLATFORM_ADMIN,
|
|
4296
4333
|
ADMIN_FULL_ACCESS,
|
|
4334
|
+
ADMIN_FULL_ACCESS_CAPABILITIES,
|
|
4297
4335
|
ORGANIZATION_ADMIN_GRANTS
|
|
4298
4336
|
} from "@objectstack/spec";
|
|
4299
4337
|
import { postureEnforcesWall as postureEnforcesWall2 } from "@objectstack/spec/security";
|
|
@@ -4316,6 +4354,20 @@ function isGrantActive(row, nowMs) {
|
|
|
4316
4354
|
if (until !== void 0 && !(nowMs < until)) return false;
|
|
4317
4355
|
return true;
|
|
4318
4356
|
}
|
|
4357
|
+
function nextGrantValidityBoundary(rows, nowMs) {
|
|
4358
|
+
let next;
|
|
4359
|
+
for (const row of rows) {
|
|
4360
|
+
if (!row) continue;
|
|
4361
|
+
const from = toEpochMs(row.valid_from ?? row.validFrom);
|
|
4362
|
+
const until = toEpochMs(row.valid_until ?? row.validUntil);
|
|
4363
|
+
for (const bound of [from, until]) {
|
|
4364
|
+
if (bound !== void 0 && bound > nowMs && (next === void 0 || bound < next)) {
|
|
4365
|
+
next = bound;
|
|
4366
|
+
}
|
|
4367
|
+
}
|
|
4368
|
+
}
|
|
4369
|
+
return next;
|
|
4370
|
+
}
|
|
4319
4371
|
function isGrantExpired(row, nowMs) {
|
|
4320
4372
|
if (!row) return false;
|
|
4321
4373
|
const until = toEpochMs(row.valid_until ?? row.validUntil);
|
|
@@ -4323,6 +4375,227 @@ function isGrantExpired(row, nowMs) {
|
|
|
4323
4375
|
return !(nowMs < until);
|
|
4324
4376
|
}
|
|
4325
4377
|
|
|
4378
|
+
// src/security/authz-cache-posture.ts
|
|
4379
|
+
var AUTHZ_GRANTS_CACHE_TTL_ENV = "OS_AUTHZ_GRANTS_CACHE_TTL_MS";
|
|
4380
|
+
function resolveAuthzCachePosture(input) {
|
|
4381
|
+
const { ttlMs, bus, driver } = input;
|
|
4382
|
+
if (!(ttlMs > 0)) {
|
|
4383
|
+
return { posture: "disabled", loud: false, message: "" };
|
|
4384
|
+
}
|
|
4385
|
+
if (bus === "bridged") {
|
|
4386
|
+
return {
|
|
4387
|
+
posture: "bus-narrowed",
|
|
4388
|
+
loud: false,
|
|
4389
|
+
message: `[authz-cache] grants cache ENABLED (ttl=${ttlMs}ms) with the "authz.invalidated" bridge attached` + (driver ? ` (cluster driver "${driver}")` : "") + ". The bus narrows the TYPICAL convergence to one network hop; the TTL remains the correctness bound, because no shipped driver delivers better than at-most-once (cluster.mdx \xA74.2)."
|
|
4390
|
+
};
|
|
4391
|
+
}
|
|
4392
|
+
const why = bus === "in-process" ? `the cluster driver "${driver ?? "memory"}" is in-process and does not fan out across replicas` : "no cluster service is registered on this node";
|
|
4393
|
+
return {
|
|
4394
|
+
posture: "ttl-only",
|
|
4395
|
+
loud: true,
|
|
4396
|
+
message: `[authz-cache] grants cache ENABLED (ttl=${ttlMs}ms) with NO "authz.invalidated" invalidation bus \u2014 ${why}. A grant revoked on another replica is honoured by this one for up to ${ttlMs}ms. That is a supported configuration, not an error: the TTL is the correctness bound and it still holds. It is stated because a silently-absent invalidation bridge is how a security control gets disabled without anyone noticing (#4785). To narrow the typical window, configure a remote cluster driver; to remove it entirely, set ${AUTHZ_GRANTS_CACHE_TTL_ENV}=0.`
|
|
4397
|
+
};
|
|
4398
|
+
}
|
|
4399
|
+
function readAuthzGrantsCacheTtlMs(env = typeof process !== "undefined" ? process.env : {}) {
|
|
4400
|
+
const raw = env[AUTHZ_GRANTS_CACHE_TTL_ENV];
|
|
4401
|
+
if (raw === void 0 || raw.trim() === "") {
|
|
4402
|
+
return { ttlMs: 0, malformed: false };
|
|
4403
|
+
}
|
|
4404
|
+
const parsed = Number(raw.trim());
|
|
4405
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
4406
|
+
return { ttlMs: 0, raw, malformed: true };
|
|
4407
|
+
}
|
|
4408
|
+
return { ttlMs: Math.floor(parsed), raw, malformed: false };
|
|
4409
|
+
}
|
|
4410
|
+
function reportAuthzCachePosture(input, sink2) {
|
|
4411
|
+
if (input.malformedTtl) {
|
|
4412
|
+
sink2.warn(
|
|
4413
|
+
`[authz-cache] ${AUTHZ_GRANTS_CACHE_TTL_ENV}=${JSON.stringify(input.malformedTtl.raw ?? "")} is not a non-negative number; the grants cache is treated as DISABLED. Set a millisecond count, or 0 to disable it deliberately.`
|
|
4414
|
+
);
|
|
4415
|
+
}
|
|
4416
|
+
const statement = resolveAuthzCachePosture(input);
|
|
4417
|
+
if (statement.posture === "disabled") return statement;
|
|
4418
|
+
if (statement.loud) sink2.warn(statement.message);
|
|
4419
|
+
else sink2.info?.(statement.message);
|
|
4420
|
+
return statement;
|
|
4421
|
+
}
|
|
4422
|
+
|
|
4423
|
+
// src/security/resolve-user-grants-cache.ts
|
|
4424
|
+
var GRANTS_CACHE_WATCHED_OBJECTS = /* @__PURE__ */ new Set([
|
|
4425
|
+
"sys_member",
|
|
4426
|
+
"sys_user_position",
|
|
4427
|
+
"sys_user_permission_set",
|
|
4428
|
+
"sys_position",
|
|
4429
|
+
"sys_position_permission_set",
|
|
4430
|
+
"sys_permission_set",
|
|
4431
|
+
"sys_user"
|
|
4432
|
+
]);
|
|
4433
|
+
var grantsCacheStates = /* @__PURE__ */ new WeakMap();
|
|
4434
|
+
var WRITE_OPERATIONS = /* @__PURE__ */ new Set(["insert", "update", "delete"]);
|
|
4435
|
+
function grantsCacheState(ql) {
|
|
4436
|
+
const existing = grantsCacheStates.get(ql);
|
|
4437
|
+
if (existing !== void 0) return existing ?? void 0;
|
|
4438
|
+
const seamQl = ql;
|
|
4439
|
+
const epoch = seamQl.writeEpoch;
|
|
4440
|
+
const hasEpoch = !!epoch && typeof epoch === "object" && typeof epoch.current === "number" && typeof epoch.bump === "function" && typeof epoch.subscribe === "function";
|
|
4441
|
+
if (!hasEpoch || typeof seamQl.registerMiddleware !== "function") {
|
|
4442
|
+
return void 0;
|
|
4443
|
+
}
|
|
4444
|
+
const state = { gen: 0, entries: /* @__PURE__ */ new Map() };
|
|
4445
|
+
try {
|
|
4446
|
+
seamQl.registerMiddleware(async (ctx, next) => {
|
|
4447
|
+
if (typeof ctx?.operation !== "string" || !WRITE_OPERATIONS.has(ctx.operation) || typeof ctx?.object !== "string" || !GRANTS_CACHE_WATCHED_OBJECTS.has(ctx.object)) {
|
|
4448
|
+
return next();
|
|
4449
|
+
}
|
|
4450
|
+
try {
|
|
4451
|
+
await next();
|
|
4452
|
+
} finally {
|
|
4453
|
+
state.gen += 1;
|
|
4454
|
+
}
|
|
4455
|
+
});
|
|
4456
|
+
epoch.subscribe((_epoch, reason) => {
|
|
4457
|
+
if (reason !== "write") state.gen += 1;
|
|
4458
|
+
});
|
|
4459
|
+
} catch {
|
|
4460
|
+
grantsCacheStates.set(ql, null);
|
|
4461
|
+
return void 0;
|
|
4462
|
+
}
|
|
4463
|
+
grantsCacheStates.set(ql, state);
|
|
4464
|
+
return state;
|
|
4465
|
+
}
|
|
4466
|
+
function grantsCacheKey(userId, opts) {
|
|
4467
|
+
return JSON.stringify([
|
|
4468
|
+
userId,
|
|
4469
|
+
opts.tenantId ?? null,
|
|
4470
|
+
opts.seedEmail ?? null,
|
|
4471
|
+
Array.isArray(opts.seedPermissions) ? opts.seedPermissions : []
|
|
4472
|
+
]);
|
|
4473
|
+
}
|
|
4474
|
+
var cloneGrants = (grants) => structuredClone(grants);
|
|
4475
|
+
function openUserGrantsCache(ql, userId, opts) {
|
|
4476
|
+
if (opts.bypassGrantsCache) return void 0;
|
|
4477
|
+
const { ttlMs } = readAuthzGrantsCacheTtlMs();
|
|
4478
|
+
if (ttlMs <= 0) return void 0;
|
|
4479
|
+
if (!ql || typeof ql !== "object" || typeof ql.find !== "function") {
|
|
4480
|
+
return void 0;
|
|
4481
|
+
}
|
|
4482
|
+
const state = grantsCacheState(ql);
|
|
4483
|
+
if (!state) return void 0;
|
|
4484
|
+
const key = grantsCacheKey(userId, opts);
|
|
4485
|
+
const now = opts.nowMs ?? Date.now();
|
|
4486
|
+
const genAtOpen = state.gen;
|
|
4487
|
+
const existing = state.entries.get(key);
|
|
4488
|
+
if (existing) {
|
|
4489
|
+
if (existing.gen === state.gen && existing.expiresAt > now) {
|
|
4490
|
+
return { hit: cloneGrants(existing.value), commit: () => {
|
|
4491
|
+
} };
|
|
4492
|
+
}
|
|
4493
|
+
state.entries.delete(key);
|
|
4494
|
+
}
|
|
4495
|
+
return {
|
|
4496
|
+
commit(grants, nextBoundaryMs) {
|
|
4497
|
+
const expiresAt = Math.min(now + ttlMs, nextBoundaryMs ?? Number.POSITIVE_INFINITY);
|
|
4498
|
+
if (expiresAt <= now) return;
|
|
4499
|
+
state.entries.set(key, { value: cloneGrants(grants), gen: genAtOpen, expiresAt });
|
|
4500
|
+
}
|
|
4501
|
+
};
|
|
4502
|
+
}
|
|
4503
|
+
|
|
4504
|
+
// src/security/platform-admin.ts
|
|
4505
|
+
import { isEmailVerifiedUserRow, PLATFORM_OWNER_EMAIL_ENV, resolvePlatformOwnerEmail } from "@objectstack/types";
|
|
4506
|
+
var PLATFORM_ADMIN_EMAIL_SEPARATOR = ",";
|
|
4507
|
+
function normalizePlatformAdminEmail(value) {
|
|
4508
|
+
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
4509
|
+
}
|
|
4510
|
+
function isParseableAddress(entry) {
|
|
4511
|
+
if (/\s/.test(entry)) return false;
|
|
4512
|
+
const at = entry.indexOf("@");
|
|
4513
|
+
if (at <= 0) return false;
|
|
4514
|
+
if (entry.indexOf("@", at + 1) !== -1) return false;
|
|
4515
|
+
return at < entry.length - 1;
|
|
4516
|
+
}
|
|
4517
|
+
var EMPTY_CONFIG = Object.freeze({
|
|
4518
|
+
emails: Object.freeze([]),
|
|
4519
|
+
declaredSpellings: Object.freeze([])
|
|
4520
|
+
});
|
|
4521
|
+
function parsePlatformAdminEmails(raw) {
|
|
4522
|
+
if (raw == null) return EMPTY_CONFIG;
|
|
4523
|
+
const text = String(raw);
|
|
4524
|
+
if (text.trim() === "") return EMPTY_CONFIG;
|
|
4525
|
+
const emails = [];
|
|
4526
|
+
const declaredSpellings = [];
|
|
4527
|
+
for (const piece of text.split(PLATFORM_ADMIN_EMAIL_SEPARATOR)) {
|
|
4528
|
+
const entry = normalizePlatformAdminEmail(piece);
|
|
4529
|
+
if (entry === "") continue;
|
|
4530
|
+
if (!isParseableAddress(entry)) {
|
|
4531
|
+
return {
|
|
4532
|
+
emails: Object.freeze([]),
|
|
4533
|
+
declaredSpellings: Object.freeze([]),
|
|
4534
|
+
raw: text,
|
|
4535
|
+
refusal: `${PLATFORM_OWNER_EMAIL_ENV} entry ${JSON.stringify(piece)} is not an email address, so the WHOLE variable is refused and this deployment has ZERO config-derived platform administrators. The entry is not skipped on purpose: silently dropping it would leave a narrower administrator set than the operator declared, with nothing to notice. Fix the entry, or remove it \u2014 ${PLATFORM_OWNER_EMAIL_ENV} takes one address or a comma-separated list of them.`
|
|
4536
|
+
};
|
|
4537
|
+
}
|
|
4538
|
+
if (!emails.includes(entry)) {
|
|
4539
|
+
emails.push(entry);
|
|
4540
|
+
declaredSpellings.push(piece.trim());
|
|
4541
|
+
}
|
|
4542
|
+
}
|
|
4543
|
+
return {
|
|
4544
|
+
emails: Object.freeze(emails),
|
|
4545
|
+
declaredSpellings: Object.freeze(declaredSpellings),
|
|
4546
|
+
raw: text
|
|
4547
|
+
};
|
|
4548
|
+
}
|
|
4549
|
+
var defaultSink = {
|
|
4550
|
+
error: (m) => console.error(m),
|
|
4551
|
+
warn: (m) => console.warn(m)
|
|
4552
|
+
};
|
|
4553
|
+
var sink = defaultSink;
|
|
4554
|
+
function setPlatformAdminConfigSink(next) {
|
|
4555
|
+
const prev = sink;
|
|
4556
|
+
sink = next ?? defaultSink;
|
|
4557
|
+
return prev;
|
|
4558
|
+
}
|
|
4559
|
+
var NOT_MEMOIZED = /* @__PURE__ */ Symbol("platform-admin-config-not-memoized");
|
|
4560
|
+
var memoKey = NOT_MEMOIZED;
|
|
4561
|
+
var memoValue = EMPTY_CONFIG;
|
|
4562
|
+
function resolvePlatformAdminEmails() {
|
|
4563
|
+
const raw = resolvePlatformOwnerEmail();
|
|
4564
|
+
if (memoKey !== NOT_MEMOIZED && memoKey === raw) return memoValue;
|
|
4565
|
+
const parsed = parsePlatformAdminEmails(raw);
|
|
4566
|
+
memoKey = raw;
|
|
4567
|
+
memoValue = parsed;
|
|
4568
|
+
if (parsed.refusal) sink.error(`[authz] ${parsed.refusal}`);
|
|
4569
|
+
return parsed;
|
|
4570
|
+
}
|
|
4571
|
+
function resetPlatformAdminEmailMemo() {
|
|
4572
|
+
memoKey = NOT_MEMOIZED;
|
|
4573
|
+
memoValue = EMPTY_CONFIG;
|
|
4574
|
+
}
|
|
4575
|
+
function matchesConfiguredPlatformAdmin(row, config) {
|
|
4576
|
+
if (config.emails.length === 0) return false;
|
|
4577
|
+
if (!row || typeof row !== "object") return false;
|
|
4578
|
+
if (!isConfiguredPlatformAdminEmail(row.email, config)) return false;
|
|
4579
|
+
return isEmailVerifiedUserRow(row);
|
|
4580
|
+
}
|
|
4581
|
+
function isConfiguredPlatformAdminEmail(email, config) {
|
|
4582
|
+
if (config.emails.length === 0) return false;
|
|
4583
|
+
const candidate = normalizePlatformAdminEmail(email);
|
|
4584
|
+
return candidate !== "" && config.emails.includes(candidate);
|
|
4585
|
+
}
|
|
4586
|
+
var legacyGrantPointerSaid = false;
|
|
4587
|
+
function reportLegacyPlatformAdminGrant(input) {
|
|
4588
|
+
if (legacyGrantPointerSaid) return;
|
|
4589
|
+
legacyGrantPointerSaid = true;
|
|
4590
|
+
const email = normalizePlatformAdminEmail(input.email);
|
|
4591
|
+
sink.warn(
|
|
4592
|
+
`[authz] user ${input.userId} holds PLATFORM_ADMIN through the legacy unscoped 'admin_full_access' grant row, not through ${PLATFORM_OWNER_EMAIL_ENV}. The grant row is the OLD anchor and is honoured for now; it is removed in a later release. Re-anchor this deployment by declaring its administrators in configuration: ${PLATFORM_OWNER_EMAIL_ENV}=${email || "<the administrator's verified email address>"} (comma-separated for several), and make sure each account's email is VERIFIED \u2014 an unverified account holding a configured address is not an administrator. Reported once per process; further holders are not listed.`
|
|
4593
|
+
);
|
|
4594
|
+
}
|
|
4595
|
+
function resetLegacyPlatformAdminGrantReport() {
|
|
4596
|
+
legacyGrantPointerSaid = false;
|
|
4597
|
+
}
|
|
4598
|
+
|
|
4326
4599
|
// src/security/posture-ladder.ts
|
|
4327
4600
|
var POSTURE_LADDER = [
|
|
4328
4601
|
"PLATFORM_ADMIN",
|
|
@@ -4396,14 +4669,16 @@ function safeJsonParse2(s, fallback) {
|
|
|
4396
4669
|
return fallback;
|
|
4397
4670
|
}
|
|
4398
4671
|
}
|
|
4399
|
-
async function tryFind(ql, object, where, limit = 100) {
|
|
4672
|
+
async function tryFind(ql, object, where, limit = 100, organizationId) {
|
|
4400
4673
|
if (!ql || typeof ql.find !== "function") return [];
|
|
4401
4674
|
try {
|
|
4402
|
-
|
|
4675
|
+
const context = organizationId ? { isSystem: true, tenantId: organizationId } : { isSystem: true };
|
|
4676
|
+
let rows = await ql.find(object, { where, limit, context });
|
|
4403
4677
|
if (rows && rows.value) rows = rows.value;
|
|
4404
4678
|
return Array.isArray(rows) ? rows : [];
|
|
4405
|
-
} catch {
|
|
4406
|
-
return [];
|
|
4679
|
+
} catch (err) {
|
|
4680
|
+
if (isMissingTableError(err, object)) return [];
|
|
4681
|
+
throw new AuthzStoreUnavailableError(object, err);
|
|
4407
4682
|
}
|
|
4408
4683
|
}
|
|
4409
4684
|
async function resolveAuthzContext(input) {
|
|
@@ -4477,6 +4752,8 @@ async function resolveAuthzContext(input) {
|
|
|
4477
4752
|
return ctx;
|
|
4478
4753
|
}
|
|
4479
4754
|
async function resolveUserAuthzGrants(ql, userId, opts = {}) {
|
|
4755
|
+
const grantsCache = openUserGrantsCache(ql, userId, opts);
|
|
4756
|
+
if (grantsCache?.hit) return grantsCache.hit;
|
|
4480
4757
|
const { tenantId } = opts;
|
|
4481
4758
|
const grants = {
|
|
4482
4759
|
positions: [],
|
|
@@ -4497,12 +4774,20 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
|
|
|
4497
4774
|
}
|
|
4498
4775
|
return userRow;
|
|
4499
4776
|
};
|
|
4777
|
+
const platformAdminConfig = resolvePlatformAdminEmails();
|
|
4778
|
+
const needsUserRow = !grants.email || !grants.permissions.includes("ai_seat") || platformAdminConfig.emails.length > 0;
|
|
4779
|
+
const [, members, userPositionRows, orgMembersLeg, upsRowsAll] = await Promise.all([
|
|
4780
|
+
needsUserRow ? getUserRow() : Promise.resolve(void 0),
|
|
4781
|
+
tryFind(ql, "sys_member", { user_id: userId }, 200),
|
|
4782
|
+
tryFind(ql, "sys_user_position", { user_id: userId }, 200),
|
|
4783
|
+
tenantId ? tryFind(ql, "sys_member", { organization_id: tenantId }, 1e3) : Promise.resolve([]),
|
|
4784
|
+
tryFind(ql, "sys_user_permission_set", { user_id: userId }, 100)
|
|
4785
|
+
]);
|
|
4500
4786
|
if (!grants.email) {
|
|
4501
4787
|
const u = await getUserRow();
|
|
4502
4788
|
if (u?.email) grants.email = String(u.email);
|
|
4503
4789
|
}
|
|
4504
4790
|
const nowMs = opts.nowMs ?? Date.now();
|
|
4505
|
-
const members = await tryFind(ql, "sys_member", { user_id: userId }, 200);
|
|
4506
4791
|
const accessibleOrgIds = /* @__PURE__ */ new Set();
|
|
4507
4792
|
for (const m of members) {
|
|
4508
4793
|
if (!isGrantActive(m, nowMs)) continue;
|
|
@@ -4510,7 +4795,9 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
|
|
|
4510
4795
|
if (typeof org === "string" && org) accessibleOrgIds.add(org);
|
|
4511
4796
|
}
|
|
4512
4797
|
grants.accessible_org_ids = Array.from(accessibleOrgIds);
|
|
4513
|
-
const activeMembers =
|
|
4798
|
+
const activeMembers = members.filter(
|
|
4799
|
+
(m) => isGrantActive(m, nowMs) && (!tenantId || (m.organization_id ?? m.organizationId) === tenantId)
|
|
4800
|
+
);
|
|
4514
4801
|
for (const m of activeMembers) {
|
|
4515
4802
|
if (m.role && typeof m.role === "string") {
|
|
4516
4803
|
for (const raw of m.role.split(",").map((s) => s.trim()).filter(Boolean)) {
|
|
@@ -4519,7 +4806,6 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
|
|
|
4519
4806
|
}
|
|
4520
4807
|
}
|
|
4521
4808
|
}
|
|
4522
|
-
const userPositionRows = await tryFind(ql, "sys_user_position", { user_id: userId }, 200);
|
|
4523
4809
|
for (const ur of userPositionRows) {
|
|
4524
4810
|
const org = ur.organization_id ?? null;
|
|
4525
4811
|
if (org && tenantId && org !== tenantId) continue;
|
|
@@ -4528,14 +4814,13 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
|
|
|
4528
4814
|
if (typeof r === "string" && r && !grants.positions.includes(r)) grants.positions.push(r);
|
|
4529
4815
|
}
|
|
4530
4816
|
if (tenantId) {
|
|
4531
|
-
const orgMembers =
|
|
4817
|
+
const orgMembers = orgMembersLeg;
|
|
4532
4818
|
const ids = new Set(
|
|
4533
4819
|
orgMembers.map((m) => m.user_id ?? m.userId).filter((v) => typeof v === "string" && v.length > 0)
|
|
4534
4820
|
);
|
|
4535
4821
|
ids.add(userId);
|
|
4536
4822
|
grants.org_user_ids = Array.from(ids);
|
|
4537
4823
|
}
|
|
4538
|
-
const upsRowsAll = await tryFind(ql, "sys_user_permission_set", { user_id: userId }, 100);
|
|
4539
4824
|
const upsRows = upsRowsAll.filter((r) => isGrantActive(r, nowMs));
|
|
4540
4825
|
const psIds = new Set(
|
|
4541
4826
|
upsRows.filter((r) => {
|
|
@@ -4549,7 +4834,7 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
|
|
|
4549
4834
|
let hasPlatformAdminGrant = false;
|
|
4550
4835
|
if (!grants.positions.includes("everyone")) grants.positions.push("everyone");
|
|
4551
4836
|
if (grants.positions.length > 0) {
|
|
4552
|
-
const positionRows = await tryFind(ql, "sys_position", { name: { $in: grants.positions } },
|
|
4837
|
+
const positionRows = await tryFind(ql, "sys_position", { name: { $in: grants.positions } }, 200, tenantId);
|
|
4553
4838
|
const deactivatedNames = new Set(
|
|
4554
4839
|
positionRows.filter((r) => !isRowActive(r)).map((r) => r.name).filter(Boolean)
|
|
4555
4840
|
);
|
|
@@ -4592,6 +4877,18 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
|
|
|
4592
4877
|
}
|
|
4593
4878
|
if (Object.keys(mergedTabs).length > 0) grants.tabPermissions = mergedTabs;
|
|
4594
4879
|
}
|
|
4880
|
+
const configConfersPlatformAdmin = platformAdminConfig.emails.length > 0 && matchesConfiguredPlatformAdmin(await getUserRow(), platformAdminConfig);
|
|
4881
|
+
if (configConfersPlatformAdmin) {
|
|
4882
|
+
hasPlatformAdminGrant = true;
|
|
4883
|
+
if (!grants.permissions.includes(ADMIN_FULL_ACCESS)) grants.permissions.push(ADMIN_FULL_ACCESS);
|
|
4884
|
+
for (const p of ADMIN_FULL_ACCESS_CAPABILITIES.systemPermissions ?? []) {
|
|
4885
|
+
if (!grants.systemPermissions.includes(p)) grants.systemPermissions.push(p);
|
|
4886
|
+
}
|
|
4887
|
+
} else if (hasPlatformAdminGrant) {
|
|
4888
|
+
if (postureEnforcesWall2(resolveTenancyPosture())) {
|
|
4889
|
+
reportLegacyPlatformAdminGrant({ userId, email: userRow?.email });
|
|
4890
|
+
}
|
|
4891
|
+
}
|
|
4595
4892
|
if (hasPlatformAdminGrant && !grants.positions.includes(BUILTIN_IDENTITY_PLATFORM_ADMIN)) {
|
|
4596
4893
|
grants.positions.unshift(BUILTIN_IDENTITY_PLATFORM_ADMIN);
|
|
4597
4894
|
}
|
|
@@ -4605,8 +4902,21 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
|
|
|
4605
4902
|
const aiAccess = (await getUserRow())?.ai_access;
|
|
4606
4903
|
if (aiAccess === true || aiAccess === 1 || aiAccess === "1") grants.permissions.push("ai_seat");
|
|
4607
4904
|
}
|
|
4905
|
+
grantsCache?.commit(
|
|
4906
|
+
grants,
|
|
4907
|
+
nextGrantValidityBoundary([...members, ...userPositionRows, ...upsRowsAll], nowMs)
|
|
4908
|
+
);
|
|
4608
4909
|
return grants;
|
|
4609
4910
|
}
|
|
4911
|
+
async function hasPlatformAdminStanding(ql, userId, opts = {}) {
|
|
4912
|
+
if (!ql || typeof userId !== "string" || userId.length === 0) return false;
|
|
4913
|
+
try {
|
|
4914
|
+
const grants = await resolveUserAuthzGrants(ql, userId, { nowMs: opts.nowMs });
|
|
4915
|
+
return grants.posture === "PLATFORM_ADMIN";
|
|
4916
|
+
} catch {
|
|
4917
|
+
return false;
|
|
4918
|
+
}
|
|
4919
|
+
}
|
|
4610
4920
|
function isValidTimeZone(tz) {
|
|
4611
4921
|
try {
|
|
4612
4922
|
new Intl.DateTimeFormat("en-US", { timeZone: tz });
|
|
@@ -4627,34 +4937,151 @@ function coerceCurrency(value) {
|
|
|
4627
4937
|
const s = typeof value === "string" ? value.trim().toUpperCase() : "";
|
|
4628
4938
|
return /^[A-Z]{3}$/.test(s) ? s : void 0;
|
|
4629
4939
|
}
|
|
4940
|
+
var LOCALIZATION_FAILURE_CACHE_TTL_MS = 3e4;
|
|
4941
|
+
var LOCALIZATION_CACHE_TTL_ENV = "OS_LOCALIZATION_CACHE_TTL_MS";
|
|
4942
|
+
var LOCALIZATION_SUCCESS_CACHE_DEFAULT_TTL_MS = 3e4;
|
|
4943
|
+
function localizationSuccessCacheTtlMs(env = typeof process !== "undefined" ? process.env : {}) {
|
|
4944
|
+
const raw = env[LOCALIZATION_CACHE_TTL_ENV];
|
|
4945
|
+
if (raw === void 0 || raw.trim() === "") return LOCALIZATION_SUCCESS_CACHE_DEFAULT_TTL_MS;
|
|
4946
|
+
const parsed = Number(raw.trim());
|
|
4947
|
+
if (!Number.isFinite(parsed) || parsed < 0) return 0;
|
|
4948
|
+
return Math.floor(parsed);
|
|
4949
|
+
}
|
|
4950
|
+
function readWriteEpoch(ql) {
|
|
4951
|
+
if (!ql || typeof ql !== "object") return void 0;
|
|
4952
|
+
const epoch = ql.writeEpoch;
|
|
4953
|
+
if (!epoch || typeof epoch !== "object") return void 0;
|
|
4954
|
+
const seam = epoch;
|
|
4955
|
+
if (typeof seam.current !== "number" || typeof seam.bump !== "function" || typeof seam.subscribe !== "function") {
|
|
4956
|
+
return void 0;
|
|
4957
|
+
}
|
|
4958
|
+
return seam.current;
|
|
4959
|
+
}
|
|
4960
|
+
var localizationSettingsStates = /* @__PURE__ */ new WeakMap();
|
|
4961
|
+
var localizationNoSettingsState = { gen: 0 };
|
|
4962
|
+
function localizationSettingsState(settings) {
|
|
4963
|
+
if (!settings || typeof settings !== "object") return localizationNoSettingsState;
|
|
4964
|
+
const existing = localizationSettingsStates.get(settings);
|
|
4965
|
+
if (existing) return existing;
|
|
4966
|
+
const state = { gen: 0 };
|
|
4967
|
+
localizationSettingsStates.set(settings, state);
|
|
4968
|
+
const subscribe = settings.subscribe;
|
|
4969
|
+
if (typeof subscribe === "function") {
|
|
4970
|
+
try {
|
|
4971
|
+
subscribe.call(
|
|
4972
|
+
settings,
|
|
4973
|
+
"localization",
|
|
4974
|
+
() => {
|
|
4975
|
+
state.gen += 1;
|
|
4976
|
+
}
|
|
4977
|
+
);
|
|
4978
|
+
} catch {
|
|
4979
|
+
}
|
|
4980
|
+
}
|
|
4981
|
+
return state;
|
|
4982
|
+
}
|
|
4983
|
+
var localizationCache = /* @__PURE__ */ new WeakMap();
|
|
4984
|
+
function localizationEntryIsLive(entry, epoch, settings) {
|
|
4985
|
+
if (entry.kind === "failure") return true;
|
|
4986
|
+
return entry.epoch === epoch && entry.settings === settings && entry.settingsGen === settings.gen;
|
|
4987
|
+
}
|
|
4988
|
+
function putLocalizationEntry(ql, key, entry) {
|
|
4989
|
+
const bucket = localizationCache.get(ql) ?? /* @__PURE__ */ new Map();
|
|
4990
|
+
bucket.set(key, entry);
|
|
4991
|
+
localizationCache.set(ql, bucket);
|
|
4992
|
+
}
|
|
4630
4993
|
async function resolveLocalizationContext(input) {
|
|
4631
4994
|
const { ql, settings, tenantId, userId } = input;
|
|
4995
|
+
const cacheKey = `${tenantId ?? ""}|${userId ?? ""}`;
|
|
4996
|
+
const cacheable = Boolean(ql) && typeof ql === "object";
|
|
4997
|
+
const epoch = cacheable ? readWriteEpoch(ql) : void 0;
|
|
4998
|
+
const settingsState = localizationSettingsState(settings);
|
|
4999
|
+
if (cacheable) {
|
|
5000
|
+
const hit = localizationCache.get(ql)?.get(cacheKey);
|
|
5001
|
+
if (hit && hit.expiresAt > Date.now() && localizationEntryIsLive(hit, epoch, settingsState)) {
|
|
5002
|
+
return hit.value;
|
|
5003
|
+
}
|
|
5004
|
+
}
|
|
5005
|
+
const { value, backendFailed } = await resolveLocalizationContextUncached(input);
|
|
5006
|
+
if (!cacheable) return value;
|
|
5007
|
+
if (backendFailed) {
|
|
5008
|
+
putLocalizationEntry(ql, cacheKey, {
|
|
5009
|
+
value,
|
|
5010
|
+
expiresAt: Date.now() + LOCALIZATION_FAILURE_CACHE_TTL_MS,
|
|
5011
|
+
kind: "failure"
|
|
5012
|
+
});
|
|
5013
|
+
return value;
|
|
5014
|
+
}
|
|
5015
|
+
const ttlMs = localizationSuccessCacheTtlMs();
|
|
5016
|
+
if (epoch !== void 0 && ttlMs > 0) {
|
|
5017
|
+
putLocalizationEntry(ql, cacheKey, {
|
|
5018
|
+
value,
|
|
5019
|
+
expiresAt: Date.now() + ttlMs,
|
|
5020
|
+
kind: "success",
|
|
5021
|
+
epoch,
|
|
5022
|
+
settings: settingsState,
|
|
5023
|
+
settingsGen: settingsState.gen
|
|
5024
|
+
});
|
|
5025
|
+
} else {
|
|
5026
|
+
localizationCache.get(ql)?.delete(cacheKey);
|
|
5027
|
+
}
|
|
5028
|
+
return value;
|
|
5029
|
+
}
|
|
5030
|
+
async function resolveLocalizationContextUncached(input) {
|
|
5031
|
+
const { ql, settings, tenantId, userId } = input;
|
|
5032
|
+
let backendFailed = false;
|
|
4632
5033
|
try {
|
|
4633
5034
|
if (settings && typeof settings.get === "function") {
|
|
4634
5035
|
const sctx = { tenantId, userId };
|
|
4635
|
-
|
|
4636
|
-
|
|
4637
|
-
|
|
4638
|
-
|
|
4639
|
-
|
|
5036
|
+
let tzRes;
|
|
5037
|
+
let localeRes;
|
|
5038
|
+
let currencyRes;
|
|
5039
|
+
if (typeof settings.getMany === "function") {
|
|
5040
|
+
try {
|
|
5041
|
+
const many = await settings.getMany("localization", ["timezone", "locale", "currency"], sctx);
|
|
5042
|
+
tzRes = many.timezone;
|
|
5043
|
+
localeRes = many.locale;
|
|
5044
|
+
currencyRes = many.currency;
|
|
5045
|
+
} catch {
|
|
5046
|
+
}
|
|
5047
|
+
} else {
|
|
5048
|
+
[tzRes, localeRes, currencyRes] = await Promise.all([
|
|
5049
|
+
settings.get("localization", "timezone", sctx).catch(() => void 0),
|
|
5050
|
+
settings.get("localization", "locale", sctx).catch(() => void 0),
|
|
5051
|
+
settings.get("localization", "currency", sctx).catch(() => void 0)
|
|
5052
|
+
]);
|
|
5053
|
+
}
|
|
4640
5054
|
const tz = coerceTimeZone(tzRes?.value);
|
|
4641
5055
|
const locale = coerceLocale(localeRes?.value);
|
|
4642
5056
|
const currency = coerceCurrency(currencyRes?.value);
|
|
4643
|
-
if (tz || locale || currency)
|
|
5057
|
+
if (tz || locale || currency) {
|
|
5058
|
+
return { value: { timezone: tz ?? "UTC", locale: locale ?? "en-US", currency }, backendFailed: false };
|
|
5059
|
+
}
|
|
4644
5060
|
}
|
|
4645
5061
|
} catch {
|
|
4646
5062
|
}
|
|
4647
|
-
|
|
4648
|
-
|
|
4649
|
-
|
|
4650
|
-
|
|
4651
|
-
|
|
4652
|
-
|
|
5063
|
+
let rows = [];
|
|
5064
|
+
if (ql && typeof ql.find === "function") {
|
|
5065
|
+
try {
|
|
5066
|
+
let result = await ql.find("sys_setting", {
|
|
5067
|
+
where: { namespace: "localization", key: { $in: ["timezone", "locale", "currency"] }, scope: "tenant" },
|
|
5068
|
+
limit: 10,
|
|
5069
|
+
context: { isSystem: true }
|
|
5070
|
+
});
|
|
5071
|
+
if (result && result.value) result = result.value;
|
|
5072
|
+
rows = Array.isArray(result) ? result : [];
|
|
5073
|
+
} catch {
|
|
5074
|
+
backendFailed = true;
|
|
5075
|
+
}
|
|
5076
|
+
}
|
|
4653
5077
|
const valueOf = (k) => rows.find((r) => r.key === k)?.value;
|
|
4654
5078
|
return {
|
|
4655
|
-
|
|
4656
|
-
|
|
4657
|
-
|
|
5079
|
+
value: {
|
|
5080
|
+
timezone: coerceTimeZone(valueOf("timezone")) ?? "UTC",
|
|
5081
|
+
locale: coerceLocale(valueOf("locale")) ?? "en-US",
|
|
5082
|
+
currency: coerceCurrency(valueOf("currency"))
|
|
5083
|
+
},
|
|
5084
|
+
backendFailed
|
|
4658
5085
|
};
|
|
4659
5086
|
}
|
|
4660
5087
|
|
|
@@ -4816,7 +5243,7 @@ function shouldDenyAnonymous(input) {
|
|
|
4816
5243
|
var ADMIN_STANDING_SURFACE = {
|
|
4817
5244
|
sys_permission_set: {
|
|
4818
5245
|
role: "derives",
|
|
4819
|
-
reason: "The row `
|
|
5246
|
+
reason: "The row `admin_full_access` is resolved BY NAME from (\xA76b) \u2014 `platform_admin` is the POSITION that row derives, not the row's own name. Renaming it, deleting it or switching it off (ADR-0049 `active`, read here since #8613) un-makes every GRANT-derived platform admin at once, with no identity table touched. \u26A0\uFE0F It does NOT un-make a CONFIG-derived one (\xA76b-config, #11970): that route sets the same standing from `ADMIN_FULL_ACCESS_CAPABILITIES` in `@objectstack/spec` and matches the caller's own stored `sys_user` row, so it touches an identity table and never reads this one. With `OS_PLATFORM_OWNER_EMAIL` unset the first sentence is the whole truth; with it declared, this row stops being the single point that un-makes every administrator.",
|
|
4820
5247
|
columns: [
|
|
4821
5248
|
"id",
|
|
4822
5249
|
"name",
|
|
@@ -4858,8 +5285,14 @@ var ADMIN_STANDING_SURFACE = {
|
|
|
4858
5285
|
]
|
|
4859
5286
|
},
|
|
4860
5287
|
sys_user: {
|
|
4861
|
-
role: "
|
|
4862
|
-
reason: "
|
|
5288
|
+
role: "derives",
|
|
5289
|
+
reason: "[#11663 L2] RECLASSIFIED from `reads-only`. This table used to be read only for the `current_user.email` RLS fallback and the ADR-0024 `ai_seat` synthesis (\xA77), and the note here said so: \"Neither confers administrator standing.\" That sentence is now FALSE. The config anchor (\xA76b-config) matches the row's own `email` against the deployment's declared administrator list and requires `email_verified` to read verified, so a write that changes either column takes platform-admin standing away from a config-derived administrator \u2014 an address change and an email_verified reset are both ordinary, reachable writes, and neither touches a grant table. `banned` stays absent from the column list because the resolver still never reads it; the guard watches the ban/delete WRITE SHAPES on this table for its own reasons, which is a different question from what this resolver consumes.",
|
|
5290
|
+
columns: [
|
|
5291
|
+
"id",
|
|
5292
|
+
"email",
|
|
5293
|
+
"email_verified",
|
|
5294
|
+
"ai_access"
|
|
5295
|
+
]
|
|
4863
5296
|
},
|
|
4864
5297
|
sys_user_position: {
|
|
4865
5298
|
role: "reads-only",
|
|
@@ -4874,6 +5307,13 @@ var ADMIN_STANDING_SURFACE = {
|
|
|
4874
5307
|
reason: "Position-bound permission sets (\xA76a). Contributes ids to `psIds` \u2014 and therefore names to `permissions` \u2014 but not to `unscopedUserPsIds`, which is the set \xA76b tests for platform-admin standing."
|
|
4875
5308
|
}
|
|
4876
5309
|
};
|
|
5310
|
+
var ADMIN_STANDING_NON_TABLE_INPUTS = [
|
|
5311
|
+
{
|
|
5312
|
+
kind: "env",
|
|
5313
|
+
name: "OS_PLATFORM_OWNER_EMAIL",
|
|
5314
|
+
reason: "The deployment's declared platform administrator(s) \u2014 one address or a comma-separated list, matched case-insensitively against `sys_user.email` and conferring standing only when that row's `email_verified` reads verified (\xA76b-config). Read live on every derivation with a per-process memo keyed on the raw string, so a rolled process picks up a change with no special path. Unset, blank, or carrying any unparseable entry means ZERO config-derived administrators, fail closed. No runtime write reaches it, so no break-glass guard can simulate a change to it: revocation is a configuration change plus a process roll, by design."
|
|
5315
|
+
}
|
|
5316
|
+
];
|
|
4877
5317
|
function adminStandingTables() {
|
|
4878
5318
|
return Object.entries(ADMIN_STANDING_SURFACE).filter(([, t]) => t.role === "derives").map(([name]) => name).sort();
|
|
4879
5319
|
}
|
|
@@ -4905,6 +5345,9 @@ function withoutOperationPrivateKeys(exec) {
|
|
|
4905
5345
|
return out;
|
|
4906
5346
|
}
|
|
4907
5347
|
|
|
5348
|
+
// src/security/authz-invalidation-channel.ts
|
|
5349
|
+
var AUTHZ_INVALIDATED_CHANNEL = "authz.invalidated";
|
|
5350
|
+
|
|
4908
5351
|
// src/utils/datetime.ts
|
|
4909
5352
|
import { nextUtcCalendarDay, utcInstantMs } from "@objectstack/spec/data";
|
|
4910
5353
|
function calendarPartsInTz(d, tz) {
|
|
@@ -5185,7 +5628,7 @@ function omitInternalFieldsFromWriteResponse(schema, records) {
|
|
|
5185
5628
|
}
|
|
5186
5629
|
|
|
5187
5630
|
// src/utils/migration-journal.ts
|
|
5188
|
-
import { createHash as
|
|
5631
|
+
import { createHash as createHash3, randomUUID } from "crypto";
|
|
5189
5632
|
import {
|
|
5190
5633
|
MIGRATION_JOURNAL_OBJECT
|
|
5191
5634
|
} from "@objectstack/spec/system";
|
|
@@ -5242,7 +5685,7 @@ function hashMigrationPlan(plan, chunks) {
|
|
|
5242
5685
|
steps: plan.steps.map((s) => s.name),
|
|
5243
5686
|
chunks: chunks.map((c) => [c.stepIndex, c.offset, c.length])
|
|
5244
5687
|
});
|
|
5245
|
-
return
|
|
5688
|
+
return createHash3("sha256").update(shape, "utf8").digest("hex").slice(0, 32);
|
|
5246
5689
|
}
|
|
5247
5690
|
async function appendEvent(engine, event, execContext) {
|
|
5248
5691
|
await engine.insert(
|
|
@@ -5810,6 +6253,110 @@ function isUninterpretableTemporalComparand(kind, value) {
|
|
|
5810
6253
|
return !(readsAsWallClock(s) || readsAsInstant(s));
|
|
5811
6254
|
}
|
|
5812
6255
|
|
|
6256
|
+
// src/utils/metadata-activation-store.ts
|
|
6257
|
+
var METADATA_ACTIVATION_TABLE = "sys_metadata_activation";
|
|
6258
|
+
var SYSTEM_CTX2 = { isSystem: true, positions: [], permissions: [] };
|
|
6259
|
+
var InMemoryMetadataActivationStore = class {
|
|
6260
|
+
constructor() {
|
|
6261
|
+
this.rows = /* @__PURE__ */ new Map();
|
|
6262
|
+
}
|
|
6263
|
+
async list() {
|
|
6264
|
+
return [...this.rows.values()];
|
|
6265
|
+
}
|
|
6266
|
+
async setActive(row) {
|
|
6267
|
+
this.rows.set(row.name, { ...row });
|
|
6268
|
+
}
|
|
6269
|
+
};
|
|
6270
|
+
var ObjectStoreMetadataActivationStore = class {
|
|
6271
|
+
constructor(engine, metadataType) {
|
|
6272
|
+
this.engine = engine;
|
|
6273
|
+
this.metadataType = metadataType;
|
|
6274
|
+
}
|
|
6275
|
+
/**
|
|
6276
|
+
* Every row of this type. Read once at boot to hydrate the consumer's
|
|
6277
|
+
* projection.
|
|
6278
|
+
*
|
|
6279
|
+
* The only scoping is the `metadata_type` discriminator: the ledger is
|
|
6280
|
+
* deployment-wide and has no tenant column, so there is no second axis to
|
|
6281
|
+
* filter on (see the module header).
|
|
6282
|
+
*/
|
|
6283
|
+
async list() {
|
|
6284
|
+
const rows = await this.engine.find(METADATA_ACTIVATION_TABLE, {
|
|
6285
|
+
where: { metadata_type: this.metadataType },
|
|
6286
|
+
context: SYSTEM_CTX2
|
|
6287
|
+
});
|
|
6288
|
+
if (!Array.isArray(rows)) return [];
|
|
6289
|
+
const out = [];
|
|
6290
|
+
for (const row of rows) {
|
|
6291
|
+
const r = row;
|
|
6292
|
+
if (typeof r.name !== "string" || !r.name) continue;
|
|
6293
|
+
out.push({
|
|
6294
|
+
name: r.name,
|
|
6295
|
+
packageId: typeof r.package_id === "string" ? r.package_id : "",
|
|
6296
|
+
// The column defaults to `true`; only an explicit `false`
|
|
6297
|
+
// disarms. A driver that round-trips booleans as 0/1
|
|
6298
|
+
// (SQLite/libsql) is read through the same `=== false || === 0`
|
|
6299
|
+
// test, so a `0` is not mistaken for `true`.
|
|
6300
|
+
active: !(r.active === false || r.active === 0)
|
|
6301
|
+
});
|
|
6302
|
+
}
|
|
6303
|
+
return out;
|
|
6304
|
+
}
|
|
6305
|
+
/**
|
|
6306
|
+
* Insert or update the row for one packaged artifact.
|
|
6307
|
+
*
|
|
6308
|
+
* Read-then-write rather than a blind upsert because the object's
|
|
6309
|
+
* uniqueness is a DECLARED index (`unique: 'global'` over
|
|
6310
|
+
* `(metadata_type, name)`), not a primary key this store controls: there is
|
|
6311
|
+
* no id to collide on, so an insert-and-catch could not tell "already
|
|
6312
|
+
* there" from a real store failure.
|
|
6313
|
+
*
|
|
6314
|
+
* That index is also why taking the FIRST match is taking the only one: the
|
|
6315
|
+
* read below is keyed on exactly the index's two columns, so it can match
|
|
6316
|
+
* at most one row. It used to pick the first row with a NULL organization
|
|
6317
|
+
* out of the result, back when the table carried a reserved tenant column;
|
|
6318
|
+
* with no such column the set it was choosing from can no longer hold more
|
|
6319
|
+
* than one member.
|
|
6320
|
+
*/
|
|
6321
|
+
async setActive(row) {
|
|
6322
|
+
const existing = await this.engine.find(METADATA_ACTIVATION_TABLE, {
|
|
6323
|
+
where: { metadata_type: this.metadataType, name: row.name },
|
|
6324
|
+
context: SYSTEM_CTX2
|
|
6325
|
+
});
|
|
6326
|
+
const current = Array.isArray(existing) ? existing[0] : void 0;
|
|
6327
|
+
if (current && current.id != null) {
|
|
6328
|
+
await this.engine.update(
|
|
6329
|
+
METADATA_ACTIVATION_TABLE,
|
|
6330
|
+
{ id: current.id, active: row.active, package_id: row.packageId },
|
|
6331
|
+
{ context: SYSTEM_CTX2 }
|
|
6332
|
+
);
|
|
6333
|
+
return;
|
|
6334
|
+
}
|
|
6335
|
+
await this.engine.insert(
|
|
6336
|
+
METADATA_ACTIVATION_TABLE,
|
|
6337
|
+
{
|
|
6338
|
+
metadata_type: this.metadataType,
|
|
6339
|
+
name: row.name,
|
|
6340
|
+
package_id: row.packageId,
|
|
6341
|
+
active: row.active
|
|
6342
|
+
},
|
|
6343
|
+
{ context: SYSTEM_CTX2 }
|
|
6344
|
+
);
|
|
6345
|
+
}
|
|
6346
|
+
/**
|
|
6347
|
+
* Read the backing table once so a misconfiguration surfaces at BOOT
|
|
6348
|
+
* rather than as a failed toggle later. Throws the driver error verbatim —
|
|
6349
|
+
* `no such table: sys_metadata_activation` means the object was never
|
|
6350
|
+
* registered (or its schema never synced) in this composition.
|
|
6351
|
+
*
|
|
6352
|
+
* ⚠️ Unscoped by design: the question is "does the TABLE read at all",
|
|
6353
|
+
* which is a property of the composition, not of one `metadata_type`.
|
|
6354
|
+
*/
|
|
6355
|
+
async probe() {
|
|
6356
|
+
await this.engine.find(METADATA_ACTIVATION_TABLE, { where: {}, limit: 1, context: SYSTEM_CTX2 });
|
|
6357
|
+
}
|
|
6358
|
+
};
|
|
6359
|
+
|
|
5813
6360
|
// src/utils/record-not-found.ts
|
|
5814
6361
|
function recordNotFoundError(object, id) {
|
|
5815
6362
|
const err = new Error(`Record ${id} not found in ${object}`);
|
|
@@ -5820,6 +6367,45 @@ function recordNotFoundError(object, id) {
|
|
|
5820
6367
|
}
|
|
5821
6368
|
|
|
5822
6369
|
// src/health-monitor.ts
|
|
6370
|
+
var RECOVERY_IS_THRESHOLD_GATED = {
|
|
6371
|
+
degraded: true,
|
|
6372
|
+
unhealthy: true,
|
|
6373
|
+
failed: true,
|
|
6374
|
+
recovering: true,
|
|
6375
|
+
healthy: false,
|
|
6376
|
+
unknown: false
|
|
6377
|
+
};
|
|
6378
|
+
function healthMonitorRefusal(message) {
|
|
6379
|
+
const err = new Error(message);
|
|
6380
|
+
err.code = "VALIDATION_ERROR";
|
|
6381
|
+
err.status = 400;
|
|
6382
|
+
return err;
|
|
6383
|
+
}
|
|
6384
|
+
var RETIRED_HEALTH_CHECK_KEYS = [
|
|
6385
|
+
[
|
|
6386
|
+
"autoRestart",
|
|
6387
|
+
"'autoRestart' was removed from PluginHealthCheck in @objectstack/spec 18 (#12032, ADR-0049 enforce-or-remove) \u2014 it never restarted a plugin. `attemptRestart` called `plugin.destroy()` and stopped there, then logged 'Plugin restarted' and set status `recovering`, and the periodic checks carried on against the destroyed instance \u2014 which the default check (`{ name: 'plugin-loaded', status: 'passed' }`) passes forever, so a destroyed, never-re-initialised plugin ended up reported `healthy`. Delete the key. This monitor no longer destroys anything: a failing plugin is reported `unhealthy` or `failed` and left alone."
|
|
6388
|
+
],
|
|
6389
|
+
[
|
|
6390
|
+
"maxRestartAttempts",
|
|
6391
|
+
"'maxRestartAttempts' was removed from PluginHealthCheck in @objectstack/spec 18 (#12032, ADR-0049 enforce-or-remove) \u2014 it capped a restart that never happened, so it only counted `destroy()` calls. Delete the key."
|
|
6392
|
+
],
|
|
6393
|
+
[
|
|
6394
|
+
"restartBackoff",
|
|
6395
|
+
"'restartBackoff' was removed from PluginHealthCheck in @objectstack/spec 18 (#12032, ADR-0049 enforce-or-remove) \u2014 it delayed a restart that never happened, so it only moved when the `destroy()` landed. Delete the key."
|
|
6396
|
+
]
|
|
6397
|
+
];
|
|
6398
|
+
var RESTART_IS_THE_HOSTS_JOB = " Restarting a plugin is the HOST's job in this host-driven library: poll `getHealthStatus(pluginName)` / `getHealthReport(pluginName)` and act on `unhealthy` / `failed` at the level that owns the plugin's lifetime \u2014 recreate the kernel, or let your supervisor restart the process.";
|
|
6399
|
+
function assertNoRetiredKeys(pluginName, config) {
|
|
6400
|
+
for (const [key, guidance] of RETIRED_HEALTH_CHECK_KEYS) {
|
|
6401
|
+
if (!Object.prototype.hasOwnProperty.call(config, key)) {
|
|
6402
|
+
continue;
|
|
6403
|
+
}
|
|
6404
|
+
throw healthMonitorRefusal(
|
|
6405
|
+
`[HealthMonitor] Plugin '${pluginName}': ${guidance}${RESTART_IS_THE_HOSTS_JOB}`
|
|
6406
|
+
);
|
|
6407
|
+
}
|
|
6408
|
+
}
|
|
5823
6409
|
var PluginHealthMonitor = class {
|
|
5824
6410
|
constructor(logger) {
|
|
5825
6411
|
this.healthChecks = /* @__PURE__ */ new Map();
|
|
@@ -5828,18 +6414,17 @@ var PluginHealthMonitor = class {
|
|
|
5828
6414
|
this.checkIntervals = /* @__PURE__ */ new Map();
|
|
5829
6415
|
this.failureCounters = /* @__PURE__ */ new Map();
|
|
5830
6416
|
this.successCounters = /* @__PURE__ */ new Map();
|
|
5831
|
-
this.restartAttempts = /* @__PURE__ */ new Map();
|
|
5832
6417
|
this.logger = logger.child({ component: "HealthMonitor" });
|
|
5833
6418
|
}
|
|
5834
6419
|
/**
|
|
5835
6420
|
* Register a plugin for health monitoring
|
|
5836
6421
|
*/
|
|
5837
6422
|
registerPlugin(pluginName, config) {
|
|
6423
|
+
assertNoRetiredKeys(pluginName, config);
|
|
5838
6424
|
this.healthChecks.set(pluginName, config);
|
|
5839
6425
|
this.healthStatus.set(pluginName, "unknown");
|
|
5840
6426
|
this.failureCounters.set(pluginName, 0);
|
|
5841
6427
|
this.successCounters.set(pluginName, 0);
|
|
5842
|
-
this.restartAttempts.set(pluginName, 0);
|
|
5843
6428
|
this.logger.info("Plugin registered for health monitoring", {
|
|
5844
6429
|
plugin: pluginName,
|
|
5845
6430
|
interval: config.interval
|
|
@@ -5891,6 +6476,7 @@ var PluginHealthMonitor = class {
|
|
|
5891
6476
|
let status = "healthy";
|
|
5892
6477
|
let message;
|
|
5893
6478
|
const checks = [];
|
|
6479
|
+
let failureRoute;
|
|
5894
6480
|
try {
|
|
5895
6481
|
if (config.checkMethod && typeof plugin[config.checkMethod] === "function") {
|
|
5896
6482
|
const checkResult = await this.raceCheckTimeout(
|
|
@@ -5911,8 +6497,8 @@ var PluginHealthMonitor = class {
|
|
|
5911
6497
|
if (status === "healthy") {
|
|
5912
6498
|
this.successCounters.set(pluginName, (this.successCounters.get(pluginName) || 0) + 1);
|
|
5913
6499
|
this.failureCounters.set(pluginName, 0);
|
|
5914
|
-
const currentStatus = this.healthStatus.get(pluginName);
|
|
5915
|
-
if (currentStatus
|
|
6500
|
+
const currentStatus = this.healthStatus.get(pluginName) ?? "unknown";
|
|
6501
|
+
if (RECOVERY_IS_THRESHOLD_GATED[currentStatus]) {
|
|
5916
6502
|
const successCount = this.successCounters.get(pluginName) || 0;
|
|
5917
6503
|
if (successCount >= config.successThreshold) {
|
|
5918
6504
|
this.healthStatus.set(pluginName, "healthy");
|
|
@@ -5924,27 +6510,11 @@ var PluginHealthMonitor = class {
|
|
|
5924
6510
|
this.healthStatus.set(pluginName, "healthy");
|
|
5925
6511
|
}
|
|
5926
6512
|
} else {
|
|
5927
|
-
|
|
5928
|
-
this.successCounters.set(pluginName, 0);
|
|
5929
|
-
const failureCount = this.failureCounters.get(pluginName) || 0;
|
|
5930
|
-
if (failureCount >= config.failureThreshold) {
|
|
5931
|
-
this.healthStatus.set(pluginName, "unhealthy");
|
|
5932
|
-
this.logger.warn("Plugin marked as unhealthy", {
|
|
5933
|
-
plugin: pluginName,
|
|
5934
|
-
failures: failureCount
|
|
5935
|
-
});
|
|
5936
|
-
if (config.autoRestart) {
|
|
5937
|
-
await this.attemptRestart(pluginName, plugin, config);
|
|
5938
|
-
}
|
|
5939
|
-
} else {
|
|
5940
|
-
this.healthStatus.set(pluginName, "degraded");
|
|
5941
|
-
}
|
|
6513
|
+
failureRoute = "returned";
|
|
5942
6514
|
}
|
|
5943
6515
|
} catch (error) {
|
|
5944
6516
|
status = "failed";
|
|
5945
6517
|
message = error instanceof Error ? error.message : "Unknown error";
|
|
5946
|
-
this.failureCounters.set(pluginName, (this.failureCounters.get(pluginName) || 0) + 1);
|
|
5947
|
-
this.healthStatus.set(pluginName, "failed");
|
|
5948
6518
|
checks.push({
|
|
5949
6519
|
name: "health-check",
|
|
5950
6520
|
status: "failed",
|
|
@@ -5954,6 +6524,10 @@ var PluginHealthMonitor = class {
|
|
|
5954
6524
|
plugin: pluginName,
|
|
5955
6525
|
error
|
|
5956
6526
|
});
|
|
6527
|
+
failureRoute = "thrown";
|
|
6528
|
+
}
|
|
6529
|
+
if (failureRoute) {
|
|
6530
|
+
this.recordFailedRound(pluginName, config, failureRoute);
|
|
5957
6531
|
}
|
|
5958
6532
|
const report = {
|
|
5959
6533
|
status: this.healthStatus.get(pluginName) || "unknown",
|
|
@@ -5967,56 +6541,42 @@ var PluginHealthMonitor = class {
|
|
|
5967
6541
|
this.healthReports.set(pluginName, report);
|
|
5968
6542
|
}
|
|
5969
6543
|
/**
|
|
5970
|
-
*
|
|
6544
|
+
* Handle one failed round — the single path BOTH failure routes take.
|
|
6545
|
+
*
|
|
6546
|
+
* `performHealthCheck` can fail two disjoint ways: the check *returns* a
|
|
6547
|
+
* failure (`false` or `{ status: 'unhealthy' }`), or it *throws* — which by
|
|
6548
|
+
* `raceCheckTimeout` includes every `timeout` overrun, the severest case of
|
|
6549
|
+
* the two. The routes used to be handled in separate blocks, and only the
|
|
6550
|
+
* returned one cleared `successCounters`, so the counters a declared
|
|
6551
|
+
* `failureThreshold` / `successThreshold` are counted with depended on which
|
|
6552
|
+
* way the round happened to fail (#11852).
|
|
6553
|
+
*
|
|
6554
|
+
* What stays route-specific is the *status label*, deliberately. A throw is
|
|
6555
|
+
* the separate `failed` status applied immediately with no threshold — that
|
|
6556
|
+
* is the documented contract (`content/docs/protocol/kernel/lifecycle.mdx`,
|
|
6557
|
+
* "Custom Health Checks") and is pinned by the timeout test. Only the
|
|
6558
|
+
* counters are shared, because that is what `failureThreshold` declares, and
|
|
6559
|
+
* it does not name a route.
|
|
6560
|
+
*
|
|
6561
|
+
* This round ENDS here. Nothing is done TO the plugin — see the #12032 note
|
|
6562
|
+
* on the class: a monitor that cannot re-initialise a plugin has no business
|
|
6563
|
+
* destroying one.
|
|
5971
6564
|
*/
|
|
5972
|
-
|
|
5973
|
-
const
|
|
5974
|
-
|
|
5975
|
-
|
|
5976
|
-
|
|
5977
|
-
|
|
5978
|
-
});
|
|
6565
|
+
recordFailedRound(pluginName, config, route) {
|
|
6566
|
+
const failureCount = (this.failureCounters.get(pluginName) || 0) + 1;
|
|
6567
|
+
this.failureCounters.set(pluginName, failureCount);
|
|
6568
|
+
this.successCounters.set(pluginName, 0);
|
|
6569
|
+
const thresholdReached = failureCount >= config.failureThreshold;
|
|
6570
|
+
if (route === "thrown") {
|
|
5979
6571
|
this.healthStatus.set(pluginName, "failed");
|
|
5980
|
-
|
|
5981
|
-
|
|
5982
|
-
|
|
5983
|
-
const delay = this.calculateBackoff(attempts, config.restartBackoff);
|
|
5984
|
-
this.logger.info("Scheduling plugin restart", {
|
|
5985
|
-
plugin: pluginName,
|
|
5986
|
-
attempt: attempts + 1,
|
|
5987
|
-
delay
|
|
5988
|
-
});
|
|
5989
|
-
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
5990
|
-
try {
|
|
5991
|
-
if (plugin.destroy) {
|
|
5992
|
-
await plugin.destroy();
|
|
5993
|
-
}
|
|
5994
|
-
this.logger.info("Plugin restarted", { plugin: pluginName });
|
|
5995
|
-
this.failureCounters.set(pluginName, 0);
|
|
5996
|
-
this.successCounters.set(pluginName, 0);
|
|
5997
|
-
this.healthStatus.set(pluginName, "recovering");
|
|
5998
|
-
} catch (error) {
|
|
5999
|
-
this.logger.error("Plugin restart failed", {
|
|
6572
|
+
} else if (thresholdReached) {
|
|
6573
|
+
this.healthStatus.set(pluginName, "unhealthy");
|
|
6574
|
+
this.logger.warn("Plugin marked as unhealthy", {
|
|
6000
6575
|
plugin: pluginName,
|
|
6001
|
-
|
|
6576
|
+
failures: failureCount
|
|
6002
6577
|
});
|
|
6003
|
-
|
|
6004
|
-
|
|
6005
|
-
}
|
|
6006
|
-
/**
|
|
6007
|
-
* Calculate backoff delay for restarts
|
|
6008
|
-
*/
|
|
6009
|
-
calculateBackoff(attempt, strategy) {
|
|
6010
|
-
const baseDelay = 1e3;
|
|
6011
|
-
switch (strategy) {
|
|
6012
|
-
case "fixed":
|
|
6013
|
-
return baseDelay;
|
|
6014
|
-
case "linear":
|
|
6015
|
-
return baseDelay * (attempt + 1);
|
|
6016
|
-
case "exponential":
|
|
6017
|
-
return baseDelay * Math.pow(2, attempt);
|
|
6018
|
-
default:
|
|
6019
|
-
return baseDelay;
|
|
6578
|
+
} else {
|
|
6579
|
+
this.healthStatus.set(pluginName, "degraded");
|
|
6020
6580
|
}
|
|
6021
6581
|
}
|
|
6022
6582
|
/**
|
|
@@ -6049,7 +6609,6 @@ var PluginHealthMonitor = class {
|
|
|
6049
6609
|
this.healthReports.clear();
|
|
6050
6610
|
this.failureCounters.clear();
|
|
6051
6611
|
this.successCounters.clear();
|
|
6052
|
-
this.restartAttempts.clear();
|
|
6053
6612
|
this.logger.info("Health monitor shutdown complete");
|
|
6054
6613
|
}
|
|
6055
6614
|
/**
|
|
@@ -6090,7 +6649,7 @@ var PluginHealthMonitor = class {
|
|
|
6090
6649
|
};
|
|
6091
6650
|
|
|
6092
6651
|
// src/hot-reload.ts
|
|
6093
|
-
import { createHash as
|
|
6652
|
+
import { createHash as createHash4 } from "crypto";
|
|
6094
6653
|
var generateUUID = () => {
|
|
6095
6654
|
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
|
6096
6655
|
return crypto.randomUUID();
|
|
@@ -6101,6 +6660,42 @@ var generateUUID = () => {
|
|
|
6101
6660
|
return v.toString(16);
|
|
6102
6661
|
});
|
|
6103
6662
|
};
|
|
6663
|
+
var HONOURED_STATE_STRATEGIES = ["memory", "none"];
|
|
6664
|
+
var RETIRED_STATE_STRATEGY_GUIDANCE = "'disk' and 'distributed' were removed from HotReloadConfig.stateStrategy in @objectstack/spec 18 (ADR-0049 enforce-or-remove) \u2014 neither was ever implemented. Both wrote to the same in-memory Map as 'memory' and reported it only at debug level, so a host that asked for durable or cluster-replicated state got process-local memory and no error. Use 'memory' for in-process state preservation across a reload, or 'none' to disable it. There is no in-tree replacement for durable or distributed plugin state \u2014 persist it in the host, which owns the process lifetime these strategies pretended to outlive.";
|
|
6665
|
+
function hotReloadRefusal(message) {
|
|
6666
|
+
const err = new Error(message);
|
|
6667
|
+
err.code = "VALIDATION_ERROR";
|
|
6668
|
+
err.status = 400;
|
|
6669
|
+
return err;
|
|
6670
|
+
}
|
|
6671
|
+
function assertHonouredStateStrategy(pluginName, strategy) {
|
|
6672
|
+
if (HONOURED_STATE_STRATEGIES.includes(strategy)) {
|
|
6673
|
+
return;
|
|
6674
|
+
}
|
|
6675
|
+
const shown = typeof strategy === "string" ? `'${strategy}'` : String(strategy);
|
|
6676
|
+
const retired = strategy === "disk" || strategy === "distributed";
|
|
6677
|
+
throw hotReloadRefusal(
|
|
6678
|
+
`[HotReload] Plugin '${pluginName}': unsupported stateStrategy ${shown}. Honoured values are ${HONOURED_STATE_STRATEGIES.map((v) => `'${v}'`).join(" and ")}. ` + (retired ? RETIRED_STATE_STRATEGY_GUIDANCE : "This value has never been implemented by PluginStateManager.")
|
|
6679
|
+
);
|
|
6680
|
+
}
|
|
6681
|
+
var RETIRED_HOT_RELOAD_KEYS = [
|
|
6682
|
+
[
|
|
6683
|
+
"distributedConfig",
|
|
6684
|
+
"'distributedConfig' was removed from HotReloadConfig in @objectstack/spec 18 (ADR-0049 enforce-or-remove) \u2014 nothing ever read it. A provider, endpoints, a key prefix, a TTL and a replication factor could all be declared and no connection was ever opened. It left with the stateStrategy: 'distributed' value it was documented as being required for. Delete the key; there is no in-tree replacement for distributed plugin state \u2014 persist it in the host."
|
|
6685
|
+
],
|
|
6686
|
+
[
|
|
6687
|
+
"watchPatterns",
|
|
6688
|
+
"'watchPatterns' was removed from HotReloadConfig in @objectstack/spec 18 (ADR-0049 enforce-or-remove) \u2014 nothing ever read it. Its only two uses were log lines: no watcher was ever constructed from it, so an author could declare a glob and no file change ever triggered a reload. File watching is the HOST's job in this host-driven library. Delete the key, declare your globs wherever your own watcher reads them, and call `HotReloadManager.scheduleReload(pluginName, reloadFn)` when one matches \u2014 that is the debounced integration point this class does implement."
|
|
6689
|
+
]
|
|
6690
|
+
];
|
|
6691
|
+
function assertNoRetiredKeys2(pluginName, config) {
|
|
6692
|
+
for (const [key, guidance] of RETIRED_HOT_RELOAD_KEYS) {
|
|
6693
|
+
if (!Object.prototype.hasOwnProperty.call(config, key)) {
|
|
6694
|
+
continue;
|
|
6695
|
+
}
|
|
6696
|
+
throw hotReloadRefusal(`[HotReload] Plugin '${pluginName}': ${guidance}`);
|
|
6697
|
+
}
|
|
6698
|
+
}
|
|
6104
6699
|
var PluginStateManager = class {
|
|
6105
6700
|
constructor(logger) {
|
|
6106
6701
|
this.stateSnapshots = /* @__PURE__ */ new Map();
|
|
@@ -6127,17 +6722,6 @@ var PluginStateManager = class {
|
|
|
6127
6722
|
this.memoryStore.set(snapshotId, snapshot);
|
|
6128
6723
|
this.logger.debug("State saved to memory", { pluginId, snapshotId });
|
|
6129
6724
|
break;
|
|
6130
|
-
case "disk":
|
|
6131
|
-
this.memoryStore.set(snapshotId, snapshot);
|
|
6132
|
-
this.logger.debug("State saved to disk (memory fallback)", { pluginId, snapshotId });
|
|
6133
|
-
break;
|
|
6134
|
-
case "distributed":
|
|
6135
|
-
this.memoryStore.set(snapshotId, snapshot);
|
|
6136
|
-
this.logger.debug("State saved to distributed store (memory fallback)", {
|
|
6137
|
-
pluginId,
|
|
6138
|
-
snapshotId
|
|
6139
|
-
});
|
|
6140
|
-
break;
|
|
6141
6725
|
case "none":
|
|
6142
6726
|
this.logger.debug("State persistence disabled", { pluginId });
|
|
6143
6727
|
break;
|
|
@@ -6185,7 +6769,7 @@ var PluginStateManager = class {
|
|
|
6185
6769
|
*/
|
|
6186
6770
|
calculateChecksum(state) {
|
|
6187
6771
|
const stateStr = JSON.stringify(state);
|
|
6188
|
-
return
|
|
6772
|
+
return createHash4("sha256").update(stateStr).digest("hex");
|
|
6189
6773
|
}
|
|
6190
6774
|
/**
|
|
6191
6775
|
* Shutdown state manager
|
|
@@ -6199,7 +6783,6 @@ var PluginStateManager = class {
|
|
|
6199
6783
|
var HotReloadManager = class {
|
|
6200
6784
|
constructor(logger) {
|
|
6201
6785
|
this.reloadConfigs = /* @__PURE__ */ new Map();
|
|
6202
|
-
this.watchHandles = /* @__PURE__ */ new Map();
|
|
6203
6786
|
this.reloadTimers = /* @__PURE__ */ new Map();
|
|
6204
6787
|
this.logger = logger.child({ component: "HotReload" });
|
|
6205
6788
|
this.stateManager = new PluginStateManager(logger);
|
|
@@ -6208,6 +6791,8 @@ var HotReloadManager = class {
|
|
|
6208
6791
|
* Register a plugin for hot reload
|
|
6209
6792
|
*/
|
|
6210
6793
|
registerPlugin(pluginName, config) {
|
|
6794
|
+
assertHonouredStateStrategy(pluginName, config.stateStrategy);
|
|
6795
|
+
assertNoRetiredKeys2(pluginName, config);
|
|
6211
6796
|
if (!config.enabled) {
|
|
6212
6797
|
this.logger.debug("Hot reload disabled for plugin", { plugin: pluginName });
|
|
6213
6798
|
return;
|
|
@@ -6215,32 +6800,45 @@ var HotReloadManager = class {
|
|
|
6215
6800
|
this.reloadConfigs.set(pluginName, config);
|
|
6216
6801
|
this.logger.info("Plugin registered for hot reload", {
|
|
6217
6802
|
plugin: pluginName,
|
|
6218
|
-
watchPatterns: config.watchPatterns,
|
|
6219
6803
|
stateStrategy: config.stateStrategy
|
|
6220
6804
|
});
|
|
6221
6805
|
}
|
|
6222
6806
|
/**
|
|
6223
|
-
*
|
|
6807
|
+
* Refuse the file-watching call this class never implemented (#12428).
|
|
6808
|
+
*
|
|
6809
|
+
* The body used to be a guard plus `logger.info('File watching started')`
|
|
6810
|
+
* over an in-source note saying real watching "would require chokidar or
|
|
6811
|
+
* similar". Nothing was ever watched, so an operator who set
|
|
6812
|
+
* `enabled: true` and read that line at INFO had been told the opposite of
|
|
6813
|
+
* the truth — positive confirmation of a capability that did not exist.
|
|
6814
|
+
* ADR-0049 leaves three states and this surface qualified for none of the
|
|
6815
|
+
* other two: no runtime composes this class, so ENFORCE would build for a
|
|
6816
|
+
* caller that does not exist, and no roadmap entry anywhere claims the
|
|
6817
|
+
* feature, so EXPERIMENTAL would be a promise nobody made.
|
|
6818
|
+
*
|
|
6819
|
+
* Kept as a throwing door rather than deleted: removing the method leaves a
|
|
6820
|
+
* JavaScript host a bare `TypeError: not a function` with no prescription,
|
|
6821
|
+
* and this is the one place a caller of the old placeholder is guaranteed
|
|
6822
|
+
* to arrive. The refusal carries an ADR-0112 envelope so it can be asserted
|
|
6823
|
+
* rather than merely caught.
|
|
6224
6824
|
*/
|
|
6225
6825
|
startWatching(pluginName) {
|
|
6226
|
-
|
|
6227
|
-
|
|
6228
|
-
|
|
6229
|
-
}
|
|
6230
|
-
this.logger.info("File watching started", {
|
|
6231
|
-
plugin: pluginName,
|
|
6232
|
-
patterns: config.watchPatterns
|
|
6233
|
-
});
|
|
6826
|
+
throw hotReloadRefusal(
|
|
6827
|
+
`[HotReload] Plugin '${pluginName}': startWatching() never watched anything and was removed in @objectstack/core 18 (ADR-0049 enforce-or-remove). It logged 'File watching started' at info level while no watcher was ever constructed, so no file change could ever trigger a reload. File watching is the HOST's job in this host-driven library: run your own watcher and call \`HotReloadManager.scheduleReload(pluginName, reloadFn)\` when a file changes \u2014 that is the debounced integration point this class does implement. \`HotReloadConfig.watchPatterns\` was removed in @objectstack/spec 18 for the same reason; declare your globs where your watcher reads them.`
|
|
6828
|
+
);
|
|
6234
6829
|
}
|
|
6235
6830
|
/**
|
|
6236
|
-
*
|
|
6831
|
+
* Cancel a pending debounced reload for a plugin.
|
|
6832
|
+
*
|
|
6833
|
+
* The name is historical (#12428). This never stopped a watcher, because
|
|
6834
|
+
* nothing in this class ever started one: its `watchHandles` cleanup branch
|
|
6835
|
+
* read a Map that had no writer anywhere in the tree, so the branch was
|
|
6836
|
+
* structurally unreachable rather than merely untaken, and it left with
|
|
6837
|
+
* `startWatching`'s placeholder. What survives is the half that always did
|
|
6838
|
+
* something — the debounce timer armed by `scheduleReload` is cleared, so a
|
|
6839
|
+
* reload that was scheduled but has not fired yet is cancelled.
|
|
6237
6840
|
*/
|
|
6238
6841
|
stopWatching(pluginName) {
|
|
6239
|
-
const handle = this.watchHandles.get(pluginName);
|
|
6240
|
-
if (handle) {
|
|
6241
|
-
this.watchHandles.delete(pluginName);
|
|
6242
|
-
this.logger.info("File watching stopped", { plugin: pluginName });
|
|
6243
|
-
}
|
|
6244
6842
|
const timer = this.reloadTimers.get(pluginName);
|
|
6245
6843
|
if (timer) {
|
|
6246
6844
|
clearTimeout(timer);
|
|
@@ -6383,14 +6981,10 @@ var HotReloadManager = class {
|
|
|
6383
6981
|
* Shutdown hot reload manager
|
|
6384
6982
|
*/
|
|
6385
6983
|
shutdown() {
|
|
6386
|
-
for (const pluginName of this.watchHandles.keys()) {
|
|
6387
|
-
this.stopWatching(pluginName);
|
|
6388
|
-
}
|
|
6389
6984
|
for (const timer of this.reloadTimers.values()) {
|
|
6390
6985
|
clearTimeout(timer);
|
|
6391
6986
|
}
|
|
6392
6987
|
this.reloadConfigs.clear();
|
|
6393
|
-
this.watchHandles.clear();
|
|
6394
6988
|
this.reloadTimers.clear();
|
|
6395
6989
|
this.stateManager.shutdown();
|
|
6396
6990
|
this.logger.info("Hot reload manager shutdown complete");
|
|
@@ -6799,6 +7393,7 @@ var NamespaceResolver = class {
|
|
|
6799
7393
|
// src/index.ts
|
|
6800
7394
|
import { UNMATCHED_ROUTE_PATTERN } from "@objectstack/spec/contracts";
|
|
6801
7395
|
export {
|
|
7396
|
+
ADMIN_STANDING_NON_TABLE_INPUTS,
|
|
6802
7397
|
ADMIN_STANDING_SURFACE,
|
|
6803
7398
|
ANONYMOUS_DENY_BODY,
|
|
6804
7399
|
ANONYMOUS_DENY_CODE,
|
|
@@ -6807,11 +7402,19 @@ export {
|
|
|
6807
7402
|
API_KEY_PREFIX,
|
|
6808
7403
|
AUDIENCE_BINDING_SUGGESTION_STATUSES,
|
|
6809
7404
|
AUDIENCE_BINDING_SUGGESTION_STATUS_VALUES,
|
|
7405
|
+
AUTHZ_GRANTS_CACHE_TTL_ENV,
|
|
7406
|
+
AUTHZ_INVALIDATED_CHANNEL,
|
|
7407
|
+
AUTHZ_STORE_UNAVAILABLE_CODE,
|
|
7408
|
+
AUTHZ_STORE_UNAVAILABLE_MESSAGE,
|
|
7409
|
+
AUTHZ_STORE_UNAVAILABLE_STATUS,
|
|
7410
|
+
AuthzStoreUnavailableError,
|
|
6810
7411
|
CORE_FALLBACK_FACTORIES,
|
|
6811
7412
|
DependencyResolver,
|
|
6812
7413
|
ENTRY_EXECUTION_CONTEXT_FIELDS,
|
|
6813
7414
|
HotReloadManager,
|
|
7415
|
+
InMemoryMetadataActivationStore,
|
|
6814
7416
|
LiteKernel,
|
|
7417
|
+
METADATA_ACTIVATION_TABLE,
|
|
6815
7418
|
MigrationJournalRefusal,
|
|
6816
7419
|
MigrationPlanRegistry,
|
|
6817
7420
|
NamespaceResolver,
|
|
@@ -6819,10 +7422,11 @@ export {
|
|
|
6819
7422
|
ObjectKernel,
|
|
6820
7423
|
ObjectKernelBase,
|
|
6821
7424
|
ObjectLogger,
|
|
7425
|
+
ObjectStoreMetadataActivationStore,
|
|
7426
|
+
PLATFORM_ADMIN_EMAIL_SEPARATOR,
|
|
6822
7427
|
POSTURE_INJECTION_RULE,
|
|
6823
7428
|
POSTURE_LADDER,
|
|
6824
7429
|
POSTURE_RANK,
|
|
6825
|
-
PluginConfigValidator,
|
|
6826
7430
|
PluginHealthMonitor,
|
|
6827
7431
|
PluginLoader,
|
|
6828
7432
|
PluginPermissionEnforcer,
|
|
@@ -6831,6 +7435,7 @@ export {
|
|
|
6831
7435
|
PluginSecurityScanner,
|
|
6832
7436
|
PluginSignatureVerifier,
|
|
6833
7437
|
qa_exports as QA,
|
|
7438
|
+
SERVICE_NOT_REGISTERED_CODE,
|
|
6834
7439
|
SIGNATURE_ALG,
|
|
6835
7440
|
SecurePluginContext,
|
|
6836
7441
|
SemanticVersionManager,
|
|
@@ -6840,6 +7445,7 @@ export {
|
|
|
6840
7445
|
UnresolvedFilterTokenError,
|
|
6841
7446
|
adminStandingColumns,
|
|
6842
7447
|
adminStandingTables,
|
|
7448
|
+
artifactPackageId,
|
|
6843
7449
|
assembleExecutionContext,
|
|
6844
7450
|
assembleExecutionContextOrGuest,
|
|
6845
7451
|
assertInitServiceRequirements,
|
|
@@ -6858,7 +7464,6 @@ export {
|
|
|
6858
7464
|
createMemoryJob,
|
|
6859
7465
|
createMemoryMetadata,
|
|
6860
7466
|
createMemoryQueue,
|
|
6861
|
-
createPluginConfigValidator,
|
|
6862
7467
|
createPluginPermissionEnforcer,
|
|
6863
7468
|
deepMerge,
|
|
6864
7469
|
defaultIsTransientError,
|
|
@@ -6870,48 +7475,67 @@ export {
|
|
|
6870
7475
|
extractApiKey,
|
|
6871
7476
|
filterTokenContextFrom,
|
|
6872
7477
|
findInterruptedRuns,
|
|
7478
|
+
formatIntegrityViolation,
|
|
6873
7479
|
generateApiKey,
|
|
6874
7480
|
generateEd25519KeyPair,
|
|
6875
7481
|
getEnv,
|
|
6876
7482
|
getMemoryUsage,
|
|
7483
|
+
hasPlatformAdminStanding,
|
|
6877
7484
|
hashApiKey,
|
|
6878
7485
|
hashMigrationPlan,
|
|
6879
7486
|
isAudienceBindingSuggestionStatus,
|
|
6880
7487
|
isAuthGateAllowlisted,
|
|
7488
|
+
isAuthzStoreUnavailableError,
|
|
7489
|
+
isConfiguredPlatformAdminEmail,
|
|
6881
7490
|
isExpired,
|
|
6882
7491
|
isGrantActive,
|
|
6883
7492
|
isGrantExpired,
|
|
6884
7493
|
isNode,
|
|
6885
7494
|
isRowActive,
|
|
7495
|
+
isServiceNotRegisteredError,
|
|
6886
7496
|
isUninterpretableTemporalComparand,
|
|
7497
|
+
matchesConfiguredPlatformAdmin,
|
|
6887
7498
|
nextUtcCalendarDay,
|
|
6888
7499
|
normalizeAuthGate,
|
|
7500
|
+
normalizePlatformAdminEmail,
|
|
6889
7501
|
omitInternalFieldsFromWriteResponse,
|
|
7502
|
+
parsePlatformAdminEmails,
|
|
6890
7503
|
parseScopes,
|
|
6891
7504
|
parseSignature,
|
|
6892
7505
|
planChunks,
|
|
6893
7506
|
postureVisibleRows,
|
|
6894
7507
|
readAuthoredTranslationLayer,
|
|
7508
|
+
readAuthzGrantsCacheTtlMs,
|
|
6895
7509
|
readRunJournal,
|
|
6896
7510
|
recordNotFoundError,
|
|
7511
|
+
reportAuthzCachePosture,
|
|
7512
|
+
reportLegacyPlatformAdminGrant,
|
|
7513
|
+
resetLegacyPlatformAdminGrantReport,
|
|
7514
|
+
resetPlatformAdminEmailMemo,
|
|
6897
7515
|
resolveApiKeyAdmission,
|
|
6898
7516
|
resolveApiKeyPrincipal,
|
|
7517
|
+
resolveArtifactPackageOrder,
|
|
7518
|
+
resolveAuthzCachePosture,
|
|
6899
7519
|
resolveAuthzContext,
|
|
6900
7520
|
resolveFilterToken,
|
|
6901
7521
|
resolveFilterTokens,
|
|
6902
7522
|
resolveLocale,
|
|
6903
7523
|
resolveLocalizationContext,
|
|
7524
|
+
resolvePlatformAdminEmails,
|
|
6904
7525
|
resolvePluginOrder,
|
|
6905
7526
|
resolveUserAuthzGrants,
|
|
6906
7527
|
resumeMigrationJournal,
|
|
7528
|
+
rethrowAuthzStoreUnavailable,
|
|
6907
7529
|
runMigrationJournal,
|
|
6908
7530
|
safeExit,
|
|
7531
|
+
setPlatformAdminConfigSink,
|
|
6909
7532
|
shouldDenyAnonymous,
|
|
6910
7533
|
signPayload,
|
|
6911
7534
|
temporalComparandKind,
|
|
6912
7535
|
unknownAudienceBindingSuggestionStatusMessage,
|
|
6913
7536
|
utcInstantMs,
|
|
6914
7537
|
validateInitServiceContract,
|
|
7538
|
+
verifyIntegrity,
|
|
6915
7539
|
verifyPayload,
|
|
6916
7540
|
verifyPlatformSignature,
|
|
6917
7541
|
verifyPluginArtifact,
|