@objectstack/core 17.2.0 → 17.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1450 -0
- package/dist/index.cjs +862 -302
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1195 -115
- package/dist/index.d.ts +1195 -115
- package/dist/index.js +822 -291
- package/dist/index.js.map +1 -1
- package/package.json +8 -4
package/dist/index.cjs
CHANGED
|
@@ -30,6 +30,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
30
30
|
// src/index.ts
|
|
31
31
|
var index_exports = {};
|
|
32
32
|
__export(index_exports, {
|
|
33
|
+
ADMIN_STANDING_NON_TABLE_INPUTS: () => ADMIN_STANDING_NON_TABLE_INPUTS,
|
|
33
34
|
ADMIN_STANDING_SURFACE: () => ADMIN_STANDING_SURFACE,
|
|
34
35
|
ANONYMOUS_DENY_BODY: () => ANONYMOUS_DENY_BODY,
|
|
35
36
|
ANONYMOUS_DENY_CODE: () => ANONYMOUS_DENY_CODE,
|
|
@@ -38,11 +39,19 @@ __export(index_exports, {
|
|
|
38
39
|
API_KEY_PREFIX: () => API_KEY_PREFIX,
|
|
39
40
|
AUDIENCE_BINDING_SUGGESTION_STATUSES: () => AUDIENCE_BINDING_SUGGESTION_STATUSES,
|
|
40
41
|
AUDIENCE_BINDING_SUGGESTION_STATUS_VALUES: () => AUDIENCE_BINDING_SUGGESTION_STATUS_VALUES,
|
|
42
|
+
AUTHZ_GRANTS_CACHE_TTL_ENV: () => AUTHZ_GRANTS_CACHE_TTL_ENV,
|
|
43
|
+
AUTHZ_INVALIDATED_CHANNEL: () => AUTHZ_INVALIDATED_CHANNEL,
|
|
44
|
+
AUTHZ_STORE_UNAVAILABLE_CODE: () => AUTHZ_STORE_UNAVAILABLE_CODE,
|
|
45
|
+
AUTHZ_STORE_UNAVAILABLE_MESSAGE: () => AUTHZ_STORE_UNAVAILABLE_MESSAGE,
|
|
46
|
+
AUTHZ_STORE_UNAVAILABLE_STATUS: () => AUTHZ_STORE_UNAVAILABLE_STATUS,
|
|
47
|
+
AuthzStoreUnavailableError: () => AuthzStoreUnavailableError,
|
|
41
48
|
CORE_FALLBACK_FACTORIES: () => CORE_FALLBACK_FACTORIES,
|
|
42
49
|
DependencyResolver: () => DependencyResolver,
|
|
43
50
|
ENTRY_EXECUTION_CONTEXT_FIELDS: () => ENTRY_EXECUTION_CONTEXT_FIELDS,
|
|
44
51
|
HotReloadManager: () => HotReloadManager,
|
|
52
|
+
InMemoryMetadataActivationStore: () => InMemoryMetadataActivationStore,
|
|
45
53
|
LiteKernel: () => LiteKernel,
|
|
54
|
+
METADATA_ACTIVATION_TABLE: () => METADATA_ACTIVATION_TABLE,
|
|
46
55
|
MigrationJournalRefusal: () => MigrationJournalRefusal,
|
|
47
56
|
MigrationPlanRegistry: () => MigrationPlanRegistry,
|
|
48
57
|
NamespaceResolver: () => NamespaceResolver,
|
|
@@ -50,10 +59,11 @@ __export(index_exports, {
|
|
|
50
59
|
ObjectKernel: () => ObjectKernel,
|
|
51
60
|
ObjectKernelBase: () => ObjectKernelBase,
|
|
52
61
|
ObjectLogger: () => ObjectLogger,
|
|
62
|
+
ObjectStoreMetadataActivationStore: () => ObjectStoreMetadataActivationStore,
|
|
63
|
+
PLATFORM_ADMIN_EMAIL_SEPARATOR: () => PLATFORM_ADMIN_EMAIL_SEPARATOR,
|
|
53
64
|
POSTURE_INJECTION_RULE: () => POSTURE_INJECTION_RULE,
|
|
54
65
|
POSTURE_LADDER: () => POSTURE_LADDER,
|
|
55
66
|
POSTURE_RANK: () => POSTURE_RANK,
|
|
56
|
-
PluginConfigValidator: () => PluginConfigValidator,
|
|
57
67
|
PluginHealthMonitor: () => PluginHealthMonitor,
|
|
58
68
|
PluginLoader: () => PluginLoader,
|
|
59
69
|
PluginPermissionEnforcer: () => PluginPermissionEnforcer,
|
|
@@ -62,6 +72,7 @@ __export(index_exports, {
|
|
|
62
72
|
PluginSecurityScanner: () => PluginSecurityScanner,
|
|
63
73
|
PluginSignatureVerifier: () => PluginSignatureVerifier,
|
|
64
74
|
QA: () => qa_exports,
|
|
75
|
+
SERVICE_NOT_REGISTERED_CODE: () => SERVICE_NOT_REGISTERED_CODE,
|
|
65
76
|
SIGNATURE_ALG: () => SIGNATURE_ALG,
|
|
66
77
|
SecurePluginContext: () => SecurePluginContext,
|
|
67
78
|
SemanticVersionManager: () => SemanticVersionManager,
|
|
@@ -71,6 +82,7 @@ __export(index_exports, {
|
|
|
71
82
|
UnresolvedFilterTokenError: () => UnresolvedFilterTokenError,
|
|
72
83
|
adminStandingColumns: () => adminStandingColumns,
|
|
73
84
|
adminStandingTables: () => adminStandingTables,
|
|
85
|
+
artifactPackageId: () => artifactPackageId,
|
|
74
86
|
assembleExecutionContext: () => assembleExecutionContext,
|
|
75
87
|
assembleExecutionContextOrGuest: () => assembleExecutionContextOrGuest,
|
|
76
88
|
assertInitServiceRequirements: () => assertInitServiceRequirements,
|
|
@@ -89,7 +101,6 @@ __export(index_exports, {
|
|
|
89
101
|
createMemoryJob: () => createMemoryJob,
|
|
90
102
|
createMemoryMetadata: () => createMemoryMetadata,
|
|
91
103
|
createMemoryQueue: () => createMemoryQueue,
|
|
92
|
-
createPluginConfigValidator: () => createPluginConfigValidator,
|
|
93
104
|
createPluginPermissionEnforcer: () => createPluginPermissionEnforcer,
|
|
94
105
|
deepMerge: () => deepMerge,
|
|
95
106
|
defaultIsTransientError: () => defaultIsTransientError,
|
|
@@ -101,48 +112,67 @@ __export(index_exports, {
|
|
|
101
112
|
extractApiKey: () => extractApiKey,
|
|
102
113
|
filterTokenContextFrom: () => filterTokenContextFrom,
|
|
103
114
|
findInterruptedRuns: () => findInterruptedRuns,
|
|
115
|
+
formatIntegrityViolation: () => formatIntegrityViolation,
|
|
104
116
|
generateApiKey: () => generateApiKey,
|
|
105
117
|
generateEd25519KeyPair: () => generateEd25519KeyPair,
|
|
106
118
|
getEnv: () => getEnv,
|
|
107
119
|
getMemoryUsage: () => getMemoryUsage,
|
|
120
|
+
hasPlatformAdminStanding: () => hasPlatformAdminStanding,
|
|
108
121
|
hashApiKey: () => hashApiKey,
|
|
109
122
|
hashMigrationPlan: () => hashMigrationPlan,
|
|
110
123
|
isAudienceBindingSuggestionStatus: () => isAudienceBindingSuggestionStatus,
|
|
111
124
|
isAuthGateAllowlisted: () => isAuthGateAllowlisted,
|
|
125
|
+
isAuthzStoreUnavailableError: () => isAuthzStoreUnavailableError,
|
|
126
|
+
isConfiguredPlatformAdminEmail: () => isConfiguredPlatformAdminEmail,
|
|
112
127
|
isExpired: () => isExpired,
|
|
113
128
|
isGrantActive: () => isGrantActive,
|
|
114
129
|
isGrantExpired: () => isGrantExpired,
|
|
115
130
|
isNode: () => isNode,
|
|
116
131
|
isRowActive: () => isRowActive,
|
|
132
|
+
isServiceNotRegisteredError: () => isServiceNotRegisteredError,
|
|
117
133
|
isUninterpretableTemporalComparand: () => isUninterpretableTemporalComparand,
|
|
134
|
+
matchesConfiguredPlatformAdmin: () => matchesConfiguredPlatformAdmin,
|
|
118
135
|
nextUtcCalendarDay: () => import_data.nextUtcCalendarDay,
|
|
119
136
|
normalizeAuthGate: () => normalizeAuthGate,
|
|
137
|
+
normalizePlatformAdminEmail: () => normalizePlatformAdminEmail,
|
|
120
138
|
omitInternalFieldsFromWriteResponse: () => omitInternalFieldsFromWriteResponse,
|
|
139
|
+
parsePlatformAdminEmails: () => parsePlatformAdminEmails,
|
|
121
140
|
parseScopes: () => parseScopes,
|
|
122
141
|
parseSignature: () => parseSignature,
|
|
123
142
|
planChunks: () => planChunks,
|
|
124
143
|
postureVisibleRows: () => postureVisibleRows,
|
|
125
144
|
readAuthoredTranslationLayer: () => readAuthoredTranslationLayer,
|
|
145
|
+
readAuthzGrantsCacheTtlMs: () => readAuthzGrantsCacheTtlMs,
|
|
126
146
|
readRunJournal: () => readRunJournal,
|
|
127
147
|
recordNotFoundError: () => recordNotFoundError,
|
|
148
|
+
reportAuthzCachePosture: () => reportAuthzCachePosture,
|
|
149
|
+
reportLegacyPlatformAdminGrant: () => reportLegacyPlatformAdminGrant,
|
|
150
|
+
resetLegacyPlatformAdminGrantReport: () => resetLegacyPlatformAdminGrantReport,
|
|
151
|
+
resetPlatformAdminEmailMemo: () => resetPlatformAdminEmailMemo,
|
|
128
152
|
resolveApiKeyAdmission: () => resolveApiKeyAdmission,
|
|
129
153
|
resolveApiKeyPrincipal: () => resolveApiKeyPrincipal,
|
|
154
|
+
resolveArtifactPackageOrder: () => resolveArtifactPackageOrder,
|
|
155
|
+
resolveAuthzCachePosture: () => resolveAuthzCachePosture,
|
|
130
156
|
resolveAuthzContext: () => resolveAuthzContext,
|
|
131
157
|
resolveFilterToken: () => resolveFilterToken,
|
|
132
158
|
resolveFilterTokens: () => resolveFilterTokens,
|
|
133
159
|
resolveLocale: () => resolveLocale,
|
|
134
160
|
resolveLocalizationContext: () => resolveLocalizationContext,
|
|
161
|
+
resolvePlatformAdminEmails: () => resolvePlatformAdminEmails,
|
|
135
162
|
resolvePluginOrder: () => resolvePluginOrder,
|
|
136
163
|
resolveUserAuthzGrants: () => resolveUserAuthzGrants,
|
|
137
164
|
resumeMigrationJournal: () => resumeMigrationJournal,
|
|
165
|
+
rethrowAuthzStoreUnavailable: () => rethrowAuthzStoreUnavailable,
|
|
138
166
|
runMigrationJournal: () => runMigrationJournal,
|
|
139
167
|
safeExit: () => safeExit,
|
|
168
|
+
setPlatformAdminConfigSink: () => setPlatformAdminConfigSink,
|
|
140
169
|
shouldDenyAnonymous: () => shouldDenyAnonymous,
|
|
141
170
|
signPayload: () => signPayload,
|
|
142
171
|
temporalComparandKind: () => temporalComparandKind,
|
|
143
172
|
unknownAudienceBindingSuggestionStatusMessage: () => unknownAudienceBindingSuggestionStatusMessage,
|
|
144
173
|
utcInstantMs: () => import_data.utcInstantMs,
|
|
145
174
|
validateInitServiceContract: () => validateInitServiceContract,
|
|
175
|
+
verifyIntegrity: () => verifyIntegrity,
|
|
146
176
|
verifyPayload: () => verifyPayload,
|
|
147
177
|
verifyPlatformSignature: () => verifyPlatformSignature,
|
|
148
178
|
verifyPluginArtifact: () => verifyPluginArtifact,
|
|
@@ -858,138 +888,6 @@ function createLogger(config) {
|
|
|
858
888
|
// src/kernel.ts
|
|
859
889
|
var import_system3 = require("@objectstack/spec/system");
|
|
860
890
|
|
|
861
|
-
// src/security/plugin-config-validator.ts
|
|
862
|
-
var import_zod = require("zod");
|
|
863
|
-
var PluginConfigValidator = class {
|
|
864
|
-
constructor(logger) {
|
|
865
|
-
this.logger = logger;
|
|
866
|
-
}
|
|
867
|
-
/**
|
|
868
|
-
* Validate plugin configuration against its Zod schema
|
|
869
|
-
*
|
|
870
|
-
* @param plugin - Plugin metadata with configSchema
|
|
871
|
-
* @param config - User-provided configuration
|
|
872
|
-
* @returns Validated and typed configuration
|
|
873
|
-
* @throws Error with detailed validation errors
|
|
874
|
-
*/
|
|
875
|
-
validatePluginConfig(plugin, config) {
|
|
876
|
-
if (!plugin.configSchema) {
|
|
877
|
-
this.logger.debug(`Plugin ${plugin.name} has no config schema - skipping validation`);
|
|
878
|
-
return config;
|
|
879
|
-
}
|
|
880
|
-
try {
|
|
881
|
-
const validatedConfig = plugin.configSchema.parse(config);
|
|
882
|
-
this.logger.debug(`\u2705 Plugin config validated: ${plugin.name}`, {
|
|
883
|
-
plugin: plugin.name,
|
|
884
|
-
configKeys: Object.keys(config || {}).length
|
|
885
|
-
});
|
|
886
|
-
return validatedConfig;
|
|
887
|
-
} catch (error) {
|
|
888
|
-
if (error instanceof import_zod.z.ZodError) {
|
|
889
|
-
const formattedErrors = this.formatZodErrors(error);
|
|
890
|
-
const errorMessage = [
|
|
891
|
-
`Plugin ${plugin.name} configuration validation failed:`,
|
|
892
|
-
...formattedErrors.map((e) => ` - ${e.path}: ${e.message}`)
|
|
893
|
-
].join("\n");
|
|
894
|
-
this.logger.error(errorMessage, void 0, {
|
|
895
|
-
plugin: plugin.name,
|
|
896
|
-
errors: formattedErrors
|
|
897
|
-
});
|
|
898
|
-
throw new Error(errorMessage);
|
|
899
|
-
}
|
|
900
|
-
throw error;
|
|
901
|
-
}
|
|
902
|
-
}
|
|
903
|
-
/**
|
|
904
|
-
* Validate partial configuration (for incremental updates)
|
|
905
|
-
*
|
|
906
|
-
* @param plugin - Plugin metadata
|
|
907
|
-
* @param partialConfig - Partial configuration to validate
|
|
908
|
-
* @returns Validated partial configuration
|
|
909
|
-
*/
|
|
910
|
-
validatePartialConfig(plugin, partialConfig) {
|
|
911
|
-
if (!plugin.configSchema) {
|
|
912
|
-
return partialConfig;
|
|
913
|
-
}
|
|
914
|
-
try {
|
|
915
|
-
const partialSchema = plugin.configSchema.partial();
|
|
916
|
-
const validatedConfig = partialSchema.parse(partialConfig);
|
|
917
|
-
this.logger.debug(`\u2705 Partial config validated: ${plugin.name}`);
|
|
918
|
-
return validatedConfig;
|
|
919
|
-
} catch (error) {
|
|
920
|
-
if (error instanceof import_zod.z.ZodError) {
|
|
921
|
-
const formattedErrors = this.formatZodErrors(error);
|
|
922
|
-
const errorMessage = [
|
|
923
|
-
`Plugin ${plugin.name} partial configuration validation failed:`,
|
|
924
|
-
...formattedErrors.map((e) => ` - ${e.path}: ${e.message}`)
|
|
925
|
-
].join("\n");
|
|
926
|
-
throw new Error(errorMessage);
|
|
927
|
-
}
|
|
928
|
-
throw error;
|
|
929
|
-
}
|
|
930
|
-
}
|
|
931
|
-
/**
|
|
932
|
-
* Get default configuration from schema
|
|
933
|
-
*
|
|
934
|
-
* @param plugin - Plugin metadata
|
|
935
|
-
* @returns Default configuration object
|
|
936
|
-
*/
|
|
937
|
-
getDefaultConfig(plugin) {
|
|
938
|
-
if (!plugin.configSchema) {
|
|
939
|
-
return void 0;
|
|
940
|
-
}
|
|
941
|
-
try {
|
|
942
|
-
const defaults = plugin.configSchema.parse({});
|
|
943
|
-
this.logger.debug(`Default config extracted: ${plugin.name}`);
|
|
944
|
-
return defaults;
|
|
945
|
-
} catch (error) {
|
|
946
|
-
this.logger.debug(`No default config available: ${plugin.name}`);
|
|
947
|
-
return void 0;
|
|
948
|
-
}
|
|
949
|
-
}
|
|
950
|
-
/**
|
|
951
|
-
* Check if configuration is valid without throwing
|
|
952
|
-
*
|
|
953
|
-
* @param plugin - Plugin metadata
|
|
954
|
-
* @param config - Configuration to check
|
|
955
|
-
* @returns True if valid, false otherwise
|
|
956
|
-
*/
|
|
957
|
-
isConfigValid(plugin, config) {
|
|
958
|
-
if (!plugin.configSchema) {
|
|
959
|
-
return true;
|
|
960
|
-
}
|
|
961
|
-
const result = plugin.configSchema.safeParse(config);
|
|
962
|
-
return result.success;
|
|
963
|
-
}
|
|
964
|
-
/**
|
|
965
|
-
* Get configuration errors without throwing
|
|
966
|
-
*
|
|
967
|
-
* @param plugin - Plugin metadata
|
|
968
|
-
* @param config - Configuration to check
|
|
969
|
-
* @returns Array of validation errors, or empty array if valid
|
|
970
|
-
*/
|
|
971
|
-
getConfigErrors(plugin, config) {
|
|
972
|
-
if (!plugin.configSchema) {
|
|
973
|
-
return [];
|
|
974
|
-
}
|
|
975
|
-
const result = plugin.configSchema.safeParse(config);
|
|
976
|
-
if (result.success) {
|
|
977
|
-
return [];
|
|
978
|
-
}
|
|
979
|
-
return this.formatZodErrors(result.error);
|
|
980
|
-
}
|
|
981
|
-
// Private methods
|
|
982
|
-
formatZodErrors(error) {
|
|
983
|
-
return error.issues.map((e) => ({
|
|
984
|
-
path: e.path.join(".") || "root",
|
|
985
|
-
message: e.message
|
|
986
|
-
}));
|
|
987
|
-
}
|
|
988
|
-
};
|
|
989
|
-
function createPluginConfigValidator(logger) {
|
|
990
|
-
return new PluginConfigValidator(logger);
|
|
991
|
-
}
|
|
992
|
-
|
|
993
891
|
// src/security/plugin-artifact-signature.ts
|
|
994
892
|
var import_node_crypto = require("crypto");
|
|
995
893
|
var SIGNATURE_ALG = "ed25519";
|
|
@@ -1086,6 +984,20 @@ async function verifyPluginArtifact(input, keys) {
|
|
|
1086
984
|
return { ok: true, publisherVerified: publisher.verified, platformVerified };
|
|
1087
985
|
}
|
|
1088
986
|
|
|
987
|
+
// src/service-not-registered.ts
|
|
988
|
+
var SERVICE_NOT_REGISTERED_CODE = "SERVICE_NOT_REGISTERED";
|
|
989
|
+
var SERVICE_NOT_REGISTERED_BRAND = "__objectstackServiceNotRegistered";
|
|
990
|
+
function serviceNotRegisteredError(name) {
|
|
991
|
+
const err = new Error(`Service '${name}' not found`);
|
|
992
|
+
err[SERVICE_NOT_REGISTERED_BRAND] = true;
|
|
993
|
+
err.code = SERVICE_NOT_REGISTERED_CODE;
|
|
994
|
+
err.serviceName = name;
|
|
995
|
+
return err;
|
|
996
|
+
}
|
|
997
|
+
function isServiceNotRegisteredError(err) {
|
|
998
|
+
return typeof err === "object" && err !== null && err[SERVICE_NOT_REGISTERED_BRAND] === true;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1089
1001
|
// src/plugin-loader.ts
|
|
1090
1002
|
var ServiceLifecycle = /* @__PURE__ */ ((ServiceLifecycle2) => {
|
|
1091
1003
|
ServiceLifecycle2["SINGLETON"] = "singleton";
|
|
@@ -1101,7 +1013,6 @@ var PluginLoader = class {
|
|
|
1101
1013
|
this.scopedServices = /* @__PURE__ */ new Map();
|
|
1102
1014
|
this.creating = /* @__PURE__ */ new Set();
|
|
1103
1015
|
this.logger = logger;
|
|
1104
|
-
this.configValidator = new PluginConfigValidator(logger);
|
|
1105
1016
|
}
|
|
1106
1017
|
/**
|
|
1107
1018
|
* Set the plugin context for service factories
|
|
@@ -1128,9 +1039,6 @@ var PluginLoader = class {
|
|
|
1128
1039
|
if (!versionCheck.compatible) {
|
|
1129
1040
|
throw new Error(`Version incompatible: ${versionCheck.message}`);
|
|
1130
1041
|
}
|
|
1131
|
-
if (metadata.configSchema) {
|
|
1132
|
-
this.validatePluginConfig(metadata);
|
|
1133
|
-
}
|
|
1134
1042
|
if (metadata.signature) {
|
|
1135
1043
|
await this.verifyPluginSignature(metadata);
|
|
1136
1044
|
}
|
|
@@ -1169,7 +1077,7 @@ var PluginLoader = class {
|
|
|
1169
1077
|
if (!registration) {
|
|
1170
1078
|
const instance = this.serviceInstances.get(name);
|
|
1171
1079
|
if (!instance) {
|
|
1172
|
-
throw
|
|
1080
|
+
throw serviceNotRegisteredError(name);
|
|
1173
1081
|
}
|
|
1174
1082
|
return instance;
|
|
1175
1083
|
}
|
|
@@ -1329,16 +1237,6 @@ var PluginLoader = class {
|
|
|
1329
1237
|
const semverRegex = /^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/;
|
|
1330
1238
|
return semverRegex.test(version);
|
|
1331
1239
|
}
|
|
1332
|
-
validatePluginConfig(plugin, config) {
|
|
1333
|
-
if (!plugin.configSchema) {
|
|
1334
|
-
return;
|
|
1335
|
-
}
|
|
1336
|
-
if (config === void 0) {
|
|
1337
|
-
this.logger.debug(`Plugin ${plugin.name} has configuration schema (config validation postponed)`);
|
|
1338
|
-
return;
|
|
1339
|
-
}
|
|
1340
|
-
this.configValidator.validatePluginConfig(plugin, config);
|
|
1341
|
-
}
|
|
1342
1240
|
async verifyPluginSignature(plugin) {
|
|
1343
1241
|
if (!plugin.signature) {
|
|
1344
1242
|
return;
|
|
@@ -1628,10 +1526,10 @@ function createMemoryI18n() {
|
|
|
1628
1526
|
}
|
|
1629
1527
|
|
|
1630
1528
|
// src/metadata-service-contract.ts
|
|
1631
|
-
var
|
|
1529
|
+
var import_meta_spelling = require("@objectstack/spec/meta-spelling");
|
|
1632
1530
|
var REGISTER_REFUSAL_CODE = "VALIDATION_ERROR";
|
|
1633
1531
|
function canonicalMetadataServiceType(type) {
|
|
1634
|
-
return (0,
|
|
1532
|
+
return (0, import_meta_spelling.pluralToSingular)(type);
|
|
1635
1533
|
}
|
|
1636
1534
|
function registerRefusal(message) {
|
|
1637
1535
|
const err = new Error(message);
|
|
@@ -2512,6 +2410,67 @@ var ObjectKernel = class {
|
|
|
2512
2410
|
}
|
|
2513
2411
|
};
|
|
2514
2412
|
|
|
2413
|
+
// src/artifact-packages.ts
|
|
2414
|
+
var import_spec = require("@objectstack/spec");
|
|
2415
|
+
var MAX_REPORTED_ENTRY_ISSUES = 5;
|
|
2416
|
+
function refuse(code, message) {
|
|
2417
|
+
const err = new Error(message);
|
|
2418
|
+
err.code = code;
|
|
2419
|
+
err.status = 422;
|
|
2420
|
+
return err;
|
|
2421
|
+
}
|
|
2422
|
+
function artifactPackageId(manifest) {
|
|
2423
|
+
const id = manifest?.id || manifest?.name;
|
|
2424
|
+
return typeof id === "string" && id !== "" ? id : void 0;
|
|
2425
|
+
}
|
|
2426
|
+
function resolveArtifactPackageOrder(artifact) {
|
|
2427
|
+
const declared = artifact?.packages;
|
|
2428
|
+
if (declared === void 0 || declared === null) return [artifact];
|
|
2429
|
+
if (!Array.isArray(declared)) {
|
|
2430
|
+
throw refuse(
|
|
2431
|
+
"INVALID_ARTIFACT_PACKAGES",
|
|
2432
|
+
`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.`
|
|
2433
|
+
);
|
|
2434
|
+
}
|
|
2435
|
+
const nodes = /* @__PURE__ */ new Map();
|
|
2436
|
+
declared.forEach((entry, index) => {
|
|
2437
|
+
const verdict = import_spec.ArtifactPackageSchema.safeParse(entry);
|
|
2438
|
+
if (!verdict.success) {
|
|
2439
|
+
const issues = verdict.error.issues;
|
|
2440
|
+
const shown = issues.slice(0, MAX_REPORTED_ENTRY_ISSUES);
|
|
2441
|
+
throw refuse(
|
|
2442
|
+
"INVALID_ARTIFACT_PACKAGE_ENTRY",
|
|
2443
|
+
`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."
|
|
2444
|
+
);
|
|
2445
|
+
}
|
|
2446
|
+
const manifest = entry.manifest;
|
|
2447
|
+
const id = artifactPackageId(manifest);
|
|
2448
|
+
if (id === void 0) {
|
|
2449
|
+
throw refuse(
|
|
2450
|
+
"INVALID_ARTIFACT_PACKAGE_ENTRY",
|
|
2451
|
+
`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.`
|
|
2452
|
+
);
|
|
2453
|
+
}
|
|
2454
|
+
if (nodes.has(id)) {
|
|
2455
|
+
throw refuse(
|
|
2456
|
+
"DUPLICATE_ARTIFACT_PACKAGE",
|
|
2457
|
+
`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.`
|
|
2458
|
+
);
|
|
2459
|
+
}
|
|
2460
|
+
nodes.set(id, {
|
|
2461
|
+
// `name` is what `resolvePluginOrder` puts in its diagnostics; the MAP KEY
|
|
2462
|
+
// is what its edges resolve against. Both are the package id, so an error
|
|
2463
|
+
// it raises names the same string the artifact author wrote.
|
|
2464
|
+
name: id,
|
|
2465
|
+
optionalDependencies: Object.keys(
|
|
2466
|
+
manifest.dependencies ?? {}
|
|
2467
|
+
),
|
|
2468
|
+
manifest
|
|
2469
|
+
});
|
|
2470
|
+
});
|
|
2471
|
+
return resolvePluginOrder(nodes).map((node) => node.manifest);
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2515
2474
|
// src/lite-kernel.ts
|
|
2516
2475
|
var LiteKernel = class extends ObjectKernelBase {
|
|
2517
2476
|
constructor(config) {
|
|
@@ -3241,6 +3200,55 @@ var PluginSignatureVerifier = class {
|
|
|
3241
3200
|
}
|
|
3242
3201
|
};
|
|
3243
3202
|
|
|
3203
|
+
// src/security/plugin-artifact-integrity.ts
|
|
3204
|
+
var import_node_crypto2 = require("crypto");
|
|
3205
|
+
var SRI_ALGORITHMS = /* @__PURE__ */ new Set(["sha256", "sha384", "sha512"]);
|
|
3206
|
+
function sriDigestFor(declared, data) {
|
|
3207
|
+
const dash = declared.indexOf("-");
|
|
3208
|
+
const alg = dash > 0 && SRI_ALGORITHMS.has(declared.slice(0, dash)) ? declared.slice(0, dash) : "sha256";
|
|
3209
|
+
return `${alg}-${(0, import_node_crypto2.createHash)(alg).update(data).digest("base64")}`;
|
|
3210
|
+
}
|
|
3211
|
+
function verifyIntegrity(files, integrity, options = {}) {
|
|
3212
|
+
if (integrity === null || integrity === void 0) {
|
|
3213
|
+
return { ok: true, skipped: true, checked: 0, violations: [] };
|
|
3214
|
+
}
|
|
3215
|
+
const exempt = new Set(options.exempt ?? []);
|
|
3216
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
3217
|
+
for (const f of files) {
|
|
3218
|
+
if (!exempt.has(f.path)) byPath.set(f.path, f.data);
|
|
3219
|
+
}
|
|
3220
|
+
const violations = [];
|
|
3221
|
+
let checked = 0;
|
|
3222
|
+
for (const [path, declaredRaw] of Object.entries(integrity)) {
|
|
3223
|
+
if (exempt.has(path)) continue;
|
|
3224
|
+
const declared = typeof declaredRaw === "string" ? declaredRaw : String(declaredRaw);
|
|
3225
|
+
const data = byPath.get(path);
|
|
3226
|
+
if (data === void 0) {
|
|
3227
|
+
violations.push({ kind: "missing_file", path, declared });
|
|
3228
|
+
continue;
|
|
3229
|
+
}
|
|
3230
|
+
checked++;
|
|
3231
|
+
const actual = sriDigestFor(declared, data);
|
|
3232
|
+
if (actual !== declared) violations.push({ kind: "digest_mismatch", path, declared, actual });
|
|
3233
|
+
}
|
|
3234
|
+
for (const path of [...byPath.keys()].sort()) {
|
|
3235
|
+
if (!Object.prototype.hasOwnProperty.call(integrity, path)) {
|
|
3236
|
+
violations.push({ kind: "extra_file", path });
|
|
3237
|
+
}
|
|
3238
|
+
}
|
|
3239
|
+
return { ok: violations.length === 0, skipped: false, checked, violations };
|
|
3240
|
+
}
|
|
3241
|
+
function formatIntegrityViolation(v) {
|
|
3242
|
+
switch (v.kind) {
|
|
3243
|
+
case "digest_mismatch":
|
|
3244
|
+
return `${v.path}: digest mismatch \u2014 manifest declares ${v.declared}, artifact bytes hash to ${v.actual}`;
|
|
3245
|
+
case "missing_file":
|
|
3246
|
+
return `${v.path}: declared in the integrity map but absent from the artifact`;
|
|
3247
|
+
case "extra_file":
|
|
3248
|
+
return `${v.path}: present in the artifact but not in the integrity map`;
|
|
3249
|
+
}
|
|
3250
|
+
}
|
|
3251
|
+
|
|
3244
3252
|
// src/security/plugin-permission-enforcer.ts
|
|
3245
3253
|
var PluginPermissionEnforcer = class {
|
|
3246
3254
|
constructor(logger) {
|
|
@@ -4335,16 +4343,16 @@ var PluginSecurityScanner = class {
|
|
|
4335
4343
|
};
|
|
4336
4344
|
|
|
4337
4345
|
// src/security/api-key.ts
|
|
4338
|
-
var
|
|
4346
|
+
var import_node_crypto3 = require("crypto");
|
|
4339
4347
|
var import_security = require("@objectstack/spec/security");
|
|
4340
4348
|
var API_KEY_PREFIX = "osk_";
|
|
4341
4349
|
var API_KEY_ENTROPY_BYTES = 32;
|
|
4342
4350
|
var VISIBLE_PREFIX_LEN = 12;
|
|
4343
4351
|
function hashApiKey(raw) {
|
|
4344
|
-
return (0,
|
|
4352
|
+
return (0, import_node_crypto3.createHash)("sha256").update(raw, "utf8").digest("hex");
|
|
4345
4353
|
}
|
|
4346
4354
|
function generateApiKey(prefix = API_KEY_PREFIX) {
|
|
4347
|
-
const secret = (0,
|
|
4355
|
+
const secret = (0, import_node_crypto3.randomBytes)(API_KEY_ENTROPY_BYTES).toString("base64url");
|
|
4348
4356
|
const raw = `${prefix}${secret}`;
|
|
4349
4357
|
return {
|
|
4350
4358
|
raw,
|
|
@@ -4458,8 +4466,37 @@ function safeJsonParse(s, fallback) {
|
|
|
4458
4466
|
}
|
|
4459
4467
|
}
|
|
4460
4468
|
|
|
4469
|
+
// src/security/authz-store-unavailable.ts
|
|
4470
|
+
var AUTHZ_STORE_UNAVAILABLE_STATUS = 503;
|
|
4471
|
+
var AUTHZ_STORE_UNAVAILABLE_CODE = "SERVICE_UNAVAILABLE";
|
|
4472
|
+
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.";
|
|
4473
|
+
var AUTHZ_STORE_UNAVAILABLE_BRAND = "__objectstackAuthzStoreUnavailable";
|
|
4474
|
+
var _a, _b;
|
|
4475
|
+
var AuthzStoreUnavailableError = class extends (_b = Error, _a = AUTHZ_STORE_UNAVAILABLE_BRAND, _b) {
|
|
4476
|
+
constructor(object, cause) {
|
|
4477
|
+
super(`${AUTHZ_STORE_UNAVAILABLE_MESSAGE} (failed read: \`${object}\`)`);
|
|
4478
|
+
/** Brand — see the module doc on why this is not `instanceof`. */
|
|
4479
|
+
this[_a] = true;
|
|
4480
|
+
/** ADR-0112 wire code. */
|
|
4481
|
+
this.code = AUTHZ_STORE_UNAVAILABLE_CODE;
|
|
4482
|
+
/** HTTP status a transport should answer. */
|
|
4483
|
+
this.status = AUTHZ_STORE_UNAVAILABLE_STATUS;
|
|
4484
|
+
this.name = "AuthzStoreUnavailableError";
|
|
4485
|
+
this.object = object;
|
|
4486
|
+
if (cause !== void 0) this.cause = cause;
|
|
4487
|
+
}
|
|
4488
|
+
};
|
|
4489
|
+
function isAuthzStoreUnavailableError(err) {
|
|
4490
|
+
return typeof err === "object" && err !== null && err[AUTHZ_STORE_UNAVAILABLE_BRAND] === true;
|
|
4491
|
+
}
|
|
4492
|
+
function rethrowAuthzStoreUnavailable(err) {
|
|
4493
|
+
if (isAuthzStoreUnavailableError(err)) throw err;
|
|
4494
|
+
return void 0;
|
|
4495
|
+
}
|
|
4496
|
+
|
|
4461
4497
|
// src/security/resolve-authz-context.ts
|
|
4462
|
-
var
|
|
4498
|
+
var import_types2 = require("@objectstack/types");
|
|
4499
|
+
var import_spec2 = require("@objectstack/spec");
|
|
4463
4500
|
var import_security2 = require("@objectstack/spec/security");
|
|
4464
4501
|
|
|
4465
4502
|
// src/security/grant-validity.ts
|
|
@@ -4480,6 +4517,20 @@ function isGrantActive(row, nowMs) {
|
|
|
4480
4517
|
if (until !== void 0 && !(nowMs < until)) return false;
|
|
4481
4518
|
return true;
|
|
4482
4519
|
}
|
|
4520
|
+
function nextGrantValidityBoundary(rows, nowMs) {
|
|
4521
|
+
let next;
|
|
4522
|
+
for (const row of rows) {
|
|
4523
|
+
if (!row) continue;
|
|
4524
|
+
const from = toEpochMs(row.valid_from ?? row.validFrom);
|
|
4525
|
+
const until = toEpochMs(row.valid_until ?? row.validUntil);
|
|
4526
|
+
for (const bound of [from, until]) {
|
|
4527
|
+
if (bound !== void 0 && bound > nowMs && (next === void 0 || bound < next)) {
|
|
4528
|
+
next = bound;
|
|
4529
|
+
}
|
|
4530
|
+
}
|
|
4531
|
+
}
|
|
4532
|
+
return next;
|
|
4533
|
+
}
|
|
4483
4534
|
function isGrantExpired(row, nowMs) {
|
|
4484
4535
|
if (!row) return false;
|
|
4485
4536
|
const until = toEpochMs(row.valid_until ?? row.validUntil);
|
|
@@ -4487,6 +4538,227 @@ function isGrantExpired(row, nowMs) {
|
|
|
4487
4538
|
return !(nowMs < until);
|
|
4488
4539
|
}
|
|
4489
4540
|
|
|
4541
|
+
// src/security/authz-cache-posture.ts
|
|
4542
|
+
var AUTHZ_GRANTS_CACHE_TTL_ENV = "OS_AUTHZ_GRANTS_CACHE_TTL_MS";
|
|
4543
|
+
function resolveAuthzCachePosture(input) {
|
|
4544
|
+
const { ttlMs, bus, driver } = input;
|
|
4545
|
+
if (!(ttlMs > 0)) {
|
|
4546
|
+
return { posture: "disabled", loud: false, message: "" };
|
|
4547
|
+
}
|
|
4548
|
+
if (bus === "bridged") {
|
|
4549
|
+
return {
|
|
4550
|
+
posture: "bus-narrowed",
|
|
4551
|
+
loud: false,
|
|
4552
|
+
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)."
|
|
4553
|
+
};
|
|
4554
|
+
}
|
|
4555
|
+
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";
|
|
4556
|
+
return {
|
|
4557
|
+
posture: "ttl-only",
|
|
4558
|
+
loud: true,
|
|
4559
|
+
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.`
|
|
4560
|
+
};
|
|
4561
|
+
}
|
|
4562
|
+
function readAuthzGrantsCacheTtlMs(env = typeof process !== "undefined" ? process.env : {}) {
|
|
4563
|
+
const raw = env[AUTHZ_GRANTS_CACHE_TTL_ENV];
|
|
4564
|
+
if (raw === void 0 || raw.trim() === "") {
|
|
4565
|
+
return { ttlMs: 0, malformed: false };
|
|
4566
|
+
}
|
|
4567
|
+
const parsed = Number(raw.trim());
|
|
4568
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
4569
|
+
return { ttlMs: 0, raw, malformed: true };
|
|
4570
|
+
}
|
|
4571
|
+
return { ttlMs: Math.floor(parsed), raw, malformed: false };
|
|
4572
|
+
}
|
|
4573
|
+
function reportAuthzCachePosture(input, sink2) {
|
|
4574
|
+
if (input.malformedTtl) {
|
|
4575
|
+
sink2.warn(
|
|
4576
|
+
`[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.`
|
|
4577
|
+
);
|
|
4578
|
+
}
|
|
4579
|
+
const statement = resolveAuthzCachePosture(input);
|
|
4580
|
+
if (statement.posture === "disabled") return statement;
|
|
4581
|
+
if (statement.loud) sink2.warn(statement.message);
|
|
4582
|
+
else sink2.info?.(statement.message);
|
|
4583
|
+
return statement;
|
|
4584
|
+
}
|
|
4585
|
+
|
|
4586
|
+
// src/security/resolve-user-grants-cache.ts
|
|
4587
|
+
var GRANTS_CACHE_WATCHED_OBJECTS = /* @__PURE__ */ new Set([
|
|
4588
|
+
"sys_member",
|
|
4589
|
+
"sys_user_position",
|
|
4590
|
+
"sys_user_permission_set",
|
|
4591
|
+
"sys_position",
|
|
4592
|
+
"sys_position_permission_set",
|
|
4593
|
+
"sys_permission_set",
|
|
4594
|
+
"sys_user"
|
|
4595
|
+
]);
|
|
4596
|
+
var grantsCacheStates = /* @__PURE__ */ new WeakMap();
|
|
4597
|
+
var WRITE_OPERATIONS = /* @__PURE__ */ new Set(["insert", "update", "delete"]);
|
|
4598
|
+
function grantsCacheState(ql) {
|
|
4599
|
+
const existing = grantsCacheStates.get(ql);
|
|
4600
|
+
if (existing !== void 0) return existing ?? void 0;
|
|
4601
|
+
const seamQl = ql;
|
|
4602
|
+
const epoch = seamQl.writeEpoch;
|
|
4603
|
+
const hasEpoch = !!epoch && typeof epoch === "object" && typeof epoch.current === "number" && typeof epoch.bump === "function" && typeof epoch.subscribe === "function";
|
|
4604
|
+
if (!hasEpoch || typeof seamQl.registerMiddleware !== "function") {
|
|
4605
|
+
return void 0;
|
|
4606
|
+
}
|
|
4607
|
+
const state = { gen: 0, entries: /* @__PURE__ */ new Map() };
|
|
4608
|
+
try {
|
|
4609
|
+
seamQl.registerMiddleware(async (ctx, next) => {
|
|
4610
|
+
if (typeof ctx?.operation !== "string" || !WRITE_OPERATIONS.has(ctx.operation) || typeof ctx?.object !== "string" || !GRANTS_CACHE_WATCHED_OBJECTS.has(ctx.object)) {
|
|
4611
|
+
return next();
|
|
4612
|
+
}
|
|
4613
|
+
try {
|
|
4614
|
+
await next();
|
|
4615
|
+
} finally {
|
|
4616
|
+
state.gen += 1;
|
|
4617
|
+
}
|
|
4618
|
+
});
|
|
4619
|
+
epoch.subscribe((_epoch, reason) => {
|
|
4620
|
+
if (reason !== "write") state.gen += 1;
|
|
4621
|
+
});
|
|
4622
|
+
} catch {
|
|
4623
|
+
grantsCacheStates.set(ql, null);
|
|
4624
|
+
return void 0;
|
|
4625
|
+
}
|
|
4626
|
+
grantsCacheStates.set(ql, state);
|
|
4627
|
+
return state;
|
|
4628
|
+
}
|
|
4629
|
+
function grantsCacheKey(userId, opts) {
|
|
4630
|
+
return JSON.stringify([
|
|
4631
|
+
userId,
|
|
4632
|
+
opts.tenantId ?? null,
|
|
4633
|
+
opts.seedEmail ?? null,
|
|
4634
|
+
Array.isArray(opts.seedPermissions) ? opts.seedPermissions : []
|
|
4635
|
+
]);
|
|
4636
|
+
}
|
|
4637
|
+
var cloneGrants = (grants) => structuredClone(grants);
|
|
4638
|
+
function openUserGrantsCache(ql, userId, opts) {
|
|
4639
|
+
if (opts.bypassGrantsCache) return void 0;
|
|
4640
|
+
const { ttlMs } = readAuthzGrantsCacheTtlMs();
|
|
4641
|
+
if (ttlMs <= 0) return void 0;
|
|
4642
|
+
if (!ql || typeof ql !== "object" || typeof ql.find !== "function") {
|
|
4643
|
+
return void 0;
|
|
4644
|
+
}
|
|
4645
|
+
const state = grantsCacheState(ql);
|
|
4646
|
+
if (!state) return void 0;
|
|
4647
|
+
const key = grantsCacheKey(userId, opts);
|
|
4648
|
+
const now = opts.nowMs ?? Date.now();
|
|
4649
|
+
const genAtOpen = state.gen;
|
|
4650
|
+
const existing = state.entries.get(key);
|
|
4651
|
+
if (existing) {
|
|
4652
|
+
if (existing.gen === state.gen && existing.expiresAt > now) {
|
|
4653
|
+
return { hit: cloneGrants(existing.value), commit: () => {
|
|
4654
|
+
} };
|
|
4655
|
+
}
|
|
4656
|
+
state.entries.delete(key);
|
|
4657
|
+
}
|
|
4658
|
+
return {
|
|
4659
|
+
commit(grants, nextBoundaryMs) {
|
|
4660
|
+
const expiresAt = Math.min(now + ttlMs, nextBoundaryMs ?? Number.POSITIVE_INFINITY);
|
|
4661
|
+
if (expiresAt <= now) return;
|
|
4662
|
+
state.entries.set(key, { value: cloneGrants(grants), gen: genAtOpen, expiresAt });
|
|
4663
|
+
}
|
|
4664
|
+
};
|
|
4665
|
+
}
|
|
4666
|
+
|
|
4667
|
+
// src/security/platform-admin.ts
|
|
4668
|
+
var import_types = require("@objectstack/types");
|
|
4669
|
+
var PLATFORM_ADMIN_EMAIL_SEPARATOR = ",";
|
|
4670
|
+
function normalizePlatformAdminEmail(value) {
|
|
4671
|
+
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
4672
|
+
}
|
|
4673
|
+
function isParseableAddress(entry) {
|
|
4674
|
+
if (/\s/.test(entry)) return false;
|
|
4675
|
+
const at = entry.indexOf("@");
|
|
4676
|
+
if (at <= 0) return false;
|
|
4677
|
+
if (entry.indexOf("@", at + 1) !== -1) return false;
|
|
4678
|
+
return at < entry.length - 1;
|
|
4679
|
+
}
|
|
4680
|
+
var EMPTY_CONFIG = Object.freeze({
|
|
4681
|
+
emails: Object.freeze([]),
|
|
4682
|
+
declaredSpellings: Object.freeze([])
|
|
4683
|
+
});
|
|
4684
|
+
function parsePlatformAdminEmails(raw) {
|
|
4685
|
+
if (raw == null) return EMPTY_CONFIG;
|
|
4686
|
+
const text = String(raw);
|
|
4687
|
+
if (text.trim() === "") return EMPTY_CONFIG;
|
|
4688
|
+
const emails = [];
|
|
4689
|
+
const declaredSpellings = [];
|
|
4690
|
+
for (const piece of text.split(PLATFORM_ADMIN_EMAIL_SEPARATOR)) {
|
|
4691
|
+
const entry = normalizePlatformAdminEmail(piece);
|
|
4692
|
+
if (entry === "") continue;
|
|
4693
|
+
if (!isParseableAddress(entry)) {
|
|
4694
|
+
return {
|
|
4695
|
+
emails: Object.freeze([]),
|
|
4696
|
+
declaredSpellings: Object.freeze([]),
|
|
4697
|
+
raw: text,
|
|
4698
|
+
refusal: `${import_types.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 ${import_types.PLATFORM_OWNER_EMAIL_ENV} takes one address or a comma-separated list of them.`
|
|
4699
|
+
};
|
|
4700
|
+
}
|
|
4701
|
+
if (!emails.includes(entry)) {
|
|
4702
|
+
emails.push(entry);
|
|
4703
|
+
declaredSpellings.push(piece.trim());
|
|
4704
|
+
}
|
|
4705
|
+
}
|
|
4706
|
+
return {
|
|
4707
|
+
emails: Object.freeze(emails),
|
|
4708
|
+
declaredSpellings: Object.freeze(declaredSpellings),
|
|
4709
|
+
raw: text
|
|
4710
|
+
};
|
|
4711
|
+
}
|
|
4712
|
+
var defaultSink = {
|
|
4713
|
+
error: (m) => console.error(m),
|
|
4714
|
+
warn: (m) => console.warn(m)
|
|
4715
|
+
};
|
|
4716
|
+
var sink = defaultSink;
|
|
4717
|
+
function setPlatformAdminConfigSink(next) {
|
|
4718
|
+
const prev = sink;
|
|
4719
|
+
sink = next ?? defaultSink;
|
|
4720
|
+
return prev;
|
|
4721
|
+
}
|
|
4722
|
+
var NOT_MEMOIZED = /* @__PURE__ */ Symbol("platform-admin-config-not-memoized");
|
|
4723
|
+
var memoKey = NOT_MEMOIZED;
|
|
4724
|
+
var memoValue = EMPTY_CONFIG;
|
|
4725
|
+
function resolvePlatformAdminEmails() {
|
|
4726
|
+
const raw = (0, import_types.resolvePlatformOwnerEmail)();
|
|
4727
|
+
if (memoKey !== NOT_MEMOIZED && memoKey === raw) return memoValue;
|
|
4728
|
+
const parsed = parsePlatformAdminEmails(raw);
|
|
4729
|
+
memoKey = raw;
|
|
4730
|
+
memoValue = parsed;
|
|
4731
|
+
if (parsed.refusal) sink.error(`[authz] ${parsed.refusal}`);
|
|
4732
|
+
return parsed;
|
|
4733
|
+
}
|
|
4734
|
+
function resetPlatformAdminEmailMemo() {
|
|
4735
|
+
memoKey = NOT_MEMOIZED;
|
|
4736
|
+
memoValue = EMPTY_CONFIG;
|
|
4737
|
+
}
|
|
4738
|
+
function matchesConfiguredPlatformAdmin(row, config) {
|
|
4739
|
+
if (config.emails.length === 0) return false;
|
|
4740
|
+
if (!row || typeof row !== "object") return false;
|
|
4741
|
+
if (!isConfiguredPlatformAdminEmail(row.email, config)) return false;
|
|
4742
|
+
return (0, import_types.isEmailVerifiedUserRow)(row);
|
|
4743
|
+
}
|
|
4744
|
+
function isConfiguredPlatformAdminEmail(email, config) {
|
|
4745
|
+
if (config.emails.length === 0) return false;
|
|
4746
|
+
const candidate = normalizePlatformAdminEmail(email);
|
|
4747
|
+
return candidate !== "" && config.emails.includes(candidate);
|
|
4748
|
+
}
|
|
4749
|
+
var legacyGrantPointerSaid = false;
|
|
4750
|
+
function reportLegacyPlatformAdminGrant(input) {
|
|
4751
|
+
if (legacyGrantPointerSaid) return;
|
|
4752
|
+
legacyGrantPointerSaid = true;
|
|
4753
|
+
const email = normalizePlatformAdminEmail(input.email);
|
|
4754
|
+
sink.warn(
|
|
4755
|
+
`[authz] user ${input.userId} holds PLATFORM_ADMIN through the legacy unscoped 'admin_full_access' grant row, not through ${import_types.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: ${import_types.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.`
|
|
4756
|
+
);
|
|
4757
|
+
}
|
|
4758
|
+
function resetLegacyPlatformAdminGrantReport() {
|
|
4759
|
+
legacyGrantPointerSaid = false;
|
|
4760
|
+
}
|
|
4761
|
+
|
|
4490
4762
|
// src/security/posture-ladder.ts
|
|
4491
4763
|
var POSTURE_LADDER = [
|
|
4492
4764
|
"PLATFORM_ADMIN",
|
|
@@ -4567,8 +4839,9 @@ async function tryFind(ql, object, where, limit = 100, organizationId) {
|
|
|
4567
4839
|
let rows = await ql.find(object, { where, limit, context });
|
|
4568
4840
|
if (rows && rows.value) rows = rows.value;
|
|
4569
4841
|
return Array.isArray(rows) ? rows : [];
|
|
4570
|
-
} catch {
|
|
4571
|
-
return [];
|
|
4842
|
+
} catch (err) {
|
|
4843
|
+
if ((0, import_types2.isMissingTableError)(err, object)) return [];
|
|
4844
|
+
throw new AuthzStoreUnavailableError(object, err);
|
|
4572
4845
|
}
|
|
4573
4846
|
}
|
|
4574
4847
|
async function resolveAuthzContext(input) {
|
|
@@ -4642,6 +4915,8 @@ async function resolveAuthzContext(input) {
|
|
|
4642
4915
|
return ctx;
|
|
4643
4916
|
}
|
|
4644
4917
|
async function resolveUserAuthzGrants(ql, userId, opts = {}) {
|
|
4918
|
+
const grantsCache = openUserGrantsCache(ql, userId, opts);
|
|
4919
|
+
if (grantsCache?.hit) return grantsCache.hit;
|
|
4645
4920
|
const { tenantId } = opts;
|
|
4646
4921
|
const grants = {
|
|
4647
4922
|
positions: [],
|
|
@@ -4662,7 +4937,8 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
|
|
|
4662
4937
|
}
|
|
4663
4938
|
return userRow;
|
|
4664
4939
|
};
|
|
4665
|
-
const
|
|
4940
|
+
const platformAdminConfig = resolvePlatformAdminEmails();
|
|
4941
|
+
const needsUserRow = !grants.email || !grants.permissions.includes("ai_seat") || platformAdminConfig.emails.length > 0;
|
|
4666
4942
|
const [, members, userPositionRows, orgMembersLeg, upsRowsAll] = await Promise.all([
|
|
4667
4943
|
needsUserRow ? getUserRow() : Promise.resolve(void 0),
|
|
4668
4944
|
tryFind(ql, "sys_member", { user_id: userId }, 200),
|
|
@@ -4688,7 +4964,7 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
|
|
|
4688
4964
|
for (const m of activeMembers) {
|
|
4689
4965
|
if (m.role && typeof m.role === "string") {
|
|
4690
4966
|
for (const raw of m.role.split(",").map((s) => s.trim()).filter(Boolean)) {
|
|
4691
|
-
const r = (0,
|
|
4967
|
+
const r = (0, import_spec2.mapMembershipRole)(raw);
|
|
4692
4968
|
if (!grants.positions.includes(r)) grants.positions.push(r);
|
|
4693
4969
|
}
|
|
4694
4970
|
}
|
|
@@ -4744,7 +5020,7 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
|
|
|
4744
5020
|
const mergedTabs = {};
|
|
4745
5021
|
for (const ps of psRows) {
|
|
4746
5022
|
if (ps.name && !grants.permissions.includes(ps.name)) grants.permissions.push(ps.name);
|
|
4747
|
-
if (ps.name ===
|
|
5023
|
+
if (ps.name === import_spec2.ADMIN_FULL_ACCESS && unscopedUserPsIds.has(ps.id)) hasPlatformAdminGrant = true;
|
|
4748
5024
|
const sysPerms = typeof ps.system_permissions === "string" ? safeJsonParse2(ps.system_permissions, []) : ps.system_permissions ?? ps.systemPermissions;
|
|
4749
5025
|
if (Array.isArray(sysPerms)) {
|
|
4750
5026
|
for (const p of sysPerms) {
|
|
@@ -4764,21 +5040,46 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
|
|
|
4764
5040
|
}
|
|
4765
5041
|
if (Object.keys(mergedTabs).length > 0) grants.tabPermissions = mergedTabs;
|
|
4766
5042
|
}
|
|
4767
|
-
|
|
4768
|
-
|
|
5043
|
+
const configConfersPlatformAdmin = platformAdminConfig.emails.length > 0 && matchesConfiguredPlatformAdmin(await getUserRow(), platformAdminConfig);
|
|
5044
|
+
if (configConfersPlatformAdmin) {
|
|
5045
|
+
hasPlatformAdminGrant = true;
|
|
5046
|
+
if (!grants.permissions.includes(import_spec2.ADMIN_FULL_ACCESS)) grants.permissions.push(import_spec2.ADMIN_FULL_ACCESS);
|
|
5047
|
+
for (const p of import_spec2.ADMIN_FULL_ACCESS_CAPABILITIES.systemPermissions ?? []) {
|
|
5048
|
+
if (!grants.systemPermissions.includes(p)) grants.systemPermissions.push(p);
|
|
5049
|
+
}
|
|
5050
|
+
} else if (hasPlatformAdminGrant) {
|
|
5051
|
+
if ((0, import_security2.postureEnforcesWall)((0, import_types2.resolveTenancyPosture)())) {
|
|
5052
|
+
reportLegacyPlatformAdminGrant({ userId, email: userRow?.email });
|
|
5053
|
+
}
|
|
5054
|
+
}
|
|
5055
|
+
if (hasPlatformAdminGrant && !grants.positions.includes(import_spec2.BUILTIN_IDENTITY_PLATFORM_ADMIN)) {
|
|
5056
|
+
grants.positions.unshift(import_spec2.BUILTIN_IDENTITY_PLATFORM_ADMIN);
|
|
4769
5057
|
}
|
|
4770
5058
|
grants.posture = derivePosture({
|
|
4771
5059
|
isPlatformAdmin: hasPlatformAdminGrant,
|
|
4772
5060
|
// [ADR-0105 D4] Either org-admin capability set resolves the rung — the
|
|
4773
5061
|
// wall-less variant differs only by withholding the superuser bits.
|
|
4774
|
-
isTenantAdmin:
|
|
5062
|
+
isTenantAdmin: import_spec2.ORGANIZATION_ADMIN_GRANTS.some((n) => grants.permissions.includes(n))
|
|
4775
5063
|
});
|
|
4776
5064
|
if (!grants.permissions.includes("ai_seat")) {
|
|
4777
5065
|
const aiAccess = (await getUserRow())?.ai_access;
|
|
4778
5066
|
if (aiAccess === true || aiAccess === 1 || aiAccess === "1") grants.permissions.push("ai_seat");
|
|
4779
5067
|
}
|
|
5068
|
+
grantsCache?.commit(
|
|
5069
|
+
grants,
|
|
5070
|
+
nextGrantValidityBoundary([...members, ...userPositionRows, ...upsRowsAll], nowMs)
|
|
5071
|
+
);
|
|
4780
5072
|
return grants;
|
|
4781
5073
|
}
|
|
5074
|
+
async function hasPlatformAdminStanding(ql, userId, opts = {}) {
|
|
5075
|
+
if (!ql || typeof userId !== "string" || userId.length === 0) return false;
|
|
5076
|
+
try {
|
|
5077
|
+
const grants = await resolveUserAuthzGrants(ql, userId, { nowMs: opts.nowMs });
|
|
5078
|
+
return grants.posture === "PLATFORM_ADMIN";
|
|
5079
|
+
} catch {
|
|
5080
|
+
return false;
|
|
5081
|
+
}
|
|
5082
|
+
}
|
|
4782
5083
|
function isValidTimeZone(tz) {
|
|
4783
5084
|
try {
|
|
4784
5085
|
new Intl.DateTimeFormat("en-US", { timeZone: tz });
|
|
@@ -4800,25 +5101,98 @@ function coerceCurrency(value) {
|
|
|
4800
5101
|
return /^[A-Z]{3}$/.test(s) ? s : void 0;
|
|
4801
5102
|
}
|
|
4802
5103
|
var LOCALIZATION_FAILURE_CACHE_TTL_MS = 3e4;
|
|
4803
|
-
var
|
|
5104
|
+
var LOCALIZATION_CACHE_TTL_ENV = "OS_LOCALIZATION_CACHE_TTL_MS";
|
|
5105
|
+
var LOCALIZATION_SUCCESS_CACHE_DEFAULT_TTL_MS = 3e4;
|
|
5106
|
+
function localizationSuccessCacheTtlMs(env = typeof process !== "undefined" ? process.env : {}) {
|
|
5107
|
+
const raw = env[LOCALIZATION_CACHE_TTL_ENV];
|
|
5108
|
+
if (raw === void 0 || raw.trim() === "") return LOCALIZATION_SUCCESS_CACHE_DEFAULT_TTL_MS;
|
|
5109
|
+
const parsed = Number(raw.trim());
|
|
5110
|
+
if (!Number.isFinite(parsed) || parsed < 0) return 0;
|
|
5111
|
+
return Math.floor(parsed);
|
|
5112
|
+
}
|
|
5113
|
+
function readWriteEpoch(ql) {
|
|
5114
|
+
if (!ql || typeof ql !== "object") return void 0;
|
|
5115
|
+
const epoch = ql.writeEpoch;
|
|
5116
|
+
if (!epoch || typeof epoch !== "object") return void 0;
|
|
5117
|
+
const seam = epoch;
|
|
5118
|
+
if (typeof seam.current !== "number" || typeof seam.bump !== "function" || typeof seam.subscribe !== "function") {
|
|
5119
|
+
return void 0;
|
|
5120
|
+
}
|
|
5121
|
+
return seam.current;
|
|
5122
|
+
}
|
|
5123
|
+
var localizationSettingsStates = /* @__PURE__ */ new WeakMap();
|
|
5124
|
+
var localizationNoSettingsState = { gen: 0 };
|
|
5125
|
+
function localizationSettingsState(settings) {
|
|
5126
|
+
if (!settings || typeof settings !== "object") return localizationNoSettingsState;
|
|
5127
|
+
const existing = localizationSettingsStates.get(settings);
|
|
5128
|
+
if (existing) return existing;
|
|
5129
|
+
const state = { gen: 0 };
|
|
5130
|
+
localizationSettingsStates.set(settings, state);
|
|
5131
|
+
const subscribe = settings.subscribe;
|
|
5132
|
+
if (typeof subscribe === "function") {
|
|
5133
|
+
try {
|
|
5134
|
+
subscribe.call(
|
|
5135
|
+
settings,
|
|
5136
|
+
"localization",
|
|
5137
|
+
() => {
|
|
5138
|
+
state.gen += 1;
|
|
5139
|
+
}
|
|
5140
|
+
);
|
|
5141
|
+
} catch {
|
|
5142
|
+
}
|
|
5143
|
+
}
|
|
5144
|
+
return state;
|
|
5145
|
+
}
|
|
5146
|
+
var localizationCache = /* @__PURE__ */ new WeakMap();
|
|
5147
|
+
function localizationEntryIsLive(entry, epoch, settings) {
|
|
5148
|
+
if (entry.kind === "failure") return true;
|
|
5149
|
+
return entry.epoch === epoch && entry.settings === settings && entry.settingsGen === settings.gen;
|
|
5150
|
+
}
|
|
5151
|
+
function putLocalizationEntry(ql, key, entry) {
|
|
5152
|
+
const bucket = localizationCache.get(ql) ?? /* @__PURE__ */ new Map();
|
|
5153
|
+
bucket.set(key, entry);
|
|
5154
|
+
localizationCache.set(ql, bucket);
|
|
5155
|
+
}
|
|
4804
5156
|
async function resolveLocalizationContext(input) {
|
|
4805
|
-
const { ql, tenantId, userId } = input;
|
|
5157
|
+
const { ql, settings, tenantId, userId } = input;
|
|
4806
5158
|
const cacheKey = `${tenantId ?? ""}|${userId ?? ""}`;
|
|
4807
|
-
|
|
4808
|
-
|
|
4809
|
-
|
|
4810
|
-
|
|
4811
|
-
|
|
4812
|
-
|
|
4813
|
-
|
|
4814
|
-
|
|
4815
|
-
|
|
5159
|
+
const cacheable = Boolean(ql) && typeof ql === "object";
|
|
5160
|
+
const epoch = cacheable ? readWriteEpoch(ql) : void 0;
|
|
5161
|
+
const settingsState = localizationSettingsState(settings);
|
|
5162
|
+
if (cacheable) {
|
|
5163
|
+
const hit = localizationCache.get(ql)?.get(cacheKey);
|
|
5164
|
+
if (hit && hit.expiresAt > Date.now() && localizationEntryIsLive(hit, epoch, settingsState)) {
|
|
5165
|
+
return hit.value;
|
|
5166
|
+
}
|
|
5167
|
+
}
|
|
5168
|
+
const { value, backendFailed } = await resolveLocalizationContextUncached(input);
|
|
5169
|
+
if (!cacheable) return value;
|
|
5170
|
+
if (backendFailed) {
|
|
5171
|
+
putLocalizationEntry(ql, cacheKey, {
|
|
5172
|
+
value,
|
|
5173
|
+
expiresAt: Date.now() + LOCALIZATION_FAILURE_CACHE_TTL_MS,
|
|
5174
|
+
kind: "failure"
|
|
5175
|
+
});
|
|
5176
|
+
return value;
|
|
5177
|
+
}
|
|
5178
|
+
const ttlMs = localizationSuccessCacheTtlMs();
|
|
5179
|
+
if (epoch !== void 0 && ttlMs > 0) {
|
|
5180
|
+
putLocalizationEntry(ql, cacheKey, {
|
|
5181
|
+
value,
|
|
5182
|
+
expiresAt: Date.now() + ttlMs,
|
|
5183
|
+
kind: "success",
|
|
5184
|
+
epoch,
|
|
5185
|
+
settings: settingsState,
|
|
5186
|
+
settingsGen: settingsState.gen
|
|
5187
|
+
});
|
|
5188
|
+
} else {
|
|
5189
|
+
localizationCache.get(ql)?.delete(cacheKey);
|
|
4816
5190
|
}
|
|
4817
5191
|
return value;
|
|
4818
5192
|
}
|
|
4819
5193
|
async function resolveLocalizationContextUncached(input) {
|
|
4820
5194
|
const { ql, settings, tenantId, userId } = input;
|
|
4821
|
-
let
|
|
5195
|
+
let backendFailed = false;
|
|
4822
5196
|
try {
|
|
4823
5197
|
if (settings && typeof settings.get === "function") {
|
|
4824
5198
|
const sctx = { tenantId, userId };
|
|
@@ -4832,33 +5206,22 @@ async function resolveLocalizationContextUncached(input) {
|
|
|
4832
5206
|
localeRes = many.locale;
|
|
4833
5207
|
currencyRes = many.currency;
|
|
4834
5208
|
} catch {
|
|
4835
|
-
failed = true;
|
|
4836
5209
|
}
|
|
4837
5210
|
} else {
|
|
4838
5211
|
[tzRes, localeRes, currencyRes] = await Promise.all([
|
|
4839
|
-
settings.get("localization", "timezone", sctx).catch(() =>
|
|
4840
|
-
|
|
4841
|
-
|
|
4842
|
-
}),
|
|
4843
|
-
settings.get("localization", "locale", sctx).catch(() => {
|
|
4844
|
-
failed = true;
|
|
4845
|
-
return void 0;
|
|
4846
|
-
}),
|
|
4847
|
-
settings.get("localization", "currency", sctx).catch(() => {
|
|
4848
|
-
failed = true;
|
|
4849
|
-
return void 0;
|
|
4850
|
-
})
|
|
5212
|
+
settings.get("localization", "timezone", sctx).catch(() => void 0),
|
|
5213
|
+
settings.get("localization", "locale", sctx).catch(() => void 0),
|
|
5214
|
+
settings.get("localization", "currency", sctx).catch(() => void 0)
|
|
4851
5215
|
]);
|
|
4852
5216
|
}
|
|
4853
5217
|
const tz = coerceTimeZone(tzRes?.value);
|
|
4854
5218
|
const locale = coerceLocale(localeRes?.value);
|
|
4855
5219
|
const currency = coerceCurrency(currencyRes?.value);
|
|
4856
5220
|
if (tz || locale || currency) {
|
|
4857
|
-
return { value: { timezone: tz ?? "UTC", locale: locale ?? "en-US", currency },
|
|
5221
|
+
return { value: { timezone: tz ?? "UTC", locale: locale ?? "en-US", currency }, backendFailed: false };
|
|
4858
5222
|
}
|
|
4859
5223
|
}
|
|
4860
5224
|
} catch {
|
|
4861
|
-
failed = true;
|
|
4862
5225
|
}
|
|
4863
5226
|
let rows = [];
|
|
4864
5227
|
if (ql && typeof ql.find === "function") {
|
|
@@ -4871,7 +5234,7 @@ async function resolveLocalizationContextUncached(input) {
|
|
|
4871
5234
|
if (result && result.value) result = result.value;
|
|
4872
5235
|
rows = Array.isArray(result) ? result : [];
|
|
4873
5236
|
} catch {
|
|
4874
|
-
|
|
5237
|
+
backendFailed = true;
|
|
4875
5238
|
}
|
|
4876
5239
|
}
|
|
4877
5240
|
const valueOf = (k) => rows.find((r) => r.key === k)?.value;
|
|
@@ -4881,7 +5244,7 @@ async function resolveLocalizationContextUncached(input) {
|
|
|
4881
5244
|
locale: coerceLocale(valueOf("locale")) ?? "en-US",
|
|
4882
5245
|
currency: coerceCurrency(valueOf("currency"))
|
|
4883
5246
|
},
|
|
4884
|
-
|
|
5247
|
+
backendFailed
|
|
4885
5248
|
};
|
|
4886
5249
|
}
|
|
4887
5250
|
|
|
@@ -5043,7 +5406,7 @@ function shouldDenyAnonymous(input) {
|
|
|
5043
5406
|
var ADMIN_STANDING_SURFACE = {
|
|
5044
5407
|
sys_permission_set: {
|
|
5045
5408
|
role: "derives",
|
|
5046
|
-
reason: "The row `
|
|
5409
|
+
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.",
|
|
5047
5410
|
columns: [
|
|
5048
5411
|
"id",
|
|
5049
5412
|
"name",
|
|
@@ -5085,8 +5448,14 @@ var ADMIN_STANDING_SURFACE = {
|
|
|
5085
5448
|
]
|
|
5086
5449
|
},
|
|
5087
5450
|
sys_user: {
|
|
5088
|
-
role: "
|
|
5089
|
-
reason: "
|
|
5451
|
+
role: "derives",
|
|
5452
|
+
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.",
|
|
5453
|
+
columns: [
|
|
5454
|
+
"id",
|
|
5455
|
+
"email",
|
|
5456
|
+
"email_verified",
|
|
5457
|
+
"ai_access"
|
|
5458
|
+
]
|
|
5090
5459
|
},
|
|
5091
5460
|
sys_user_position: {
|
|
5092
5461
|
role: "reads-only",
|
|
@@ -5101,6 +5470,13 @@ var ADMIN_STANDING_SURFACE = {
|
|
|
5101
5470
|
reason: "Position-bound permission sets (\xA76a). Contributes ids to `psIds` \u2014 and therefore names to `permissions` \u2014 but not to `unscopedUserPsIds`, which is the set \xA76b tests for platform-admin standing."
|
|
5102
5471
|
}
|
|
5103
5472
|
};
|
|
5473
|
+
var ADMIN_STANDING_NON_TABLE_INPUTS = [
|
|
5474
|
+
{
|
|
5475
|
+
kind: "env",
|
|
5476
|
+
name: "OS_PLATFORM_OWNER_EMAIL",
|
|
5477
|
+
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."
|
|
5478
|
+
}
|
|
5479
|
+
];
|
|
5104
5480
|
function adminStandingTables() {
|
|
5105
5481
|
return Object.entries(ADMIN_STANDING_SURFACE).filter(([, t]) => t.role === "derives").map(([name]) => name).sort();
|
|
5106
5482
|
}
|
|
@@ -5132,6 +5508,9 @@ function withoutOperationPrivateKeys(exec) {
|
|
|
5132
5508
|
return out;
|
|
5133
5509
|
}
|
|
5134
5510
|
|
|
5511
|
+
// src/security/authz-invalidation-channel.ts
|
|
5512
|
+
var AUTHZ_INVALIDATED_CHANNEL = "authz.invalidated";
|
|
5513
|
+
|
|
5135
5514
|
// src/utils/datetime.ts
|
|
5136
5515
|
var import_data = require("@objectstack/spec/data");
|
|
5137
5516
|
function calendarPartsInTz(d, tz) {
|
|
@@ -5412,7 +5791,7 @@ function omitInternalFieldsFromWriteResponse(schema, records) {
|
|
|
5412
5791
|
}
|
|
5413
5792
|
|
|
5414
5793
|
// src/utils/migration-journal.ts
|
|
5415
|
-
var
|
|
5794
|
+
var import_node_crypto4 = require("crypto");
|
|
5416
5795
|
var import_system4 = require("@objectstack/spec/system");
|
|
5417
5796
|
var SYSTEM_CTX = { isSystem: true };
|
|
5418
5797
|
var DEFAULT_CHUNK_SIZE = 200;
|
|
@@ -5467,7 +5846,7 @@ function hashMigrationPlan(plan, chunks) {
|
|
|
5467
5846
|
steps: plan.steps.map((s) => s.name),
|
|
5468
5847
|
chunks: chunks.map((c) => [c.stepIndex, c.offset, c.length])
|
|
5469
5848
|
});
|
|
5470
|
-
return (0,
|
|
5849
|
+
return (0, import_node_crypto4.createHash)("sha256").update(shape, "utf8").digest("hex").slice(0, 32);
|
|
5471
5850
|
}
|
|
5472
5851
|
async function appendEvent(engine, event, execContext) {
|
|
5473
5852
|
await engine.insert(
|
|
@@ -5540,7 +5919,7 @@ async function runMigrationJournal(engine, plan, options = {}) {
|
|
|
5540
5919
|
}
|
|
5541
5920
|
const { chunks, planHash, rowsByStep } = await loadPlan(engine, plan, options.chunkSize);
|
|
5542
5921
|
const resuming = Boolean(options.runId);
|
|
5543
|
-
const runId = options.runId ?? (0,
|
|
5922
|
+
const runId = options.runId ?? (0, import_node_crypto4.randomUUID)();
|
|
5544
5923
|
let events = [];
|
|
5545
5924
|
let seq = 0;
|
|
5546
5925
|
let committed = /* @__PURE__ */ new Set();
|
|
@@ -6032,6 +6411,110 @@ function isUninterpretableTemporalComparand(kind, value) {
|
|
|
6032
6411
|
return !(readsAsWallClock(s) || readsAsInstant(s));
|
|
6033
6412
|
}
|
|
6034
6413
|
|
|
6414
|
+
// src/utils/metadata-activation-store.ts
|
|
6415
|
+
var METADATA_ACTIVATION_TABLE = "sys_metadata_activation";
|
|
6416
|
+
var SYSTEM_CTX2 = { isSystem: true, positions: [], permissions: [] };
|
|
6417
|
+
var InMemoryMetadataActivationStore = class {
|
|
6418
|
+
constructor() {
|
|
6419
|
+
this.rows = /* @__PURE__ */ new Map();
|
|
6420
|
+
}
|
|
6421
|
+
async list() {
|
|
6422
|
+
return [...this.rows.values()];
|
|
6423
|
+
}
|
|
6424
|
+
async setActive(row) {
|
|
6425
|
+
this.rows.set(row.name, { ...row });
|
|
6426
|
+
}
|
|
6427
|
+
};
|
|
6428
|
+
var ObjectStoreMetadataActivationStore = class {
|
|
6429
|
+
constructor(engine, metadataType) {
|
|
6430
|
+
this.engine = engine;
|
|
6431
|
+
this.metadataType = metadataType;
|
|
6432
|
+
}
|
|
6433
|
+
/**
|
|
6434
|
+
* Every row of this type. Read once at boot to hydrate the consumer's
|
|
6435
|
+
* projection.
|
|
6436
|
+
*
|
|
6437
|
+
* The only scoping is the `metadata_type` discriminator: the ledger is
|
|
6438
|
+
* deployment-wide and has no tenant column, so there is no second axis to
|
|
6439
|
+
* filter on (see the module header).
|
|
6440
|
+
*/
|
|
6441
|
+
async list() {
|
|
6442
|
+
const rows = await this.engine.find(METADATA_ACTIVATION_TABLE, {
|
|
6443
|
+
where: { metadata_type: this.metadataType },
|
|
6444
|
+
context: SYSTEM_CTX2
|
|
6445
|
+
});
|
|
6446
|
+
if (!Array.isArray(rows)) return [];
|
|
6447
|
+
const out = [];
|
|
6448
|
+
for (const row of rows) {
|
|
6449
|
+
const r = row;
|
|
6450
|
+
if (typeof r.name !== "string" || !r.name) continue;
|
|
6451
|
+
out.push({
|
|
6452
|
+
name: r.name,
|
|
6453
|
+
packageId: typeof r.package_id === "string" ? r.package_id : "",
|
|
6454
|
+
// The column defaults to `true`; only an explicit `false`
|
|
6455
|
+
// disarms. A driver that round-trips booleans as 0/1
|
|
6456
|
+
// (SQLite/libsql) is read through the same `=== false || === 0`
|
|
6457
|
+
// test, so a `0` is not mistaken for `true`.
|
|
6458
|
+
active: !(r.active === false || r.active === 0)
|
|
6459
|
+
});
|
|
6460
|
+
}
|
|
6461
|
+
return out;
|
|
6462
|
+
}
|
|
6463
|
+
/**
|
|
6464
|
+
* Insert or update the row for one packaged artifact.
|
|
6465
|
+
*
|
|
6466
|
+
* Read-then-write rather than a blind upsert because the object's
|
|
6467
|
+
* uniqueness is a DECLARED index (`unique: 'global'` over
|
|
6468
|
+
* `(metadata_type, name)`), not a primary key this store controls: there is
|
|
6469
|
+
* no id to collide on, so an insert-and-catch could not tell "already
|
|
6470
|
+
* there" from a real store failure.
|
|
6471
|
+
*
|
|
6472
|
+
* That index is also why taking the FIRST match is taking the only one: the
|
|
6473
|
+
* read below is keyed on exactly the index's two columns, so it can match
|
|
6474
|
+
* at most one row. It used to pick the first row with a NULL organization
|
|
6475
|
+
* out of the result, back when the table carried a reserved tenant column;
|
|
6476
|
+
* with no such column the set it was choosing from can no longer hold more
|
|
6477
|
+
* than one member.
|
|
6478
|
+
*/
|
|
6479
|
+
async setActive(row) {
|
|
6480
|
+
const existing = await this.engine.find(METADATA_ACTIVATION_TABLE, {
|
|
6481
|
+
where: { metadata_type: this.metadataType, name: row.name },
|
|
6482
|
+
context: SYSTEM_CTX2
|
|
6483
|
+
});
|
|
6484
|
+
const current = Array.isArray(existing) ? existing[0] : void 0;
|
|
6485
|
+
if (current && current.id != null) {
|
|
6486
|
+
await this.engine.update(
|
|
6487
|
+
METADATA_ACTIVATION_TABLE,
|
|
6488
|
+
{ id: current.id, active: row.active, package_id: row.packageId },
|
|
6489
|
+
{ context: SYSTEM_CTX2 }
|
|
6490
|
+
);
|
|
6491
|
+
return;
|
|
6492
|
+
}
|
|
6493
|
+
await this.engine.insert(
|
|
6494
|
+
METADATA_ACTIVATION_TABLE,
|
|
6495
|
+
{
|
|
6496
|
+
metadata_type: this.metadataType,
|
|
6497
|
+
name: row.name,
|
|
6498
|
+
package_id: row.packageId,
|
|
6499
|
+
active: row.active
|
|
6500
|
+
},
|
|
6501
|
+
{ context: SYSTEM_CTX2 }
|
|
6502
|
+
);
|
|
6503
|
+
}
|
|
6504
|
+
/**
|
|
6505
|
+
* Read the backing table once so a misconfiguration surfaces at BOOT
|
|
6506
|
+
* rather than as a failed toggle later. Throws the driver error verbatim —
|
|
6507
|
+
* `no such table: sys_metadata_activation` means the object was never
|
|
6508
|
+
* registered (or its schema never synced) in this composition.
|
|
6509
|
+
*
|
|
6510
|
+
* ⚠️ Unscoped by design: the question is "does the TABLE read at all",
|
|
6511
|
+
* which is a property of the composition, not of one `metadata_type`.
|
|
6512
|
+
*/
|
|
6513
|
+
async probe() {
|
|
6514
|
+
await this.engine.find(METADATA_ACTIVATION_TABLE, { where: {}, limit: 1, context: SYSTEM_CTX2 });
|
|
6515
|
+
}
|
|
6516
|
+
};
|
|
6517
|
+
|
|
6035
6518
|
// src/utils/record-not-found.ts
|
|
6036
6519
|
function recordNotFoundError(object, id) {
|
|
6037
6520
|
const err = new Error(`Record ${id} not found in ${object}`);
|
|
@@ -6042,6 +6525,45 @@ function recordNotFoundError(object, id) {
|
|
|
6042
6525
|
}
|
|
6043
6526
|
|
|
6044
6527
|
// src/health-monitor.ts
|
|
6528
|
+
var RECOVERY_IS_THRESHOLD_GATED = {
|
|
6529
|
+
degraded: true,
|
|
6530
|
+
unhealthy: true,
|
|
6531
|
+
failed: true,
|
|
6532
|
+
recovering: true,
|
|
6533
|
+
healthy: false,
|
|
6534
|
+
unknown: false
|
|
6535
|
+
};
|
|
6536
|
+
function healthMonitorRefusal(message) {
|
|
6537
|
+
const err = new Error(message);
|
|
6538
|
+
err.code = "VALIDATION_ERROR";
|
|
6539
|
+
err.status = 400;
|
|
6540
|
+
return err;
|
|
6541
|
+
}
|
|
6542
|
+
var RETIRED_HEALTH_CHECK_KEYS = [
|
|
6543
|
+
[
|
|
6544
|
+
"autoRestart",
|
|
6545
|
+
"'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."
|
|
6546
|
+
],
|
|
6547
|
+
[
|
|
6548
|
+
"maxRestartAttempts",
|
|
6549
|
+
"'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."
|
|
6550
|
+
],
|
|
6551
|
+
[
|
|
6552
|
+
"restartBackoff",
|
|
6553
|
+
"'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."
|
|
6554
|
+
]
|
|
6555
|
+
];
|
|
6556
|
+
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.";
|
|
6557
|
+
function assertNoRetiredKeys(pluginName, config) {
|
|
6558
|
+
for (const [key, guidance] of RETIRED_HEALTH_CHECK_KEYS) {
|
|
6559
|
+
if (!Object.prototype.hasOwnProperty.call(config, key)) {
|
|
6560
|
+
continue;
|
|
6561
|
+
}
|
|
6562
|
+
throw healthMonitorRefusal(
|
|
6563
|
+
`[HealthMonitor] Plugin '${pluginName}': ${guidance}${RESTART_IS_THE_HOSTS_JOB}`
|
|
6564
|
+
);
|
|
6565
|
+
}
|
|
6566
|
+
}
|
|
6045
6567
|
var PluginHealthMonitor = class {
|
|
6046
6568
|
constructor(logger) {
|
|
6047
6569
|
this.healthChecks = /* @__PURE__ */ new Map();
|
|
@@ -6050,18 +6572,17 @@ var PluginHealthMonitor = class {
|
|
|
6050
6572
|
this.checkIntervals = /* @__PURE__ */ new Map();
|
|
6051
6573
|
this.failureCounters = /* @__PURE__ */ new Map();
|
|
6052
6574
|
this.successCounters = /* @__PURE__ */ new Map();
|
|
6053
|
-
this.restartAttempts = /* @__PURE__ */ new Map();
|
|
6054
6575
|
this.logger = logger.child({ component: "HealthMonitor" });
|
|
6055
6576
|
}
|
|
6056
6577
|
/**
|
|
6057
6578
|
* Register a plugin for health monitoring
|
|
6058
6579
|
*/
|
|
6059
6580
|
registerPlugin(pluginName, config) {
|
|
6581
|
+
assertNoRetiredKeys(pluginName, config);
|
|
6060
6582
|
this.healthChecks.set(pluginName, config);
|
|
6061
6583
|
this.healthStatus.set(pluginName, "unknown");
|
|
6062
6584
|
this.failureCounters.set(pluginName, 0);
|
|
6063
6585
|
this.successCounters.set(pluginName, 0);
|
|
6064
|
-
this.restartAttempts.set(pluginName, 0);
|
|
6065
6586
|
this.logger.info("Plugin registered for health monitoring", {
|
|
6066
6587
|
plugin: pluginName,
|
|
6067
6588
|
interval: config.interval
|
|
@@ -6113,6 +6634,7 @@ var PluginHealthMonitor = class {
|
|
|
6113
6634
|
let status = "healthy";
|
|
6114
6635
|
let message;
|
|
6115
6636
|
const checks = [];
|
|
6637
|
+
let failureRoute;
|
|
6116
6638
|
try {
|
|
6117
6639
|
if (config.checkMethod && typeof plugin[config.checkMethod] === "function") {
|
|
6118
6640
|
const checkResult = await this.raceCheckTimeout(
|
|
@@ -6133,8 +6655,8 @@ var PluginHealthMonitor = class {
|
|
|
6133
6655
|
if (status === "healthy") {
|
|
6134
6656
|
this.successCounters.set(pluginName, (this.successCounters.get(pluginName) || 0) + 1);
|
|
6135
6657
|
this.failureCounters.set(pluginName, 0);
|
|
6136
|
-
const currentStatus = this.healthStatus.get(pluginName);
|
|
6137
|
-
if (currentStatus
|
|
6658
|
+
const currentStatus = this.healthStatus.get(pluginName) ?? "unknown";
|
|
6659
|
+
if (RECOVERY_IS_THRESHOLD_GATED[currentStatus]) {
|
|
6138
6660
|
const successCount = this.successCounters.get(pluginName) || 0;
|
|
6139
6661
|
if (successCount >= config.successThreshold) {
|
|
6140
6662
|
this.healthStatus.set(pluginName, "healthy");
|
|
@@ -6146,27 +6668,11 @@ var PluginHealthMonitor = class {
|
|
|
6146
6668
|
this.healthStatus.set(pluginName, "healthy");
|
|
6147
6669
|
}
|
|
6148
6670
|
} else {
|
|
6149
|
-
|
|
6150
|
-
this.successCounters.set(pluginName, 0);
|
|
6151
|
-
const failureCount = this.failureCounters.get(pluginName) || 0;
|
|
6152
|
-
if (failureCount >= config.failureThreshold) {
|
|
6153
|
-
this.healthStatus.set(pluginName, "unhealthy");
|
|
6154
|
-
this.logger.warn("Plugin marked as unhealthy", {
|
|
6155
|
-
plugin: pluginName,
|
|
6156
|
-
failures: failureCount
|
|
6157
|
-
});
|
|
6158
|
-
if (config.autoRestart) {
|
|
6159
|
-
await this.attemptRestart(pluginName, plugin, config);
|
|
6160
|
-
}
|
|
6161
|
-
} else {
|
|
6162
|
-
this.healthStatus.set(pluginName, "degraded");
|
|
6163
|
-
}
|
|
6671
|
+
failureRoute = "returned";
|
|
6164
6672
|
}
|
|
6165
6673
|
} catch (error) {
|
|
6166
6674
|
status = "failed";
|
|
6167
6675
|
message = error instanceof Error ? error.message : "Unknown error";
|
|
6168
|
-
this.failureCounters.set(pluginName, (this.failureCounters.get(pluginName) || 0) + 1);
|
|
6169
|
-
this.healthStatus.set(pluginName, "failed");
|
|
6170
6676
|
checks.push({
|
|
6171
6677
|
name: "health-check",
|
|
6172
6678
|
status: "failed",
|
|
@@ -6176,6 +6682,10 @@ var PluginHealthMonitor = class {
|
|
|
6176
6682
|
plugin: pluginName,
|
|
6177
6683
|
error
|
|
6178
6684
|
});
|
|
6685
|
+
failureRoute = "thrown";
|
|
6686
|
+
}
|
|
6687
|
+
if (failureRoute) {
|
|
6688
|
+
this.recordFailedRound(pluginName, config, failureRoute);
|
|
6179
6689
|
}
|
|
6180
6690
|
const report = {
|
|
6181
6691
|
status: this.healthStatus.get(pluginName) || "unknown",
|
|
@@ -6189,56 +6699,42 @@ var PluginHealthMonitor = class {
|
|
|
6189
6699
|
this.healthReports.set(pluginName, report);
|
|
6190
6700
|
}
|
|
6191
6701
|
/**
|
|
6192
|
-
*
|
|
6702
|
+
* Handle one failed round — the single path BOTH failure routes take.
|
|
6703
|
+
*
|
|
6704
|
+
* `performHealthCheck` can fail two disjoint ways: the check *returns* a
|
|
6705
|
+
* failure (`false` or `{ status: 'unhealthy' }`), or it *throws* — which by
|
|
6706
|
+
* `raceCheckTimeout` includes every `timeout` overrun, the severest case of
|
|
6707
|
+
* the two. The routes used to be handled in separate blocks, and only the
|
|
6708
|
+
* returned one cleared `successCounters`, so the counters a declared
|
|
6709
|
+
* `failureThreshold` / `successThreshold` are counted with depended on which
|
|
6710
|
+
* way the round happened to fail (#11852).
|
|
6711
|
+
*
|
|
6712
|
+
* What stays route-specific is the *status label*, deliberately. A throw is
|
|
6713
|
+
* the separate `failed` status applied immediately with no threshold — that
|
|
6714
|
+
* is the documented contract (`content/docs/protocol/kernel/lifecycle.mdx`,
|
|
6715
|
+
* "Custom Health Checks") and is pinned by the timeout test. Only the
|
|
6716
|
+
* counters are shared, because that is what `failureThreshold` declares, and
|
|
6717
|
+
* it does not name a route.
|
|
6718
|
+
*
|
|
6719
|
+
* This round ENDS here. Nothing is done TO the plugin — see the #12032 note
|
|
6720
|
+
* on the class: a monitor that cannot re-initialise a plugin has no business
|
|
6721
|
+
* destroying one.
|
|
6193
6722
|
*/
|
|
6194
|
-
|
|
6195
|
-
const
|
|
6196
|
-
|
|
6197
|
-
|
|
6198
|
-
|
|
6199
|
-
|
|
6200
|
-
});
|
|
6723
|
+
recordFailedRound(pluginName, config, route) {
|
|
6724
|
+
const failureCount = (this.failureCounters.get(pluginName) || 0) + 1;
|
|
6725
|
+
this.failureCounters.set(pluginName, failureCount);
|
|
6726
|
+
this.successCounters.set(pluginName, 0);
|
|
6727
|
+
const thresholdReached = failureCount >= config.failureThreshold;
|
|
6728
|
+
if (route === "thrown") {
|
|
6201
6729
|
this.healthStatus.set(pluginName, "failed");
|
|
6202
|
-
|
|
6203
|
-
|
|
6204
|
-
|
|
6205
|
-
const delay = this.calculateBackoff(attempts, config.restartBackoff);
|
|
6206
|
-
this.logger.info("Scheduling plugin restart", {
|
|
6207
|
-
plugin: pluginName,
|
|
6208
|
-
attempt: attempts + 1,
|
|
6209
|
-
delay
|
|
6210
|
-
});
|
|
6211
|
-
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
6212
|
-
try {
|
|
6213
|
-
if (plugin.destroy) {
|
|
6214
|
-
await plugin.destroy();
|
|
6215
|
-
}
|
|
6216
|
-
this.logger.info("Plugin restarted", { plugin: pluginName });
|
|
6217
|
-
this.failureCounters.set(pluginName, 0);
|
|
6218
|
-
this.successCounters.set(pluginName, 0);
|
|
6219
|
-
this.healthStatus.set(pluginName, "recovering");
|
|
6220
|
-
} catch (error) {
|
|
6221
|
-
this.logger.error("Plugin restart failed", {
|
|
6730
|
+
} else if (thresholdReached) {
|
|
6731
|
+
this.healthStatus.set(pluginName, "unhealthy");
|
|
6732
|
+
this.logger.warn("Plugin marked as unhealthy", {
|
|
6222
6733
|
plugin: pluginName,
|
|
6223
|
-
|
|
6734
|
+
failures: failureCount
|
|
6224
6735
|
});
|
|
6225
|
-
|
|
6226
|
-
|
|
6227
|
-
}
|
|
6228
|
-
/**
|
|
6229
|
-
* Calculate backoff delay for restarts
|
|
6230
|
-
*/
|
|
6231
|
-
calculateBackoff(attempt, strategy) {
|
|
6232
|
-
const baseDelay = 1e3;
|
|
6233
|
-
switch (strategy) {
|
|
6234
|
-
case "fixed":
|
|
6235
|
-
return baseDelay;
|
|
6236
|
-
case "linear":
|
|
6237
|
-
return baseDelay * (attempt + 1);
|
|
6238
|
-
case "exponential":
|
|
6239
|
-
return baseDelay * Math.pow(2, attempt);
|
|
6240
|
-
default:
|
|
6241
|
-
return baseDelay;
|
|
6736
|
+
} else {
|
|
6737
|
+
this.healthStatus.set(pluginName, "degraded");
|
|
6242
6738
|
}
|
|
6243
6739
|
}
|
|
6244
6740
|
/**
|
|
@@ -6271,7 +6767,6 @@ var PluginHealthMonitor = class {
|
|
|
6271
6767
|
this.healthReports.clear();
|
|
6272
6768
|
this.failureCounters.clear();
|
|
6273
6769
|
this.successCounters.clear();
|
|
6274
|
-
this.restartAttempts.clear();
|
|
6275
6770
|
this.logger.info("Health monitor shutdown complete");
|
|
6276
6771
|
}
|
|
6277
6772
|
/**
|
|
@@ -6312,7 +6807,7 @@ var PluginHealthMonitor = class {
|
|
|
6312
6807
|
};
|
|
6313
6808
|
|
|
6314
6809
|
// src/hot-reload.ts
|
|
6315
|
-
var
|
|
6810
|
+
var import_node_crypto5 = require("crypto");
|
|
6316
6811
|
var generateUUID = () => {
|
|
6317
6812
|
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
|
6318
6813
|
return crypto.randomUUID();
|
|
@@ -6323,6 +6818,42 @@ var generateUUID = () => {
|
|
|
6323
6818
|
return v.toString(16);
|
|
6324
6819
|
});
|
|
6325
6820
|
};
|
|
6821
|
+
var HONOURED_STATE_STRATEGIES = ["memory", "none"];
|
|
6822
|
+
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.";
|
|
6823
|
+
function hotReloadRefusal(message) {
|
|
6824
|
+
const err = new Error(message);
|
|
6825
|
+
err.code = "VALIDATION_ERROR";
|
|
6826
|
+
err.status = 400;
|
|
6827
|
+
return err;
|
|
6828
|
+
}
|
|
6829
|
+
function assertHonouredStateStrategy(pluginName, strategy) {
|
|
6830
|
+
if (HONOURED_STATE_STRATEGIES.includes(strategy)) {
|
|
6831
|
+
return;
|
|
6832
|
+
}
|
|
6833
|
+
const shown = typeof strategy === "string" ? `'${strategy}'` : String(strategy);
|
|
6834
|
+
const retired = strategy === "disk" || strategy === "distributed";
|
|
6835
|
+
throw hotReloadRefusal(
|
|
6836
|
+
`[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.")
|
|
6837
|
+
);
|
|
6838
|
+
}
|
|
6839
|
+
var RETIRED_HOT_RELOAD_KEYS = [
|
|
6840
|
+
[
|
|
6841
|
+
"distributedConfig",
|
|
6842
|
+
"'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."
|
|
6843
|
+
],
|
|
6844
|
+
[
|
|
6845
|
+
"watchPatterns",
|
|
6846
|
+
"'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."
|
|
6847
|
+
]
|
|
6848
|
+
];
|
|
6849
|
+
function assertNoRetiredKeys2(pluginName, config) {
|
|
6850
|
+
for (const [key, guidance] of RETIRED_HOT_RELOAD_KEYS) {
|
|
6851
|
+
if (!Object.prototype.hasOwnProperty.call(config, key)) {
|
|
6852
|
+
continue;
|
|
6853
|
+
}
|
|
6854
|
+
throw hotReloadRefusal(`[HotReload] Plugin '${pluginName}': ${guidance}`);
|
|
6855
|
+
}
|
|
6856
|
+
}
|
|
6326
6857
|
var PluginStateManager = class {
|
|
6327
6858
|
constructor(logger) {
|
|
6328
6859
|
this.stateSnapshots = /* @__PURE__ */ new Map();
|
|
@@ -6349,17 +6880,6 @@ var PluginStateManager = class {
|
|
|
6349
6880
|
this.memoryStore.set(snapshotId, snapshot);
|
|
6350
6881
|
this.logger.debug("State saved to memory", { pluginId, snapshotId });
|
|
6351
6882
|
break;
|
|
6352
|
-
case "disk":
|
|
6353
|
-
this.memoryStore.set(snapshotId, snapshot);
|
|
6354
|
-
this.logger.debug("State saved to disk (memory fallback)", { pluginId, snapshotId });
|
|
6355
|
-
break;
|
|
6356
|
-
case "distributed":
|
|
6357
|
-
this.memoryStore.set(snapshotId, snapshot);
|
|
6358
|
-
this.logger.debug("State saved to distributed store (memory fallback)", {
|
|
6359
|
-
pluginId,
|
|
6360
|
-
snapshotId
|
|
6361
|
-
});
|
|
6362
|
-
break;
|
|
6363
6883
|
case "none":
|
|
6364
6884
|
this.logger.debug("State persistence disabled", { pluginId });
|
|
6365
6885
|
break;
|
|
@@ -6407,7 +6927,7 @@ var PluginStateManager = class {
|
|
|
6407
6927
|
*/
|
|
6408
6928
|
calculateChecksum(state) {
|
|
6409
6929
|
const stateStr = JSON.stringify(state);
|
|
6410
|
-
return (0,
|
|
6930
|
+
return (0, import_node_crypto5.createHash)("sha256").update(stateStr).digest("hex");
|
|
6411
6931
|
}
|
|
6412
6932
|
/**
|
|
6413
6933
|
* Shutdown state manager
|
|
@@ -6421,7 +6941,6 @@ var PluginStateManager = class {
|
|
|
6421
6941
|
var HotReloadManager = class {
|
|
6422
6942
|
constructor(logger) {
|
|
6423
6943
|
this.reloadConfigs = /* @__PURE__ */ new Map();
|
|
6424
|
-
this.watchHandles = /* @__PURE__ */ new Map();
|
|
6425
6944
|
this.reloadTimers = /* @__PURE__ */ new Map();
|
|
6426
6945
|
this.logger = logger.child({ component: "HotReload" });
|
|
6427
6946
|
this.stateManager = new PluginStateManager(logger);
|
|
@@ -6430,6 +6949,8 @@ var HotReloadManager = class {
|
|
|
6430
6949
|
* Register a plugin for hot reload
|
|
6431
6950
|
*/
|
|
6432
6951
|
registerPlugin(pluginName, config) {
|
|
6952
|
+
assertHonouredStateStrategy(pluginName, config.stateStrategy);
|
|
6953
|
+
assertNoRetiredKeys2(pluginName, config);
|
|
6433
6954
|
if (!config.enabled) {
|
|
6434
6955
|
this.logger.debug("Hot reload disabled for plugin", { plugin: pluginName });
|
|
6435
6956
|
return;
|
|
@@ -6437,32 +6958,45 @@ var HotReloadManager = class {
|
|
|
6437
6958
|
this.reloadConfigs.set(pluginName, config);
|
|
6438
6959
|
this.logger.info("Plugin registered for hot reload", {
|
|
6439
6960
|
plugin: pluginName,
|
|
6440
|
-
watchPatterns: config.watchPatterns,
|
|
6441
6961
|
stateStrategy: config.stateStrategy
|
|
6442
6962
|
});
|
|
6443
6963
|
}
|
|
6444
6964
|
/**
|
|
6445
|
-
*
|
|
6965
|
+
* Refuse the file-watching call this class never implemented (#12428).
|
|
6966
|
+
*
|
|
6967
|
+
* The body used to be a guard plus `logger.info('File watching started')`
|
|
6968
|
+
* over an in-source note saying real watching "would require chokidar or
|
|
6969
|
+
* similar". Nothing was ever watched, so an operator who set
|
|
6970
|
+
* `enabled: true` and read that line at INFO had been told the opposite of
|
|
6971
|
+
* the truth — positive confirmation of a capability that did not exist.
|
|
6972
|
+
* ADR-0049 leaves three states and this surface qualified for none of the
|
|
6973
|
+
* other two: no runtime composes this class, so ENFORCE would build for a
|
|
6974
|
+
* caller that does not exist, and no roadmap entry anywhere claims the
|
|
6975
|
+
* feature, so EXPERIMENTAL would be a promise nobody made.
|
|
6976
|
+
*
|
|
6977
|
+
* Kept as a throwing door rather than deleted: removing the method leaves a
|
|
6978
|
+
* JavaScript host a bare `TypeError: not a function` with no prescription,
|
|
6979
|
+
* and this is the one place a caller of the old placeholder is guaranteed
|
|
6980
|
+
* to arrive. The refusal carries an ADR-0112 envelope so it can be asserted
|
|
6981
|
+
* rather than merely caught.
|
|
6446
6982
|
*/
|
|
6447
6983
|
startWatching(pluginName) {
|
|
6448
|
-
|
|
6449
|
-
|
|
6450
|
-
|
|
6451
|
-
}
|
|
6452
|
-
this.logger.info("File watching started", {
|
|
6453
|
-
plugin: pluginName,
|
|
6454
|
-
patterns: config.watchPatterns
|
|
6455
|
-
});
|
|
6984
|
+
throw hotReloadRefusal(
|
|
6985
|
+
`[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.`
|
|
6986
|
+
);
|
|
6456
6987
|
}
|
|
6457
6988
|
/**
|
|
6458
|
-
*
|
|
6989
|
+
* Cancel a pending debounced reload for a plugin.
|
|
6990
|
+
*
|
|
6991
|
+
* The name is historical (#12428). This never stopped a watcher, because
|
|
6992
|
+
* nothing in this class ever started one: its `watchHandles` cleanup branch
|
|
6993
|
+
* read a Map that had no writer anywhere in the tree, so the branch was
|
|
6994
|
+
* structurally unreachable rather than merely untaken, and it left with
|
|
6995
|
+
* `startWatching`'s placeholder. What survives is the half that always did
|
|
6996
|
+
* something — the debounce timer armed by `scheduleReload` is cleared, so a
|
|
6997
|
+
* reload that was scheduled but has not fired yet is cancelled.
|
|
6459
6998
|
*/
|
|
6460
6999
|
stopWatching(pluginName) {
|
|
6461
|
-
const handle = this.watchHandles.get(pluginName);
|
|
6462
|
-
if (handle) {
|
|
6463
|
-
this.watchHandles.delete(pluginName);
|
|
6464
|
-
this.logger.info("File watching stopped", { plugin: pluginName });
|
|
6465
|
-
}
|
|
6466
7000
|
const timer = this.reloadTimers.get(pluginName);
|
|
6467
7001
|
if (timer) {
|
|
6468
7002
|
clearTimeout(timer);
|
|
@@ -6605,14 +7139,10 @@ var HotReloadManager = class {
|
|
|
6605
7139
|
* Shutdown hot reload manager
|
|
6606
7140
|
*/
|
|
6607
7141
|
shutdown() {
|
|
6608
|
-
for (const pluginName of this.watchHandles.keys()) {
|
|
6609
|
-
this.stopWatching(pluginName);
|
|
6610
|
-
}
|
|
6611
7142
|
for (const timer of this.reloadTimers.values()) {
|
|
6612
7143
|
clearTimeout(timer);
|
|
6613
7144
|
}
|
|
6614
7145
|
this.reloadConfigs.clear();
|
|
6615
|
-
this.watchHandles.clear();
|
|
6616
7146
|
this.reloadTimers.clear();
|
|
6617
7147
|
this.stateManager.shutdown();
|
|
6618
7148
|
this.logger.info("Hot reload manager shutdown complete");
|
|
@@ -7022,6 +7552,7 @@ var NamespaceResolver = class {
|
|
|
7022
7552
|
var import_contracts = require("@objectstack/spec/contracts");
|
|
7023
7553
|
// Annotate the CommonJS export names for ESM import in node:
|
|
7024
7554
|
0 && (module.exports = {
|
|
7555
|
+
ADMIN_STANDING_NON_TABLE_INPUTS,
|
|
7025
7556
|
ADMIN_STANDING_SURFACE,
|
|
7026
7557
|
ANONYMOUS_DENY_BODY,
|
|
7027
7558
|
ANONYMOUS_DENY_CODE,
|
|
@@ -7030,11 +7561,19 @@ var import_contracts = require("@objectstack/spec/contracts");
|
|
|
7030
7561
|
API_KEY_PREFIX,
|
|
7031
7562
|
AUDIENCE_BINDING_SUGGESTION_STATUSES,
|
|
7032
7563
|
AUDIENCE_BINDING_SUGGESTION_STATUS_VALUES,
|
|
7564
|
+
AUTHZ_GRANTS_CACHE_TTL_ENV,
|
|
7565
|
+
AUTHZ_INVALIDATED_CHANNEL,
|
|
7566
|
+
AUTHZ_STORE_UNAVAILABLE_CODE,
|
|
7567
|
+
AUTHZ_STORE_UNAVAILABLE_MESSAGE,
|
|
7568
|
+
AUTHZ_STORE_UNAVAILABLE_STATUS,
|
|
7569
|
+
AuthzStoreUnavailableError,
|
|
7033
7570
|
CORE_FALLBACK_FACTORIES,
|
|
7034
7571
|
DependencyResolver,
|
|
7035
7572
|
ENTRY_EXECUTION_CONTEXT_FIELDS,
|
|
7036
7573
|
HotReloadManager,
|
|
7574
|
+
InMemoryMetadataActivationStore,
|
|
7037
7575
|
LiteKernel,
|
|
7576
|
+
METADATA_ACTIVATION_TABLE,
|
|
7038
7577
|
MigrationJournalRefusal,
|
|
7039
7578
|
MigrationPlanRegistry,
|
|
7040
7579
|
NamespaceResolver,
|
|
@@ -7042,10 +7581,11 @@ var import_contracts = require("@objectstack/spec/contracts");
|
|
|
7042
7581
|
ObjectKernel,
|
|
7043
7582
|
ObjectKernelBase,
|
|
7044
7583
|
ObjectLogger,
|
|
7584
|
+
ObjectStoreMetadataActivationStore,
|
|
7585
|
+
PLATFORM_ADMIN_EMAIL_SEPARATOR,
|
|
7045
7586
|
POSTURE_INJECTION_RULE,
|
|
7046
7587
|
POSTURE_LADDER,
|
|
7047
7588
|
POSTURE_RANK,
|
|
7048
|
-
PluginConfigValidator,
|
|
7049
7589
|
PluginHealthMonitor,
|
|
7050
7590
|
PluginLoader,
|
|
7051
7591
|
PluginPermissionEnforcer,
|
|
@@ -7054,6 +7594,7 @@ var import_contracts = require("@objectstack/spec/contracts");
|
|
|
7054
7594
|
PluginSecurityScanner,
|
|
7055
7595
|
PluginSignatureVerifier,
|
|
7056
7596
|
QA,
|
|
7597
|
+
SERVICE_NOT_REGISTERED_CODE,
|
|
7057
7598
|
SIGNATURE_ALG,
|
|
7058
7599
|
SecurePluginContext,
|
|
7059
7600
|
SemanticVersionManager,
|
|
@@ -7063,6 +7604,7 @@ var import_contracts = require("@objectstack/spec/contracts");
|
|
|
7063
7604
|
UnresolvedFilterTokenError,
|
|
7064
7605
|
adminStandingColumns,
|
|
7065
7606
|
adminStandingTables,
|
|
7607
|
+
artifactPackageId,
|
|
7066
7608
|
assembleExecutionContext,
|
|
7067
7609
|
assembleExecutionContextOrGuest,
|
|
7068
7610
|
assertInitServiceRequirements,
|
|
@@ -7081,7 +7623,6 @@ var import_contracts = require("@objectstack/spec/contracts");
|
|
|
7081
7623
|
createMemoryJob,
|
|
7082
7624
|
createMemoryMetadata,
|
|
7083
7625
|
createMemoryQueue,
|
|
7084
|
-
createPluginConfigValidator,
|
|
7085
7626
|
createPluginPermissionEnforcer,
|
|
7086
7627
|
deepMerge,
|
|
7087
7628
|
defaultIsTransientError,
|
|
@@ -7093,48 +7634,67 @@ var import_contracts = require("@objectstack/spec/contracts");
|
|
|
7093
7634
|
extractApiKey,
|
|
7094
7635
|
filterTokenContextFrom,
|
|
7095
7636
|
findInterruptedRuns,
|
|
7637
|
+
formatIntegrityViolation,
|
|
7096
7638
|
generateApiKey,
|
|
7097
7639
|
generateEd25519KeyPair,
|
|
7098
7640
|
getEnv,
|
|
7099
7641
|
getMemoryUsage,
|
|
7642
|
+
hasPlatformAdminStanding,
|
|
7100
7643
|
hashApiKey,
|
|
7101
7644
|
hashMigrationPlan,
|
|
7102
7645
|
isAudienceBindingSuggestionStatus,
|
|
7103
7646
|
isAuthGateAllowlisted,
|
|
7647
|
+
isAuthzStoreUnavailableError,
|
|
7648
|
+
isConfiguredPlatformAdminEmail,
|
|
7104
7649
|
isExpired,
|
|
7105
7650
|
isGrantActive,
|
|
7106
7651
|
isGrantExpired,
|
|
7107
7652
|
isNode,
|
|
7108
7653
|
isRowActive,
|
|
7654
|
+
isServiceNotRegisteredError,
|
|
7109
7655
|
isUninterpretableTemporalComparand,
|
|
7656
|
+
matchesConfiguredPlatformAdmin,
|
|
7110
7657
|
nextUtcCalendarDay,
|
|
7111
7658
|
normalizeAuthGate,
|
|
7659
|
+
normalizePlatformAdminEmail,
|
|
7112
7660
|
omitInternalFieldsFromWriteResponse,
|
|
7661
|
+
parsePlatformAdminEmails,
|
|
7113
7662
|
parseScopes,
|
|
7114
7663
|
parseSignature,
|
|
7115
7664
|
planChunks,
|
|
7116
7665
|
postureVisibleRows,
|
|
7117
7666
|
readAuthoredTranslationLayer,
|
|
7667
|
+
readAuthzGrantsCacheTtlMs,
|
|
7118
7668
|
readRunJournal,
|
|
7119
7669
|
recordNotFoundError,
|
|
7670
|
+
reportAuthzCachePosture,
|
|
7671
|
+
reportLegacyPlatformAdminGrant,
|
|
7672
|
+
resetLegacyPlatformAdminGrantReport,
|
|
7673
|
+
resetPlatformAdminEmailMemo,
|
|
7120
7674
|
resolveApiKeyAdmission,
|
|
7121
7675
|
resolveApiKeyPrincipal,
|
|
7676
|
+
resolveArtifactPackageOrder,
|
|
7677
|
+
resolveAuthzCachePosture,
|
|
7122
7678
|
resolveAuthzContext,
|
|
7123
7679
|
resolveFilterToken,
|
|
7124
7680
|
resolveFilterTokens,
|
|
7125
7681
|
resolveLocale,
|
|
7126
7682
|
resolveLocalizationContext,
|
|
7683
|
+
resolvePlatformAdminEmails,
|
|
7127
7684
|
resolvePluginOrder,
|
|
7128
7685
|
resolveUserAuthzGrants,
|
|
7129
7686
|
resumeMigrationJournal,
|
|
7687
|
+
rethrowAuthzStoreUnavailable,
|
|
7130
7688
|
runMigrationJournal,
|
|
7131
7689
|
safeExit,
|
|
7690
|
+
setPlatformAdminConfigSink,
|
|
7132
7691
|
shouldDenyAnonymous,
|
|
7133
7692
|
signPayload,
|
|
7134
7693
|
temporalComparandKind,
|
|
7135
7694
|
unknownAudienceBindingSuggestionStatusMessage,
|
|
7136
7695
|
utcInstantMs,
|
|
7137
7696
|
validateInitServiceContract,
|
|
7697
|
+
verifyIntegrity,
|
|
7138
7698
|
verifyPayload,
|
|
7139
7699
|
verifyPlatformSignature,
|
|
7140
7700
|
verifyPluginArtifact,
|