@objectstack/core 17.2.0 → 17.4.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 +2099 -0
- package/dist/index.cjs +1108 -543
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1445 -233
- package/dist/index.d.ts +1445 -233
- package/dist/index.js +1064 -531
- package/dist/index.js.map +1 -1
- package/dist/logger.cjs.map +1 -1
- package/dist/logger.js.map +1 -1
- package/package.json +11 -6
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,43 @@ 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
|
+
|
|
832
|
+
// src/plugin-contract.ts
|
|
833
|
+
import { PluginSchema } from "@objectstack/spec/kernel";
|
|
834
|
+
var PLUGIN_CONTRACT_VIOLATION_CODE = "PLUGIN_CONTRACT_VIOLATION";
|
|
835
|
+
function assertPluginContract(plugin) {
|
|
836
|
+
const result = PluginSchema.safeParse(plugin);
|
|
837
|
+
if (result.success) {
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
const issues = result.error.issues.filter((issue) => issue.path[0] !== "version");
|
|
841
|
+
if (issues.length === 0) {
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
const first = issues[0];
|
|
845
|
+
const at = first.path.length > 0 ? first.path.join(".") : "(root)";
|
|
846
|
+
const id = plugin.id;
|
|
847
|
+
const named = typeof id === "string" && id.length > 0 ? `'${plugin.name}' (id: ${id})` : `'${plugin.name}'`;
|
|
848
|
+
const error = new Error(
|
|
849
|
+
`${PLUGIN_CONTRACT_VIOLATION_CODE}: plugin ${named} is refused by the declared plugin contract at '${at}': ${first.message}`
|
|
850
|
+
);
|
|
851
|
+
error.code = PLUGIN_CONTRACT_VIOLATION_CODE;
|
|
852
|
+
throw error;
|
|
853
|
+
}
|
|
854
|
+
|
|
950
855
|
// src/plugin-loader.ts
|
|
951
856
|
var ServiceLifecycle = /* @__PURE__ */ ((ServiceLifecycle2) => {
|
|
952
857
|
ServiceLifecycle2["SINGLETON"] = "singleton";
|
|
@@ -962,7 +867,6 @@ var PluginLoader = class {
|
|
|
962
867
|
this.scopedServices = /* @__PURE__ */ new Map();
|
|
963
868
|
this.creating = /* @__PURE__ */ new Set();
|
|
964
869
|
this.logger = logger;
|
|
965
|
-
this.configValidator = new PluginConfigValidator(logger);
|
|
966
870
|
}
|
|
967
871
|
/**
|
|
968
872
|
* Set the plugin context for service factories
|
|
@@ -985,13 +889,11 @@ var PluginLoader = class {
|
|
|
985
889
|
this.logger.info(`Loading plugin: ${plugin.name}`);
|
|
986
890
|
const metadata = this.toPluginMetadata(plugin);
|
|
987
891
|
this.validatePluginStructure(metadata);
|
|
892
|
+
this.validatePluginContract(metadata);
|
|
988
893
|
const versionCheck = this.checkVersionCompatibility(metadata);
|
|
989
894
|
if (!versionCheck.compatible) {
|
|
990
895
|
throw new Error(`Version incompatible: ${versionCheck.message}`);
|
|
991
896
|
}
|
|
992
|
-
if (metadata.configSchema) {
|
|
993
|
-
this.validatePluginConfig(metadata);
|
|
994
|
-
}
|
|
995
897
|
if (metadata.signature) {
|
|
996
898
|
await this.verifyPluginSignature(metadata);
|
|
997
899
|
}
|
|
@@ -1030,7 +932,7 @@ var PluginLoader = class {
|
|
|
1030
932
|
if (!registration) {
|
|
1031
933
|
const instance = this.serviceInstances.get(name);
|
|
1032
934
|
if (!instance) {
|
|
1033
|
-
throw
|
|
935
|
+
throw serviceNotRegisteredError(name);
|
|
1034
936
|
}
|
|
1035
937
|
return instance;
|
|
1036
938
|
}
|
|
@@ -1172,6 +1074,27 @@ var PluginLoader = class {
|
|
|
1172
1074
|
throw new Error(`Invalid semantic version: ${plugin.version}`);
|
|
1173
1075
|
}
|
|
1174
1076
|
}
|
|
1077
|
+
/**
|
|
1078
|
+
* Refuse a plugin object the DECLARED plugin contract refuses (#16049,
|
|
1079
|
+
* maintainer ruling 2026-09-06: "the protocol is the baseline; the runtime
|
|
1080
|
+
* aligns to it").
|
|
1081
|
+
*
|
|
1082
|
+
* The check itself — `PluginSchema.safeParse` for validation only, the
|
|
1083
|
+
* eight keys it reaches, the `version` exclusion and the
|
|
1084
|
+
* `PLUGIN_CONTRACT_VIOLATION` envelope — lives in `plugin-contract.ts`,
|
|
1085
|
+
* because since #16721 it is ONE statement run by BOTH kernels:
|
|
1086
|
+
* `LiteKernel.use()` calls it directly, and `ObjectKernel.use()` reaches
|
|
1087
|
+
* it here, through `loadPlugin`. That module's comment is the authority on
|
|
1088
|
+
* what is refused; this method adds nothing to it and subtracts nothing.
|
|
1089
|
+
*
|
|
1090
|
+
* What stays THIS loader's own, and is deliberately not shared: the
|
|
1091
|
+
* structural checks one call up ({@link validatePluginStructure} —
|
|
1092
|
+
* `name`, `init`, semver) and the version-compatibility check below.
|
|
1093
|
+
* The convergence is on the schema, not on the loader.
|
|
1094
|
+
*/
|
|
1095
|
+
validatePluginContract(plugin) {
|
|
1096
|
+
assertPluginContract(plugin);
|
|
1097
|
+
}
|
|
1175
1098
|
checkVersionCompatibility(plugin) {
|
|
1176
1099
|
const version = plugin.version;
|
|
1177
1100
|
if (!this.isValidSemanticVersion(version)) {
|
|
@@ -1190,16 +1113,6 @@ var PluginLoader = class {
|
|
|
1190
1113
|
const semverRegex = /^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/;
|
|
1191
1114
|
return semverRegex.test(version);
|
|
1192
1115
|
}
|
|
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
1116
|
async verifyPluginSignature(plugin) {
|
|
1204
1117
|
if (!plugin.signature) {
|
|
1205
1118
|
return;
|
|
@@ -1391,6 +1304,7 @@ function createMemoryI18n() {
|
|
|
1391
1304
|
const authored = /* @__PURE__ */ new Map();
|
|
1392
1305
|
let defaultLocale = "en";
|
|
1393
1306
|
let supportedLocales;
|
|
1307
|
+
let fallbackLocale;
|
|
1394
1308
|
function resolveKey(data, key) {
|
|
1395
1309
|
const parts = key.split(".");
|
|
1396
1310
|
let current = data;
|
|
@@ -1427,7 +1341,11 @@ function createMemoryI18n() {
|
|
|
1427
1341
|
_serviceName: "i18n",
|
|
1428
1342
|
t(key, locale, params) {
|
|
1429
1343
|
const data = resolveTranslations(locale) ?? mergedLocale(defaultLocale);
|
|
1430
|
-
|
|
1344
|
+
let value = data ? resolveKey(data, key) : void 0;
|
|
1345
|
+
if (value === void 0 && fallbackLocale && fallbackLocale !== locale) {
|
|
1346
|
+
const fallbackData = resolveTranslations(fallbackLocale);
|
|
1347
|
+
value = fallbackData ? resolveKey(fallbackData, key) : void 0;
|
|
1348
|
+
}
|
|
1431
1349
|
if (value == null) return key;
|
|
1432
1350
|
if (!params) return value;
|
|
1433
1351
|
return value.replace(/\{\{(\w+)\}\}/g, (_, name) => String(params[name] ?? `{{${name}}}`));
|
|
@@ -1484,12 +1402,27 @@ function createMemoryI18n() {
|
|
|
1484
1402
|
},
|
|
1485
1403
|
setDefaultLocale(locale) {
|
|
1486
1404
|
defaultLocale = locale;
|
|
1405
|
+
},
|
|
1406
|
+
/**
|
|
1407
|
+
* @see II18nService.setFallbackLocale — [#15694]
|
|
1408
|
+
*
|
|
1409
|
+
* ⛔ There is deliberately NO `getFallbackLocale()` beside this. The two
|
|
1410
|
+
* are different questions: this one is what the provider was TOLD, the
|
|
1411
|
+
* accessor is what the serving layer ASKS it in order to build the
|
|
1412
|
+
* metadata-document translators' fallback chain (#14882). Answering the
|
|
1413
|
+
* second from `defaultLocale` — the only value that was always available
|
|
1414
|
+
* here — would settle the default-locale contract question #14882 leaves
|
|
1415
|
+
* deliberately open, from a degraded provider. Without the accessor those
|
|
1416
|
+
* reads keep the resolvers' own default, which is known and intentional.
|
|
1417
|
+
*/
|
|
1418
|
+
setFallbackLocale(locale) {
|
|
1419
|
+
fallbackLocale = locale;
|
|
1487
1420
|
}
|
|
1488
1421
|
};
|
|
1489
1422
|
}
|
|
1490
1423
|
|
|
1491
1424
|
// src/metadata-service-contract.ts
|
|
1492
|
-
import { pluralToSingular } from "@objectstack/spec/
|
|
1425
|
+
import { pluralToSingular } from "@objectstack/spec/meta-spelling";
|
|
1493
1426
|
var REGISTER_REFUSAL_CODE = "VALIDATION_ERROR";
|
|
1494
1427
|
function canonicalMetadataServiceType(type) {
|
|
1495
1428
|
return pluralToSingular(type);
|
|
@@ -1809,6 +1742,7 @@ async function raceWithTimeout(operation, timeoutMs, createTimeoutError) {
|
|
|
1809
1742
|
}
|
|
1810
1743
|
|
|
1811
1744
|
// src/kernel.ts
|
|
1745
|
+
var DEGRADED_CAPABILITIES_SERVICE = "kernel.degraded-capabilities";
|
|
1812
1746
|
var ObjectKernel = class {
|
|
1813
1747
|
constructor(config = {}) {
|
|
1814
1748
|
this.plugins = /* @__PURE__ */ new Map();
|
|
@@ -1816,7 +1750,12 @@ var ObjectKernel = class {
|
|
|
1816
1750
|
this.hooks = /* @__PURE__ */ new Map();
|
|
1817
1751
|
this.state = "idle";
|
|
1818
1752
|
this.startedPlugins = /* @__PURE__ */ new Set();
|
|
1819
|
-
|
|
1753
|
+
/**
|
|
1754
|
+
* Plugin name -> elapsed milliseconds that plugin's `start()` took. These
|
|
1755
|
+
* are DURATIONS, never start instants; the old spelling `pluginStartTimes`
|
|
1756
|
+
* said the opposite of what it held.
|
|
1757
|
+
*/
|
|
1758
|
+
this.pluginStartupDurations = /* @__PURE__ */ new Map();
|
|
1820
1759
|
this.shutdownHandlers = [];
|
|
1821
1760
|
this.config = {
|
|
1822
1761
|
defaultStartupTimeout: 3e4,
|
|
@@ -2003,9 +1942,40 @@ var ObjectKernel = class {
|
|
|
2003
1942
|
}
|
|
2004
1943
|
if (missingCoreServices.length > 0) {
|
|
2005
1944
|
this.logger.warn(`System started with degraded capabilities. Missing core services: ${missingCoreServices.join(", ")}`);
|
|
1945
|
+
this.publishDegradedCapabilities(missingCoreServices);
|
|
2006
1946
|
}
|
|
2007
1947
|
this.logger.info("System requirement check passed");
|
|
2008
1948
|
}
|
|
1949
|
+
/**
|
|
1950
|
+
* Publish this boot's degraded-capabilities conclusion on
|
|
1951
|
+
* {@link DEGRADED_CAPABILITIES_SERVICE} — the data half of the warning
|
|
1952
|
+
* `validateSystemRequirements()` just logged (#16630).
|
|
1953
|
+
*
|
|
1954
|
+
* ⛔ Best-effort, and silent on failure BY DESIGN: this is a diagnostic
|
|
1955
|
+
* readout, and a readout must never be able to fail a boot that the kernel
|
|
1956
|
+
* has just decided is good enough to run. The one way `registerService`
|
|
1957
|
+
* can throw here is a name collision, which the guard above already
|
|
1958
|
+
* forecloses; the `catch` is there so that stays true if either ever
|
|
1959
|
+
* changes. (`recordSeedOutcome` in `@objectstack/runtime` states the same
|
|
1960
|
+
* rule for the same reason.)
|
|
1961
|
+
*
|
|
1962
|
+
* The value is FROZEN and holds a COPY. `getService` hands out the stored
|
|
1963
|
+
* reference, so an unfrozen live array would let any reader edit the
|
|
1964
|
+
* kernel's own record of what was missing — and this record exists
|
|
1965
|
+
* precisely so that two packages cannot disagree about it.
|
|
1966
|
+
*/
|
|
1967
|
+
publishDegradedCapabilities(missingCoreServices) {
|
|
1968
|
+
try {
|
|
1969
|
+
if (this.services.has(DEGRADED_CAPABILITIES_SERVICE) || this.pluginLoader.hasService(DEGRADED_CAPABILITIES_SERVICE)) {
|
|
1970
|
+
return;
|
|
1971
|
+
}
|
|
1972
|
+
this.registerService(
|
|
1973
|
+
DEGRADED_CAPABILITIES_SERVICE,
|
|
1974
|
+
Object.freeze({ missingCoreServices: Object.freeze([...missingCoreServices]) })
|
|
1975
|
+
);
|
|
1976
|
+
} catch {
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
2009
1979
|
/**
|
|
2010
1980
|
* Bootstrap the kernel with enhanced features
|
|
2011
1981
|
*/
|
|
@@ -2118,11 +2088,23 @@ var ObjectKernel = class {
|
|
|
2118
2088
|
}
|
|
2119
2089
|
return results;
|
|
2120
2090
|
}
|
|
2091
|
+
/**
|
|
2092
|
+
* Per-plugin startup durations: plugin name -> elapsed milliseconds that
|
|
2093
|
+
* plugin's `start()` took. Not start instants -- see
|
|
2094
|
+
* {@link PluginStartupResult.durationMs}.
|
|
2095
|
+
*/
|
|
2096
|
+
getPluginStartupDurations() {
|
|
2097
|
+
return new Map(this.pluginStartupDurations);
|
|
2098
|
+
}
|
|
2121
2099
|
/**
|
|
2122
2100
|
* Get plugin startup metrics
|
|
2101
|
+
*
|
|
2102
|
+
* @deprecated Renamed to {@link ObjectKernel.getPluginStartupDurations},
|
|
2103
|
+
* which states what the values are. Retained as a delegating alias so
|
|
2104
|
+
* nothing has to change on this release; slated for removal.
|
|
2123
2105
|
*/
|
|
2124
2106
|
getPluginMetrics() {
|
|
2125
|
-
return
|
|
2107
|
+
return this.getPluginStartupDurations();
|
|
2126
2108
|
}
|
|
2127
2109
|
/**
|
|
2128
2110
|
* Whether a plugin with the given name has been registered on this kernel.
|
|
@@ -2244,11 +2226,14 @@ var ObjectKernel = class {
|
|
|
2244
2226
|
);
|
|
2245
2227
|
const duration = Date.now() - startTime;
|
|
2246
2228
|
this.startedPlugins.add(plugin.name);
|
|
2247
|
-
this.
|
|
2229
|
+
this.pluginStartupDurations.set(plugin.name, duration);
|
|
2248
2230
|
this.logger.debug(`Plugin started: ${plugin.name} (${duration}ms)`);
|
|
2249
2231
|
return {
|
|
2250
2232
|
success: true,
|
|
2251
2233
|
pluginName: plugin.name,
|
|
2234
|
+
durationMs: duration,
|
|
2235
|
+
// Deprecated alias carrying the same elapsed value; see
|
|
2236
|
+
// PluginStartupResult.startTime.
|
|
2252
2237
|
startTime: duration
|
|
2253
2238
|
};
|
|
2254
2239
|
} catch (error) {
|
|
@@ -2258,6 +2243,9 @@ var ObjectKernel = class {
|
|
|
2258
2243
|
success: false,
|
|
2259
2244
|
pluginName: plugin.name,
|
|
2260
2245
|
error,
|
|
2246
|
+
durationMs: duration,
|
|
2247
|
+
// Deprecated alias carrying the same elapsed value; see
|
|
2248
|
+
// PluginStartupResult.startTime.
|
|
2261
2249
|
startTime: duration,
|
|
2262
2250
|
timedOut: isTimeout
|
|
2263
2251
|
};
|
|
@@ -2373,6 +2361,67 @@ var ObjectKernel = class {
|
|
|
2373
2361
|
}
|
|
2374
2362
|
};
|
|
2375
2363
|
|
|
2364
|
+
// src/artifact-packages.ts
|
|
2365
|
+
import { ArtifactPackageSchema } from "@objectstack/spec";
|
|
2366
|
+
var MAX_REPORTED_ENTRY_ISSUES = 5;
|
|
2367
|
+
function refuse(code, message) {
|
|
2368
|
+
const err = new Error(message);
|
|
2369
|
+
err.code = code;
|
|
2370
|
+
err.status = 422;
|
|
2371
|
+
return err;
|
|
2372
|
+
}
|
|
2373
|
+
function artifactPackageId(manifest) {
|
|
2374
|
+
const id = manifest?.id || manifest?.name;
|
|
2375
|
+
return typeof id === "string" && id !== "" ? id : void 0;
|
|
2376
|
+
}
|
|
2377
|
+
function resolveArtifactPackageOrder(artifact) {
|
|
2378
|
+
const declared = artifact?.packages;
|
|
2379
|
+
if (declared === void 0 || declared === null) return [artifact];
|
|
2380
|
+
if (!Array.isArray(declared)) {
|
|
2381
|
+
throw refuse(
|
|
2382
|
+
"INVALID_ARTIFACT_PACKAGES",
|
|
2383
|
+
`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.`
|
|
2384
|
+
);
|
|
2385
|
+
}
|
|
2386
|
+
const nodes = /* @__PURE__ */ new Map();
|
|
2387
|
+
declared.forEach((entry, index) => {
|
|
2388
|
+
const verdict = ArtifactPackageSchema.safeParse(entry);
|
|
2389
|
+
if (!verdict.success) {
|
|
2390
|
+
const issues = verdict.error.issues;
|
|
2391
|
+
const shown = issues.slice(0, MAX_REPORTED_ENTRY_ISSUES);
|
|
2392
|
+
throw refuse(
|
|
2393
|
+
"INVALID_ARTIFACT_PACKAGE_ENTRY",
|
|
2394
|
+
`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."
|
|
2395
|
+
);
|
|
2396
|
+
}
|
|
2397
|
+
const manifest = entry.manifest;
|
|
2398
|
+
const id = artifactPackageId(manifest);
|
|
2399
|
+
if (id === void 0) {
|
|
2400
|
+
throw refuse(
|
|
2401
|
+
"INVALID_ARTIFACT_PACKAGE_ENTRY",
|
|
2402
|
+
`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.`
|
|
2403
|
+
);
|
|
2404
|
+
}
|
|
2405
|
+
if (nodes.has(id)) {
|
|
2406
|
+
throw refuse(
|
|
2407
|
+
"DUPLICATE_ARTIFACT_PACKAGE",
|
|
2408
|
+
`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.`
|
|
2409
|
+
);
|
|
2410
|
+
}
|
|
2411
|
+
nodes.set(id, {
|
|
2412
|
+
// `name` is what `resolvePluginOrder` puts in its diagnostics; the MAP KEY
|
|
2413
|
+
// is what its edges resolve against. Both are the package id, so an error
|
|
2414
|
+
// it raises names the same string the artifact author wrote.
|
|
2415
|
+
name: id,
|
|
2416
|
+
optionalDependencies: Object.keys(
|
|
2417
|
+
manifest.dependencies ?? {}
|
|
2418
|
+
),
|
|
2419
|
+
manifest
|
|
2420
|
+
});
|
|
2421
|
+
});
|
|
2422
|
+
return resolvePluginOrder(nodes).map((node) => node.manifest);
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2376
2425
|
// src/lite-kernel.ts
|
|
2377
2426
|
var LiteKernel = class extends ObjectKernelBase {
|
|
2378
2427
|
constructor(config) {
|
|
@@ -2384,6 +2433,26 @@ var LiteKernel = class extends ObjectKernelBase {
|
|
|
2384
2433
|
* Register a plugin
|
|
2385
2434
|
* @param plugin - Plugin instance
|
|
2386
2435
|
*
|
|
2436
|
+
* A plugin object the DECLARED plugin contract refuses is refused here,
|
|
2437
|
+
* with `PLUGIN_CONTRACT_VIOLATION` — the same check, the same envelope,
|
|
2438
|
+
* that `ObjectKernel.use()` runs through `PluginLoader` (`plugin-contract.ts`
|
|
2439
|
+
* is the one statement both kernels call; #16721, maintainer ruling
|
|
2440
|
+
* 2026-09-08, option A under #9864's precedent that the kernels converge).
|
|
2441
|
+
*
|
|
2442
|
+
* This method used to write the object straight into the registry, so the
|
|
2443
|
+
* same plugin was accepted by this kernel and refused by `ObjectKernel` —
|
|
2444
|
+
* and `AGENTS.md` names THIS kernel for tests, so a plugin could be green
|
|
2445
|
+
* in vitest and refused at production boot. Measured before converging
|
|
2446
|
+
* (#16721 step 1): of 813 `LiteKernel.use()` calls reachable in this
|
|
2447
|
+
* repository's suites, 807 were accepted by the schema unchanged and the
|
|
2448
|
+
* six refusals came from three test-local fixture objects, none of them
|
|
2449
|
+
* product code.
|
|
2450
|
+
*
|
|
2451
|
+
* Ordering, and why it is pinned: state first (`validateIdle`), then the
|
|
2452
|
+
* contract, then registration — a refused plugin never reaches the
|
|
2453
|
+
* registry, so it can neither be booted nor supersede an earlier
|
|
2454
|
+
* registration under its name.
|
|
2455
|
+
*
|
|
2387
2456
|
* Duplicate names OVERWRITE, with one `warn` naming both versions — the
|
|
2388
2457
|
* declared contract in `plugin-registration.ts`, applied identically by
|
|
2389
2458
|
* `ObjectKernel.use()` (#9864, maintainer ruling 2026-08-19).
|
|
@@ -2397,6 +2466,7 @@ var LiteKernel = class extends ObjectKernelBase {
|
|
|
2397
2466
|
*/
|
|
2398
2467
|
use(plugin) {
|
|
2399
2468
|
this.validateIdle();
|
|
2469
|
+
assertPluginContract(plugin);
|
|
2400
2470
|
registerPluginByName(this.plugins, plugin, this.logger);
|
|
2401
2471
|
return this;
|
|
2402
2472
|
}
|
|
@@ -3102,6 +3172,55 @@ var PluginSignatureVerifier = class {
|
|
|
3102
3172
|
}
|
|
3103
3173
|
};
|
|
3104
3174
|
|
|
3175
|
+
// src/security/plugin-artifact-integrity.ts
|
|
3176
|
+
import { createHash } from "crypto";
|
|
3177
|
+
var SRI_ALGORITHMS = /* @__PURE__ */ new Set(["sha256", "sha384", "sha512"]);
|
|
3178
|
+
function sriDigestFor(declared, data) {
|
|
3179
|
+
const dash = declared.indexOf("-");
|
|
3180
|
+
const alg = dash > 0 && SRI_ALGORITHMS.has(declared.slice(0, dash)) ? declared.slice(0, dash) : "sha256";
|
|
3181
|
+
return `${alg}-${createHash(alg).update(data).digest("base64")}`;
|
|
3182
|
+
}
|
|
3183
|
+
function verifyIntegrity(files, integrity, options = {}) {
|
|
3184
|
+
if (integrity === null || integrity === void 0) {
|
|
3185
|
+
return { ok: true, skipped: true, checked: 0, violations: [] };
|
|
3186
|
+
}
|
|
3187
|
+
const exempt = new Set(options.exempt ?? []);
|
|
3188
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
3189
|
+
for (const f of files) {
|
|
3190
|
+
if (!exempt.has(f.path)) byPath.set(f.path, f.data);
|
|
3191
|
+
}
|
|
3192
|
+
const violations = [];
|
|
3193
|
+
let checked = 0;
|
|
3194
|
+
for (const [path, declaredRaw] of Object.entries(integrity)) {
|
|
3195
|
+
if (exempt.has(path)) continue;
|
|
3196
|
+
const declared = typeof declaredRaw === "string" ? declaredRaw : String(declaredRaw);
|
|
3197
|
+
const data = byPath.get(path);
|
|
3198
|
+
if (data === void 0) {
|
|
3199
|
+
violations.push({ kind: "missing_file", path, declared });
|
|
3200
|
+
continue;
|
|
3201
|
+
}
|
|
3202
|
+
checked++;
|
|
3203
|
+
const actual = sriDigestFor(declared, data);
|
|
3204
|
+
if (actual !== declared) violations.push({ kind: "digest_mismatch", path, declared, actual });
|
|
3205
|
+
}
|
|
3206
|
+
for (const path of [...byPath.keys()].sort()) {
|
|
3207
|
+
if (!Object.prototype.hasOwnProperty.call(integrity, path)) {
|
|
3208
|
+
violations.push({ kind: "extra_file", path });
|
|
3209
|
+
}
|
|
3210
|
+
}
|
|
3211
|
+
return { ok: violations.length === 0, skipped: false, checked, violations };
|
|
3212
|
+
}
|
|
3213
|
+
function formatIntegrityViolation(v) {
|
|
3214
|
+
switch (v.kind) {
|
|
3215
|
+
case "digest_mismatch":
|
|
3216
|
+
return `${v.path}: digest mismatch \u2014 manifest declares ${v.declared}, artifact bytes hash to ${v.actual}`;
|
|
3217
|
+
case "missing_file":
|
|
3218
|
+
return `${v.path}: declared in the integrity map but absent from the artifact`;
|
|
3219
|
+
case "extra_file":
|
|
3220
|
+
return `${v.path}: present in the artifact but not in the integrity map`;
|
|
3221
|
+
}
|
|
3222
|
+
}
|
|
3223
|
+
|
|
3105
3224
|
// src/security/plugin-permission-enforcer.ts
|
|
3106
3225
|
var PluginPermissionEnforcer = class {
|
|
3107
3226
|
constructor(logger) {
|
|
@@ -3973,236 +4092,14 @@ var _PluginSandboxRuntime = class _PluginSandboxRuntime {
|
|
|
3973
4092
|
_PluginSandboxRuntime.MONITORING_INTERVAL_MS = 5e3;
|
|
3974
4093
|
var PluginSandboxRuntime = _PluginSandboxRuntime;
|
|
3975
4094
|
|
|
3976
|
-
// src/security/security-scanner.ts
|
|
3977
|
-
var PluginSecurityScanner = class {
|
|
3978
|
-
constructor(logger, config) {
|
|
3979
|
-
// Known vulnerabilities database (CVE cache)
|
|
3980
|
-
this.vulnerabilityDb = /* @__PURE__ */ new Map();
|
|
3981
|
-
// Scan results cache
|
|
3982
|
-
this.scanResults = /* @__PURE__ */ new Map();
|
|
3983
|
-
this.passThreshold = 70;
|
|
3984
|
-
this.logger = logger.child({ component: "SecurityScanner" });
|
|
3985
|
-
if (config?.passThreshold !== void 0) {
|
|
3986
|
-
this.passThreshold = config.passThreshold;
|
|
3987
|
-
}
|
|
3988
|
-
}
|
|
3989
|
-
/**
|
|
3990
|
-
* Perform a comprehensive security scan on a plugin
|
|
3991
|
-
*/
|
|
3992
|
-
async scan(target) {
|
|
3993
|
-
this.logger.info("Starting security scan", {
|
|
3994
|
-
pluginId: target.pluginId,
|
|
3995
|
-
version: target.version
|
|
3996
|
-
});
|
|
3997
|
-
const issues = [];
|
|
3998
|
-
try {
|
|
3999
|
-
const codeIssues = await this.scanCode(target);
|
|
4000
|
-
issues.push(...codeIssues);
|
|
4001
|
-
const depIssues = await this.scanDependencies(target);
|
|
4002
|
-
issues.push(...depIssues);
|
|
4003
|
-
const malwareIssues = await this.scanMalware(target);
|
|
4004
|
-
issues.push(...malwareIssues);
|
|
4005
|
-
const licenseIssues = await this.scanLicenses(target);
|
|
4006
|
-
issues.push(...licenseIssues);
|
|
4007
|
-
const configIssues = await this.scanConfiguration(target);
|
|
4008
|
-
issues.push(...configIssues);
|
|
4009
|
-
const score = this.calculateSecurityScore(issues);
|
|
4010
|
-
const result = {
|
|
4011
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4012
|
-
scanner: { name: "ObjectStack Security Scanner", version: "1.0.0" },
|
|
4013
|
-
status: score >= this.passThreshold ? "passed" : "failed",
|
|
4014
|
-
vulnerabilities: issues.map((issue) => ({
|
|
4015
|
-
id: issue.id,
|
|
4016
|
-
severity: issue.severity,
|
|
4017
|
-
category: issue.category,
|
|
4018
|
-
title: issue.title,
|
|
4019
|
-
description: issue.description,
|
|
4020
|
-
location: issue.location ? `${issue.location.file}:${issue.location.line}` : void 0,
|
|
4021
|
-
remediation: issue.remediation,
|
|
4022
|
-
affectedVersions: [],
|
|
4023
|
-
exploitAvailable: false,
|
|
4024
|
-
patchAvailable: false
|
|
4025
|
-
})),
|
|
4026
|
-
summary: {
|
|
4027
|
-
totalVulnerabilities: issues.length,
|
|
4028
|
-
criticalCount: issues.filter((i) => i.severity === "critical").length,
|
|
4029
|
-
highCount: issues.filter((i) => i.severity === "high").length,
|
|
4030
|
-
mediumCount: issues.filter((i) => i.severity === "medium").length,
|
|
4031
|
-
lowCount: issues.filter((i) => i.severity === "low").length,
|
|
4032
|
-
infoCount: issues.filter((i) => i.severity === "info").length
|
|
4033
|
-
}
|
|
4034
|
-
};
|
|
4035
|
-
this.scanResults.set(`${target.pluginId}:${target.version}`, result);
|
|
4036
|
-
this.logger.info("Security scan complete", {
|
|
4037
|
-
pluginId: target.pluginId,
|
|
4038
|
-
score,
|
|
4039
|
-
status: result.status,
|
|
4040
|
-
summary: result.summary
|
|
4041
|
-
});
|
|
4042
|
-
return result;
|
|
4043
|
-
} catch (error) {
|
|
4044
|
-
this.logger.error("Security scan failed", {
|
|
4045
|
-
pluginId: target.pluginId,
|
|
4046
|
-
error
|
|
4047
|
-
});
|
|
4048
|
-
throw error;
|
|
4049
|
-
}
|
|
4050
|
-
}
|
|
4051
|
-
/**
|
|
4052
|
-
* Scan code for vulnerabilities
|
|
4053
|
-
*/
|
|
4054
|
-
async scanCode(target) {
|
|
4055
|
-
const issues = [];
|
|
4056
|
-
this.logger.debug("Code scan complete", {
|
|
4057
|
-
pluginId: target.pluginId,
|
|
4058
|
-
issuesFound: issues.length
|
|
4059
|
-
});
|
|
4060
|
-
return issues;
|
|
4061
|
-
}
|
|
4062
|
-
/**
|
|
4063
|
-
* Scan dependencies for known vulnerabilities
|
|
4064
|
-
*/
|
|
4065
|
-
async scanDependencies(target) {
|
|
4066
|
-
const issues = [];
|
|
4067
|
-
if (!target.dependencies) {
|
|
4068
|
-
return issues;
|
|
4069
|
-
}
|
|
4070
|
-
for (const [depName, version] of Object.entries(target.dependencies)) {
|
|
4071
|
-
const vulnKey = `${depName}@${version}`;
|
|
4072
|
-
const vulnerability = this.vulnerabilityDb.get(vulnKey);
|
|
4073
|
-
if (vulnerability) {
|
|
4074
|
-
issues.push({
|
|
4075
|
-
id: `vuln-${vulnerability.cve || depName}`,
|
|
4076
|
-
severity: vulnerability.severity,
|
|
4077
|
-
category: "vulnerability",
|
|
4078
|
-
title: `Vulnerable dependency: ${depName}`,
|
|
4079
|
-
description: `${depName}@${version} has known security vulnerabilities`,
|
|
4080
|
-
remediation: vulnerability.fixedIn ? `Upgrade to ${vulnerability.fixedIn.join(" or ")}` : "No fix available",
|
|
4081
|
-
cve: vulnerability.cve
|
|
4082
|
-
});
|
|
4083
|
-
}
|
|
4084
|
-
}
|
|
4085
|
-
this.logger.debug("Dependency scan complete", {
|
|
4086
|
-
pluginId: target.pluginId,
|
|
4087
|
-
dependencies: Object.keys(target.dependencies).length,
|
|
4088
|
-
vulnerabilities: issues.length
|
|
4089
|
-
});
|
|
4090
|
-
return issues;
|
|
4091
|
-
}
|
|
4092
|
-
/**
|
|
4093
|
-
* Scan for malware patterns
|
|
4094
|
-
*/
|
|
4095
|
-
async scanMalware(target) {
|
|
4096
|
-
const issues = [];
|
|
4097
|
-
this.logger.debug("Malware scan complete", {
|
|
4098
|
-
pluginId: target.pluginId,
|
|
4099
|
-
issuesFound: issues.length
|
|
4100
|
-
});
|
|
4101
|
-
return issues;
|
|
4102
|
-
}
|
|
4103
|
-
/**
|
|
4104
|
-
* Check license compliance
|
|
4105
|
-
*/
|
|
4106
|
-
async scanLicenses(target) {
|
|
4107
|
-
const issues = [];
|
|
4108
|
-
if (!target.dependencies) {
|
|
4109
|
-
return issues;
|
|
4110
|
-
}
|
|
4111
|
-
this.logger.debug("License scan complete", {
|
|
4112
|
-
pluginId: target.pluginId,
|
|
4113
|
-
issuesFound: issues.length
|
|
4114
|
-
});
|
|
4115
|
-
return issues;
|
|
4116
|
-
}
|
|
4117
|
-
/**
|
|
4118
|
-
* Check configuration security
|
|
4119
|
-
*/
|
|
4120
|
-
async scanConfiguration(target) {
|
|
4121
|
-
const issues = [];
|
|
4122
|
-
this.logger.debug("Configuration scan complete", {
|
|
4123
|
-
pluginId: target.pluginId,
|
|
4124
|
-
issuesFound: issues.length
|
|
4125
|
-
});
|
|
4126
|
-
return issues;
|
|
4127
|
-
}
|
|
4128
|
-
/**
|
|
4129
|
-
* Calculate security score based on issues
|
|
4130
|
-
*/
|
|
4131
|
-
calculateSecurityScore(issues) {
|
|
4132
|
-
let score = 100;
|
|
4133
|
-
for (const issue of issues) {
|
|
4134
|
-
switch (issue.severity) {
|
|
4135
|
-
case "critical":
|
|
4136
|
-
score -= 20;
|
|
4137
|
-
break;
|
|
4138
|
-
case "high":
|
|
4139
|
-
score -= 10;
|
|
4140
|
-
break;
|
|
4141
|
-
case "medium":
|
|
4142
|
-
score -= 5;
|
|
4143
|
-
break;
|
|
4144
|
-
case "low":
|
|
4145
|
-
score -= 2;
|
|
4146
|
-
break;
|
|
4147
|
-
case "info":
|
|
4148
|
-
score -= 0;
|
|
4149
|
-
break;
|
|
4150
|
-
}
|
|
4151
|
-
}
|
|
4152
|
-
return Math.max(0, score);
|
|
4153
|
-
}
|
|
4154
|
-
/**
|
|
4155
|
-
* Add a vulnerability to the database
|
|
4156
|
-
*/
|
|
4157
|
-
addVulnerability(packageName, version, vulnerability) {
|
|
4158
|
-
const key = `${packageName}@${version}`;
|
|
4159
|
-
this.vulnerabilityDb.set(key, vulnerability);
|
|
4160
|
-
this.logger.debug("Vulnerability added to database", {
|
|
4161
|
-
package: packageName,
|
|
4162
|
-
version,
|
|
4163
|
-
cve: vulnerability.cve
|
|
4164
|
-
});
|
|
4165
|
-
}
|
|
4166
|
-
/**
|
|
4167
|
-
* Get scan result from cache
|
|
4168
|
-
*/
|
|
4169
|
-
getScanResult(pluginId, version) {
|
|
4170
|
-
return this.scanResults.get(`${pluginId}:${version}`);
|
|
4171
|
-
}
|
|
4172
|
-
/**
|
|
4173
|
-
* Clear scan results cache
|
|
4174
|
-
*/
|
|
4175
|
-
clearCache() {
|
|
4176
|
-
this.scanResults.clear();
|
|
4177
|
-
this.logger.debug("Scan results cache cleared");
|
|
4178
|
-
}
|
|
4179
|
-
/**
|
|
4180
|
-
* Update vulnerability database from external source
|
|
4181
|
-
*/
|
|
4182
|
-
async updateVulnerabilityDatabase() {
|
|
4183
|
-
this.logger.info("Updating vulnerability database");
|
|
4184
|
-
this.logger.info("Vulnerability database updated", {
|
|
4185
|
-
entries: this.vulnerabilityDb.size
|
|
4186
|
-
});
|
|
4187
|
-
}
|
|
4188
|
-
/**
|
|
4189
|
-
* Shutdown security scanner
|
|
4190
|
-
*/
|
|
4191
|
-
shutdown() {
|
|
4192
|
-
this.vulnerabilityDb.clear();
|
|
4193
|
-
this.scanResults.clear();
|
|
4194
|
-
this.logger.info("Security scanner shutdown complete");
|
|
4195
|
-
}
|
|
4196
|
-
};
|
|
4197
|
-
|
|
4198
4095
|
// src/security/api-key.ts
|
|
4199
|
-
import { createHash, randomBytes } from "crypto";
|
|
4096
|
+
import { createHash as createHash2, randomBytes } from "crypto";
|
|
4200
4097
|
import { postureEnforcesWall, postureUsesUnionScope, normalizeTenancyPosture } from "@objectstack/spec/security";
|
|
4201
4098
|
var API_KEY_PREFIX = "osk_";
|
|
4202
4099
|
var API_KEY_ENTROPY_BYTES = 32;
|
|
4203
4100
|
var VISIBLE_PREFIX_LEN = 12;
|
|
4204
4101
|
function hashApiKey(raw) {
|
|
4205
|
-
return
|
|
4102
|
+
return createHash2("sha256").update(raw, "utf8").digest("hex");
|
|
4206
4103
|
}
|
|
4207
4104
|
function generateApiKey(prefix = API_KEY_PREFIX) {
|
|
4208
4105
|
const secret = randomBytes(API_KEY_ENTROPY_BYTES).toString("base64url");
|
|
@@ -4281,19 +4178,22 @@ async function resolveApiKeyAdmission(ql, headers, nowMs = Date.now(), tenancyPo
|
|
|
4281
4178
|
const userId = row.user_id ?? row.userId;
|
|
4282
4179
|
if (!userId || typeof userId !== "string") return { outcome: "none" };
|
|
4283
4180
|
const tenantId = typeof row.active_organization_id === "string" && row.active_organization_id ? row.active_organization_id : void 0;
|
|
4181
|
+
const keyId = typeof row.id === "string" && row.id ? row.id : void 0;
|
|
4284
4182
|
if (!tenantId && tenancyPosture) {
|
|
4285
4183
|
const posture = tenancyPosture;
|
|
4286
4184
|
if (postureEnforcesWall(posture) && !postureUsesUnionScope(posture)) {
|
|
4287
4185
|
return {
|
|
4288
4186
|
outcome: "refused",
|
|
4289
4187
|
reason: "organization_required",
|
|
4188
|
+
keyId,
|
|
4189
|
+
userId,
|
|
4290
4190
|
message: "This API key carries no organization and cannot be used under the `isolated` tenancy posture, where every organization-scoped read is walled to an active organization. Mint a replacement key \u2014 new keys inherit the minter\u2019s active organization."
|
|
4291
4191
|
};
|
|
4292
4192
|
}
|
|
4293
4193
|
}
|
|
4294
4194
|
return {
|
|
4295
4195
|
outcome: "admitted",
|
|
4296
|
-
principal: { userId, tenantId, scopes: parseScopes(row.scopes) }
|
|
4196
|
+
principal: { userId, keyId, tenantId, scopes: parseScopes(row.scopes) }
|
|
4297
4197
|
};
|
|
4298
4198
|
}
|
|
4299
4199
|
function readHeader(headers, name) {
|
|
@@ -4319,14 +4219,45 @@ function safeJsonParse(s, fallback) {
|
|
|
4319
4219
|
}
|
|
4320
4220
|
}
|
|
4321
4221
|
|
|
4222
|
+
// src/security/authz-store-unavailable.ts
|
|
4223
|
+
var AUTHZ_STORE_UNAVAILABLE_STATUS = 503;
|
|
4224
|
+
var AUTHZ_STORE_UNAVAILABLE_CODE = "SERVICE_UNAVAILABLE";
|
|
4225
|
+
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.";
|
|
4226
|
+
var AUTHZ_STORE_UNAVAILABLE_BRAND = "__objectstackAuthzStoreUnavailable";
|
|
4227
|
+
var _a, _b;
|
|
4228
|
+
var AuthzStoreUnavailableError = class extends (_b = Error, _a = AUTHZ_STORE_UNAVAILABLE_BRAND, _b) {
|
|
4229
|
+
constructor(object, cause) {
|
|
4230
|
+
super(`${AUTHZ_STORE_UNAVAILABLE_MESSAGE} (failed read: \`${object}\`)`);
|
|
4231
|
+
/** Brand — see the module doc on why this is not `instanceof`. */
|
|
4232
|
+
this[_a] = true;
|
|
4233
|
+
/** ADR-0112 wire code. */
|
|
4234
|
+
this.code = AUTHZ_STORE_UNAVAILABLE_CODE;
|
|
4235
|
+
/** HTTP status a transport should answer. */
|
|
4236
|
+
this.status = AUTHZ_STORE_UNAVAILABLE_STATUS;
|
|
4237
|
+
this.name = "AuthzStoreUnavailableError";
|
|
4238
|
+
this.object = object;
|
|
4239
|
+
if (cause !== void 0) this.cause = cause;
|
|
4240
|
+
}
|
|
4241
|
+
};
|
|
4242
|
+
function isAuthzStoreUnavailableError(err) {
|
|
4243
|
+
return typeof err === "object" && err !== null && err[AUTHZ_STORE_UNAVAILABLE_BRAND] === true;
|
|
4244
|
+
}
|
|
4245
|
+
function rethrowAuthzStoreUnavailable(err) {
|
|
4246
|
+
if (isAuthzStoreUnavailableError(err)) throw err;
|
|
4247
|
+
return void 0;
|
|
4248
|
+
}
|
|
4249
|
+
|
|
4322
4250
|
// src/security/resolve-authz-context.ts
|
|
4251
|
+
import { isMissingTableError, resolveTenancyPosture } from "@objectstack/types";
|
|
4323
4252
|
import {
|
|
4324
4253
|
mapMembershipRole,
|
|
4325
4254
|
BUILTIN_IDENTITY_PLATFORM_ADMIN,
|
|
4326
4255
|
ADMIN_FULL_ACCESS,
|
|
4256
|
+
ADMIN_FULL_ACCESS_CAPABILITIES,
|
|
4327
4257
|
ORGANIZATION_ADMIN_GRANTS
|
|
4328
4258
|
} from "@objectstack/spec";
|
|
4329
4259
|
import { postureEnforcesWall as postureEnforcesWall2 } from "@objectstack/spec/security";
|
|
4260
|
+
import { isValueDomainMember } from "@objectstack/spec/shared";
|
|
4330
4261
|
|
|
4331
4262
|
// src/security/grant-validity.ts
|
|
4332
4263
|
function toEpochMs(value) {
|
|
@@ -4346,6 +4277,20 @@ function isGrantActive(row, nowMs) {
|
|
|
4346
4277
|
if (until !== void 0 && !(nowMs < until)) return false;
|
|
4347
4278
|
return true;
|
|
4348
4279
|
}
|
|
4280
|
+
function nextGrantValidityBoundary(rows, nowMs) {
|
|
4281
|
+
let next;
|
|
4282
|
+
for (const row of rows) {
|
|
4283
|
+
if (!row) continue;
|
|
4284
|
+
const from = toEpochMs(row.valid_from ?? row.validFrom);
|
|
4285
|
+
const until = toEpochMs(row.valid_until ?? row.validUntil);
|
|
4286
|
+
for (const bound of [from, until]) {
|
|
4287
|
+
if (bound !== void 0 && bound > nowMs && (next === void 0 || bound < next)) {
|
|
4288
|
+
next = bound;
|
|
4289
|
+
}
|
|
4290
|
+
}
|
|
4291
|
+
}
|
|
4292
|
+
return next;
|
|
4293
|
+
}
|
|
4349
4294
|
function isGrantExpired(row, nowMs) {
|
|
4350
4295
|
if (!row) return false;
|
|
4351
4296
|
const until = toEpochMs(row.valid_until ?? row.validUntil);
|
|
@@ -4353,6 +4298,227 @@ function isGrantExpired(row, nowMs) {
|
|
|
4353
4298
|
return !(nowMs < until);
|
|
4354
4299
|
}
|
|
4355
4300
|
|
|
4301
|
+
// src/security/authz-cache-posture.ts
|
|
4302
|
+
var AUTHZ_GRANTS_CACHE_TTL_ENV = "OS_AUTHZ_GRANTS_CACHE_TTL_MS";
|
|
4303
|
+
function resolveAuthzCachePosture(input) {
|
|
4304
|
+
const { ttlMs, bus, driver } = input;
|
|
4305
|
+
if (!(ttlMs > 0)) {
|
|
4306
|
+
return { posture: "disabled", loud: false, message: "" };
|
|
4307
|
+
}
|
|
4308
|
+
if (bus === "bridged") {
|
|
4309
|
+
return {
|
|
4310
|
+
posture: "bus-narrowed",
|
|
4311
|
+
loud: false,
|
|
4312
|
+
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)."
|
|
4313
|
+
};
|
|
4314
|
+
}
|
|
4315
|
+
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";
|
|
4316
|
+
return {
|
|
4317
|
+
posture: "ttl-only",
|
|
4318
|
+
loud: true,
|
|
4319
|
+
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.`
|
|
4320
|
+
};
|
|
4321
|
+
}
|
|
4322
|
+
function readAuthzGrantsCacheTtlMs(env = typeof process !== "undefined" ? process.env : {}) {
|
|
4323
|
+
const raw = env[AUTHZ_GRANTS_CACHE_TTL_ENV];
|
|
4324
|
+
if (raw === void 0 || raw.trim() === "") {
|
|
4325
|
+
return { ttlMs: 0, malformed: false };
|
|
4326
|
+
}
|
|
4327
|
+
const parsed = Number(raw.trim());
|
|
4328
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
4329
|
+
return { ttlMs: 0, raw, malformed: true };
|
|
4330
|
+
}
|
|
4331
|
+
return { ttlMs: Math.floor(parsed), raw, malformed: false };
|
|
4332
|
+
}
|
|
4333
|
+
function reportAuthzCachePosture(input, sink2) {
|
|
4334
|
+
if (input.malformedTtl) {
|
|
4335
|
+
sink2.warn(
|
|
4336
|
+
`[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.`
|
|
4337
|
+
);
|
|
4338
|
+
}
|
|
4339
|
+
const statement = resolveAuthzCachePosture(input);
|
|
4340
|
+
if (statement.posture === "disabled") return statement;
|
|
4341
|
+
if (statement.loud) sink2.warn(statement.message);
|
|
4342
|
+
else sink2.info?.(statement.message);
|
|
4343
|
+
return statement;
|
|
4344
|
+
}
|
|
4345
|
+
|
|
4346
|
+
// src/security/resolve-user-grants-cache.ts
|
|
4347
|
+
var GRANTS_CACHE_WATCHED_OBJECTS = /* @__PURE__ */ new Set([
|
|
4348
|
+
"sys_member",
|
|
4349
|
+
"sys_user_position",
|
|
4350
|
+
"sys_user_permission_set",
|
|
4351
|
+
"sys_position",
|
|
4352
|
+
"sys_position_permission_set",
|
|
4353
|
+
"sys_permission_set",
|
|
4354
|
+
"sys_user"
|
|
4355
|
+
]);
|
|
4356
|
+
var grantsCacheStates = /* @__PURE__ */ new WeakMap();
|
|
4357
|
+
var WRITE_OPERATIONS = /* @__PURE__ */ new Set(["insert", "update", "delete"]);
|
|
4358
|
+
function grantsCacheState(ql) {
|
|
4359
|
+
const existing = grantsCacheStates.get(ql);
|
|
4360
|
+
if (existing !== void 0) return existing ?? void 0;
|
|
4361
|
+
const seamQl = ql;
|
|
4362
|
+
const epoch = seamQl.writeEpoch;
|
|
4363
|
+
const hasEpoch = !!epoch && typeof epoch === "object" && typeof epoch.current === "number" && typeof epoch.bump === "function" && typeof epoch.subscribe === "function";
|
|
4364
|
+
if (!hasEpoch || typeof seamQl.registerMiddleware !== "function") {
|
|
4365
|
+
return void 0;
|
|
4366
|
+
}
|
|
4367
|
+
const state = { gen: 0, entries: /* @__PURE__ */ new Map() };
|
|
4368
|
+
try {
|
|
4369
|
+
seamQl.registerMiddleware(async (ctx, next) => {
|
|
4370
|
+
if (typeof ctx?.operation !== "string" || !WRITE_OPERATIONS.has(ctx.operation) || typeof ctx?.object !== "string" || !GRANTS_CACHE_WATCHED_OBJECTS.has(ctx.object)) {
|
|
4371
|
+
return next();
|
|
4372
|
+
}
|
|
4373
|
+
try {
|
|
4374
|
+
await next();
|
|
4375
|
+
} finally {
|
|
4376
|
+
state.gen += 1;
|
|
4377
|
+
}
|
|
4378
|
+
});
|
|
4379
|
+
epoch.subscribe((_epoch, reason) => {
|
|
4380
|
+
if (reason !== "write") state.gen += 1;
|
|
4381
|
+
});
|
|
4382
|
+
} catch {
|
|
4383
|
+
grantsCacheStates.set(ql, null);
|
|
4384
|
+
return void 0;
|
|
4385
|
+
}
|
|
4386
|
+
grantsCacheStates.set(ql, state);
|
|
4387
|
+
return state;
|
|
4388
|
+
}
|
|
4389
|
+
function grantsCacheKey(userId, opts) {
|
|
4390
|
+
return JSON.stringify([
|
|
4391
|
+
userId,
|
|
4392
|
+
opts.tenantId ?? null,
|
|
4393
|
+
opts.seedEmail ?? null,
|
|
4394
|
+
Array.isArray(opts.seedPermissions) ? opts.seedPermissions : []
|
|
4395
|
+
]);
|
|
4396
|
+
}
|
|
4397
|
+
var cloneGrants = (grants) => structuredClone(grants);
|
|
4398
|
+
function openUserGrantsCache(ql, userId, opts) {
|
|
4399
|
+
if (opts.bypassGrantsCache) return void 0;
|
|
4400
|
+
const { ttlMs } = readAuthzGrantsCacheTtlMs();
|
|
4401
|
+
if (ttlMs <= 0) return void 0;
|
|
4402
|
+
if (!ql || typeof ql !== "object" || typeof ql.find !== "function") {
|
|
4403
|
+
return void 0;
|
|
4404
|
+
}
|
|
4405
|
+
const state = grantsCacheState(ql);
|
|
4406
|
+
if (!state) return void 0;
|
|
4407
|
+
const key = grantsCacheKey(userId, opts);
|
|
4408
|
+
const now = opts.nowMs ?? Date.now();
|
|
4409
|
+
const genAtOpen = state.gen;
|
|
4410
|
+
const existing = state.entries.get(key);
|
|
4411
|
+
if (existing) {
|
|
4412
|
+
if (existing.gen === state.gen && existing.expiresAt > now) {
|
|
4413
|
+
return { hit: cloneGrants(existing.value), commit: () => {
|
|
4414
|
+
} };
|
|
4415
|
+
}
|
|
4416
|
+
state.entries.delete(key);
|
|
4417
|
+
}
|
|
4418
|
+
return {
|
|
4419
|
+
commit(grants, nextBoundaryMs) {
|
|
4420
|
+
const expiresAt = Math.min(now + ttlMs, nextBoundaryMs ?? Number.POSITIVE_INFINITY);
|
|
4421
|
+
if (expiresAt <= now) return;
|
|
4422
|
+
state.entries.set(key, { value: cloneGrants(grants), gen: genAtOpen, expiresAt });
|
|
4423
|
+
}
|
|
4424
|
+
};
|
|
4425
|
+
}
|
|
4426
|
+
|
|
4427
|
+
// src/security/platform-admin.ts
|
|
4428
|
+
import { isEmailVerifiedUserRow, PLATFORM_OWNER_EMAIL_ENV, resolvePlatformOwnerEmail } from "@objectstack/types";
|
|
4429
|
+
var PLATFORM_ADMIN_EMAIL_SEPARATOR = ",";
|
|
4430
|
+
function normalizePlatformAdminEmail(value) {
|
|
4431
|
+
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
4432
|
+
}
|
|
4433
|
+
function isParseableAddress(entry) {
|
|
4434
|
+
if (/\s/.test(entry)) return false;
|
|
4435
|
+
const at = entry.indexOf("@");
|
|
4436
|
+
if (at <= 0) return false;
|
|
4437
|
+
if (entry.indexOf("@", at + 1) !== -1) return false;
|
|
4438
|
+
return at < entry.length - 1;
|
|
4439
|
+
}
|
|
4440
|
+
var EMPTY_CONFIG = Object.freeze({
|
|
4441
|
+
emails: Object.freeze([]),
|
|
4442
|
+
declaredSpellings: Object.freeze([])
|
|
4443
|
+
});
|
|
4444
|
+
function parsePlatformAdminEmails(raw) {
|
|
4445
|
+
if (raw == null) return EMPTY_CONFIG;
|
|
4446
|
+
const text = String(raw);
|
|
4447
|
+
if (text.trim() === "") return EMPTY_CONFIG;
|
|
4448
|
+
const emails = [];
|
|
4449
|
+
const declaredSpellings = [];
|
|
4450
|
+
for (const piece of text.split(PLATFORM_ADMIN_EMAIL_SEPARATOR)) {
|
|
4451
|
+
const entry = normalizePlatformAdminEmail(piece);
|
|
4452
|
+
if (entry === "") continue;
|
|
4453
|
+
if (!isParseableAddress(entry)) {
|
|
4454
|
+
return {
|
|
4455
|
+
emails: Object.freeze([]),
|
|
4456
|
+
declaredSpellings: Object.freeze([]),
|
|
4457
|
+
raw: text,
|
|
4458
|
+
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.`
|
|
4459
|
+
};
|
|
4460
|
+
}
|
|
4461
|
+
if (!emails.includes(entry)) {
|
|
4462
|
+
emails.push(entry);
|
|
4463
|
+
declaredSpellings.push(piece.trim());
|
|
4464
|
+
}
|
|
4465
|
+
}
|
|
4466
|
+
return {
|
|
4467
|
+
emails: Object.freeze(emails),
|
|
4468
|
+
declaredSpellings: Object.freeze(declaredSpellings),
|
|
4469
|
+
raw: text
|
|
4470
|
+
};
|
|
4471
|
+
}
|
|
4472
|
+
var defaultSink = {
|
|
4473
|
+
error: (m) => console.error(m),
|
|
4474
|
+
warn: (m) => console.warn(m)
|
|
4475
|
+
};
|
|
4476
|
+
var sink = defaultSink;
|
|
4477
|
+
function setPlatformAdminConfigSink(next) {
|
|
4478
|
+
const prev = sink;
|
|
4479
|
+
sink = next ?? defaultSink;
|
|
4480
|
+
return prev;
|
|
4481
|
+
}
|
|
4482
|
+
var NOT_MEMOIZED = /* @__PURE__ */ Symbol("platform-admin-config-not-memoized");
|
|
4483
|
+
var memoKey = NOT_MEMOIZED;
|
|
4484
|
+
var memoValue = EMPTY_CONFIG;
|
|
4485
|
+
function resolvePlatformAdminEmails() {
|
|
4486
|
+
const raw = resolvePlatformOwnerEmail();
|
|
4487
|
+
if (memoKey !== NOT_MEMOIZED && memoKey === raw) return memoValue;
|
|
4488
|
+
const parsed = parsePlatformAdminEmails(raw);
|
|
4489
|
+
memoKey = raw;
|
|
4490
|
+
memoValue = parsed;
|
|
4491
|
+
if (parsed.refusal) sink.error(`[authz] ${parsed.refusal}`);
|
|
4492
|
+
return parsed;
|
|
4493
|
+
}
|
|
4494
|
+
function resetPlatformAdminEmailMemo() {
|
|
4495
|
+
memoKey = NOT_MEMOIZED;
|
|
4496
|
+
memoValue = EMPTY_CONFIG;
|
|
4497
|
+
}
|
|
4498
|
+
function matchesConfiguredPlatformAdmin(row, config) {
|
|
4499
|
+
if (config.emails.length === 0) return false;
|
|
4500
|
+
if (!row || typeof row !== "object") return false;
|
|
4501
|
+
if (!isConfiguredPlatformAdminEmail(row.email, config)) return false;
|
|
4502
|
+
return isEmailVerifiedUserRow(row);
|
|
4503
|
+
}
|
|
4504
|
+
function isConfiguredPlatformAdminEmail(email, config) {
|
|
4505
|
+
if (config.emails.length === 0) return false;
|
|
4506
|
+
const candidate = normalizePlatformAdminEmail(email);
|
|
4507
|
+
return candidate !== "" && config.emails.includes(candidate);
|
|
4508
|
+
}
|
|
4509
|
+
var legacyGrantPointerSaid = false;
|
|
4510
|
+
function reportLegacyPlatformAdminGrant(input) {
|
|
4511
|
+
if (legacyGrantPointerSaid) return;
|
|
4512
|
+
legacyGrantPointerSaid = true;
|
|
4513
|
+
const email = normalizePlatformAdminEmail(input.email);
|
|
4514
|
+
sink.warn(
|
|
4515
|
+
`[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.`
|
|
4516
|
+
);
|
|
4517
|
+
}
|
|
4518
|
+
function resetLegacyPlatformAdminGrantReport() {
|
|
4519
|
+
legacyGrantPointerSaid = false;
|
|
4520
|
+
}
|
|
4521
|
+
|
|
4356
4522
|
// src/security/posture-ladder.ts
|
|
4357
4523
|
var POSTURE_LADDER = [
|
|
4358
4524
|
"PLATFORM_ADMIN",
|
|
@@ -4426,6 +4592,18 @@ function safeJsonParse2(s, fallback) {
|
|
|
4426
4592
|
return fallback;
|
|
4427
4593
|
}
|
|
4428
4594
|
}
|
|
4595
|
+
function warnApiKeyRefusal(details) {
|
|
4596
|
+
const { reason, keyId, userId, organizationId } = details;
|
|
4597
|
+
console.warn(
|
|
4598
|
+
`[security] API key refused (${reason}): key=${keyId ?? "<unknown>"} principal=${userId ?? "<unknown>"} organization=${organizationId ?? "<none>"}. The caller received the generic 401 UNAUTHENTICATED \u2014 this reason is server-side only.`
|
|
4599
|
+
);
|
|
4600
|
+
}
|
|
4601
|
+
function warnSessionOrganizationClaimDropped(details) {
|
|
4602
|
+
const { reason, sessionId, userId, organizationId } = details;
|
|
4603
|
+
console.warn(
|
|
4604
|
+
`[security] Session organization claim dropped (${reason}): session=${sessionId ?? "<unknown>"} principal=${userId ?? "<unknown>"} organization=${organizationId ?? "<none>"}. The session stays authenticated with NO active organization \u2014 the wire is unchanged.`
|
|
4605
|
+
);
|
|
4606
|
+
}
|
|
4429
4607
|
async function tryFind(ql, object, where, limit = 100, organizationId) {
|
|
4430
4608
|
if (!ql || typeof ql.find !== "function") return [];
|
|
4431
4609
|
try {
|
|
@@ -4433,8 +4611,9 @@ async function tryFind(ql, object, where, limit = 100, organizationId) {
|
|
|
4433
4611
|
let rows = await ql.find(object, { where, limit, context });
|
|
4434
4612
|
if (rows && rows.value) rows = rows.value;
|
|
4435
4613
|
return Array.isArray(rows) ? rows : [];
|
|
4436
|
-
} catch {
|
|
4437
|
-
return [];
|
|
4614
|
+
} catch (err) {
|
|
4615
|
+
if (isMissingTableError(err, object)) return [];
|
|
4616
|
+
throw new AuthzStoreUnavailableError(object, err);
|
|
4438
4617
|
}
|
|
4439
4618
|
}
|
|
4440
4619
|
async function resolveAuthzContext(input) {
|
|
@@ -4448,9 +4627,15 @@ async function resolveAuthzContext(input) {
|
|
|
4448
4627
|
};
|
|
4449
4628
|
let userId;
|
|
4450
4629
|
let tenantId;
|
|
4630
|
+
let sessionId;
|
|
4451
4631
|
const admission = await resolveApiKeyAdmission(ql, headers, input.nowMs, input.tenancyPosture);
|
|
4452
4632
|
if (admission.outcome === "refused") {
|
|
4453
|
-
|
|
4633
|
+
warnApiKeyRefusal({
|
|
4634
|
+
reason: admission.reason,
|
|
4635
|
+
keyId: admission.keyId,
|
|
4636
|
+
userId: admission.userId,
|
|
4637
|
+
organizationId: admission.organizationId
|
|
4638
|
+
});
|
|
4454
4639
|
return ctx;
|
|
4455
4640
|
}
|
|
4456
4641
|
const keyPrincipal = admission.outcome === "admitted" ? admission.principal : void 0;
|
|
@@ -4466,6 +4651,8 @@ async function resolveAuthzContext(input) {
|
|
|
4466
4651
|
const sessionData = await input.getSession(headers);
|
|
4467
4652
|
userId = sessionData?.user?.id ?? sessionData?.session?.userId;
|
|
4468
4653
|
tenantId = tenantId ?? sessionData?.session?.activeOrganizationId;
|
|
4654
|
+
const rawSessionId = sessionData?.session?.id;
|
|
4655
|
+
sessionId = typeof rawSessionId === "string" && rawSessionId ? rawSessionId : void 0;
|
|
4469
4656
|
ctx.accessToken = sessionData?.session?.token ?? ctx.accessToken;
|
|
4470
4657
|
if (sessionData?.user?.email) ctx.email = String(sessionData.user.email);
|
|
4471
4658
|
} catch {
|
|
@@ -4475,7 +4662,7 @@ async function resolveAuthzContext(input) {
|
|
|
4475
4662
|
ctx.userId = userId;
|
|
4476
4663
|
if (tenantId) ctx.tenantId = tenantId;
|
|
4477
4664
|
if (!ql || typeof ql.find !== "function") return ctx;
|
|
4478
|
-
|
|
4665
|
+
let grants = await resolveUserAuthzGrants(ql, userId, {
|
|
4479
4666
|
tenantId,
|
|
4480
4667
|
nowMs: input.nowMs,
|
|
4481
4668
|
seedPermissions: ctx.permissions,
|
|
@@ -4484,19 +4671,37 @@ async function resolveAuthzContext(input) {
|
|
|
4484
4671
|
if (keyPrincipal?.tenantId && input.tenancyPosture) {
|
|
4485
4672
|
const posture = input.tenancyPosture;
|
|
4486
4673
|
if (postureEnforcesWall2(posture) && !grants.accessible_org_ids.includes(keyPrincipal.tenantId)) {
|
|
4674
|
+
warnApiKeyRefusal({
|
|
4675
|
+
reason: "organization_membership_ended",
|
|
4676
|
+
keyId: keyPrincipal.keyId,
|
|
4677
|
+
userId: keyPrincipal.userId,
|
|
4678
|
+
organizationId: keyPrincipal.tenantId
|
|
4679
|
+
});
|
|
4487
4680
|
return {
|
|
4488
4681
|
positions: [],
|
|
4489
4682
|
permissions: [],
|
|
4490
4683
|
systemPermissions: [],
|
|
4491
4684
|
org_user_ids: [],
|
|
4492
|
-
accessible_org_ids: []
|
|
4493
|
-
authRefusal: {
|
|
4494
|
-
reason: "organization_membership_ended",
|
|
4495
|
-
message: "This API key authenticates into an organization its owner is no longer a member of. The key was not revoked \u2014 the membership that backed it ended."
|
|
4496
|
-
}
|
|
4685
|
+
accessible_org_ids: []
|
|
4497
4686
|
};
|
|
4498
4687
|
}
|
|
4499
4688
|
}
|
|
4689
|
+
if (!keyPrincipal && tenantId && input.tenancyPosture && postureEnforcesWall2(input.tenancyPosture) && !grants.accessible_org_ids.includes(tenantId)) {
|
|
4690
|
+
warnSessionOrganizationClaimDropped({
|
|
4691
|
+
reason: "organization_membership_ended",
|
|
4692
|
+
sessionId,
|
|
4693
|
+
userId,
|
|
4694
|
+
organizationId: tenantId
|
|
4695
|
+
});
|
|
4696
|
+
tenantId = void 0;
|
|
4697
|
+
delete ctx.tenantId;
|
|
4698
|
+
grants = await resolveUserAuthzGrants(ql, userId, {
|
|
4699
|
+
tenantId,
|
|
4700
|
+
nowMs: input.nowMs,
|
|
4701
|
+
seedPermissions: ctx.permissions,
|
|
4702
|
+
seedEmail: ctx.email
|
|
4703
|
+
});
|
|
4704
|
+
}
|
|
4500
4705
|
ctx.positions = grants.positions;
|
|
4501
4706
|
ctx.permissions = grants.permissions;
|
|
4502
4707
|
ctx.systemPermissions = grants.systemPermissions;
|
|
@@ -4508,6 +4713,8 @@ async function resolveAuthzContext(input) {
|
|
|
4508
4713
|
return ctx;
|
|
4509
4714
|
}
|
|
4510
4715
|
async function resolveUserAuthzGrants(ql, userId, opts = {}) {
|
|
4716
|
+
const grantsCache = openUserGrantsCache(ql, userId, opts);
|
|
4717
|
+
if (grantsCache?.hit) return grantsCache.hit;
|
|
4511
4718
|
const { tenantId } = opts;
|
|
4512
4719
|
const grants = {
|
|
4513
4720
|
positions: [],
|
|
@@ -4528,7 +4735,8 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
|
|
|
4528
4735
|
}
|
|
4529
4736
|
return userRow;
|
|
4530
4737
|
};
|
|
4531
|
-
const
|
|
4738
|
+
const platformAdminConfig = resolvePlatformAdminEmails();
|
|
4739
|
+
const needsUserRow = !grants.email || !grants.permissions.includes("ai_seat") || platformAdminConfig.emails.length > 0;
|
|
4532
4740
|
const [, members, userPositionRows, orgMembersLeg, upsRowsAll] = await Promise.all([
|
|
4533
4741
|
needsUserRow ? getUserRow() : Promise.resolve(void 0),
|
|
4534
4742
|
tryFind(ql, "sys_member", { user_id: userId }, 200),
|
|
@@ -4630,6 +4838,18 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
|
|
|
4630
4838
|
}
|
|
4631
4839
|
if (Object.keys(mergedTabs).length > 0) grants.tabPermissions = mergedTabs;
|
|
4632
4840
|
}
|
|
4841
|
+
const configConfersPlatformAdmin = platformAdminConfig.emails.length > 0 && matchesConfiguredPlatformAdmin(await getUserRow(), platformAdminConfig);
|
|
4842
|
+
if (configConfersPlatformAdmin) {
|
|
4843
|
+
hasPlatformAdminGrant = true;
|
|
4844
|
+
if (!grants.permissions.includes(ADMIN_FULL_ACCESS)) grants.permissions.push(ADMIN_FULL_ACCESS);
|
|
4845
|
+
for (const p of ADMIN_FULL_ACCESS_CAPABILITIES.systemPermissions ?? []) {
|
|
4846
|
+
if (!grants.systemPermissions.includes(p)) grants.systemPermissions.push(p);
|
|
4847
|
+
}
|
|
4848
|
+
} else if (hasPlatformAdminGrant) {
|
|
4849
|
+
if (postureEnforcesWall2(resolveTenancyPosture())) {
|
|
4850
|
+
reportLegacyPlatformAdminGrant({ userId, email: userRow?.email });
|
|
4851
|
+
}
|
|
4852
|
+
}
|
|
4633
4853
|
if (hasPlatformAdminGrant && !grants.positions.includes(BUILTIN_IDENTITY_PLATFORM_ADMIN)) {
|
|
4634
4854
|
grants.positions.unshift(BUILTIN_IDENTITY_PLATFORM_ADMIN);
|
|
4635
4855
|
}
|
|
@@ -4643,19 +4863,24 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
|
|
|
4643
4863
|
const aiAccess = (await getUserRow())?.ai_access;
|
|
4644
4864
|
if (aiAccess === true || aiAccess === 1 || aiAccess === "1") grants.permissions.push("ai_seat");
|
|
4645
4865
|
}
|
|
4866
|
+
grantsCache?.commit(
|
|
4867
|
+
grants,
|
|
4868
|
+
nextGrantValidityBoundary([...members, ...userPositionRows, ...upsRowsAll], nowMs)
|
|
4869
|
+
);
|
|
4646
4870
|
return grants;
|
|
4647
4871
|
}
|
|
4648
|
-
function
|
|
4872
|
+
async function hasPlatformAdminStanding(ql, userId, opts = {}) {
|
|
4873
|
+
if (!ql || typeof userId !== "string" || userId.length === 0) return false;
|
|
4649
4874
|
try {
|
|
4650
|
-
|
|
4651
|
-
return
|
|
4875
|
+
const grants = await resolveUserAuthzGrants(ql, userId, { nowMs: opts.nowMs });
|
|
4876
|
+
return grants.posture === "PLATFORM_ADMIN";
|
|
4652
4877
|
} catch {
|
|
4653
4878
|
return false;
|
|
4654
4879
|
}
|
|
4655
4880
|
}
|
|
4656
4881
|
function coerceTimeZone(value) {
|
|
4657
4882
|
const s = typeof value === "string" ? value.trim() : value != null ? String(value).trim() : "";
|
|
4658
|
-
return s &&
|
|
4883
|
+
return s && isValueDomainMember("iana_time_zone", s) ? s : void 0;
|
|
4659
4884
|
}
|
|
4660
4885
|
function coerceLocale(value) {
|
|
4661
4886
|
const s = typeof value === "string" ? value.trim() : value != null ? String(value).trim() : "";
|
|
@@ -4666,25 +4891,98 @@ function coerceCurrency(value) {
|
|
|
4666
4891
|
return /^[A-Z]{3}$/.test(s) ? s : void 0;
|
|
4667
4892
|
}
|
|
4668
4893
|
var LOCALIZATION_FAILURE_CACHE_TTL_MS = 3e4;
|
|
4669
|
-
var
|
|
4894
|
+
var LOCALIZATION_CACHE_TTL_ENV = "OS_LOCALIZATION_CACHE_TTL_MS";
|
|
4895
|
+
var LOCALIZATION_SUCCESS_CACHE_DEFAULT_TTL_MS = 3e4;
|
|
4896
|
+
function localizationSuccessCacheTtlMs(env = typeof process !== "undefined" ? process.env : {}) {
|
|
4897
|
+
const raw = env[LOCALIZATION_CACHE_TTL_ENV];
|
|
4898
|
+
if (raw === void 0 || raw.trim() === "") return LOCALIZATION_SUCCESS_CACHE_DEFAULT_TTL_MS;
|
|
4899
|
+
const parsed = Number(raw.trim());
|
|
4900
|
+
if (!Number.isFinite(parsed) || parsed < 0) return 0;
|
|
4901
|
+
return Math.floor(parsed);
|
|
4902
|
+
}
|
|
4903
|
+
function readWriteEpoch(ql) {
|
|
4904
|
+
if (!ql || typeof ql !== "object") return void 0;
|
|
4905
|
+
const epoch = ql.writeEpoch;
|
|
4906
|
+
if (!epoch || typeof epoch !== "object") return void 0;
|
|
4907
|
+
const seam = epoch;
|
|
4908
|
+
if (typeof seam.current !== "number" || typeof seam.bump !== "function" || typeof seam.subscribe !== "function") {
|
|
4909
|
+
return void 0;
|
|
4910
|
+
}
|
|
4911
|
+
return seam.current;
|
|
4912
|
+
}
|
|
4913
|
+
var localizationSettingsStates = /* @__PURE__ */ new WeakMap();
|
|
4914
|
+
var localizationNoSettingsState = { gen: 0 };
|
|
4915
|
+
function localizationSettingsState(settings) {
|
|
4916
|
+
if (!settings || typeof settings !== "object") return localizationNoSettingsState;
|
|
4917
|
+
const existing = localizationSettingsStates.get(settings);
|
|
4918
|
+
if (existing) return existing;
|
|
4919
|
+
const state = { gen: 0 };
|
|
4920
|
+
localizationSettingsStates.set(settings, state);
|
|
4921
|
+
const subscribe = settings.subscribe;
|
|
4922
|
+
if (typeof subscribe === "function") {
|
|
4923
|
+
try {
|
|
4924
|
+
subscribe.call(
|
|
4925
|
+
settings,
|
|
4926
|
+
"localization",
|
|
4927
|
+
() => {
|
|
4928
|
+
state.gen += 1;
|
|
4929
|
+
}
|
|
4930
|
+
);
|
|
4931
|
+
} catch {
|
|
4932
|
+
}
|
|
4933
|
+
}
|
|
4934
|
+
return state;
|
|
4935
|
+
}
|
|
4936
|
+
var localizationCache = /* @__PURE__ */ new WeakMap();
|
|
4937
|
+
function localizationEntryIsLive(entry, epoch, settings) {
|
|
4938
|
+
if (entry.kind === "failure") return true;
|
|
4939
|
+
return entry.epoch === epoch && entry.settings === settings && entry.settingsGen === settings.gen;
|
|
4940
|
+
}
|
|
4941
|
+
function putLocalizationEntry(ql, key, entry) {
|
|
4942
|
+
const bucket = localizationCache.get(ql) ?? /* @__PURE__ */ new Map();
|
|
4943
|
+
bucket.set(key, entry);
|
|
4944
|
+
localizationCache.set(ql, bucket);
|
|
4945
|
+
}
|
|
4670
4946
|
async function resolveLocalizationContext(input) {
|
|
4671
|
-
const { ql, tenantId, userId } = input;
|
|
4947
|
+
const { ql, settings, tenantId, userId } = input;
|
|
4672
4948
|
const cacheKey = `${tenantId ?? ""}|${userId ?? ""}`;
|
|
4673
|
-
|
|
4674
|
-
|
|
4675
|
-
|
|
4676
|
-
|
|
4677
|
-
|
|
4678
|
-
|
|
4679
|
-
|
|
4680
|
-
|
|
4681
|
-
|
|
4949
|
+
const cacheable = Boolean(ql) && typeof ql === "object";
|
|
4950
|
+
const epoch = cacheable ? readWriteEpoch(ql) : void 0;
|
|
4951
|
+
const settingsState = localizationSettingsState(settings);
|
|
4952
|
+
if (cacheable) {
|
|
4953
|
+
const hit = localizationCache.get(ql)?.get(cacheKey);
|
|
4954
|
+
if (hit && hit.expiresAt > Date.now() && localizationEntryIsLive(hit, epoch, settingsState)) {
|
|
4955
|
+
return hit.value;
|
|
4956
|
+
}
|
|
4957
|
+
}
|
|
4958
|
+
const { value, backendFailed } = await resolveLocalizationContextUncached(input);
|
|
4959
|
+
if (!cacheable) return value;
|
|
4960
|
+
if (backendFailed) {
|
|
4961
|
+
putLocalizationEntry(ql, cacheKey, {
|
|
4962
|
+
value,
|
|
4963
|
+
expiresAt: Date.now() + LOCALIZATION_FAILURE_CACHE_TTL_MS,
|
|
4964
|
+
kind: "failure"
|
|
4965
|
+
});
|
|
4966
|
+
return value;
|
|
4967
|
+
}
|
|
4968
|
+
const ttlMs = localizationSuccessCacheTtlMs();
|
|
4969
|
+
if (epoch !== void 0 && ttlMs > 0) {
|
|
4970
|
+
putLocalizationEntry(ql, cacheKey, {
|
|
4971
|
+
value,
|
|
4972
|
+
expiresAt: Date.now() + ttlMs,
|
|
4973
|
+
kind: "success",
|
|
4974
|
+
epoch,
|
|
4975
|
+
settings: settingsState,
|
|
4976
|
+
settingsGen: settingsState.gen
|
|
4977
|
+
});
|
|
4978
|
+
} else {
|
|
4979
|
+
localizationCache.get(ql)?.delete(cacheKey);
|
|
4682
4980
|
}
|
|
4683
4981
|
return value;
|
|
4684
4982
|
}
|
|
4685
4983
|
async function resolveLocalizationContextUncached(input) {
|
|
4686
4984
|
const { ql, settings, tenantId, userId } = input;
|
|
4687
|
-
let
|
|
4985
|
+
let backendFailed = false;
|
|
4688
4986
|
try {
|
|
4689
4987
|
if (settings && typeof settings.get === "function") {
|
|
4690
4988
|
const sctx = { tenantId, userId };
|
|
@@ -4698,33 +4996,22 @@ async function resolveLocalizationContextUncached(input) {
|
|
|
4698
4996
|
localeRes = many.locale;
|
|
4699
4997
|
currencyRes = many.currency;
|
|
4700
4998
|
} catch {
|
|
4701
|
-
failed = true;
|
|
4702
4999
|
}
|
|
4703
5000
|
} else {
|
|
4704
5001
|
[tzRes, localeRes, currencyRes] = await Promise.all([
|
|
4705
|
-
settings.get("localization", "timezone", sctx).catch(() =>
|
|
4706
|
-
|
|
4707
|
-
|
|
4708
|
-
}),
|
|
4709
|
-
settings.get("localization", "locale", sctx).catch(() => {
|
|
4710
|
-
failed = true;
|
|
4711
|
-
return void 0;
|
|
4712
|
-
}),
|
|
4713
|
-
settings.get("localization", "currency", sctx).catch(() => {
|
|
4714
|
-
failed = true;
|
|
4715
|
-
return void 0;
|
|
4716
|
-
})
|
|
5002
|
+
settings.get("localization", "timezone", sctx).catch(() => void 0),
|
|
5003
|
+
settings.get("localization", "locale", sctx).catch(() => void 0),
|
|
5004
|
+
settings.get("localization", "currency", sctx).catch(() => void 0)
|
|
4717
5005
|
]);
|
|
4718
5006
|
}
|
|
4719
5007
|
const tz = coerceTimeZone(tzRes?.value);
|
|
4720
5008
|
const locale = coerceLocale(localeRes?.value);
|
|
4721
5009
|
const currency = coerceCurrency(currencyRes?.value);
|
|
4722
5010
|
if (tz || locale || currency) {
|
|
4723
|
-
return { value: { timezone: tz ?? "UTC", locale: locale ?? "en-US", currency },
|
|
5011
|
+
return { value: { timezone: tz ?? "UTC", locale: locale ?? "en-US", currency }, backendFailed: false };
|
|
4724
5012
|
}
|
|
4725
5013
|
}
|
|
4726
5014
|
} catch {
|
|
4727
|
-
failed = true;
|
|
4728
5015
|
}
|
|
4729
5016
|
let rows = [];
|
|
4730
5017
|
if (ql && typeof ql.find === "function") {
|
|
@@ -4737,7 +5024,7 @@ async function resolveLocalizationContextUncached(input) {
|
|
|
4737
5024
|
if (result && result.value) result = result.value;
|
|
4738
5025
|
rows = Array.isArray(result) ? result : [];
|
|
4739
5026
|
} catch {
|
|
4740
|
-
|
|
5027
|
+
backendFailed = true;
|
|
4741
5028
|
}
|
|
4742
5029
|
}
|
|
4743
5030
|
const valueOf = (k) => rows.find((r) => r.key === k)?.value;
|
|
@@ -4747,7 +5034,7 @@ async function resolveLocalizationContextUncached(input) {
|
|
|
4747
5034
|
locale: coerceLocale(valueOf("locale")) ?? "en-US",
|
|
4748
5035
|
currency: coerceCurrency(valueOf("currency"))
|
|
4749
5036
|
},
|
|
4750
|
-
|
|
5037
|
+
backendFailed
|
|
4751
5038
|
};
|
|
4752
5039
|
}
|
|
4753
5040
|
|
|
@@ -4909,7 +5196,7 @@ function shouldDenyAnonymous(input) {
|
|
|
4909
5196
|
var ADMIN_STANDING_SURFACE = {
|
|
4910
5197
|
sys_permission_set: {
|
|
4911
5198
|
role: "derives",
|
|
4912
|
-
reason: "The row `
|
|
5199
|
+
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.",
|
|
4913
5200
|
columns: [
|
|
4914
5201
|
"id",
|
|
4915
5202
|
"name",
|
|
@@ -4951,8 +5238,14 @@ var ADMIN_STANDING_SURFACE = {
|
|
|
4951
5238
|
]
|
|
4952
5239
|
},
|
|
4953
5240
|
sys_user: {
|
|
4954
|
-
role: "
|
|
4955
|
-
reason: "
|
|
5241
|
+
role: "derives",
|
|
5242
|
+
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.",
|
|
5243
|
+
columns: [
|
|
5244
|
+
"id",
|
|
5245
|
+
"email",
|
|
5246
|
+
"email_verified",
|
|
5247
|
+
"ai_access"
|
|
5248
|
+
]
|
|
4956
5249
|
},
|
|
4957
5250
|
sys_user_position: {
|
|
4958
5251
|
role: "reads-only",
|
|
@@ -4967,6 +5260,13 @@ var ADMIN_STANDING_SURFACE = {
|
|
|
4967
5260
|
reason: "Position-bound permission sets (\xA76a). Contributes ids to `psIds` \u2014 and therefore names to `permissions` \u2014 but not to `unscopedUserPsIds`, which is the set \xA76b tests for platform-admin standing."
|
|
4968
5261
|
}
|
|
4969
5262
|
};
|
|
5263
|
+
var ADMIN_STANDING_NON_TABLE_INPUTS = [
|
|
5264
|
+
{
|
|
5265
|
+
kind: "env",
|
|
5266
|
+
name: "OS_PLATFORM_OWNER_EMAIL",
|
|
5267
|
+
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."
|
|
5268
|
+
}
|
|
5269
|
+
];
|
|
4970
5270
|
function adminStandingTables() {
|
|
4971
5271
|
return Object.entries(ADMIN_STANDING_SURFACE).filter(([, t]) => t.role === "derives").map(([name]) => name).sort();
|
|
4972
5272
|
}
|
|
@@ -4998,6 +5298,9 @@ function withoutOperationPrivateKeys(exec) {
|
|
|
4998
5298
|
return out;
|
|
4999
5299
|
}
|
|
5000
5300
|
|
|
5301
|
+
// src/security/authz-invalidation-channel.ts
|
|
5302
|
+
var AUTHZ_INVALIDATED_CHANNEL = "authz.invalidated";
|
|
5303
|
+
|
|
5001
5304
|
// src/utils/datetime.ts
|
|
5002
5305
|
import { nextUtcCalendarDay, utcInstantMs } from "@objectstack/spec/data";
|
|
5003
5306
|
function calendarPartsInTz(d, tz) {
|
|
@@ -5278,7 +5581,7 @@ function omitInternalFieldsFromWriteResponse(schema, records) {
|
|
|
5278
5581
|
}
|
|
5279
5582
|
|
|
5280
5583
|
// src/utils/migration-journal.ts
|
|
5281
|
-
import { createHash as
|
|
5584
|
+
import { createHash as createHash3, randomUUID } from "crypto";
|
|
5282
5585
|
import {
|
|
5283
5586
|
MIGRATION_JOURNAL_OBJECT
|
|
5284
5587
|
} from "@objectstack/spec/system";
|
|
@@ -5335,7 +5638,7 @@ function hashMigrationPlan(plan, chunks) {
|
|
|
5335
5638
|
steps: plan.steps.map((s) => s.name),
|
|
5336
5639
|
chunks: chunks.map((c) => [c.stepIndex, c.offset, c.length])
|
|
5337
5640
|
});
|
|
5338
|
-
return
|
|
5641
|
+
return createHash3("sha256").update(shape, "utf8").digest("hex").slice(0, 32);
|
|
5339
5642
|
}
|
|
5340
5643
|
async function appendEvent(engine, event, execContext) {
|
|
5341
5644
|
await engine.insert(
|
|
@@ -5666,6 +5969,52 @@ function errText(err) {
|
|
|
5666
5969
|
}
|
|
5667
5970
|
}
|
|
5668
5971
|
|
|
5972
|
+
// src/utils/advisory-aggregation.ts
|
|
5973
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
5974
|
+
var ADVISORY_SAMPLE_ROWS = 5;
|
|
5975
|
+
var storage = new AsyncLocalStorage();
|
|
5976
|
+
function keyOf(object, rule) {
|
|
5977
|
+
return JSON.stringify([object, rule]);
|
|
5978
|
+
}
|
|
5979
|
+
function recordAdvisoryHit(hit) {
|
|
5980
|
+
const collector = storage.getStore();
|
|
5981
|
+
if (!collector) return false;
|
|
5982
|
+
const key = keyOf(hit.object, hit.rule);
|
|
5983
|
+
const existing = collector.groups.get(key);
|
|
5984
|
+
if (existing) {
|
|
5985
|
+
existing.rows += 1;
|
|
5986
|
+
if (hit.recordRef != null && existing.sampleRows.length < ADVISORY_SAMPLE_ROWS) {
|
|
5987
|
+
existing.sampleRows.push(hit.recordRef);
|
|
5988
|
+
}
|
|
5989
|
+
return true;
|
|
5990
|
+
}
|
|
5991
|
+
collector.groups.set(key, {
|
|
5992
|
+
object: hit.object,
|
|
5993
|
+
rule: hit.rule,
|
|
5994
|
+
severity: hit.severity,
|
|
5995
|
+
message: hit.message,
|
|
5996
|
+
rows: 1,
|
|
5997
|
+
sampleRows: hit.recordRef != null ? [hit.recordRef] : []
|
|
5998
|
+
});
|
|
5999
|
+
return true;
|
|
6000
|
+
}
|
|
6001
|
+
function isAggregatingAdvisories() {
|
|
6002
|
+
return storage.getStore() !== void 0;
|
|
6003
|
+
}
|
|
6004
|
+
async function runWithAdvisoryAggregation(fn, report) {
|
|
6005
|
+
const collector = { groups: /* @__PURE__ */ new Map() };
|
|
6006
|
+
try {
|
|
6007
|
+
return await storage.run(collector, fn);
|
|
6008
|
+
} finally {
|
|
6009
|
+
if (collector.groups.size > 0) {
|
|
6010
|
+
try {
|
|
6011
|
+
report([...collector.groups.values()]);
|
|
6012
|
+
} catch {
|
|
6013
|
+
}
|
|
6014
|
+
}
|
|
6015
|
+
}
|
|
6016
|
+
}
|
|
6017
|
+
|
|
5669
6018
|
// src/utils/filter-tokens.ts
|
|
5670
6019
|
import {
|
|
5671
6020
|
classifyFilterToken,
|
|
@@ -5903,6 +6252,110 @@ function isUninterpretableTemporalComparand(kind, value) {
|
|
|
5903
6252
|
return !(readsAsWallClock(s) || readsAsInstant(s));
|
|
5904
6253
|
}
|
|
5905
6254
|
|
|
6255
|
+
// src/utils/metadata-activation-store.ts
|
|
6256
|
+
var METADATA_ACTIVATION_TABLE = "sys_metadata_activation";
|
|
6257
|
+
var SYSTEM_CTX2 = { isSystem: true, positions: [], permissions: [] };
|
|
6258
|
+
var InMemoryMetadataActivationStore = class {
|
|
6259
|
+
constructor() {
|
|
6260
|
+
this.rows = /* @__PURE__ */ new Map();
|
|
6261
|
+
}
|
|
6262
|
+
async list() {
|
|
6263
|
+
return [...this.rows.values()];
|
|
6264
|
+
}
|
|
6265
|
+
async setActive(row) {
|
|
6266
|
+
this.rows.set(row.name, { ...row });
|
|
6267
|
+
}
|
|
6268
|
+
};
|
|
6269
|
+
var ObjectStoreMetadataActivationStore = class {
|
|
6270
|
+
constructor(engine, metadataType) {
|
|
6271
|
+
this.engine = engine;
|
|
6272
|
+
this.metadataType = metadataType;
|
|
6273
|
+
}
|
|
6274
|
+
/**
|
|
6275
|
+
* Every row of this type. Read once at boot to hydrate the consumer's
|
|
6276
|
+
* projection.
|
|
6277
|
+
*
|
|
6278
|
+
* The only scoping is the `metadata_type` discriminator: the ledger is
|
|
6279
|
+
* deployment-wide and has no tenant column, so there is no second axis to
|
|
6280
|
+
* filter on (see the module header).
|
|
6281
|
+
*/
|
|
6282
|
+
async list() {
|
|
6283
|
+
const rows = await this.engine.find(METADATA_ACTIVATION_TABLE, {
|
|
6284
|
+
where: { metadata_type: this.metadataType },
|
|
6285
|
+
context: SYSTEM_CTX2
|
|
6286
|
+
});
|
|
6287
|
+
if (!Array.isArray(rows)) return [];
|
|
6288
|
+
const out = [];
|
|
6289
|
+
for (const row of rows) {
|
|
6290
|
+
const r = row;
|
|
6291
|
+
if (typeof r.name !== "string" || !r.name) continue;
|
|
6292
|
+
out.push({
|
|
6293
|
+
name: r.name,
|
|
6294
|
+
packageId: typeof r.package_id === "string" ? r.package_id : "",
|
|
6295
|
+
// The column defaults to `true`; only an explicit `false`
|
|
6296
|
+
// disarms. A driver that round-trips booleans as 0/1
|
|
6297
|
+
// (SQLite/libsql) is read through the same `=== false || === 0`
|
|
6298
|
+
// test, so a `0` is not mistaken for `true`.
|
|
6299
|
+
active: !(r.active === false || r.active === 0)
|
|
6300
|
+
});
|
|
6301
|
+
}
|
|
6302
|
+
return out;
|
|
6303
|
+
}
|
|
6304
|
+
/**
|
|
6305
|
+
* Insert or update the row for one packaged artifact.
|
|
6306
|
+
*
|
|
6307
|
+
* Read-then-write rather than a blind upsert because the object's
|
|
6308
|
+
* uniqueness is a DECLARED index (`unique: 'global'` over
|
|
6309
|
+
* `(metadata_type, name)`), not a primary key this store controls: there is
|
|
6310
|
+
* no id to collide on, so an insert-and-catch could not tell "already
|
|
6311
|
+
* there" from a real store failure.
|
|
6312
|
+
*
|
|
6313
|
+
* That index is also why taking the FIRST match is taking the only one: the
|
|
6314
|
+
* read below is keyed on exactly the index's two columns, so it can match
|
|
6315
|
+
* at most one row. It used to pick the first row with a NULL organization
|
|
6316
|
+
* out of the result, back when the table carried a reserved tenant column;
|
|
6317
|
+
* with no such column the set it was choosing from can no longer hold more
|
|
6318
|
+
* than one member.
|
|
6319
|
+
*/
|
|
6320
|
+
async setActive(row) {
|
|
6321
|
+
const existing = await this.engine.find(METADATA_ACTIVATION_TABLE, {
|
|
6322
|
+
where: { metadata_type: this.metadataType, name: row.name },
|
|
6323
|
+
context: SYSTEM_CTX2
|
|
6324
|
+
});
|
|
6325
|
+
const current = Array.isArray(existing) ? existing[0] : void 0;
|
|
6326
|
+
if (current && current.id != null) {
|
|
6327
|
+
await this.engine.update(
|
|
6328
|
+
METADATA_ACTIVATION_TABLE,
|
|
6329
|
+
{ id: current.id, active: row.active, package_id: row.packageId },
|
|
6330
|
+
{ context: SYSTEM_CTX2 }
|
|
6331
|
+
);
|
|
6332
|
+
return;
|
|
6333
|
+
}
|
|
6334
|
+
await this.engine.insert(
|
|
6335
|
+
METADATA_ACTIVATION_TABLE,
|
|
6336
|
+
{
|
|
6337
|
+
metadata_type: this.metadataType,
|
|
6338
|
+
name: row.name,
|
|
6339
|
+
package_id: row.packageId,
|
|
6340
|
+
active: row.active
|
|
6341
|
+
},
|
|
6342
|
+
{ context: SYSTEM_CTX2 }
|
|
6343
|
+
);
|
|
6344
|
+
}
|
|
6345
|
+
/**
|
|
6346
|
+
* Read the backing table once so a misconfiguration surfaces at BOOT
|
|
6347
|
+
* rather than as a failed toggle later. Throws the driver error verbatim —
|
|
6348
|
+
* `no such table: sys_metadata_activation` means the object was never
|
|
6349
|
+
* registered (or its schema never synced) in this composition.
|
|
6350
|
+
*
|
|
6351
|
+
* ⚠️ Unscoped by design: the question is "does the TABLE read at all",
|
|
6352
|
+
* which is a property of the composition, not of one `metadata_type`.
|
|
6353
|
+
*/
|
|
6354
|
+
async probe() {
|
|
6355
|
+
await this.engine.find(METADATA_ACTIVATION_TABLE, { where: {}, limit: 1, context: SYSTEM_CTX2 });
|
|
6356
|
+
}
|
|
6357
|
+
};
|
|
6358
|
+
|
|
5906
6359
|
// src/utils/record-not-found.ts
|
|
5907
6360
|
function recordNotFoundError(object, id) {
|
|
5908
6361
|
const err = new Error(`Record ${id} not found in ${object}`);
|
|
@@ -5913,6 +6366,45 @@ function recordNotFoundError(object, id) {
|
|
|
5913
6366
|
}
|
|
5914
6367
|
|
|
5915
6368
|
// src/health-monitor.ts
|
|
6369
|
+
var RECOVERY_IS_THRESHOLD_GATED = {
|
|
6370
|
+
degraded: true,
|
|
6371
|
+
unhealthy: true,
|
|
6372
|
+
failed: true,
|
|
6373
|
+
recovering: true,
|
|
6374
|
+
healthy: false,
|
|
6375
|
+
unknown: false
|
|
6376
|
+
};
|
|
6377
|
+
function healthMonitorRefusal(message) {
|
|
6378
|
+
const err = new Error(message);
|
|
6379
|
+
err.code = "VALIDATION_ERROR";
|
|
6380
|
+
err.status = 400;
|
|
6381
|
+
return err;
|
|
6382
|
+
}
|
|
6383
|
+
var RETIRED_HEALTH_CHECK_KEYS = [
|
|
6384
|
+
[
|
|
6385
|
+
"autoRestart",
|
|
6386
|
+
"'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."
|
|
6387
|
+
],
|
|
6388
|
+
[
|
|
6389
|
+
"maxRestartAttempts",
|
|
6390
|
+
"'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."
|
|
6391
|
+
],
|
|
6392
|
+
[
|
|
6393
|
+
"restartBackoff",
|
|
6394
|
+
"'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."
|
|
6395
|
+
]
|
|
6396
|
+
];
|
|
6397
|
+
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.";
|
|
6398
|
+
function assertNoRetiredKeys(pluginName, config) {
|
|
6399
|
+
for (const [key, guidance] of RETIRED_HEALTH_CHECK_KEYS) {
|
|
6400
|
+
if (!Object.prototype.hasOwnProperty.call(config, key)) {
|
|
6401
|
+
continue;
|
|
6402
|
+
}
|
|
6403
|
+
throw healthMonitorRefusal(
|
|
6404
|
+
`[HealthMonitor] Plugin '${pluginName}': ${guidance}${RESTART_IS_THE_HOSTS_JOB}`
|
|
6405
|
+
);
|
|
6406
|
+
}
|
|
6407
|
+
}
|
|
5916
6408
|
var PluginHealthMonitor = class {
|
|
5917
6409
|
constructor(logger) {
|
|
5918
6410
|
this.healthChecks = /* @__PURE__ */ new Map();
|
|
@@ -5921,18 +6413,17 @@ var PluginHealthMonitor = class {
|
|
|
5921
6413
|
this.checkIntervals = /* @__PURE__ */ new Map();
|
|
5922
6414
|
this.failureCounters = /* @__PURE__ */ new Map();
|
|
5923
6415
|
this.successCounters = /* @__PURE__ */ new Map();
|
|
5924
|
-
this.restartAttempts = /* @__PURE__ */ new Map();
|
|
5925
6416
|
this.logger = logger.child({ component: "HealthMonitor" });
|
|
5926
6417
|
}
|
|
5927
6418
|
/**
|
|
5928
6419
|
* Register a plugin for health monitoring
|
|
5929
6420
|
*/
|
|
5930
6421
|
registerPlugin(pluginName, config) {
|
|
6422
|
+
assertNoRetiredKeys(pluginName, config);
|
|
5931
6423
|
this.healthChecks.set(pluginName, config);
|
|
5932
6424
|
this.healthStatus.set(pluginName, "unknown");
|
|
5933
6425
|
this.failureCounters.set(pluginName, 0);
|
|
5934
6426
|
this.successCounters.set(pluginName, 0);
|
|
5935
|
-
this.restartAttempts.set(pluginName, 0);
|
|
5936
6427
|
this.logger.info("Plugin registered for health monitoring", {
|
|
5937
6428
|
plugin: pluginName,
|
|
5938
6429
|
interval: config.interval
|
|
@@ -5984,6 +6475,7 @@ var PluginHealthMonitor = class {
|
|
|
5984
6475
|
let status = "healthy";
|
|
5985
6476
|
let message;
|
|
5986
6477
|
const checks = [];
|
|
6478
|
+
let failureRoute;
|
|
5987
6479
|
try {
|
|
5988
6480
|
if (config.checkMethod && typeof plugin[config.checkMethod] === "function") {
|
|
5989
6481
|
const checkResult = await this.raceCheckTimeout(
|
|
@@ -6004,8 +6496,8 @@ var PluginHealthMonitor = class {
|
|
|
6004
6496
|
if (status === "healthy") {
|
|
6005
6497
|
this.successCounters.set(pluginName, (this.successCounters.get(pluginName) || 0) + 1);
|
|
6006
6498
|
this.failureCounters.set(pluginName, 0);
|
|
6007
|
-
const currentStatus = this.healthStatus.get(pluginName);
|
|
6008
|
-
if (currentStatus
|
|
6499
|
+
const currentStatus = this.healthStatus.get(pluginName) ?? "unknown";
|
|
6500
|
+
if (RECOVERY_IS_THRESHOLD_GATED[currentStatus]) {
|
|
6009
6501
|
const successCount = this.successCounters.get(pluginName) || 0;
|
|
6010
6502
|
if (successCount >= config.successThreshold) {
|
|
6011
6503
|
this.healthStatus.set(pluginName, "healthy");
|
|
@@ -6017,27 +6509,11 @@ var PluginHealthMonitor = class {
|
|
|
6017
6509
|
this.healthStatus.set(pluginName, "healthy");
|
|
6018
6510
|
}
|
|
6019
6511
|
} else {
|
|
6020
|
-
|
|
6021
|
-
this.successCounters.set(pluginName, 0);
|
|
6022
|
-
const failureCount = this.failureCounters.get(pluginName) || 0;
|
|
6023
|
-
if (failureCount >= config.failureThreshold) {
|
|
6024
|
-
this.healthStatus.set(pluginName, "unhealthy");
|
|
6025
|
-
this.logger.warn("Plugin marked as unhealthy", {
|
|
6026
|
-
plugin: pluginName,
|
|
6027
|
-
failures: failureCount
|
|
6028
|
-
});
|
|
6029
|
-
if (config.autoRestart) {
|
|
6030
|
-
await this.attemptRestart(pluginName, plugin, config);
|
|
6031
|
-
}
|
|
6032
|
-
} else {
|
|
6033
|
-
this.healthStatus.set(pluginName, "degraded");
|
|
6034
|
-
}
|
|
6512
|
+
failureRoute = "returned";
|
|
6035
6513
|
}
|
|
6036
6514
|
} catch (error) {
|
|
6037
6515
|
status = "failed";
|
|
6038
6516
|
message = error instanceof Error ? error.message : "Unknown error";
|
|
6039
|
-
this.failureCounters.set(pluginName, (this.failureCounters.get(pluginName) || 0) + 1);
|
|
6040
|
-
this.healthStatus.set(pluginName, "failed");
|
|
6041
6517
|
checks.push({
|
|
6042
6518
|
name: "health-check",
|
|
6043
6519
|
status: "failed",
|
|
@@ -6047,69 +6523,59 @@ var PluginHealthMonitor = class {
|
|
|
6047
6523
|
plugin: pluginName,
|
|
6048
6524
|
error
|
|
6049
6525
|
});
|
|
6526
|
+
failureRoute = "thrown";
|
|
6527
|
+
}
|
|
6528
|
+
if (failureRoute) {
|
|
6529
|
+
this.recordFailedRound(pluginName, config, failureRoute);
|
|
6050
6530
|
}
|
|
6051
6531
|
const report = {
|
|
6052
6532
|
status: this.healthStatus.get(pluginName) || "unknown",
|
|
6053
6533
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
6054
6534
|
message,
|
|
6055
6535
|
metrics: {
|
|
6056
|
-
|
|
6536
|
+
uptimeMs: Date.now() - startTime
|
|
6057
6537
|
},
|
|
6058
6538
|
checks: checks.length > 0 ? checks : void 0
|
|
6059
6539
|
};
|
|
6060
6540
|
this.healthReports.set(pluginName, report);
|
|
6061
6541
|
}
|
|
6062
6542
|
/**
|
|
6063
|
-
*
|
|
6543
|
+
* Handle one failed round — the single path BOTH failure routes take.
|
|
6544
|
+
*
|
|
6545
|
+
* `performHealthCheck` can fail two disjoint ways: the check *returns* a
|
|
6546
|
+
* failure (`false` or `{ status: 'unhealthy' }`), or it *throws* — which by
|
|
6547
|
+
* `raceCheckTimeout` includes every `timeout` overrun, the severest case of
|
|
6548
|
+
* the two. The routes used to be handled in separate blocks, and only the
|
|
6549
|
+
* returned one cleared `successCounters`, so the counters a declared
|
|
6550
|
+
* `failureThreshold` / `successThreshold` are counted with depended on which
|
|
6551
|
+
* way the round happened to fail (#11852).
|
|
6552
|
+
*
|
|
6553
|
+
* What stays route-specific is the *status label*, deliberately. A throw is
|
|
6554
|
+
* the separate `failed` status applied immediately with no threshold — that
|
|
6555
|
+
* is the documented contract (`content/docs/protocol/kernel/lifecycle.mdx`,
|
|
6556
|
+
* "Custom Health Checks") and is pinned by the timeout test. Only the
|
|
6557
|
+
* counters are shared, because that is what `failureThreshold` declares, and
|
|
6558
|
+
* it does not name a route.
|
|
6559
|
+
*
|
|
6560
|
+
* This round ENDS here. Nothing is done TO the plugin — see the #12032 note
|
|
6561
|
+
* on the class: a monitor that cannot re-initialise a plugin has no business
|
|
6562
|
+
* destroying one.
|
|
6064
6563
|
*/
|
|
6065
|
-
|
|
6066
|
-
const
|
|
6067
|
-
|
|
6068
|
-
|
|
6069
|
-
|
|
6070
|
-
|
|
6071
|
-
});
|
|
6564
|
+
recordFailedRound(pluginName, config, route) {
|
|
6565
|
+
const failureCount = (this.failureCounters.get(pluginName) || 0) + 1;
|
|
6566
|
+
this.failureCounters.set(pluginName, failureCount);
|
|
6567
|
+
this.successCounters.set(pluginName, 0);
|
|
6568
|
+
const thresholdReached = failureCount >= config.failureThreshold;
|
|
6569
|
+
if (route === "thrown") {
|
|
6072
6570
|
this.healthStatus.set(pluginName, "failed");
|
|
6073
|
-
|
|
6074
|
-
|
|
6075
|
-
|
|
6076
|
-
const delay = this.calculateBackoff(attempts, config.restartBackoff);
|
|
6077
|
-
this.logger.info("Scheduling plugin restart", {
|
|
6078
|
-
plugin: pluginName,
|
|
6079
|
-
attempt: attempts + 1,
|
|
6080
|
-
delay
|
|
6081
|
-
});
|
|
6082
|
-
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
6083
|
-
try {
|
|
6084
|
-
if (plugin.destroy) {
|
|
6085
|
-
await plugin.destroy();
|
|
6086
|
-
}
|
|
6087
|
-
this.logger.info("Plugin restarted", { plugin: pluginName });
|
|
6088
|
-
this.failureCounters.set(pluginName, 0);
|
|
6089
|
-
this.successCounters.set(pluginName, 0);
|
|
6090
|
-
this.healthStatus.set(pluginName, "recovering");
|
|
6091
|
-
} catch (error) {
|
|
6092
|
-
this.logger.error("Plugin restart failed", {
|
|
6571
|
+
} else if (thresholdReached) {
|
|
6572
|
+
this.healthStatus.set(pluginName, "unhealthy");
|
|
6573
|
+
this.logger.warn("Plugin marked as unhealthy", {
|
|
6093
6574
|
plugin: pluginName,
|
|
6094
|
-
|
|
6575
|
+
failures: failureCount
|
|
6095
6576
|
});
|
|
6096
|
-
|
|
6097
|
-
|
|
6098
|
-
}
|
|
6099
|
-
/**
|
|
6100
|
-
* Calculate backoff delay for restarts
|
|
6101
|
-
*/
|
|
6102
|
-
calculateBackoff(attempt, strategy) {
|
|
6103
|
-
const baseDelay = 1e3;
|
|
6104
|
-
switch (strategy) {
|
|
6105
|
-
case "fixed":
|
|
6106
|
-
return baseDelay;
|
|
6107
|
-
case "linear":
|
|
6108
|
-
return baseDelay * (attempt + 1);
|
|
6109
|
-
case "exponential":
|
|
6110
|
-
return baseDelay * Math.pow(2, attempt);
|
|
6111
|
-
default:
|
|
6112
|
-
return baseDelay;
|
|
6577
|
+
} else {
|
|
6578
|
+
this.healthStatus.set(pluginName, "degraded");
|
|
6113
6579
|
}
|
|
6114
6580
|
}
|
|
6115
6581
|
/**
|
|
@@ -6142,7 +6608,6 @@ var PluginHealthMonitor = class {
|
|
|
6142
6608
|
this.healthReports.clear();
|
|
6143
6609
|
this.failureCounters.clear();
|
|
6144
6610
|
this.successCounters.clear();
|
|
6145
|
-
this.restartAttempts.clear();
|
|
6146
6611
|
this.logger.info("Health monitor shutdown complete");
|
|
6147
6612
|
}
|
|
6148
6613
|
/**
|
|
@@ -6183,7 +6648,7 @@ var PluginHealthMonitor = class {
|
|
|
6183
6648
|
};
|
|
6184
6649
|
|
|
6185
6650
|
// src/hot-reload.ts
|
|
6186
|
-
import { createHash as
|
|
6651
|
+
import { createHash as createHash4 } from "crypto";
|
|
6187
6652
|
var generateUUID = () => {
|
|
6188
6653
|
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
|
6189
6654
|
return crypto.randomUUID();
|
|
@@ -6194,6 +6659,42 @@ var generateUUID = () => {
|
|
|
6194
6659
|
return v.toString(16);
|
|
6195
6660
|
});
|
|
6196
6661
|
};
|
|
6662
|
+
var HONOURED_STATE_STRATEGIES = ["memory", "none"];
|
|
6663
|
+
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.";
|
|
6664
|
+
function hotReloadRefusal(message) {
|
|
6665
|
+
const err = new Error(message);
|
|
6666
|
+
err.code = "VALIDATION_ERROR";
|
|
6667
|
+
err.status = 400;
|
|
6668
|
+
return err;
|
|
6669
|
+
}
|
|
6670
|
+
function assertHonouredStateStrategy(pluginName, strategy) {
|
|
6671
|
+
if (HONOURED_STATE_STRATEGIES.includes(strategy)) {
|
|
6672
|
+
return;
|
|
6673
|
+
}
|
|
6674
|
+
const shown = typeof strategy === "string" ? `'${strategy}'` : String(strategy);
|
|
6675
|
+
const retired = strategy === "disk" || strategy === "distributed";
|
|
6676
|
+
throw hotReloadRefusal(
|
|
6677
|
+
`[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.")
|
|
6678
|
+
);
|
|
6679
|
+
}
|
|
6680
|
+
var RETIRED_HOT_RELOAD_KEYS = [
|
|
6681
|
+
[
|
|
6682
|
+
"distributedConfig",
|
|
6683
|
+
"'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."
|
|
6684
|
+
],
|
|
6685
|
+
[
|
|
6686
|
+
"watchPatterns",
|
|
6687
|
+
"'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."
|
|
6688
|
+
]
|
|
6689
|
+
];
|
|
6690
|
+
function assertNoRetiredKeys2(pluginName, config) {
|
|
6691
|
+
for (const [key, guidance] of RETIRED_HOT_RELOAD_KEYS) {
|
|
6692
|
+
if (!Object.prototype.hasOwnProperty.call(config, key)) {
|
|
6693
|
+
continue;
|
|
6694
|
+
}
|
|
6695
|
+
throw hotReloadRefusal(`[HotReload] Plugin '${pluginName}': ${guidance}`);
|
|
6696
|
+
}
|
|
6697
|
+
}
|
|
6197
6698
|
var PluginStateManager = class {
|
|
6198
6699
|
constructor(logger) {
|
|
6199
6700
|
this.stateSnapshots = /* @__PURE__ */ new Map();
|
|
@@ -6220,17 +6721,6 @@ var PluginStateManager = class {
|
|
|
6220
6721
|
this.memoryStore.set(snapshotId, snapshot);
|
|
6221
6722
|
this.logger.debug("State saved to memory", { pluginId, snapshotId });
|
|
6222
6723
|
break;
|
|
6223
|
-
case "disk":
|
|
6224
|
-
this.memoryStore.set(snapshotId, snapshot);
|
|
6225
|
-
this.logger.debug("State saved to disk (memory fallback)", { pluginId, snapshotId });
|
|
6226
|
-
break;
|
|
6227
|
-
case "distributed":
|
|
6228
|
-
this.memoryStore.set(snapshotId, snapshot);
|
|
6229
|
-
this.logger.debug("State saved to distributed store (memory fallback)", {
|
|
6230
|
-
pluginId,
|
|
6231
|
-
snapshotId
|
|
6232
|
-
});
|
|
6233
|
-
break;
|
|
6234
6724
|
case "none":
|
|
6235
6725
|
this.logger.debug("State persistence disabled", { pluginId });
|
|
6236
6726
|
break;
|
|
@@ -6278,7 +6768,7 @@ var PluginStateManager = class {
|
|
|
6278
6768
|
*/
|
|
6279
6769
|
calculateChecksum(state) {
|
|
6280
6770
|
const stateStr = JSON.stringify(state);
|
|
6281
|
-
return
|
|
6771
|
+
return createHash4("sha256").update(stateStr).digest("hex");
|
|
6282
6772
|
}
|
|
6283
6773
|
/**
|
|
6284
6774
|
* Shutdown state manager
|
|
@@ -6292,7 +6782,6 @@ var PluginStateManager = class {
|
|
|
6292
6782
|
var HotReloadManager = class {
|
|
6293
6783
|
constructor(logger) {
|
|
6294
6784
|
this.reloadConfigs = /* @__PURE__ */ new Map();
|
|
6295
|
-
this.watchHandles = /* @__PURE__ */ new Map();
|
|
6296
6785
|
this.reloadTimers = /* @__PURE__ */ new Map();
|
|
6297
6786
|
this.logger = logger.child({ component: "HotReload" });
|
|
6298
6787
|
this.stateManager = new PluginStateManager(logger);
|
|
@@ -6301,6 +6790,8 @@ var HotReloadManager = class {
|
|
|
6301
6790
|
* Register a plugin for hot reload
|
|
6302
6791
|
*/
|
|
6303
6792
|
registerPlugin(pluginName, config) {
|
|
6793
|
+
assertHonouredStateStrategy(pluginName, config.stateStrategy);
|
|
6794
|
+
assertNoRetiredKeys2(pluginName, config);
|
|
6304
6795
|
if (!config.enabled) {
|
|
6305
6796
|
this.logger.debug("Hot reload disabled for plugin", { plugin: pluginName });
|
|
6306
6797
|
return;
|
|
@@ -6308,32 +6799,45 @@ var HotReloadManager = class {
|
|
|
6308
6799
|
this.reloadConfigs.set(pluginName, config);
|
|
6309
6800
|
this.logger.info("Plugin registered for hot reload", {
|
|
6310
6801
|
plugin: pluginName,
|
|
6311
|
-
watchPatterns: config.watchPatterns,
|
|
6312
6802
|
stateStrategy: config.stateStrategy
|
|
6313
6803
|
});
|
|
6314
6804
|
}
|
|
6315
6805
|
/**
|
|
6316
|
-
*
|
|
6806
|
+
* Refuse the file-watching call this class never implemented (#12428).
|
|
6807
|
+
*
|
|
6808
|
+
* The body used to be a guard plus `logger.info('File watching started')`
|
|
6809
|
+
* over an in-source note saying real watching "would require chokidar or
|
|
6810
|
+
* similar". Nothing was ever watched, so an operator who set
|
|
6811
|
+
* `enabled: true` and read that line at INFO had been told the opposite of
|
|
6812
|
+
* the truth — positive confirmation of a capability that did not exist.
|
|
6813
|
+
* ADR-0049 leaves three states and this surface qualified for none of the
|
|
6814
|
+
* other two: no runtime composes this class, so ENFORCE would build for a
|
|
6815
|
+
* caller that does not exist, and no roadmap entry anywhere claims the
|
|
6816
|
+
* feature, so EXPERIMENTAL would be a promise nobody made.
|
|
6817
|
+
*
|
|
6818
|
+
* Kept as a throwing door rather than deleted: removing the method leaves a
|
|
6819
|
+
* JavaScript host a bare `TypeError: not a function` with no prescription,
|
|
6820
|
+
* and this is the one place a caller of the old placeholder is guaranteed
|
|
6821
|
+
* to arrive. The refusal carries an ADR-0112 envelope so it can be asserted
|
|
6822
|
+
* rather than merely caught.
|
|
6317
6823
|
*/
|
|
6318
6824
|
startWatching(pluginName) {
|
|
6319
|
-
|
|
6320
|
-
|
|
6321
|
-
|
|
6322
|
-
}
|
|
6323
|
-
this.logger.info("File watching started", {
|
|
6324
|
-
plugin: pluginName,
|
|
6325
|
-
patterns: config.watchPatterns
|
|
6326
|
-
});
|
|
6825
|
+
throw hotReloadRefusal(
|
|
6826
|
+
`[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.`
|
|
6827
|
+
);
|
|
6327
6828
|
}
|
|
6328
6829
|
/**
|
|
6329
|
-
*
|
|
6830
|
+
* Cancel a pending debounced reload for a plugin.
|
|
6831
|
+
*
|
|
6832
|
+
* The name is historical (#12428). This never stopped a watcher, because
|
|
6833
|
+
* nothing in this class ever started one: its `watchHandles` cleanup branch
|
|
6834
|
+
* read a Map that had no writer anywhere in the tree, so the branch was
|
|
6835
|
+
* structurally unreachable rather than merely untaken, and it left with
|
|
6836
|
+
* `startWatching`'s placeholder. What survives is the half that always did
|
|
6837
|
+
* something — the debounce timer armed by `scheduleReload` is cleared, so a
|
|
6838
|
+
* reload that was scheduled but has not fired yet is cancelled.
|
|
6330
6839
|
*/
|
|
6331
6840
|
stopWatching(pluginName) {
|
|
6332
|
-
const handle = this.watchHandles.get(pluginName);
|
|
6333
|
-
if (handle) {
|
|
6334
|
-
this.watchHandles.delete(pluginName);
|
|
6335
|
-
this.logger.info("File watching stopped", { plugin: pluginName });
|
|
6336
|
-
}
|
|
6337
6841
|
const timer = this.reloadTimers.get(pluginName);
|
|
6338
6842
|
if (timer) {
|
|
6339
6843
|
clearTimeout(timer);
|
|
@@ -6476,14 +6980,10 @@ var HotReloadManager = class {
|
|
|
6476
6980
|
* Shutdown hot reload manager
|
|
6477
6981
|
*/
|
|
6478
6982
|
shutdown() {
|
|
6479
|
-
for (const pluginName of this.watchHandles.keys()) {
|
|
6480
|
-
this.stopWatching(pluginName);
|
|
6481
|
-
}
|
|
6482
6983
|
for (const timer of this.reloadTimers.values()) {
|
|
6483
6984
|
clearTimeout(timer);
|
|
6484
6985
|
}
|
|
6485
6986
|
this.reloadConfigs.clear();
|
|
6486
|
-
this.watchHandles.clear();
|
|
6487
6987
|
this.reloadTimers.clear();
|
|
6488
6988
|
this.stateManager.shutdown();
|
|
6489
6989
|
this.logger.info("Hot reload manager shutdown complete");
|
|
@@ -6892,7 +7392,9 @@ var NamespaceResolver = class {
|
|
|
6892
7392
|
// src/index.ts
|
|
6893
7393
|
import { UNMATCHED_ROUTE_PATTERN } from "@objectstack/spec/contracts";
|
|
6894
7394
|
export {
|
|
7395
|
+
ADMIN_STANDING_NON_TABLE_INPUTS,
|
|
6895
7396
|
ADMIN_STANDING_SURFACE,
|
|
7397
|
+
ADVISORY_SAMPLE_ROWS,
|
|
6896
7398
|
ANONYMOUS_DENY_BODY,
|
|
6897
7399
|
ANONYMOUS_DENY_CODE,
|
|
6898
7400
|
ANONYMOUS_DENY_MESSAGE,
|
|
@@ -6900,11 +7402,19 @@ export {
|
|
|
6900
7402
|
API_KEY_PREFIX,
|
|
6901
7403
|
AUDIENCE_BINDING_SUGGESTION_STATUSES,
|
|
6902
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,
|
|
6903
7411
|
CORE_FALLBACK_FACTORIES,
|
|
6904
7412
|
DependencyResolver,
|
|
6905
7413
|
ENTRY_EXECUTION_CONTEXT_FIELDS,
|
|
6906
7414
|
HotReloadManager,
|
|
7415
|
+
InMemoryMetadataActivationStore,
|
|
6907
7416
|
LiteKernel,
|
|
7417
|
+
METADATA_ACTIVATION_TABLE,
|
|
6908
7418
|
MigrationJournalRefusal,
|
|
6909
7419
|
MigrationPlanRegistry,
|
|
6910
7420
|
NamespaceResolver,
|
|
@@ -6912,18 +7422,19 @@ export {
|
|
|
6912
7422
|
ObjectKernel,
|
|
6913
7423
|
ObjectKernelBase,
|
|
6914
7424
|
ObjectLogger,
|
|
7425
|
+
ObjectStoreMetadataActivationStore,
|
|
7426
|
+
PLATFORM_ADMIN_EMAIL_SEPARATOR,
|
|
6915
7427
|
POSTURE_INJECTION_RULE,
|
|
6916
7428
|
POSTURE_LADDER,
|
|
6917
7429
|
POSTURE_RANK,
|
|
6918
|
-
PluginConfigValidator,
|
|
6919
7430
|
PluginHealthMonitor,
|
|
6920
7431
|
PluginLoader,
|
|
6921
7432
|
PluginPermissionEnforcer,
|
|
6922
7433
|
PluginPermissionManager,
|
|
6923
7434
|
PluginSandboxRuntime,
|
|
6924
|
-
PluginSecurityScanner,
|
|
6925
7435
|
PluginSignatureVerifier,
|
|
6926
7436
|
qa_exports as QA,
|
|
7437
|
+
SERVICE_NOT_REGISTERED_CODE,
|
|
6927
7438
|
SIGNATURE_ALG,
|
|
6928
7439
|
SecurePluginContext,
|
|
6929
7440
|
SemanticVersionManager,
|
|
@@ -6933,6 +7444,7 @@ export {
|
|
|
6933
7444
|
UnresolvedFilterTokenError,
|
|
6934
7445
|
adminStandingColumns,
|
|
6935
7446
|
adminStandingTables,
|
|
7447
|
+
artifactPackageId,
|
|
6936
7448
|
assembleExecutionContext,
|
|
6937
7449
|
assembleExecutionContextOrGuest,
|
|
6938
7450
|
assertInitServiceRequirements,
|
|
@@ -6951,7 +7463,6 @@ export {
|
|
|
6951
7463
|
createMemoryJob,
|
|
6952
7464
|
createMemoryMetadata,
|
|
6953
7465
|
createMemoryQueue,
|
|
6954
|
-
createPluginConfigValidator,
|
|
6955
7466
|
createPluginPermissionEnforcer,
|
|
6956
7467
|
deepMerge,
|
|
6957
7468
|
defaultIsTransientError,
|
|
@@ -6963,48 +7474,70 @@ export {
|
|
|
6963
7474
|
extractApiKey,
|
|
6964
7475
|
filterTokenContextFrom,
|
|
6965
7476
|
findInterruptedRuns,
|
|
7477
|
+
formatIntegrityViolation,
|
|
6966
7478
|
generateApiKey,
|
|
6967
7479
|
generateEd25519KeyPair,
|
|
6968
7480
|
getEnv,
|
|
6969
7481
|
getMemoryUsage,
|
|
7482
|
+
hasPlatformAdminStanding,
|
|
6970
7483
|
hashApiKey,
|
|
6971
7484
|
hashMigrationPlan,
|
|
7485
|
+
isAggregatingAdvisories,
|
|
6972
7486
|
isAudienceBindingSuggestionStatus,
|
|
6973
7487
|
isAuthGateAllowlisted,
|
|
7488
|
+
isAuthzStoreUnavailableError,
|
|
7489
|
+
isConfiguredPlatformAdminEmail,
|
|
6974
7490
|
isExpired,
|
|
6975
7491
|
isGrantActive,
|
|
6976
7492
|
isGrantExpired,
|
|
6977
7493
|
isNode,
|
|
6978
7494
|
isRowActive,
|
|
7495
|
+
isServiceNotRegisteredError,
|
|
6979
7496
|
isUninterpretableTemporalComparand,
|
|
7497
|
+
matchesConfiguredPlatformAdmin,
|
|
6980
7498
|
nextUtcCalendarDay,
|
|
6981
7499
|
normalizeAuthGate,
|
|
7500
|
+
normalizePlatformAdminEmail,
|
|
6982
7501
|
omitInternalFieldsFromWriteResponse,
|
|
7502
|
+
parsePlatformAdminEmails,
|
|
6983
7503
|
parseScopes,
|
|
6984
7504
|
parseSignature,
|
|
6985
7505
|
planChunks,
|
|
6986
7506
|
postureVisibleRows,
|
|
6987
7507
|
readAuthoredTranslationLayer,
|
|
7508
|
+
readAuthzGrantsCacheTtlMs,
|
|
6988
7509
|
readRunJournal,
|
|
7510
|
+
recordAdvisoryHit,
|
|
6989
7511
|
recordNotFoundError,
|
|
7512
|
+
reportAuthzCachePosture,
|
|
7513
|
+
reportLegacyPlatformAdminGrant,
|
|
7514
|
+
resetLegacyPlatformAdminGrantReport,
|
|
7515
|
+
resetPlatformAdminEmailMemo,
|
|
6990
7516
|
resolveApiKeyAdmission,
|
|
6991
7517
|
resolveApiKeyPrincipal,
|
|
7518
|
+
resolveArtifactPackageOrder,
|
|
7519
|
+
resolveAuthzCachePosture,
|
|
6992
7520
|
resolveAuthzContext,
|
|
6993
7521
|
resolveFilterToken,
|
|
6994
7522
|
resolveFilterTokens,
|
|
6995
7523
|
resolveLocale,
|
|
6996
7524
|
resolveLocalizationContext,
|
|
7525
|
+
resolvePlatformAdminEmails,
|
|
6997
7526
|
resolvePluginOrder,
|
|
6998
7527
|
resolveUserAuthzGrants,
|
|
6999
7528
|
resumeMigrationJournal,
|
|
7529
|
+
rethrowAuthzStoreUnavailable,
|
|
7000
7530
|
runMigrationJournal,
|
|
7531
|
+
runWithAdvisoryAggregation,
|
|
7001
7532
|
safeExit,
|
|
7533
|
+
setPlatformAdminConfigSink,
|
|
7002
7534
|
shouldDenyAnonymous,
|
|
7003
7535
|
signPayload,
|
|
7004
7536
|
temporalComparandKind,
|
|
7005
7537
|
unknownAudienceBindingSuggestionStatusMessage,
|
|
7006
7538
|
utcInstantMs,
|
|
7007
7539
|
validateInitServiceContract,
|
|
7540
|
+
verifyIntegrity,
|
|
7008
7541
|
verifyPayload,
|
|
7009
7542
|
verifyPlatformSignature,
|
|
7010
7543
|
verifyPluginArtifact,
|