@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.cjs
CHANGED
|
@@ -30,7 +30,9 @@ 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,
|
|
35
|
+
ADVISORY_SAMPLE_ROWS: () => ADVISORY_SAMPLE_ROWS,
|
|
34
36
|
ANONYMOUS_DENY_BODY: () => ANONYMOUS_DENY_BODY,
|
|
35
37
|
ANONYMOUS_DENY_CODE: () => ANONYMOUS_DENY_CODE,
|
|
36
38
|
ANONYMOUS_DENY_MESSAGE: () => ANONYMOUS_DENY_MESSAGE,
|
|
@@ -38,11 +40,19 @@ __export(index_exports, {
|
|
|
38
40
|
API_KEY_PREFIX: () => API_KEY_PREFIX,
|
|
39
41
|
AUDIENCE_BINDING_SUGGESTION_STATUSES: () => AUDIENCE_BINDING_SUGGESTION_STATUSES,
|
|
40
42
|
AUDIENCE_BINDING_SUGGESTION_STATUS_VALUES: () => AUDIENCE_BINDING_SUGGESTION_STATUS_VALUES,
|
|
43
|
+
AUTHZ_GRANTS_CACHE_TTL_ENV: () => AUTHZ_GRANTS_CACHE_TTL_ENV,
|
|
44
|
+
AUTHZ_INVALIDATED_CHANNEL: () => AUTHZ_INVALIDATED_CHANNEL,
|
|
45
|
+
AUTHZ_STORE_UNAVAILABLE_CODE: () => AUTHZ_STORE_UNAVAILABLE_CODE,
|
|
46
|
+
AUTHZ_STORE_UNAVAILABLE_MESSAGE: () => AUTHZ_STORE_UNAVAILABLE_MESSAGE,
|
|
47
|
+
AUTHZ_STORE_UNAVAILABLE_STATUS: () => AUTHZ_STORE_UNAVAILABLE_STATUS,
|
|
48
|
+
AuthzStoreUnavailableError: () => AuthzStoreUnavailableError,
|
|
41
49
|
CORE_FALLBACK_FACTORIES: () => CORE_FALLBACK_FACTORIES,
|
|
42
50
|
DependencyResolver: () => DependencyResolver,
|
|
43
51
|
ENTRY_EXECUTION_CONTEXT_FIELDS: () => ENTRY_EXECUTION_CONTEXT_FIELDS,
|
|
44
52
|
HotReloadManager: () => HotReloadManager,
|
|
53
|
+
InMemoryMetadataActivationStore: () => InMemoryMetadataActivationStore,
|
|
45
54
|
LiteKernel: () => LiteKernel,
|
|
55
|
+
METADATA_ACTIVATION_TABLE: () => METADATA_ACTIVATION_TABLE,
|
|
46
56
|
MigrationJournalRefusal: () => MigrationJournalRefusal,
|
|
47
57
|
MigrationPlanRegistry: () => MigrationPlanRegistry,
|
|
48
58
|
NamespaceResolver: () => NamespaceResolver,
|
|
@@ -50,18 +60,19 @@ __export(index_exports, {
|
|
|
50
60
|
ObjectKernel: () => ObjectKernel,
|
|
51
61
|
ObjectKernelBase: () => ObjectKernelBase,
|
|
52
62
|
ObjectLogger: () => ObjectLogger,
|
|
63
|
+
ObjectStoreMetadataActivationStore: () => ObjectStoreMetadataActivationStore,
|
|
64
|
+
PLATFORM_ADMIN_EMAIL_SEPARATOR: () => PLATFORM_ADMIN_EMAIL_SEPARATOR,
|
|
53
65
|
POSTURE_INJECTION_RULE: () => POSTURE_INJECTION_RULE,
|
|
54
66
|
POSTURE_LADDER: () => POSTURE_LADDER,
|
|
55
67
|
POSTURE_RANK: () => POSTURE_RANK,
|
|
56
|
-
PluginConfigValidator: () => PluginConfigValidator,
|
|
57
68
|
PluginHealthMonitor: () => PluginHealthMonitor,
|
|
58
69
|
PluginLoader: () => PluginLoader,
|
|
59
70
|
PluginPermissionEnforcer: () => PluginPermissionEnforcer,
|
|
60
71
|
PluginPermissionManager: () => PluginPermissionManager,
|
|
61
72
|
PluginSandboxRuntime: () => PluginSandboxRuntime,
|
|
62
|
-
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,70 @@ __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,
|
|
123
|
+
isAggregatingAdvisories: () => isAggregatingAdvisories,
|
|
110
124
|
isAudienceBindingSuggestionStatus: () => isAudienceBindingSuggestionStatus,
|
|
111
125
|
isAuthGateAllowlisted: () => isAuthGateAllowlisted,
|
|
126
|
+
isAuthzStoreUnavailableError: () => isAuthzStoreUnavailableError,
|
|
127
|
+
isConfiguredPlatformAdminEmail: () => isConfiguredPlatformAdminEmail,
|
|
112
128
|
isExpired: () => isExpired,
|
|
113
129
|
isGrantActive: () => isGrantActive,
|
|
114
130
|
isGrantExpired: () => isGrantExpired,
|
|
115
131
|
isNode: () => isNode,
|
|
116
132
|
isRowActive: () => isRowActive,
|
|
133
|
+
isServiceNotRegisteredError: () => isServiceNotRegisteredError,
|
|
117
134
|
isUninterpretableTemporalComparand: () => isUninterpretableTemporalComparand,
|
|
135
|
+
matchesConfiguredPlatformAdmin: () => matchesConfiguredPlatformAdmin,
|
|
118
136
|
nextUtcCalendarDay: () => import_data.nextUtcCalendarDay,
|
|
119
137
|
normalizeAuthGate: () => normalizeAuthGate,
|
|
138
|
+
normalizePlatformAdminEmail: () => normalizePlatformAdminEmail,
|
|
120
139
|
omitInternalFieldsFromWriteResponse: () => omitInternalFieldsFromWriteResponse,
|
|
140
|
+
parsePlatformAdminEmails: () => parsePlatformAdminEmails,
|
|
121
141
|
parseScopes: () => parseScopes,
|
|
122
142
|
parseSignature: () => parseSignature,
|
|
123
143
|
planChunks: () => planChunks,
|
|
124
144
|
postureVisibleRows: () => postureVisibleRows,
|
|
125
145
|
readAuthoredTranslationLayer: () => readAuthoredTranslationLayer,
|
|
146
|
+
readAuthzGrantsCacheTtlMs: () => readAuthzGrantsCacheTtlMs,
|
|
126
147
|
readRunJournal: () => readRunJournal,
|
|
148
|
+
recordAdvisoryHit: () => recordAdvisoryHit,
|
|
127
149
|
recordNotFoundError: () => recordNotFoundError,
|
|
150
|
+
reportAuthzCachePosture: () => reportAuthzCachePosture,
|
|
151
|
+
reportLegacyPlatformAdminGrant: () => reportLegacyPlatformAdminGrant,
|
|
152
|
+
resetLegacyPlatformAdminGrantReport: () => resetLegacyPlatformAdminGrantReport,
|
|
153
|
+
resetPlatformAdminEmailMemo: () => resetPlatformAdminEmailMemo,
|
|
128
154
|
resolveApiKeyAdmission: () => resolveApiKeyAdmission,
|
|
129
155
|
resolveApiKeyPrincipal: () => resolveApiKeyPrincipal,
|
|
156
|
+
resolveArtifactPackageOrder: () => resolveArtifactPackageOrder,
|
|
157
|
+
resolveAuthzCachePosture: () => resolveAuthzCachePosture,
|
|
130
158
|
resolveAuthzContext: () => resolveAuthzContext,
|
|
131
159
|
resolveFilterToken: () => resolveFilterToken,
|
|
132
160
|
resolveFilterTokens: () => resolveFilterTokens,
|
|
133
161
|
resolveLocale: () => resolveLocale,
|
|
134
162
|
resolveLocalizationContext: () => resolveLocalizationContext,
|
|
163
|
+
resolvePlatformAdminEmails: () => resolvePlatformAdminEmails,
|
|
135
164
|
resolvePluginOrder: () => resolvePluginOrder,
|
|
136
165
|
resolveUserAuthzGrants: () => resolveUserAuthzGrants,
|
|
137
166
|
resumeMigrationJournal: () => resumeMigrationJournal,
|
|
167
|
+
rethrowAuthzStoreUnavailable: () => rethrowAuthzStoreUnavailable,
|
|
138
168
|
runMigrationJournal: () => runMigrationJournal,
|
|
169
|
+
runWithAdvisoryAggregation: () => runWithAdvisoryAggregation,
|
|
139
170
|
safeExit: () => safeExit,
|
|
171
|
+
setPlatformAdminConfigSink: () => setPlatformAdminConfigSink,
|
|
140
172
|
shouldDenyAnonymous: () => shouldDenyAnonymous,
|
|
141
173
|
signPayload: () => signPayload,
|
|
142
174
|
temporalComparandKind: () => temporalComparandKind,
|
|
143
175
|
unknownAudienceBindingSuggestionStatusMessage: () => unknownAudienceBindingSuggestionStatusMessage,
|
|
144
176
|
utcInstantMs: () => import_data.utcInstantMs,
|
|
145
177
|
validateInitServiceContract: () => validateInitServiceContract,
|
|
178
|
+
verifyIntegrity: () => verifyIntegrity,
|
|
146
179
|
verifyPayload: () => verifyPayload,
|
|
147
180
|
verifyPlatformSignature: () => verifyPlatformSignature,
|
|
148
181
|
verifyPluginArtifact: () => verifyPluginArtifact,
|
|
@@ -858,138 +891,6 @@ function createLogger(config) {
|
|
|
858
891
|
// src/kernel.ts
|
|
859
892
|
var import_system3 = require("@objectstack/spec/system");
|
|
860
893
|
|
|
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
894
|
// src/security/plugin-artifact-signature.ts
|
|
994
895
|
var import_node_crypto = require("crypto");
|
|
995
896
|
var SIGNATURE_ALG = "ed25519";
|
|
@@ -1086,6 +987,43 @@ async function verifyPluginArtifact(input, keys) {
|
|
|
1086
987
|
return { ok: true, publisherVerified: publisher.verified, platformVerified };
|
|
1087
988
|
}
|
|
1088
989
|
|
|
990
|
+
// src/service-not-registered.ts
|
|
991
|
+
var SERVICE_NOT_REGISTERED_CODE = "SERVICE_NOT_REGISTERED";
|
|
992
|
+
var SERVICE_NOT_REGISTERED_BRAND = "__objectstackServiceNotRegistered";
|
|
993
|
+
function serviceNotRegisteredError(name) {
|
|
994
|
+
const err = new Error(`Service '${name}' not found`);
|
|
995
|
+
err[SERVICE_NOT_REGISTERED_BRAND] = true;
|
|
996
|
+
err.code = SERVICE_NOT_REGISTERED_CODE;
|
|
997
|
+
err.serviceName = name;
|
|
998
|
+
return err;
|
|
999
|
+
}
|
|
1000
|
+
function isServiceNotRegisteredError(err) {
|
|
1001
|
+
return typeof err === "object" && err !== null && err[SERVICE_NOT_REGISTERED_BRAND] === true;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// src/plugin-contract.ts
|
|
1005
|
+
var import_kernel = require("@objectstack/spec/kernel");
|
|
1006
|
+
var PLUGIN_CONTRACT_VIOLATION_CODE = "PLUGIN_CONTRACT_VIOLATION";
|
|
1007
|
+
function assertPluginContract(plugin) {
|
|
1008
|
+
const result = import_kernel.PluginSchema.safeParse(plugin);
|
|
1009
|
+
if (result.success) {
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
const issues = result.error.issues.filter((issue) => issue.path[0] !== "version");
|
|
1013
|
+
if (issues.length === 0) {
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
const first = issues[0];
|
|
1017
|
+
const at = first.path.length > 0 ? first.path.join(".") : "(root)";
|
|
1018
|
+
const id = plugin.id;
|
|
1019
|
+
const named = typeof id === "string" && id.length > 0 ? `'${plugin.name}' (id: ${id})` : `'${plugin.name}'`;
|
|
1020
|
+
const error = new Error(
|
|
1021
|
+
`${PLUGIN_CONTRACT_VIOLATION_CODE}: plugin ${named} is refused by the declared plugin contract at '${at}': ${first.message}`
|
|
1022
|
+
);
|
|
1023
|
+
error.code = PLUGIN_CONTRACT_VIOLATION_CODE;
|
|
1024
|
+
throw error;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1089
1027
|
// src/plugin-loader.ts
|
|
1090
1028
|
var ServiceLifecycle = /* @__PURE__ */ ((ServiceLifecycle2) => {
|
|
1091
1029
|
ServiceLifecycle2["SINGLETON"] = "singleton";
|
|
@@ -1101,7 +1039,6 @@ var PluginLoader = class {
|
|
|
1101
1039
|
this.scopedServices = /* @__PURE__ */ new Map();
|
|
1102
1040
|
this.creating = /* @__PURE__ */ new Set();
|
|
1103
1041
|
this.logger = logger;
|
|
1104
|
-
this.configValidator = new PluginConfigValidator(logger);
|
|
1105
1042
|
}
|
|
1106
1043
|
/**
|
|
1107
1044
|
* Set the plugin context for service factories
|
|
@@ -1124,13 +1061,11 @@ var PluginLoader = class {
|
|
|
1124
1061
|
this.logger.info(`Loading plugin: ${plugin.name}`);
|
|
1125
1062
|
const metadata = this.toPluginMetadata(plugin);
|
|
1126
1063
|
this.validatePluginStructure(metadata);
|
|
1064
|
+
this.validatePluginContract(metadata);
|
|
1127
1065
|
const versionCheck = this.checkVersionCompatibility(metadata);
|
|
1128
1066
|
if (!versionCheck.compatible) {
|
|
1129
1067
|
throw new Error(`Version incompatible: ${versionCheck.message}`);
|
|
1130
1068
|
}
|
|
1131
|
-
if (metadata.configSchema) {
|
|
1132
|
-
this.validatePluginConfig(metadata);
|
|
1133
|
-
}
|
|
1134
1069
|
if (metadata.signature) {
|
|
1135
1070
|
await this.verifyPluginSignature(metadata);
|
|
1136
1071
|
}
|
|
@@ -1169,7 +1104,7 @@ var PluginLoader = class {
|
|
|
1169
1104
|
if (!registration) {
|
|
1170
1105
|
const instance = this.serviceInstances.get(name);
|
|
1171
1106
|
if (!instance) {
|
|
1172
|
-
throw
|
|
1107
|
+
throw serviceNotRegisteredError(name);
|
|
1173
1108
|
}
|
|
1174
1109
|
return instance;
|
|
1175
1110
|
}
|
|
@@ -1311,6 +1246,27 @@ var PluginLoader = class {
|
|
|
1311
1246
|
throw new Error(`Invalid semantic version: ${plugin.version}`);
|
|
1312
1247
|
}
|
|
1313
1248
|
}
|
|
1249
|
+
/**
|
|
1250
|
+
* Refuse a plugin object the DECLARED plugin contract refuses (#16049,
|
|
1251
|
+
* maintainer ruling 2026-09-06: "the protocol is the baseline; the runtime
|
|
1252
|
+
* aligns to it").
|
|
1253
|
+
*
|
|
1254
|
+
* The check itself — `PluginSchema.safeParse` for validation only, the
|
|
1255
|
+
* eight keys it reaches, the `version` exclusion and the
|
|
1256
|
+
* `PLUGIN_CONTRACT_VIOLATION` envelope — lives in `plugin-contract.ts`,
|
|
1257
|
+
* because since #16721 it is ONE statement run by BOTH kernels:
|
|
1258
|
+
* `LiteKernel.use()` calls it directly, and `ObjectKernel.use()` reaches
|
|
1259
|
+
* it here, through `loadPlugin`. That module's comment is the authority on
|
|
1260
|
+
* what is refused; this method adds nothing to it and subtracts nothing.
|
|
1261
|
+
*
|
|
1262
|
+
* What stays THIS loader's own, and is deliberately not shared: the
|
|
1263
|
+
* structural checks one call up ({@link validatePluginStructure} —
|
|
1264
|
+
* `name`, `init`, semver) and the version-compatibility check below.
|
|
1265
|
+
* The convergence is on the schema, not on the loader.
|
|
1266
|
+
*/
|
|
1267
|
+
validatePluginContract(plugin) {
|
|
1268
|
+
assertPluginContract(plugin);
|
|
1269
|
+
}
|
|
1314
1270
|
checkVersionCompatibility(plugin) {
|
|
1315
1271
|
const version = plugin.version;
|
|
1316
1272
|
if (!this.isValidSemanticVersion(version)) {
|
|
@@ -1329,16 +1285,6 @@ var PluginLoader = class {
|
|
|
1329
1285
|
const semverRegex = /^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/;
|
|
1330
1286
|
return semverRegex.test(version);
|
|
1331
1287
|
}
|
|
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
1288
|
async verifyPluginSignature(plugin) {
|
|
1343
1289
|
if (!plugin.signature) {
|
|
1344
1290
|
return;
|
|
@@ -1530,6 +1476,7 @@ function createMemoryI18n() {
|
|
|
1530
1476
|
const authored = /* @__PURE__ */ new Map();
|
|
1531
1477
|
let defaultLocale = "en";
|
|
1532
1478
|
let supportedLocales;
|
|
1479
|
+
let fallbackLocale;
|
|
1533
1480
|
function resolveKey(data, key) {
|
|
1534
1481
|
const parts = key.split(".");
|
|
1535
1482
|
let current = data;
|
|
@@ -1566,7 +1513,11 @@ function createMemoryI18n() {
|
|
|
1566
1513
|
_serviceName: "i18n",
|
|
1567
1514
|
t(key, locale, params) {
|
|
1568
1515
|
const data = resolveTranslations(locale) ?? mergedLocale(defaultLocale);
|
|
1569
|
-
|
|
1516
|
+
let value = data ? resolveKey(data, key) : void 0;
|
|
1517
|
+
if (value === void 0 && fallbackLocale && fallbackLocale !== locale) {
|
|
1518
|
+
const fallbackData = resolveTranslations(fallbackLocale);
|
|
1519
|
+
value = fallbackData ? resolveKey(fallbackData, key) : void 0;
|
|
1520
|
+
}
|
|
1570
1521
|
if (value == null) return key;
|
|
1571
1522
|
if (!params) return value;
|
|
1572
1523
|
return value.replace(/\{\{(\w+)\}\}/g, (_, name) => String(params[name] ?? `{{${name}}}`));
|
|
@@ -1623,15 +1574,30 @@ function createMemoryI18n() {
|
|
|
1623
1574
|
},
|
|
1624
1575
|
setDefaultLocale(locale) {
|
|
1625
1576
|
defaultLocale = locale;
|
|
1577
|
+
},
|
|
1578
|
+
/**
|
|
1579
|
+
* @see II18nService.setFallbackLocale — [#15694]
|
|
1580
|
+
*
|
|
1581
|
+
* ⛔ There is deliberately NO `getFallbackLocale()` beside this. The two
|
|
1582
|
+
* are different questions: this one is what the provider was TOLD, the
|
|
1583
|
+
* accessor is what the serving layer ASKS it in order to build the
|
|
1584
|
+
* metadata-document translators' fallback chain (#14882). Answering the
|
|
1585
|
+
* second from `defaultLocale` — the only value that was always available
|
|
1586
|
+
* here — would settle the default-locale contract question #14882 leaves
|
|
1587
|
+
* deliberately open, from a degraded provider. Without the accessor those
|
|
1588
|
+
* reads keep the resolvers' own default, which is known and intentional.
|
|
1589
|
+
*/
|
|
1590
|
+
setFallbackLocale(locale) {
|
|
1591
|
+
fallbackLocale = locale;
|
|
1626
1592
|
}
|
|
1627
1593
|
};
|
|
1628
1594
|
}
|
|
1629
1595
|
|
|
1630
1596
|
// src/metadata-service-contract.ts
|
|
1631
|
-
var
|
|
1597
|
+
var import_meta_spelling = require("@objectstack/spec/meta-spelling");
|
|
1632
1598
|
var REGISTER_REFUSAL_CODE = "VALIDATION_ERROR";
|
|
1633
1599
|
function canonicalMetadataServiceType(type) {
|
|
1634
|
-
return (0,
|
|
1600
|
+
return (0, import_meta_spelling.pluralToSingular)(type);
|
|
1635
1601
|
}
|
|
1636
1602
|
function registerRefusal(message) {
|
|
1637
1603
|
const err = new Error(message);
|
|
@@ -1948,6 +1914,7 @@ async function raceWithTimeout(operation, timeoutMs, createTimeoutError) {
|
|
|
1948
1914
|
}
|
|
1949
1915
|
|
|
1950
1916
|
// src/kernel.ts
|
|
1917
|
+
var DEGRADED_CAPABILITIES_SERVICE = "kernel.degraded-capabilities";
|
|
1951
1918
|
var ObjectKernel = class {
|
|
1952
1919
|
constructor(config = {}) {
|
|
1953
1920
|
this.plugins = /* @__PURE__ */ new Map();
|
|
@@ -1955,7 +1922,12 @@ var ObjectKernel = class {
|
|
|
1955
1922
|
this.hooks = /* @__PURE__ */ new Map();
|
|
1956
1923
|
this.state = "idle";
|
|
1957
1924
|
this.startedPlugins = /* @__PURE__ */ new Set();
|
|
1958
|
-
|
|
1925
|
+
/**
|
|
1926
|
+
* Plugin name -> elapsed milliseconds that plugin's `start()` took. These
|
|
1927
|
+
* are DURATIONS, never start instants; the old spelling `pluginStartTimes`
|
|
1928
|
+
* said the opposite of what it held.
|
|
1929
|
+
*/
|
|
1930
|
+
this.pluginStartupDurations = /* @__PURE__ */ new Map();
|
|
1959
1931
|
this.shutdownHandlers = [];
|
|
1960
1932
|
this.config = {
|
|
1961
1933
|
defaultStartupTimeout: 3e4,
|
|
@@ -2142,9 +2114,40 @@ var ObjectKernel = class {
|
|
|
2142
2114
|
}
|
|
2143
2115
|
if (missingCoreServices.length > 0) {
|
|
2144
2116
|
this.logger.warn(`System started with degraded capabilities. Missing core services: ${missingCoreServices.join(", ")}`);
|
|
2117
|
+
this.publishDegradedCapabilities(missingCoreServices);
|
|
2145
2118
|
}
|
|
2146
2119
|
this.logger.info("System requirement check passed");
|
|
2147
2120
|
}
|
|
2121
|
+
/**
|
|
2122
|
+
* Publish this boot's degraded-capabilities conclusion on
|
|
2123
|
+
* {@link DEGRADED_CAPABILITIES_SERVICE} — the data half of the warning
|
|
2124
|
+
* `validateSystemRequirements()` just logged (#16630).
|
|
2125
|
+
*
|
|
2126
|
+
* ⛔ Best-effort, and silent on failure BY DESIGN: this is a diagnostic
|
|
2127
|
+
* readout, and a readout must never be able to fail a boot that the kernel
|
|
2128
|
+
* has just decided is good enough to run. The one way `registerService`
|
|
2129
|
+
* can throw here is a name collision, which the guard above already
|
|
2130
|
+
* forecloses; the `catch` is there so that stays true if either ever
|
|
2131
|
+
* changes. (`recordSeedOutcome` in `@objectstack/runtime` states the same
|
|
2132
|
+
* rule for the same reason.)
|
|
2133
|
+
*
|
|
2134
|
+
* The value is FROZEN and holds a COPY. `getService` hands out the stored
|
|
2135
|
+
* reference, so an unfrozen live array would let any reader edit the
|
|
2136
|
+
* kernel's own record of what was missing — and this record exists
|
|
2137
|
+
* precisely so that two packages cannot disagree about it.
|
|
2138
|
+
*/
|
|
2139
|
+
publishDegradedCapabilities(missingCoreServices) {
|
|
2140
|
+
try {
|
|
2141
|
+
if (this.services.has(DEGRADED_CAPABILITIES_SERVICE) || this.pluginLoader.hasService(DEGRADED_CAPABILITIES_SERVICE)) {
|
|
2142
|
+
return;
|
|
2143
|
+
}
|
|
2144
|
+
this.registerService(
|
|
2145
|
+
DEGRADED_CAPABILITIES_SERVICE,
|
|
2146
|
+
Object.freeze({ missingCoreServices: Object.freeze([...missingCoreServices]) })
|
|
2147
|
+
);
|
|
2148
|
+
} catch {
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2148
2151
|
/**
|
|
2149
2152
|
* Bootstrap the kernel with enhanced features
|
|
2150
2153
|
*/
|
|
@@ -2257,11 +2260,23 @@ var ObjectKernel = class {
|
|
|
2257
2260
|
}
|
|
2258
2261
|
return results;
|
|
2259
2262
|
}
|
|
2263
|
+
/**
|
|
2264
|
+
* Per-plugin startup durations: plugin name -> elapsed milliseconds that
|
|
2265
|
+
* plugin's `start()` took. Not start instants -- see
|
|
2266
|
+
* {@link PluginStartupResult.durationMs}.
|
|
2267
|
+
*/
|
|
2268
|
+
getPluginStartupDurations() {
|
|
2269
|
+
return new Map(this.pluginStartupDurations);
|
|
2270
|
+
}
|
|
2260
2271
|
/**
|
|
2261
2272
|
* Get plugin startup metrics
|
|
2273
|
+
*
|
|
2274
|
+
* @deprecated Renamed to {@link ObjectKernel.getPluginStartupDurations},
|
|
2275
|
+
* which states what the values are. Retained as a delegating alias so
|
|
2276
|
+
* nothing has to change on this release; slated for removal.
|
|
2262
2277
|
*/
|
|
2263
2278
|
getPluginMetrics() {
|
|
2264
|
-
return
|
|
2279
|
+
return this.getPluginStartupDurations();
|
|
2265
2280
|
}
|
|
2266
2281
|
/**
|
|
2267
2282
|
* Whether a plugin with the given name has been registered on this kernel.
|
|
@@ -2383,11 +2398,14 @@ var ObjectKernel = class {
|
|
|
2383
2398
|
);
|
|
2384
2399
|
const duration = Date.now() - startTime;
|
|
2385
2400
|
this.startedPlugins.add(plugin.name);
|
|
2386
|
-
this.
|
|
2401
|
+
this.pluginStartupDurations.set(plugin.name, duration);
|
|
2387
2402
|
this.logger.debug(`Plugin started: ${plugin.name} (${duration}ms)`);
|
|
2388
2403
|
return {
|
|
2389
2404
|
success: true,
|
|
2390
2405
|
pluginName: plugin.name,
|
|
2406
|
+
durationMs: duration,
|
|
2407
|
+
// Deprecated alias carrying the same elapsed value; see
|
|
2408
|
+
// PluginStartupResult.startTime.
|
|
2391
2409
|
startTime: duration
|
|
2392
2410
|
};
|
|
2393
2411
|
} catch (error) {
|
|
@@ -2397,6 +2415,9 @@ var ObjectKernel = class {
|
|
|
2397
2415
|
success: false,
|
|
2398
2416
|
pluginName: plugin.name,
|
|
2399
2417
|
error,
|
|
2418
|
+
durationMs: duration,
|
|
2419
|
+
// Deprecated alias carrying the same elapsed value; see
|
|
2420
|
+
// PluginStartupResult.startTime.
|
|
2400
2421
|
startTime: duration,
|
|
2401
2422
|
timedOut: isTimeout
|
|
2402
2423
|
};
|
|
@@ -2512,6 +2533,67 @@ var ObjectKernel = class {
|
|
|
2512
2533
|
}
|
|
2513
2534
|
};
|
|
2514
2535
|
|
|
2536
|
+
// src/artifact-packages.ts
|
|
2537
|
+
var import_spec = require("@objectstack/spec");
|
|
2538
|
+
var MAX_REPORTED_ENTRY_ISSUES = 5;
|
|
2539
|
+
function refuse(code, message) {
|
|
2540
|
+
const err = new Error(message);
|
|
2541
|
+
err.code = code;
|
|
2542
|
+
err.status = 422;
|
|
2543
|
+
return err;
|
|
2544
|
+
}
|
|
2545
|
+
function artifactPackageId(manifest) {
|
|
2546
|
+
const id = manifest?.id || manifest?.name;
|
|
2547
|
+
return typeof id === "string" && id !== "" ? id : void 0;
|
|
2548
|
+
}
|
|
2549
|
+
function resolveArtifactPackageOrder(artifact) {
|
|
2550
|
+
const declared = artifact?.packages;
|
|
2551
|
+
if (declared === void 0 || declared === null) return [artifact];
|
|
2552
|
+
if (!Array.isArray(declared)) {
|
|
2553
|
+
throw refuse(
|
|
2554
|
+
"INVALID_ARTIFACT_PACKAGES",
|
|
2555
|
+
`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.`
|
|
2556
|
+
);
|
|
2557
|
+
}
|
|
2558
|
+
const nodes = /* @__PURE__ */ new Map();
|
|
2559
|
+
declared.forEach((entry, index) => {
|
|
2560
|
+
const verdict = import_spec.ArtifactPackageSchema.safeParse(entry);
|
|
2561
|
+
if (!verdict.success) {
|
|
2562
|
+
const issues = verdict.error.issues;
|
|
2563
|
+
const shown = issues.slice(0, MAX_REPORTED_ENTRY_ISSUES);
|
|
2564
|
+
throw refuse(
|
|
2565
|
+
"INVALID_ARTIFACT_PACKAGE_ENTRY",
|
|
2566
|
+
`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."
|
|
2567
|
+
);
|
|
2568
|
+
}
|
|
2569
|
+
const manifest = entry.manifest;
|
|
2570
|
+
const id = artifactPackageId(manifest);
|
|
2571
|
+
if (id === void 0) {
|
|
2572
|
+
throw refuse(
|
|
2573
|
+
"INVALID_ARTIFACT_PACKAGE_ENTRY",
|
|
2574
|
+
`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.`
|
|
2575
|
+
);
|
|
2576
|
+
}
|
|
2577
|
+
if (nodes.has(id)) {
|
|
2578
|
+
throw refuse(
|
|
2579
|
+
"DUPLICATE_ARTIFACT_PACKAGE",
|
|
2580
|
+
`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.`
|
|
2581
|
+
);
|
|
2582
|
+
}
|
|
2583
|
+
nodes.set(id, {
|
|
2584
|
+
// `name` is what `resolvePluginOrder` puts in its diagnostics; the MAP KEY
|
|
2585
|
+
// is what its edges resolve against. Both are the package id, so an error
|
|
2586
|
+
// it raises names the same string the artifact author wrote.
|
|
2587
|
+
name: id,
|
|
2588
|
+
optionalDependencies: Object.keys(
|
|
2589
|
+
manifest.dependencies ?? {}
|
|
2590
|
+
),
|
|
2591
|
+
manifest
|
|
2592
|
+
});
|
|
2593
|
+
});
|
|
2594
|
+
return resolvePluginOrder(nodes).map((node) => node.manifest);
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2515
2597
|
// src/lite-kernel.ts
|
|
2516
2598
|
var LiteKernel = class extends ObjectKernelBase {
|
|
2517
2599
|
constructor(config) {
|
|
@@ -2523,6 +2605,26 @@ var LiteKernel = class extends ObjectKernelBase {
|
|
|
2523
2605
|
* Register a plugin
|
|
2524
2606
|
* @param plugin - Plugin instance
|
|
2525
2607
|
*
|
|
2608
|
+
* A plugin object the DECLARED plugin contract refuses is refused here,
|
|
2609
|
+
* with `PLUGIN_CONTRACT_VIOLATION` — the same check, the same envelope,
|
|
2610
|
+
* that `ObjectKernel.use()` runs through `PluginLoader` (`plugin-contract.ts`
|
|
2611
|
+
* is the one statement both kernels call; #16721, maintainer ruling
|
|
2612
|
+
* 2026-09-08, option A under #9864's precedent that the kernels converge).
|
|
2613
|
+
*
|
|
2614
|
+
* This method used to write the object straight into the registry, so the
|
|
2615
|
+
* same plugin was accepted by this kernel and refused by `ObjectKernel` —
|
|
2616
|
+
* and `AGENTS.md` names THIS kernel for tests, so a plugin could be green
|
|
2617
|
+
* in vitest and refused at production boot. Measured before converging
|
|
2618
|
+
* (#16721 step 1): of 813 `LiteKernel.use()` calls reachable in this
|
|
2619
|
+
* repository's suites, 807 were accepted by the schema unchanged and the
|
|
2620
|
+
* six refusals came from three test-local fixture objects, none of them
|
|
2621
|
+
* product code.
|
|
2622
|
+
*
|
|
2623
|
+
* Ordering, and why it is pinned: state first (`validateIdle`), then the
|
|
2624
|
+
* contract, then registration — a refused plugin never reaches the
|
|
2625
|
+
* registry, so it can neither be booted nor supersede an earlier
|
|
2626
|
+
* registration under its name.
|
|
2627
|
+
*
|
|
2526
2628
|
* Duplicate names OVERWRITE, with one `warn` naming both versions — the
|
|
2527
2629
|
* declared contract in `plugin-registration.ts`, applied identically by
|
|
2528
2630
|
* `ObjectKernel.use()` (#9864, maintainer ruling 2026-08-19).
|
|
@@ -2536,6 +2638,7 @@ var LiteKernel = class extends ObjectKernelBase {
|
|
|
2536
2638
|
*/
|
|
2537
2639
|
use(plugin) {
|
|
2538
2640
|
this.validateIdle();
|
|
2641
|
+
assertPluginContract(plugin);
|
|
2539
2642
|
registerPluginByName(this.plugins, plugin, this.logger);
|
|
2540
2643
|
return this;
|
|
2541
2644
|
}
|
|
@@ -3241,6 +3344,55 @@ var PluginSignatureVerifier = class {
|
|
|
3241
3344
|
}
|
|
3242
3345
|
};
|
|
3243
3346
|
|
|
3347
|
+
// src/security/plugin-artifact-integrity.ts
|
|
3348
|
+
var import_node_crypto2 = require("crypto");
|
|
3349
|
+
var SRI_ALGORITHMS = /* @__PURE__ */ new Set(["sha256", "sha384", "sha512"]);
|
|
3350
|
+
function sriDigestFor(declared, data) {
|
|
3351
|
+
const dash = declared.indexOf("-");
|
|
3352
|
+
const alg = dash > 0 && SRI_ALGORITHMS.has(declared.slice(0, dash)) ? declared.slice(0, dash) : "sha256";
|
|
3353
|
+
return `${alg}-${(0, import_node_crypto2.createHash)(alg).update(data).digest("base64")}`;
|
|
3354
|
+
}
|
|
3355
|
+
function verifyIntegrity(files, integrity, options = {}) {
|
|
3356
|
+
if (integrity === null || integrity === void 0) {
|
|
3357
|
+
return { ok: true, skipped: true, checked: 0, violations: [] };
|
|
3358
|
+
}
|
|
3359
|
+
const exempt = new Set(options.exempt ?? []);
|
|
3360
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
3361
|
+
for (const f of files) {
|
|
3362
|
+
if (!exempt.has(f.path)) byPath.set(f.path, f.data);
|
|
3363
|
+
}
|
|
3364
|
+
const violations = [];
|
|
3365
|
+
let checked = 0;
|
|
3366
|
+
for (const [path, declaredRaw] of Object.entries(integrity)) {
|
|
3367
|
+
if (exempt.has(path)) continue;
|
|
3368
|
+
const declared = typeof declaredRaw === "string" ? declaredRaw : String(declaredRaw);
|
|
3369
|
+
const data = byPath.get(path);
|
|
3370
|
+
if (data === void 0) {
|
|
3371
|
+
violations.push({ kind: "missing_file", path, declared });
|
|
3372
|
+
continue;
|
|
3373
|
+
}
|
|
3374
|
+
checked++;
|
|
3375
|
+
const actual = sriDigestFor(declared, data);
|
|
3376
|
+
if (actual !== declared) violations.push({ kind: "digest_mismatch", path, declared, actual });
|
|
3377
|
+
}
|
|
3378
|
+
for (const path of [...byPath.keys()].sort()) {
|
|
3379
|
+
if (!Object.prototype.hasOwnProperty.call(integrity, path)) {
|
|
3380
|
+
violations.push({ kind: "extra_file", path });
|
|
3381
|
+
}
|
|
3382
|
+
}
|
|
3383
|
+
return { ok: violations.length === 0, skipped: false, checked, violations };
|
|
3384
|
+
}
|
|
3385
|
+
function formatIntegrityViolation(v) {
|
|
3386
|
+
switch (v.kind) {
|
|
3387
|
+
case "digest_mismatch":
|
|
3388
|
+
return `${v.path}: digest mismatch \u2014 manifest declares ${v.declared}, artifact bytes hash to ${v.actual}`;
|
|
3389
|
+
case "missing_file":
|
|
3390
|
+
return `${v.path}: declared in the integrity map but absent from the artifact`;
|
|
3391
|
+
case "extra_file":
|
|
3392
|
+
return `${v.path}: present in the artifact but not in the integrity map`;
|
|
3393
|
+
}
|
|
3394
|
+
}
|
|
3395
|
+
|
|
3244
3396
|
// src/security/plugin-permission-enforcer.ts
|
|
3245
3397
|
var PluginPermissionEnforcer = class {
|
|
3246
3398
|
constructor(logger) {
|
|
@@ -4112,239 +4264,17 @@ var _PluginSandboxRuntime = class _PluginSandboxRuntime {
|
|
|
4112
4264
|
_PluginSandboxRuntime.MONITORING_INTERVAL_MS = 5e3;
|
|
4113
4265
|
var PluginSandboxRuntime = _PluginSandboxRuntime;
|
|
4114
4266
|
|
|
4115
|
-
// src/security/security-scanner.ts
|
|
4116
|
-
var PluginSecurityScanner = class {
|
|
4117
|
-
constructor(logger, config) {
|
|
4118
|
-
// Known vulnerabilities database (CVE cache)
|
|
4119
|
-
this.vulnerabilityDb = /* @__PURE__ */ new Map();
|
|
4120
|
-
// Scan results cache
|
|
4121
|
-
this.scanResults = /* @__PURE__ */ new Map();
|
|
4122
|
-
this.passThreshold = 70;
|
|
4123
|
-
this.logger = logger.child({ component: "SecurityScanner" });
|
|
4124
|
-
if (config?.passThreshold !== void 0) {
|
|
4125
|
-
this.passThreshold = config.passThreshold;
|
|
4126
|
-
}
|
|
4127
|
-
}
|
|
4128
|
-
/**
|
|
4129
|
-
* Perform a comprehensive security scan on a plugin
|
|
4130
|
-
*/
|
|
4131
|
-
async scan(target) {
|
|
4132
|
-
this.logger.info("Starting security scan", {
|
|
4133
|
-
pluginId: target.pluginId,
|
|
4134
|
-
version: target.version
|
|
4135
|
-
});
|
|
4136
|
-
const issues = [];
|
|
4137
|
-
try {
|
|
4138
|
-
const codeIssues = await this.scanCode(target);
|
|
4139
|
-
issues.push(...codeIssues);
|
|
4140
|
-
const depIssues = await this.scanDependencies(target);
|
|
4141
|
-
issues.push(...depIssues);
|
|
4142
|
-
const malwareIssues = await this.scanMalware(target);
|
|
4143
|
-
issues.push(...malwareIssues);
|
|
4144
|
-
const licenseIssues = await this.scanLicenses(target);
|
|
4145
|
-
issues.push(...licenseIssues);
|
|
4146
|
-
const configIssues = await this.scanConfiguration(target);
|
|
4147
|
-
issues.push(...configIssues);
|
|
4148
|
-
const score = this.calculateSecurityScore(issues);
|
|
4149
|
-
const result = {
|
|
4150
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4151
|
-
scanner: { name: "ObjectStack Security Scanner", version: "1.0.0" },
|
|
4152
|
-
status: score >= this.passThreshold ? "passed" : "failed",
|
|
4153
|
-
vulnerabilities: issues.map((issue) => ({
|
|
4154
|
-
id: issue.id,
|
|
4155
|
-
severity: issue.severity,
|
|
4156
|
-
category: issue.category,
|
|
4157
|
-
title: issue.title,
|
|
4158
|
-
description: issue.description,
|
|
4159
|
-
location: issue.location ? `${issue.location.file}:${issue.location.line}` : void 0,
|
|
4160
|
-
remediation: issue.remediation,
|
|
4161
|
-
affectedVersions: [],
|
|
4162
|
-
exploitAvailable: false,
|
|
4163
|
-
patchAvailable: false
|
|
4164
|
-
})),
|
|
4165
|
-
summary: {
|
|
4166
|
-
totalVulnerabilities: issues.length,
|
|
4167
|
-
criticalCount: issues.filter((i) => i.severity === "critical").length,
|
|
4168
|
-
highCount: issues.filter((i) => i.severity === "high").length,
|
|
4169
|
-
mediumCount: issues.filter((i) => i.severity === "medium").length,
|
|
4170
|
-
lowCount: issues.filter((i) => i.severity === "low").length,
|
|
4171
|
-
infoCount: issues.filter((i) => i.severity === "info").length
|
|
4172
|
-
}
|
|
4173
|
-
};
|
|
4174
|
-
this.scanResults.set(`${target.pluginId}:${target.version}`, result);
|
|
4175
|
-
this.logger.info("Security scan complete", {
|
|
4176
|
-
pluginId: target.pluginId,
|
|
4177
|
-
score,
|
|
4178
|
-
status: result.status,
|
|
4179
|
-
summary: result.summary
|
|
4180
|
-
});
|
|
4181
|
-
return result;
|
|
4182
|
-
} catch (error) {
|
|
4183
|
-
this.logger.error("Security scan failed", {
|
|
4184
|
-
pluginId: target.pluginId,
|
|
4185
|
-
error
|
|
4186
|
-
});
|
|
4187
|
-
throw error;
|
|
4188
|
-
}
|
|
4189
|
-
}
|
|
4190
|
-
/**
|
|
4191
|
-
* Scan code for vulnerabilities
|
|
4192
|
-
*/
|
|
4193
|
-
async scanCode(target) {
|
|
4194
|
-
const issues = [];
|
|
4195
|
-
this.logger.debug("Code scan complete", {
|
|
4196
|
-
pluginId: target.pluginId,
|
|
4197
|
-
issuesFound: issues.length
|
|
4198
|
-
});
|
|
4199
|
-
return issues;
|
|
4200
|
-
}
|
|
4201
|
-
/**
|
|
4202
|
-
* Scan dependencies for known vulnerabilities
|
|
4203
|
-
*/
|
|
4204
|
-
async scanDependencies(target) {
|
|
4205
|
-
const issues = [];
|
|
4206
|
-
if (!target.dependencies) {
|
|
4207
|
-
return issues;
|
|
4208
|
-
}
|
|
4209
|
-
for (const [depName, version] of Object.entries(target.dependencies)) {
|
|
4210
|
-
const vulnKey = `${depName}@${version}`;
|
|
4211
|
-
const vulnerability = this.vulnerabilityDb.get(vulnKey);
|
|
4212
|
-
if (vulnerability) {
|
|
4213
|
-
issues.push({
|
|
4214
|
-
id: `vuln-${vulnerability.cve || depName}`,
|
|
4215
|
-
severity: vulnerability.severity,
|
|
4216
|
-
category: "vulnerability",
|
|
4217
|
-
title: `Vulnerable dependency: ${depName}`,
|
|
4218
|
-
description: `${depName}@${version} has known security vulnerabilities`,
|
|
4219
|
-
remediation: vulnerability.fixedIn ? `Upgrade to ${vulnerability.fixedIn.join(" or ")}` : "No fix available",
|
|
4220
|
-
cve: vulnerability.cve
|
|
4221
|
-
});
|
|
4222
|
-
}
|
|
4223
|
-
}
|
|
4224
|
-
this.logger.debug("Dependency scan complete", {
|
|
4225
|
-
pluginId: target.pluginId,
|
|
4226
|
-
dependencies: Object.keys(target.dependencies).length,
|
|
4227
|
-
vulnerabilities: issues.length
|
|
4228
|
-
});
|
|
4229
|
-
return issues;
|
|
4230
|
-
}
|
|
4231
|
-
/**
|
|
4232
|
-
* Scan for malware patterns
|
|
4233
|
-
*/
|
|
4234
|
-
async scanMalware(target) {
|
|
4235
|
-
const issues = [];
|
|
4236
|
-
this.logger.debug("Malware scan complete", {
|
|
4237
|
-
pluginId: target.pluginId,
|
|
4238
|
-
issuesFound: issues.length
|
|
4239
|
-
});
|
|
4240
|
-
return issues;
|
|
4241
|
-
}
|
|
4242
|
-
/**
|
|
4243
|
-
* Check license compliance
|
|
4244
|
-
*/
|
|
4245
|
-
async scanLicenses(target) {
|
|
4246
|
-
const issues = [];
|
|
4247
|
-
if (!target.dependencies) {
|
|
4248
|
-
return issues;
|
|
4249
|
-
}
|
|
4250
|
-
this.logger.debug("License scan complete", {
|
|
4251
|
-
pluginId: target.pluginId,
|
|
4252
|
-
issuesFound: issues.length
|
|
4253
|
-
});
|
|
4254
|
-
return issues;
|
|
4255
|
-
}
|
|
4256
|
-
/**
|
|
4257
|
-
* Check configuration security
|
|
4258
|
-
*/
|
|
4259
|
-
async scanConfiguration(target) {
|
|
4260
|
-
const issues = [];
|
|
4261
|
-
this.logger.debug("Configuration scan complete", {
|
|
4262
|
-
pluginId: target.pluginId,
|
|
4263
|
-
issuesFound: issues.length
|
|
4264
|
-
});
|
|
4265
|
-
return issues;
|
|
4266
|
-
}
|
|
4267
|
-
/**
|
|
4268
|
-
* Calculate security score based on issues
|
|
4269
|
-
*/
|
|
4270
|
-
calculateSecurityScore(issues) {
|
|
4271
|
-
let score = 100;
|
|
4272
|
-
for (const issue of issues) {
|
|
4273
|
-
switch (issue.severity) {
|
|
4274
|
-
case "critical":
|
|
4275
|
-
score -= 20;
|
|
4276
|
-
break;
|
|
4277
|
-
case "high":
|
|
4278
|
-
score -= 10;
|
|
4279
|
-
break;
|
|
4280
|
-
case "medium":
|
|
4281
|
-
score -= 5;
|
|
4282
|
-
break;
|
|
4283
|
-
case "low":
|
|
4284
|
-
score -= 2;
|
|
4285
|
-
break;
|
|
4286
|
-
case "info":
|
|
4287
|
-
score -= 0;
|
|
4288
|
-
break;
|
|
4289
|
-
}
|
|
4290
|
-
}
|
|
4291
|
-
return Math.max(0, score);
|
|
4292
|
-
}
|
|
4293
|
-
/**
|
|
4294
|
-
* Add a vulnerability to the database
|
|
4295
|
-
*/
|
|
4296
|
-
addVulnerability(packageName, version, vulnerability) {
|
|
4297
|
-
const key = `${packageName}@${version}`;
|
|
4298
|
-
this.vulnerabilityDb.set(key, vulnerability);
|
|
4299
|
-
this.logger.debug("Vulnerability added to database", {
|
|
4300
|
-
package: packageName,
|
|
4301
|
-
version,
|
|
4302
|
-
cve: vulnerability.cve
|
|
4303
|
-
});
|
|
4304
|
-
}
|
|
4305
|
-
/**
|
|
4306
|
-
* Get scan result from cache
|
|
4307
|
-
*/
|
|
4308
|
-
getScanResult(pluginId, version) {
|
|
4309
|
-
return this.scanResults.get(`${pluginId}:${version}`);
|
|
4310
|
-
}
|
|
4311
|
-
/**
|
|
4312
|
-
* Clear scan results cache
|
|
4313
|
-
*/
|
|
4314
|
-
clearCache() {
|
|
4315
|
-
this.scanResults.clear();
|
|
4316
|
-
this.logger.debug("Scan results cache cleared");
|
|
4317
|
-
}
|
|
4318
|
-
/**
|
|
4319
|
-
* Update vulnerability database from external source
|
|
4320
|
-
*/
|
|
4321
|
-
async updateVulnerabilityDatabase() {
|
|
4322
|
-
this.logger.info("Updating vulnerability database");
|
|
4323
|
-
this.logger.info("Vulnerability database updated", {
|
|
4324
|
-
entries: this.vulnerabilityDb.size
|
|
4325
|
-
});
|
|
4326
|
-
}
|
|
4327
|
-
/**
|
|
4328
|
-
* Shutdown security scanner
|
|
4329
|
-
*/
|
|
4330
|
-
shutdown() {
|
|
4331
|
-
this.vulnerabilityDb.clear();
|
|
4332
|
-
this.scanResults.clear();
|
|
4333
|
-
this.logger.info("Security scanner shutdown complete");
|
|
4334
|
-
}
|
|
4335
|
-
};
|
|
4336
|
-
|
|
4337
4267
|
// src/security/api-key.ts
|
|
4338
|
-
var
|
|
4268
|
+
var import_node_crypto3 = require("crypto");
|
|
4339
4269
|
var import_security = require("@objectstack/spec/security");
|
|
4340
4270
|
var API_KEY_PREFIX = "osk_";
|
|
4341
4271
|
var API_KEY_ENTROPY_BYTES = 32;
|
|
4342
4272
|
var VISIBLE_PREFIX_LEN = 12;
|
|
4343
4273
|
function hashApiKey(raw) {
|
|
4344
|
-
return (0,
|
|
4274
|
+
return (0, import_node_crypto3.createHash)("sha256").update(raw, "utf8").digest("hex");
|
|
4345
4275
|
}
|
|
4346
4276
|
function generateApiKey(prefix = API_KEY_PREFIX) {
|
|
4347
|
-
const secret = (0,
|
|
4277
|
+
const secret = (0, import_node_crypto3.randomBytes)(API_KEY_ENTROPY_BYTES).toString("base64url");
|
|
4348
4278
|
const raw = `${prefix}${secret}`;
|
|
4349
4279
|
return {
|
|
4350
4280
|
raw,
|
|
@@ -4420,19 +4350,22 @@ async function resolveApiKeyAdmission(ql, headers, nowMs = Date.now(), tenancyPo
|
|
|
4420
4350
|
const userId = row.user_id ?? row.userId;
|
|
4421
4351
|
if (!userId || typeof userId !== "string") return { outcome: "none" };
|
|
4422
4352
|
const tenantId = typeof row.active_organization_id === "string" && row.active_organization_id ? row.active_organization_id : void 0;
|
|
4353
|
+
const keyId = typeof row.id === "string" && row.id ? row.id : void 0;
|
|
4423
4354
|
if (!tenantId && tenancyPosture) {
|
|
4424
4355
|
const posture = tenancyPosture;
|
|
4425
4356
|
if ((0, import_security.postureEnforcesWall)(posture) && !(0, import_security.postureUsesUnionScope)(posture)) {
|
|
4426
4357
|
return {
|
|
4427
4358
|
outcome: "refused",
|
|
4428
4359
|
reason: "organization_required",
|
|
4360
|
+
keyId,
|
|
4361
|
+
userId,
|
|
4429
4362
|
message: "This API key carries no organization and cannot be used under the `isolated` tenancy posture, where every organization-scoped read is walled to an active organization. Mint a replacement key \u2014 new keys inherit the minter\u2019s active organization."
|
|
4430
4363
|
};
|
|
4431
4364
|
}
|
|
4432
4365
|
}
|
|
4433
4366
|
return {
|
|
4434
4367
|
outcome: "admitted",
|
|
4435
|
-
principal: { userId, tenantId, scopes: parseScopes(row.scopes) }
|
|
4368
|
+
principal: { userId, keyId, tenantId, scopes: parseScopes(row.scopes) }
|
|
4436
4369
|
};
|
|
4437
4370
|
}
|
|
4438
4371
|
function readHeader(headers, name) {
|
|
@@ -4458,9 +4391,39 @@ function safeJsonParse(s, fallback) {
|
|
|
4458
4391
|
}
|
|
4459
4392
|
}
|
|
4460
4393
|
|
|
4394
|
+
// src/security/authz-store-unavailable.ts
|
|
4395
|
+
var AUTHZ_STORE_UNAVAILABLE_STATUS = 503;
|
|
4396
|
+
var AUTHZ_STORE_UNAVAILABLE_CODE = "SERVICE_UNAVAILABLE";
|
|
4397
|
+
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.";
|
|
4398
|
+
var AUTHZ_STORE_UNAVAILABLE_BRAND = "__objectstackAuthzStoreUnavailable";
|
|
4399
|
+
var _a, _b;
|
|
4400
|
+
var AuthzStoreUnavailableError = class extends (_b = Error, _a = AUTHZ_STORE_UNAVAILABLE_BRAND, _b) {
|
|
4401
|
+
constructor(object, cause) {
|
|
4402
|
+
super(`${AUTHZ_STORE_UNAVAILABLE_MESSAGE} (failed read: \`${object}\`)`);
|
|
4403
|
+
/** Brand — see the module doc on why this is not `instanceof`. */
|
|
4404
|
+
this[_a] = true;
|
|
4405
|
+
/** ADR-0112 wire code. */
|
|
4406
|
+
this.code = AUTHZ_STORE_UNAVAILABLE_CODE;
|
|
4407
|
+
/** HTTP status a transport should answer. */
|
|
4408
|
+
this.status = AUTHZ_STORE_UNAVAILABLE_STATUS;
|
|
4409
|
+
this.name = "AuthzStoreUnavailableError";
|
|
4410
|
+
this.object = object;
|
|
4411
|
+
if (cause !== void 0) this.cause = cause;
|
|
4412
|
+
}
|
|
4413
|
+
};
|
|
4414
|
+
function isAuthzStoreUnavailableError(err) {
|
|
4415
|
+
return typeof err === "object" && err !== null && err[AUTHZ_STORE_UNAVAILABLE_BRAND] === true;
|
|
4416
|
+
}
|
|
4417
|
+
function rethrowAuthzStoreUnavailable(err) {
|
|
4418
|
+
if (isAuthzStoreUnavailableError(err)) throw err;
|
|
4419
|
+
return void 0;
|
|
4420
|
+
}
|
|
4421
|
+
|
|
4461
4422
|
// src/security/resolve-authz-context.ts
|
|
4462
|
-
var
|
|
4423
|
+
var import_types2 = require("@objectstack/types");
|
|
4424
|
+
var import_spec2 = require("@objectstack/spec");
|
|
4463
4425
|
var import_security2 = require("@objectstack/spec/security");
|
|
4426
|
+
var import_shared = require("@objectstack/spec/shared");
|
|
4464
4427
|
|
|
4465
4428
|
// src/security/grant-validity.ts
|
|
4466
4429
|
function toEpochMs(value) {
|
|
@@ -4480,6 +4443,20 @@ function isGrantActive(row, nowMs) {
|
|
|
4480
4443
|
if (until !== void 0 && !(nowMs < until)) return false;
|
|
4481
4444
|
return true;
|
|
4482
4445
|
}
|
|
4446
|
+
function nextGrantValidityBoundary(rows, nowMs) {
|
|
4447
|
+
let next;
|
|
4448
|
+
for (const row of rows) {
|
|
4449
|
+
if (!row) continue;
|
|
4450
|
+
const from = toEpochMs(row.valid_from ?? row.validFrom);
|
|
4451
|
+
const until = toEpochMs(row.valid_until ?? row.validUntil);
|
|
4452
|
+
for (const bound of [from, until]) {
|
|
4453
|
+
if (bound !== void 0 && bound > nowMs && (next === void 0 || bound < next)) {
|
|
4454
|
+
next = bound;
|
|
4455
|
+
}
|
|
4456
|
+
}
|
|
4457
|
+
}
|
|
4458
|
+
return next;
|
|
4459
|
+
}
|
|
4483
4460
|
function isGrantExpired(row, nowMs) {
|
|
4484
4461
|
if (!row) return false;
|
|
4485
4462
|
const until = toEpochMs(row.valid_until ?? row.validUntil);
|
|
@@ -4487,6 +4464,227 @@ function isGrantExpired(row, nowMs) {
|
|
|
4487
4464
|
return !(nowMs < until);
|
|
4488
4465
|
}
|
|
4489
4466
|
|
|
4467
|
+
// src/security/authz-cache-posture.ts
|
|
4468
|
+
var AUTHZ_GRANTS_CACHE_TTL_ENV = "OS_AUTHZ_GRANTS_CACHE_TTL_MS";
|
|
4469
|
+
function resolveAuthzCachePosture(input) {
|
|
4470
|
+
const { ttlMs, bus, driver } = input;
|
|
4471
|
+
if (!(ttlMs > 0)) {
|
|
4472
|
+
return { posture: "disabled", loud: false, message: "" };
|
|
4473
|
+
}
|
|
4474
|
+
if (bus === "bridged") {
|
|
4475
|
+
return {
|
|
4476
|
+
posture: "bus-narrowed",
|
|
4477
|
+
loud: false,
|
|
4478
|
+
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)."
|
|
4479
|
+
};
|
|
4480
|
+
}
|
|
4481
|
+
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";
|
|
4482
|
+
return {
|
|
4483
|
+
posture: "ttl-only",
|
|
4484
|
+
loud: true,
|
|
4485
|
+
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.`
|
|
4486
|
+
};
|
|
4487
|
+
}
|
|
4488
|
+
function readAuthzGrantsCacheTtlMs(env = typeof process !== "undefined" ? process.env : {}) {
|
|
4489
|
+
const raw = env[AUTHZ_GRANTS_CACHE_TTL_ENV];
|
|
4490
|
+
if (raw === void 0 || raw.trim() === "") {
|
|
4491
|
+
return { ttlMs: 0, malformed: false };
|
|
4492
|
+
}
|
|
4493
|
+
const parsed = Number(raw.trim());
|
|
4494
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
4495
|
+
return { ttlMs: 0, raw, malformed: true };
|
|
4496
|
+
}
|
|
4497
|
+
return { ttlMs: Math.floor(parsed), raw, malformed: false };
|
|
4498
|
+
}
|
|
4499
|
+
function reportAuthzCachePosture(input, sink2) {
|
|
4500
|
+
if (input.malformedTtl) {
|
|
4501
|
+
sink2.warn(
|
|
4502
|
+
`[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.`
|
|
4503
|
+
);
|
|
4504
|
+
}
|
|
4505
|
+
const statement = resolveAuthzCachePosture(input);
|
|
4506
|
+
if (statement.posture === "disabled") return statement;
|
|
4507
|
+
if (statement.loud) sink2.warn(statement.message);
|
|
4508
|
+
else sink2.info?.(statement.message);
|
|
4509
|
+
return statement;
|
|
4510
|
+
}
|
|
4511
|
+
|
|
4512
|
+
// src/security/resolve-user-grants-cache.ts
|
|
4513
|
+
var GRANTS_CACHE_WATCHED_OBJECTS = /* @__PURE__ */ new Set([
|
|
4514
|
+
"sys_member",
|
|
4515
|
+
"sys_user_position",
|
|
4516
|
+
"sys_user_permission_set",
|
|
4517
|
+
"sys_position",
|
|
4518
|
+
"sys_position_permission_set",
|
|
4519
|
+
"sys_permission_set",
|
|
4520
|
+
"sys_user"
|
|
4521
|
+
]);
|
|
4522
|
+
var grantsCacheStates = /* @__PURE__ */ new WeakMap();
|
|
4523
|
+
var WRITE_OPERATIONS = /* @__PURE__ */ new Set(["insert", "update", "delete"]);
|
|
4524
|
+
function grantsCacheState(ql) {
|
|
4525
|
+
const existing = grantsCacheStates.get(ql);
|
|
4526
|
+
if (existing !== void 0) return existing ?? void 0;
|
|
4527
|
+
const seamQl = ql;
|
|
4528
|
+
const epoch = seamQl.writeEpoch;
|
|
4529
|
+
const hasEpoch = !!epoch && typeof epoch === "object" && typeof epoch.current === "number" && typeof epoch.bump === "function" && typeof epoch.subscribe === "function";
|
|
4530
|
+
if (!hasEpoch || typeof seamQl.registerMiddleware !== "function") {
|
|
4531
|
+
return void 0;
|
|
4532
|
+
}
|
|
4533
|
+
const state = { gen: 0, entries: /* @__PURE__ */ new Map() };
|
|
4534
|
+
try {
|
|
4535
|
+
seamQl.registerMiddleware(async (ctx, next) => {
|
|
4536
|
+
if (typeof ctx?.operation !== "string" || !WRITE_OPERATIONS.has(ctx.operation) || typeof ctx?.object !== "string" || !GRANTS_CACHE_WATCHED_OBJECTS.has(ctx.object)) {
|
|
4537
|
+
return next();
|
|
4538
|
+
}
|
|
4539
|
+
try {
|
|
4540
|
+
await next();
|
|
4541
|
+
} finally {
|
|
4542
|
+
state.gen += 1;
|
|
4543
|
+
}
|
|
4544
|
+
});
|
|
4545
|
+
epoch.subscribe((_epoch, reason) => {
|
|
4546
|
+
if (reason !== "write") state.gen += 1;
|
|
4547
|
+
});
|
|
4548
|
+
} catch {
|
|
4549
|
+
grantsCacheStates.set(ql, null);
|
|
4550
|
+
return void 0;
|
|
4551
|
+
}
|
|
4552
|
+
grantsCacheStates.set(ql, state);
|
|
4553
|
+
return state;
|
|
4554
|
+
}
|
|
4555
|
+
function grantsCacheKey(userId, opts) {
|
|
4556
|
+
return JSON.stringify([
|
|
4557
|
+
userId,
|
|
4558
|
+
opts.tenantId ?? null,
|
|
4559
|
+
opts.seedEmail ?? null,
|
|
4560
|
+
Array.isArray(opts.seedPermissions) ? opts.seedPermissions : []
|
|
4561
|
+
]);
|
|
4562
|
+
}
|
|
4563
|
+
var cloneGrants = (grants) => structuredClone(grants);
|
|
4564
|
+
function openUserGrantsCache(ql, userId, opts) {
|
|
4565
|
+
if (opts.bypassGrantsCache) return void 0;
|
|
4566
|
+
const { ttlMs } = readAuthzGrantsCacheTtlMs();
|
|
4567
|
+
if (ttlMs <= 0) return void 0;
|
|
4568
|
+
if (!ql || typeof ql !== "object" || typeof ql.find !== "function") {
|
|
4569
|
+
return void 0;
|
|
4570
|
+
}
|
|
4571
|
+
const state = grantsCacheState(ql);
|
|
4572
|
+
if (!state) return void 0;
|
|
4573
|
+
const key = grantsCacheKey(userId, opts);
|
|
4574
|
+
const now = opts.nowMs ?? Date.now();
|
|
4575
|
+
const genAtOpen = state.gen;
|
|
4576
|
+
const existing = state.entries.get(key);
|
|
4577
|
+
if (existing) {
|
|
4578
|
+
if (existing.gen === state.gen && existing.expiresAt > now) {
|
|
4579
|
+
return { hit: cloneGrants(existing.value), commit: () => {
|
|
4580
|
+
} };
|
|
4581
|
+
}
|
|
4582
|
+
state.entries.delete(key);
|
|
4583
|
+
}
|
|
4584
|
+
return {
|
|
4585
|
+
commit(grants, nextBoundaryMs) {
|
|
4586
|
+
const expiresAt = Math.min(now + ttlMs, nextBoundaryMs ?? Number.POSITIVE_INFINITY);
|
|
4587
|
+
if (expiresAt <= now) return;
|
|
4588
|
+
state.entries.set(key, { value: cloneGrants(grants), gen: genAtOpen, expiresAt });
|
|
4589
|
+
}
|
|
4590
|
+
};
|
|
4591
|
+
}
|
|
4592
|
+
|
|
4593
|
+
// src/security/platform-admin.ts
|
|
4594
|
+
var import_types = require("@objectstack/types");
|
|
4595
|
+
var PLATFORM_ADMIN_EMAIL_SEPARATOR = ",";
|
|
4596
|
+
function normalizePlatformAdminEmail(value) {
|
|
4597
|
+
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
4598
|
+
}
|
|
4599
|
+
function isParseableAddress(entry) {
|
|
4600
|
+
if (/\s/.test(entry)) return false;
|
|
4601
|
+
const at = entry.indexOf("@");
|
|
4602
|
+
if (at <= 0) return false;
|
|
4603
|
+
if (entry.indexOf("@", at + 1) !== -1) return false;
|
|
4604
|
+
return at < entry.length - 1;
|
|
4605
|
+
}
|
|
4606
|
+
var EMPTY_CONFIG = Object.freeze({
|
|
4607
|
+
emails: Object.freeze([]),
|
|
4608
|
+
declaredSpellings: Object.freeze([])
|
|
4609
|
+
});
|
|
4610
|
+
function parsePlatformAdminEmails(raw) {
|
|
4611
|
+
if (raw == null) return EMPTY_CONFIG;
|
|
4612
|
+
const text = String(raw);
|
|
4613
|
+
if (text.trim() === "") return EMPTY_CONFIG;
|
|
4614
|
+
const emails = [];
|
|
4615
|
+
const declaredSpellings = [];
|
|
4616
|
+
for (const piece of text.split(PLATFORM_ADMIN_EMAIL_SEPARATOR)) {
|
|
4617
|
+
const entry = normalizePlatformAdminEmail(piece);
|
|
4618
|
+
if (entry === "") continue;
|
|
4619
|
+
if (!isParseableAddress(entry)) {
|
|
4620
|
+
return {
|
|
4621
|
+
emails: Object.freeze([]),
|
|
4622
|
+
declaredSpellings: Object.freeze([]),
|
|
4623
|
+
raw: text,
|
|
4624
|
+
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.`
|
|
4625
|
+
};
|
|
4626
|
+
}
|
|
4627
|
+
if (!emails.includes(entry)) {
|
|
4628
|
+
emails.push(entry);
|
|
4629
|
+
declaredSpellings.push(piece.trim());
|
|
4630
|
+
}
|
|
4631
|
+
}
|
|
4632
|
+
return {
|
|
4633
|
+
emails: Object.freeze(emails),
|
|
4634
|
+
declaredSpellings: Object.freeze(declaredSpellings),
|
|
4635
|
+
raw: text
|
|
4636
|
+
};
|
|
4637
|
+
}
|
|
4638
|
+
var defaultSink = {
|
|
4639
|
+
error: (m) => console.error(m),
|
|
4640
|
+
warn: (m) => console.warn(m)
|
|
4641
|
+
};
|
|
4642
|
+
var sink = defaultSink;
|
|
4643
|
+
function setPlatformAdminConfigSink(next) {
|
|
4644
|
+
const prev = sink;
|
|
4645
|
+
sink = next ?? defaultSink;
|
|
4646
|
+
return prev;
|
|
4647
|
+
}
|
|
4648
|
+
var NOT_MEMOIZED = /* @__PURE__ */ Symbol("platform-admin-config-not-memoized");
|
|
4649
|
+
var memoKey = NOT_MEMOIZED;
|
|
4650
|
+
var memoValue = EMPTY_CONFIG;
|
|
4651
|
+
function resolvePlatformAdminEmails() {
|
|
4652
|
+
const raw = (0, import_types.resolvePlatformOwnerEmail)();
|
|
4653
|
+
if (memoKey !== NOT_MEMOIZED && memoKey === raw) return memoValue;
|
|
4654
|
+
const parsed = parsePlatformAdminEmails(raw);
|
|
4655
|
+
memoKey = raw;
|
|
4656
|
+
memoValue = parsed;
|
|
4657
|
+
if (parsed.refusal) sink.error(`[authz] ${parsed.refusal}`);
|
|
4658
|
+
return parsed;
|
|
4659
|
+
}
|
|
4660
|
+
function resetPlatformAdminEmailMemo() {
|
|
4661
|
+
memoKey = NOT_MEMOIZED;
|
|
4662
|
+
memoValue = EMPTY_CONFIG;
|
|
4663
|
+
}
|
|
4664
|
+
function matchesConfiguredPlatformAdmin(row, config) {
|
|
4665
|
+
if (config.emails.length === 0) return false;
|
|
4666
|
+
if (!row || typeof row !== "object") return false;
|
|
4667
|
+
if (!isConfiguredPlatformAdminEmail(row.email, config)) return false;
|
|
4668
|
+
return (0, import_types.isEmailVerifiedUserRow)(row);
|
|
4669
|
+
}
|
|
4670
|
+
function isConfiguredPlatformAdminEmail(email, config) {
|
|
4671
|
+
if (config.emails.length === 0) return false;
|
|
4672
|
+
const candidate = normalizePlatformAdminEmail(email);
|
|
4673
|
+
return candidate !== "" && config.emails.includes(candidate);
|
|
4674
|
+
}
|
|
4675
|
+
var legacyGrantPointerSaid = false;
|
|
4676
|
+
function reportLegacyPlatformAdminGrant(input) {
|
|
4677
|
+
if (legacyGrantPointerSaid) return;
|
|
4678
|
+
legacyGrantPointerSaid = true;
|
|
4679
|
+
const email = normalizePlatformAdminEmail(input.email);
|
|
4680
|
+
sink.warn(
|
|
4681
|
+
`[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.`
|
|
4682
|
+
);
|
|
4683
|
+
}
|
|
4684
|
+
function resetLegacyPlatformAdminGrantReport() {
|
|
4685
|
+
legacyGrantPointerSaid = false;
|
|
4686
|
+
}
|
|
4687
|
+
|
|
4490
4688
|
// src/security/posture-ladder.ts
|
|
4491
4689
|
var POSTURE_LADDER = [
|
|
4492
4690
|
"PLATFORM_ADMIN",
|
|
@@ -4560,6 +4758,18 @@ function safeJsonParse2(s, fallback) {
|
|
|
4560
4758
|
return fallback;
|
|
4561
4759
|
}
|
|
4562
4760
|
}
|
|
4761
|
+
function warnApiKeyRefusal(details) {
|
|
4762
|
+
const { reason, keyId, userId, organizationId } = details;
|
|
4763
|
+
console.warn(
|
|
4764
|
+
`[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.`
|
|
4765
|
+
);
|
|
4766
|
+
}
|
|
4767
|
+
function warnSessionOrganizationClaimDropped(details) {
|
|
4768
|
+
const { reason, sessionId, userId, organizationId } = details;
|
|
4769
|
+
console.warn(
|
|
4770
|
+
`[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.`
|
|
4771
|
+
);
|
|
4772
|
+
}
|
|
4563
4773
|
async function tryFind(ql, object, where, limit = 100, organizationId) {
|
|
4564
4774
|
if (!ql || typeof ql.find !== "function") return [];
|
|
4565
4775
|
try {
|
|
@@ -4567,8 +4777,9 @@ async function tryFind(ql, object, where, limit = 100, organizationId) {
|
|
|
4567
4777
|
let rows = await ql.find(object, { where, limit, context });
|
|
4568
4778
|
if (rows && rows.value) rows = rows.value;
|
|
4569
4779
|
return Array.isArray(rows) ? rows : [];
|
|
4570
|
-
} catch {
|
|
4571
|
-
return [];
|
|
4780
|
+
} catch (err) {
|
|
4781
|
+
if ((0, import_types2.isMissingTableError)(err, object)) return [];
|
|
4782
|
+
throw new AuthzStoreUnavailableError(object, err);
|
|
4572
4783
|
}
|
|
4573
4784
|
}
|
|
4574
4785
|
async function resolveAuthzContext(input) {
|
|
@@ -4582,9 +4793,15 @@ async function resolveAuthzContext(input) {
|
|
|
4582
4793
|
};
|
|
4583
4794
|
let userId;
|
|
4584
4795
|
let tenantId;
|
|
4796
|
+
let sessionId;
|
|
4585
4797
|
const admission = await resolveApiKeyAdmission(ql, headers, input.nowMs, input.tenancyPosture);
|
|
4586
4798
|
if (admission.outcome === "refused") {
|
|
4587
|
-
|
|
4799
|
+
warnApiKeyRefusal({
|
|
4800
|
+
reason: admission.reason,
|
|
4801
|
+
keyId: admission.keyId,
|
|
4802
|
+
userId: admission.userId,
|
|
4803
|
+
organizationId: admission.organizationId
|
|
4804
|
+
});
|
|
4588
4805
|
return ctx;
|
|
4589
4806
|
}
|
|
4590
4807
|
const keyPrincipal = admission.outcome === "admitted" ? admission.principal : void 0;
|
|
@@ -4600,6 +4817,8 @@ async function resolveAuthzContext(input) {
|
|
|
4600
4817
|
const sessionData = await input.getSession(headers);
|
|
4601
4818
|
userId = sessionData?.user?.id ?? sessionData?.session?.userId;
|
|
4602
4819
|
tenantId = tenantId ?? sessionData?.session?.activeOrganizationId;
|
|
4820
|
+
const rawSessionId = sessionData?.session?.id;
|
|
4821
|
+
sessionId = typeof rawSessionId === "string" && rawSessionId ? rawSessionId : void 0;
|
|
4603
4822
|
ctx.accessToken = sessionData?.session?.token ?? ctx.accessToken;
|
|
4604
4823
|
if (sessionData?.user?.email) ctx.email = String(sessionData.user.email);
|
|
4605
4824
|
} catch {
|
|
@@ -4609,7 +4828,7 @@ async function resolveAuthzContext(input) {
|
|
|
4609
4828
|
ctx.userId = userId;
|
|
4610
4829
|
if (tenantId) ctx.tenantId = tenantId;
|
|
4611
4830
|
if (!ql || typeof ql.find !== "function") return ctx;
|
|
4612
|
-
|
|
4831
|
+
let grants = await resolveUserAuthzGrants(ql, userId, {
|
|
4613
4832
|
tenantId,
|
|
4614
4833
|
nowMs: input.nowMs,
|
|
4615
4834
|
seedPermissions: ctx.permissions,
|
|
@@ -4618,19 +4837,37 @@ async function resolveAuthzContext(input) {
|
|
|
4618
4837
|
if (keyPrincipal?.tenantId && input.tenancyPosture) {
|
|
4619
4838
|
const posture = input.tenancyPosture;
|
|
4620
4839
|
if ((0, import_security2.postureEnforcesWall)(posture) && !grants.accessible_org_ids.includes(keyPrincipal.tenantId)) {
|
|
4840
|
+
warnApiKeyRefusal({
|
|
4841
|
+
reason: "organization_membership_ended",
|
|
4842
|
+
keyId: keyPrincipal.keyId,
|
|
4843
|
+
userId: keyPrincipal.userId,
|
|
4844
|
+
organizationId: keyPrincipal.tenantId
|
|
4845
|
+
});
|
|
4621
4846
|
return {
|
|
4622
4847
|
positions: [],
|
|
4623
4848
|
permissions: [],
|
|
4624
4849
|
systemPermissions: [],
|
|
4625
4850
|
org_user_ids: [],
|
|
4626
|
-
accessible_org_ids: []
|
|
4627
|
-
authRefusal: {
|
|
4628
|
-
reason: "organization_membership_ended",
|
|
4629
|
-
message: "This API key authenticates into an organization its owner is no longer a member of. The key was not revoked \u2014 the membership that backed it ended."
|
|
4630
|
-
}
|
|
4851
|
+
accessible_org_ids: []
|
|
4631
4852
|
};
|
|
4632
4853
|
}
|
|
4633
4854
|
}
|
|
4855
|
+
if (!keyPrincipal && tenantId && input.tenancyPosture && (0, import_security2.postureEnforcesWall)(input.tenancyPosture) && !grants.accessible_org_ids.includes(tenantId)) {
|
|
4856
|
+
warnSessionOrganizationClaimDropped({
|
|
4857
|
+
reason: "organization_membership_ended",
|
|
4858
|
+
sessionId,
|
|
4859
|
+
userId,
|
|
4860
|
+
organizationId: tenantId
|
|
4861
|
+
});
|
|
4862
|
+
tenantId = void 0;
|
|
4863
|
+
delete ctx.tenantId;
|
|
4864
|
+
grants = await resolveUserAuthzGrants(ql, userId, {
|
|
4865
|
+
tenantId,
|
|
4866
|
+
nowMs: input.nowMs,
|
|
4867
|
+
seedPermissions: ctx.permissions,
|
|
4868
|
+
seedEmail: ctx.email
|
|
4869
|
+
});
|
|
4870
|
+
}
|
|
4634
4871
|
ctx.positions = grants.positions;
|
|
4635
4872
|
ctx.permissions = grants.permissions;
|
|
4636
4873
|
ctx.systemPermissions = grants.systemPermissions;
|
|
@@ -4642,6 +4879,8 @@ async function resolveAuthzContext(input) {
|
|
|
4642
4879
|
return ctx;
|
|
4643
4880
|
}
|
|
4644
4881
|
async function resolveUserAuthzGrants(ql, userId, opts = {}) {
|
|
4882
|
+
const grantsCache = openUserGrantsCache(ql, userId, opts);
|
|
4883
|
+
if (grantsCache?.hit) return grantsCache.hit;
|
|
4645
4884
|
const { tenantId } = opts;
|
|
4646
4885
|
const grants = {
|
|
4647
4886
|
positions: [],
|
|
@@ -4662,7 +4901,8 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
|
|
|
4662
4901
|
}
|
|
4663
4902
|
return userRow;
|
|
4664
4903
|
};
|
|
4665
|
-
const
|
|
4904
|
+
const platformAdminConfig = resolvePlatformAdminEmails();
|
|
4905
|
+
const needsUserRow = !grants.email || !grants.permissions.includes("ai_seat") || platformAdminConfig.emails.length > 0;
|
|
4666
4906
|
const [, members, userPositionRows, orgMembersLeg, upsRowsAll] = await Promise.all([
|
|
4667
4907
|
needsUserRow ? getUserRow() : Promise.resolve(void 0),
|
|
4668
4908
|
tryFind(ql, "sys_member", { user_id: userId }, 200),
|
|
@@ -4688,7 +4928,7 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
|
|
|
4688
4928
|
for (const m of activeMembers) {
|
|
4689
4929
|
if (m.role && typeof m.role === "string") {
|
|
4690
4930
|
for (const raw of m.role.split(",").map((s) => s.trim()).filter(Boolean)) {
|
|
4691
|
-
const r = (0,
|
|
4931
|
+
const r = (0, import_spec2.mapMembershipRole)(raw);
|
|
4692
4932
|
if (!grants.positions.includes(r)) grants.positions.push(r);
|
|
4693
4933
|
}
|
|
4694
4934
|
}
|
|
@@ -4744,7 +4984,7 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
|
|
|
4744
4984
|
const mergedTabs = {};
|
|
4745
4985
|
for (const ps of psRows) {
|
|
4746
4986
|
if (ps.name && !grants.permissions.includes(ps.name)) grants.permissions.push(ps.name);
|
|
4747
|
-
if (ps.name ===
|
|
4987
|
+
if (ps.name === import_spec2.ADMIN_FULL_ACCESS && unscopedUserPsIds.has(ps.id)) hasPlatformAdminGrant = true;
|
|
4748
4988
|
const sysPerms = typeof ps.system_permissions === "string" ? safeJsonParse2(ps.system_permissions, []) : ps.system_permissions ?? ps.systemPermissions;
|
|
4749
4989
|
if (Array.isArray(sysPerms)) {
|
|
4750
4990
|
for (const p of sysPerms) {
|
|
@@ -4764,32 +5004,49 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
|
|
|
4764
5004
|
}
|
|
4765
5005
|
if (Object.keys(mergedTabs).length > 0) grants.tabPermissions = mergedTabs;
|
|
4766
5006
|
}
|
|
4767
|
-
|
|
4768
|
-
|
|
5007
|
+
const configConfersPlatformAdmin = platformAdminConfig.emails.length > 0 && matchesConfiguredPlatformAdmin(await getUserRow(), platformAdminConfig);
|
|
5008
|
+
if (configConfersPlatformAdmin) {
|
|
5009
|
+
hasPlatformAdminGrant = true;
|
|
5010
|
+
if (!grants.permissions.includes(import_spec2.ADMIN_FULL_ACCESS)) grants.permissions.push(import_spec2.ADMIN_FULL_ACCESS);
|
|
5011
|
+
for (const p of import_spec2.ADMIN_FULL_ACCESS_CAPABILITIES.systemPermissions ?? []) {
|
|
5012
|
+
if (!grants.systemPermissions.includes(p)) grants.systemPermissions.push(p);
|
|
5013
|
+
}
|
|
5014
|
+
} else if (hasPlatformAdminGrant) {
|
|
5015
|
+
if ((0, import_security2.postureEnforcesWall)((0, import_types2.resolveTenancyPosture)())) {
|
|
5016
|
+
reportLegacyPlatformAdminGrant({ userId, email: userRow?.email });
|
|
5017
|
+
}
|
|
5018
|
+
}
|
|
5019
|
+
if (hasPlatformAdminGrant && !grants.positions.includes(import_spec2.BUILTIN_IDENTITY_PLATFORM_ADMIN)) {
|
|
5020
|
+
grants.positions.unshift(import_spec2.BUILTIN_IDENTITY_PLATFORM_ADMIN);
|
|
4769
5021
|
}
|
|
4770
5022
|
grants.posture = derivePosture({
|
|
4771
5023
|
isPlatformAdmin: hasPlatformAdminGrant,
|
|
4772
5024
|
// [ADR-0105 D4] Either org-admin capability set resolves the rung — the
|
|
4773
5025
|
// wall-less variant differs only by withholding the superuser bits.
|
|
4774
|
-
isTenantAdmin:
|
|
5026
|
+
isTenantAdmin: import_spec2.ORGANIZATION_ADMIN_GRANTS.some((n) => grants.permissions.includes(n))
|
|
4775
5027
|
});
|
|
4776
5028
|
if (!grants.permissions.includes("ai_seat")) {
|
|
4777
5029
|
const aiAccess = (await getUserRow())?.ai_access;
|
|
4778
5030
|
if (aiAccess === true || aiAccess === 1 || aiAccess === "1") grants.permissions.push("ai_seat");
|
|
4779
5031
|
}
|
|
5032
|
+
grantsCache?.commit(
|
|
5033
|
+
grants,
|
|
5034
|
+
nextGrantValidityBoundary([...members, ...userPositionRows, ...upsRowsAll], nowMs)
|
|
5035
|
+
);
|
|
4780
5036
|
return grants;
|
|
4781
5037
|
}
|
|
4782
|
-
function
|
|
5038
|
+
async function hasPlatformAdminStanding(ql, userId, opts = {}) {
|
|
5039
|
+
if (!ql || typeof userId !== "string" || userId.length === 0) return false;
|
|
4783
5040
|
try {
|
|
4784
|
-
|
|
4785
|
-
return
|
|
5041
|
+
const grants = await resolveUserAuthzGrants(ql, userId, { nowMs: opts.nowMs });
|
|
5042
|
+
return grants.posture === "PLATFORM_ADMIN";
|
|
4786
5043
|
} catch {
|
|
4787
5044
|
return false;
|
|
4788
5045
|
}
|
|
4789
5046
|
}
|
|
4790
5047
|
function coerceTimeZone(value) {
|
|
4791
5048
|
const s = typeof value === "string" ? value.trim() : value != null ? String(value).trim() : "";
|
|
4792
|
-
return s &&
|
|
5049
|
+
return s && (0, import_shared.isValueDomainMember)("iana_time_zone", s) ? s : void 0;
|
|
4793
5050
|
}
|
|
4794
5051
|
function coerceLocale(value) {
|
|
4795
5052
|
const s = typeof value === "string" ? value.trim() : value != null ? String(value).trim() : "";
|
|
@@ -4800,25 +5057,98 @@ function coerceCurrency(value) {
|
|
|
4800
5057
|
return /^[A-Z]{3}$/.test(s) ? s : void 0;
|
|
4801
5058
|
}
|
|
4802
5059
|
var LOCALIZATION_FAILURE_CACHE_TTL_MS = 3e4;
|
|
4803
|
-
var
|
|
5060
|
+
var LOCALIZATION_CACHE_TTL_ENV = "OS_LOCALIZATION_CACHE_TTL_MS";
|
|
5061
|
+
var LOCALIZATION_SUCCESS_CACHE_DEFAULT_TTL_MS = 3e4;
|
|
5062
|
+
function localizationSuccessCacheTtlMs(env = typeof process !== "undefined" ? process.env : {}) {
|
|
5063
|
+
const raw = env[LOCALIZATION_CACHE_TTL_ENV];
|
|
5064
|
+
if (raw === void 0 || raw.trim() === "") return LOCALIZATION_SUCCESS_CACHE_DEFAULT_TTL_MS;
|
|
5065
|
+
const parsed = Number(raw.trim());
|
|
5066
|
+
if (!Number.isFinite(parsed) || parsed < 0) return 0;
|
|
5067
|
+
return Math.floor(parsed);
|
|
5068
|
+
}
|
|
5069
|
+
function readWriteEpoch(ql) {
|
|
5070
|
+
if (!ql || typeof ql !== "object") return void 0;
|
|
5071
|
+
const epoch = ql.writeEpoch;
|
|
5072
|
+
if (!epoch || typeof epoch !== "object") return void 0;
|
|
5073
|
+
const seam = epoch;
|
|
5074
|
+
if (typeof seam.current !== "number" || typeof seam.bump !== "function" || typeof seam.subscribe !== "function") {
|
|
5075
|
+
return void 0;
|
|
5076
|
+
}
|
|
5077
|
+
return seam.current;
|
|
5078
|
+
}
|
|
5079
|
+
var localizationSettingsStates = /* @__PURE__ */ new WeakMap();
|
|
5080
|
+
var localizationNoSettingsState = { gen: 0 };
|
|
5081
|
+
function localizationSettingsState(settings) {
|
|
5082
|
+
if (!settings || typeof settings !== "object") return localizationNoSettingsState;
|
|
5083
|
+
const existing = localizationSettingsStates.get(settings);
|
|
5084
|
+
if (existing) return existing;
|
|
5085
|
+
const state = { gen: 0 };
|
|
5086
|
+
localizationSettingsStates.set(settings, state);
|
|
5087
|
+
const subscribe = settings.subscribe;
|
|
5088
|
+
if (typeof subscribe === "function") {
|
|
5089
|
+
try {
|
|
5090
|
+
subscribe.call(
|
|
5091
|
+
settings,
|
|
5092
|
+
"localization",
|
|
5093
|
+
() => {
|
|
5094
|
+
state.gen += 1;
|
|
5095
|
+
}
|
|
5096
|
+
);
|
|
5097
|
+
} catch {
|
|
5098
|
+
}
|
|
5099
|
+
}
|
|
5100
|
+
return state;
|
|
5101
|
+
}
|
|
5102
|
+
var localizationCache = /* @__PURE__ */ new WeakMap();
|
|
5103
|
+
function localizationEntryIsLive(entry, epoch, settings) {
|
|
5104
|
+
if (entry.kind === "failure") return true;
|
|
5105
|
+
return entry.epoch === epoch && entry.settings === settings && entry.settingsGen === settings.gen;
|
|
5106
|
+
}
|
|
5107
|
+
function putLocalizationEntry(ql, key, entry) {
|
|
5108
|
+
const bucket = localizationCache.get(ql) ?? /* @__PURE__ */ new Map();
|
|
5109
|
+
bucket.set(key, entry);
|
|
5110
|
+
localizationCache.set(ql, bucket);
|
|
5111
|
+
}
|
|
4804
5112
|
async function resolveLocalizationContext(input) {
|
|
4805
|
-
const { ql, tenantId, userId } = input;
|
|
5113
|
+
const { ql, settings, tenantId, userId } = input;
|
|
4806
5114
|
const cacheKey = `${tenantId ?? ""}|${userId ?? ""}`;
|
|
4807
|
-
|
|
4808
|
-
|
|
4809
|
-
|
|
4810
|
-
|
|
4811
|
-
|
|
4812
|
-
|
|
4813
|
-
|
|
4814
|
-
|
|
4815
|
-
|
|
5115
|
+
const cacheable = Boolean(ql) && typeof ql === "object";
|
|
5116
|
+
const epoch = cacheable ? readWriteEpoch(ql) : void 0;
|
|
5117
|
+
const settingsState = localizationSettingsState(settings);
|
|
5118
|
+
if (cacheable) {
|
|
5119
|
+
const hit = localizationCache.get(ql)?.get(cacheKey);
|
|
5120
|
+
if (hit && hit.expiresAt > Date.now() && localizationEntryIsLive(hit, epoch, settingsState)) {
|
|
5121
|
+
return hit.value;
|
|
5122
|
+
}
|
|
5123
|
+
}
|
|
5124
|
+
const { value, backendFailed } = await resolveLocalizationContextUncached(input);
|
|
5125
|
+
if (!cacheable) return value;
|
|
5126
|
+
if (backendFailed) {
|
|
5127
|
+
putLocalizationEntry(ql, cacheKey, {
|
|
5128
|
+
value,
|
|
5129
|
+
expiresAt: Date.now() + LOCALIZATION_FAILURE_CACHE_TTL_MS,
|
|
5130
|
+
kind: "failure"
|
|
5131
|
+
});
|
|
5132
|
+
return value;
|
|
5133
|
+
}
|
|
5134
|
+
const ttlMs = localizationSuccessCacheTtlMs();
|
|
5135
|
+
if (epoch !== void 0 && ttlMs > 0) {
|
|
5136
|
+
putLocalizationEntry(ql, cacheKey, {
|
|
5137
|
+
value,
|
|
5138
|
+
expiresAt: Date.now() + ttlMs,
|
|
5139
|
+
kind: "success",
|
|
5140
|
+
epoch,
|
|
5141
|
+
settings: settingsState,
|
|
5142
|
+
settingsGen: settingsState.gen
|
|
5143
|
+
});
|
|
5144
|
+
} else {
|
|
5145
|
+
localizationCache.get(ql)?.delete(cacheKey);
|
|
4816
5146
|
}
|
|
4817
5147
|
return value;
|
|
4818
5148
|
}
|
|
4819
5149
|
async function resolveLocalizationContextUncached(input) {
|
|
4820
5150
|
const { ql, settings, tenantId, userId } = input;
|
|
4821
|
-
let
|
|
5151
|
+
let backendFailed = false;
|
|
4822
5152
|
try {
|
|
4823
5153
|
if (settings && typeof settings.get === "function") {
|
|
4824
5154
|
const sctx = { tenantId, userId };
|
|
@@ -4832,33 +5162,22 @@ async function resolveLocalizationContextUncached(input) {
|
|
|
4832
5162
|
localeRes = many.locale;
|
|
4833
5163
|
currencyRes = many.currency;
|
|
4834
5164
|
} catch {
|
|
4835
|
-
failed = true;
|
|
4836
5165
|
}
|
|
4837
5166
|
} else {
|
|
4838
5167
|
[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
|
-
})
|
|
5168
|
+
settings.get("localization", "timezone", sctx).catch(() => void 0),
|
|
5169
|
+
settings.get("localization", "locale", sctx).catch(() => void 0),
|
|
5170
|
+
settings.get("localization", "currency", sctx).catch(() => void 0)
|
|
4851
5171
|
]);
|
|
4852
5172
|
}
|
|
4853
5173
|
const tz = coerceTimeZone(tzRes?.value);
|
|
4854
5174
|
const locale = coerceLocale(localeRes?.value);
|
|
4855
5175
|
const currency = coerceCurrency(currencyRes?.value);
|
|
4856
5176
|
if (tz || locale || currency) {
|
|
4857
|
-
return { value: { timezone: tz ?? "UTC", locale: locale ?? "en-US", currency },
|
|
5177
|
+
return { value: { timezone: tz ?? "UTC", locale: locale ?? "en-US", currency }, backendFailed: false };
|
|
4858
5178
|
}
|
|
4859
5179
|
}
|
|
4860
5180
|
} catch {
|
|
4861
|
-
failed = true;
|
|
4862
5181
|
}
|
|
4863
5182
|
let rows = [];
|
|
4864
5183
|
if (ql && typeof ql.find === "function") {
|
|
@@ -4871,7 +5190,7 @@ async function resolveLocalizationContextUncached(input) {
|
|
|
4871
5190
|
if (result && result.value) result = result.value;
|
|
4872
5191
|
rows = Array.isArray(result) ? result : [];
|
|
4873
5192
|
} catch {
|
|
4874
|
-
|
|
5193
|
+
backendFailed = true;
|
|
4875
5194
|
}
|
|
4876
5195
|
}
|
|
4877
5196
|
const valueOf = (k) => rows.find((r) => r.key === k)?.value;
|
|
@@ -4881,7 +5200,7 @@ async function resolveLocalizationContextUncached(input) {
|
|
|
4881
5200
|
locale: coerceLocale(valueOf("locale")) ?? "en-US",
|
|
4882
5201
|
currency: coerceCurrency(valueOf("currency"))
|
|
4883
5202
|
},
|
|
4884
|
-
|
|
5203
|
+
backendFailed
|
|
4885
5204
|
};
|
|
4886
5205
|
}
|
|
4887
5206
|
|
|
@@ -5043,7 +5362,7 @@ function shouldDenyAnonymous(input) {
|
|
|
5043
5362
|
var ADMIN_STANDING_SURFACE = {
|
|
5044
5363
|
sys_permission_set: {
|
|
5045
5364
|
role: "derives",
|
|
5046
|
-
reason: "The row `
|
|
5365
|
+
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
5366
|
columns: [
|
|
5048
5367
|
"id",
|
|
5049
5368
|
"name",
|
|
@@ -5085,8 +5404,14 @@ var ADMIN_STANDING_SURFACE = {
|
|
|
5085
5404
|
]
|
|
5086
5405
|
},
|
|
5087
5406
|
sys_user: {
|
|
5088
|
-
role: "
|
|
5089
|
-
reason: "
|
|
5407
|
+
role: "derives",
|
|
5408
|
+
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.",
|
|
5409
|
+
columns: [
|
|
5410
|
+
"id",
|
|
5411
|
+
"email",
|
|
5412
|
+
"email_verified",
|
|
5413
|
+
"ai_access"
|
|
5414
|
+
]
|
|
5090
5415
|
},
|
|
5091
5416
|
sys_user_position: {
|
|
5092
5417
|
role: "reads-only",
|
|
@@ -5101,6 +5426,13 @@ var ADMIN_STANDING_SURFACE = {
|
|
|
5101
5426
|
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
5427
|
}
|
|
5103
5428
|
};
|
|
5429
|
+
var ADMIN_STANDING_NON_TABLE_INPUTS = [
|
|
5430
|
+
{
|
|
5431
|
+
kind: "env",
|
|
5432
|
+
name: "OS_PLATFORM_OWNER_EMAIL",
|
|
5433
|
+
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."
|
|
5434
|
+
}
|
|
5435
|
+
];
|
|
5104
5436
|
function adminStandingTables() {
|
|
5105
5437
|
return Object.entries(ADMIN_STANDING_SURFACE).filter(([, t]) => t.role === "derives").map(([name]) => name).sort();
|
|
5106
5438
|
}
|
|
@@ -5132,6 +5464,9 @@ function withoutOperationPrivateKeys(exec) {
|
|
|
5132
5464
|
return out;
|
|
5133
5465
|
}
|
|
5134
5466
|
|
|
5467
|
+
// src/security/authz-invalidation-channel.ts
|
|
5468
|
+
var AUTHZ_INVALIDATED_CHANNEL = "authz.invalidated";
|
|
5469
|
+
|
|
5135
5470
|
// src/utils/datetime.ts
|
|
5136
5471
|
var import_data = require("@objectstack/spec/data");
|
|
5137
5472
|
function calendarPartsInTz(d, tz) {
|
|
@@ -5412,7 +5747,7 @@ function omitInternalFieldsFromWriteResponse(schema, records) {
|
|
|
5412
5747
|
}
|
|
5413
5748
|
|
|
5414
5749
|
// src/utils/migration-journal.ts
|
|
5415
|
-
var
|
|
5750
|
+
var import_node_crypto4 = require("crypto");
|
|
5416
5751
|
var import_system4 = require("@objectstack/spec/system");
|
|
5417
5752
|
var SYSTEM_CTX = { isSystem: true };
|
|
5418
5753
|
var DEFAULT_CHUNK_SIZE = 200;
|
|
@@ -5467,7 +5802,7 @@ function hashMigrationPlan(plan, chunks) {
|
|
|
5467
5802
|
steps: plan.steps.map((s) => s.name),
|
|
5468
5803
|
chunks: chunks.map((c) => [c.stepIndex, c.offset, c.length])
|
|
5469
5804
|
});
|
|
5470
|
-
return (0,
|
|
5805
|
+
return (0, import_node_crypto4.createHash)("sha256").update(shape, "utf8").digest("hex").slice(0, 32);
|
|
5471
5806
|
}
|
|
5472
5807
|
async function appendEvent(engine, event, execContext) {
|
|
5473
5808
|
await engine.insert(
|
|
@@ -5540,7 +5875,7 @@ async function runMigrationJournal(engine, plan, options = {}) {
|
|
|
5540
5875
|
}
|
|
5541
5876
|
const { chunks, planHash, rowsByStep } = await loadPlan(engine, plan, options.chunkSize);
|
|
5542
5877
|
const resuming = Boolean(options.runId);
|
|
5543
|
-
const runId = options.runId ?? (0,
|
|
5878
|
+
const runId = options.runId ?? (0, import_node_crypto4.randomUUID)();
|
|
5544
5879
|
let events = [];
|
|
5545
5880
|
let seq = 0;
|
|
5546
5881
|
let committed = /* @__PURE__ */ new Set();
|
|
@@ -5798,6 +6133,52 @@ function errText(err) {
|
|
|
5798
6133
|
}
|
|
5799
6134
|
}
|
|
5800
6135
|
|
|
6136
|
+
// src/utils/advisory-aggregation.ts
|
|
6137
|
+
var import_node_async_hooks = require("async_hooks");
|
|
6138
|
+
var ADVISORY_SAMPLE_ROWS = 5;
|
|
6139
|
+
var storage = new import_node_async_hooks.AsyncLocalStorage();
|
|
6140
|
+
function keyOf(object, rule) {
|
|
6141
|
+
return JSON.stringify([object, rule]);
|
|
6142
|
+
}
|
|
6143
|
+
function recordAdvisoryHit(hit) {
|
|
6144
|
+
const collector = storage.getStore();
|
|
6145
|
+
if (!collector) return false;
|
|
6146
|
+
const key = keyOf(hit.object, hit.rule);
|
|
6147
|
+
const existing = collector.groups.get(key);
|
|
6148
|
+
if (existing) {
|
|
6149
|
+
existing.rows += 1;
|
|
6150
|
+
if (hit.recordRef != null && existing.sampleRows.length < ADVISORY_SAMPLE_ROWS) {
|
|
6151
|
+
existing.sampleRows.push(hit.recordRef);
|
|
6152
|
+
}
|
|
6153
|
+
return true;
|
|
6154
|
+
}
|
|
6155
|
+
collector.groups.set(key, {
|
|
6156
|
+
object: hit.object,
|
|
6157
|
+
rule: hit.rule,
|
|
6158
|
+
severity: hit.severity,
|
|
6159
|
+
message: hit.message,
|
|
6160
|
+
rows: 1,
|
|
6161
|
+
sampleRows: hit.recordRef != null ? [hit.recordRef] : []
|
|
6162
|
+
});
|
|
6163
|
+
return true;
|
|
6164
|
+
}
|
|
6165
|
+
function isAggregatingAdvisories() {
|
|
6166
|
+
return storage.getStore() !== void 0;
|
|
6167
|
+
}
|
|
6168
|
+
async function runWithAdvisoryAggregation(fn, report) {
|
|
6169
|
+
const collector = { groups: /* @__PURE__ */ new Map() };
|
|
6170
|
+
try {
|
|
6171
|
+
return await storage.run(collector, fn);
|
|
6172
|
+
} finally {
|
|
6173
|
+
if (collector.groups.size > 0) {
|
|
6174
|
+
try {
|
|
6175
|
+
report([...collector.groups.values()]);
|
|
6176
|
+
} catch {
|
|
6177
|
+
}
|
|
6178
|
+
}
|
|
6179
|
+
}
|
|
6180
|
+
}
|
|
6181
|
+
|
|
5801
6182
|
// src/utils/filter-tokens.ts
|
|
5802
6183
|
var import_data2 = require("@objectstack/spec/data");
|
|
5803
6184
|
var UnknownFilterTokenError = class extends Error {
|
|
@@ -6032,6 +6413,110 @@ function isUninterpretableTemporalComparand(kind, value) {
|
|
|
6032
6413
|
return !(readsAsWallClock(s) || readsAsInstant(s));
|
|
6033
6414
|
}
|
|
6034
6415
|
|
|
6416
|
+
// src/utils/metadata-activation-store.ts
|
|
6417
|
+
var METADATA_ACTIVATION_TABLE = "sys_metadata_activation";
|
|
6418
|
+
var SYSTEM_CTX2 = { isSystem: true, positions: [], permissions: [] };
|
|
6419
|
+
var InMemoryMetadataActivationStore = class {
|
|
6420
|
+
constructor() {
|
|
6421
|
+
this.rows = /* @__PURE__ */ new Map();
|
|
6422
|
+
}
|
|
6423
|
+
async list() {
|
|
6424
|
+
return [...this.rows.values()];
|
|
6425
|
+
}
|
|
6426
|
+
async setActive(row) {
|
|
6427
|
+
this.rows.set(row.name, { ...row });
|
|
6428
|
+
}
|
|
6429
|
+
};
|
|
6430
|
+
var ObjectStoreMetadataActivationStore = class {
|
|
6431
|
+
constructor(engine, metadataType) {
|
|
6432
|
+
this.engine = engine;
|
|
6433
|
+
this.metadataType = metadataType;
|
|
6434
|
+
}
|
|
6435
|
+
/**
|
|
6436
|
+
* Every row of this type. Read once at boot to hydrate the consumer's
|
|
6437
|
+
* projection.
|
|
6438
|
+
*
|
|
6439
|
+
* The only scoping is the `metadata_type` discriminator: the ledger is
|
|
6440
|
+
* deployment-wide and has no tenant column, so there is no second axis to
|
|
6441
|
+
* filter on (see the module header).
|
|
6442
|
+
*/
|
|
6443
|
+
async list() {
|
|
6444
|
+
const rows = await this.engine.find(METADATA_ACTIVATION_TABLE, {
|
|
6445
|
+
where: { metadata_type: this.metadataType },
|
|
6446
|
+
context: SYSTEM_CTX2
|
|
6447
|
+
});
|
|
6448
|
+
if (!Array.isArray(rows)) return [];
|
|
6449
|
+
const out = [];
|
|
6450
|
+
for (const row of rows) {
|
|
6451
|
+
const r = row;
|
|
6452
|
+
if (typeof r.name !== "string" || !r.name) continue;
|
|
6453
|
+
out.push({
|
|
6454
|
+
name: r.name,
|
|
6455
|
+
packageId: typeof r.package_id === "string" ? r.package_id : "",
|
|
6456
|
+
// The column defaults to `true`; only an explicit `false`
|
|
6457
|
+
// disarms. A driver that round-trips booleans as 0/1
|
|
6458
|
+
// (SQLite/libsql) is read through the same `=== false || === 0`
|
|
6459
|
+
// test, so a `0` is not mistaken for `true`.
|
|
6460
|
+
active: !(r.active === false || r.active === 0)
|
|
6461
|
+
});
|
|
6462
|
+
}
|
|
6463
|
+
return out;
|
|
6464
|
+
}
|
|
6465
|
+
/**
|
|
6466
|
+
* Insert or update the row for one packaged artifact.
|
|
6467
|
+
*
|
|
6468
|
+
* Read-then-write rather than a blind upsert because the object's
|
|
6469
|
+
* uniqueness is a DECLARED index (`unique: 'global'` over
|
|
6470
|
+
* `(metadata_type, name)`), not a primary key this store controls: there is
|
|
6471
|
+
* no id to collide on, so an insert-and-catch could not tell "already
|
|
6472
|
+
* there" from a real store failure.
|
|
6473
|
+
*
|
|
6474
|
+
* That index is also why taking the FIRST match is taking the only one: the
|
|
6475
|
+
* read below is keyed on exactly the index's two columns, so it can match
|
|
6476
|
+
* at most one row. It used to pick the first row with a NULL organization
|
|
6477
|
+
* out of the result, back when the table carried a reserved tenant column;
|
|
6478
|
+
* with no such column the set it was choosing from can no longer hold more
|
|
6479
|
+
* than one member.
|
|
6480
|
+
*/
|
|
6481
|
+
async setActive(row) {
|
|
6482
|
+
const existing = await this.engine.find(METADATA_ACTIVATION_TABLE, {
|
|
6483
|
+
where: { metadata_type: this.metadataType, name: row.name },
|
|
6484
|
+
context: SYSTEM_CTX2
|
|
6485
|
+
});
|
|
6486
|
+
const current = Array.isArray(existing) ? existing[0] : void 0;
|
|
6487
|
+
if (current && current.id != null) {
|
|
6488
|
+
await this.engine.update(
|
|
6489
|
+
METADATA_ACTIVATION_TABLE,
|
|
6490
|
+
{ id: current.id, active: row.active, package_id: row.packageId },
|
|
6491
|
+
{ context: SYSTEM_CTX2 }
|
|
6492
|
+
);
|
|
6493
|
+
return;
|
|
6494
|
+
}
|
|
6495
|
+
await this.engine.insert(
|
|
6496
|
+
METADATA_ACTIVATION_TABLE,
|
|
6497
|
+
{
|
|
6498
|
+
metadata_type: this.metadataType,
|
|
6499
|
+
name: row.name,
|
|
6500
|
+
package_id: row.packageId,
|
|
6501
|
+
active: row.active
|
|
6502
|
+
},
|
|
6503
|
+
{ context: SYSTEM_CTX2 }
|
|
6504
|
+
);
|
|
6505
|
+
}
|
|
6506
|
+
/**
|
|
6507
|
+
* Read the backing table once so a misconfiguration surfaces at BOOT
|
|
6508
|
+
* rather than as a failed toggle later. Throws the driver error verbatim —
|
|
6509
|
+
* `no such table: sys_metadata_activation` means the object was never
|
|
6510
|
+
* registered (or its schema never synced) in this composition.
|
|
6511
|
+
*
|
|
6512
|
+
* ⚠️ Unscoped by design: the question is "does the TABLE read at all",
|
|
6513
|
+
* which is a property of the composition, not of one `metadata_type`.
|
|
6514
|
+
*/
|
|
6515
|
+
async probe() {
|
|
6516
|
+
await this.engine.find(METADATA_ACTIVATION_TABLE, { where: {}, limit: 1, context: SYSTEM_CTX2 });
|
|
6517
|
+
}
|
|
6518
|
+
};
|
|
6519
|
+
|
|
6035
6520
|
// src/utils/record-not-found.ts
|
|
6036
6521
|
function recordNotFoundError(object, id) {
|
|
6037
6522
|
const err = new Error(`Record ${id} not found in ${object}`);
|
|
@@ -6042,6 +6527,45 @@ function recordNotFoundError(object, id) {
|
|
|
6042
6527
|
}
|
|
6043
6528
|
|
|
6044
6529
|
// src/health-monitor.ts
|
|
6530
|
+
var RECOVERY_IS_THRESHOLD_GATED = {
|
|
6531
|
+
degraded: true,
|
|
6532
|
+
unhealthy: true,
|
|
6533
|
+
failed: true,
|
|
6534
|
+
recovering: true,
|
|
6535
|
+
healthy: false,
|
|
6536
|
+
unknown: false
|
|
6537
|
+
};
|
|
6538
|
+
function healthMonitorRefusal(message) {
|
|
6539
|
+
const err = new Error(message);
|
|
6540
|
+
err.code = "VALIDATION_ERROR";
|
|
6541
|
+
err.status = 400;
|
|
6542
|
+
return err;
|
|
6543
|
+
}
|
|
6544
|
+
var RETIRED_HEALTH_CHECK_KEYS = [
|
|
6545
|
+
[
|
|
6546
|
+
"autoRestart",
|
|
6547
|
+
"'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."
|
|
6548
|
+
],
|
|
6549
|
+
[
|
|
6550
|
+
"maxRestartAttempts",
|
|
6551
|
+
"'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."
|
|
6552
|
+
],
|
|
6553
|
+
[
|
|
6554
|
+
"restartBackoff",
|
|
6555
|
+
"'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."
|
|
6556
|
+
]
|
|
6557
|
+
];
|
|
6558
|
+
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.";
|
|
6559
|
+
function assertNoRetiredKeys(pluginName, config) {
|
|
6560
|
+
for (const [key, guidance] of RETIRED_HEALTH_CHECK_KEYS) {
|
|
6561
|
+
if (!Object.prototype.hasOwnProperty.call(config, key)) {
|
|
6562
|
+
continue;
|
|
6563
|
+
}
|
|
6564
|
+
throw healthMonitorRefusal(
|
|
6565
|
+
`[HealthMonitor] Plugin '${pluginName}': ${guidance}${RESTART_IS_THE_HOSTS_JOB}`
|
|
6566
|
+
);
|
|
6567
|
+
}
|
|
6568
|
+
}
|
|
6045
6569
|
var PluginHealthMonitor = class {
|
|
6046
6570
|
constructor(logger) {
|
|
6047
6571
|
this.healthChecks = /* @__PURE__ */ new Map();
|
|
@@ -6050,18 +6574,17 @@ var PluginHealthMonitor = class {
|
|
|
6050
6574
|
this.checkIntervals = /* @__PURE__ */ new Map();
|
|
6051
6575
|
this.failureCounters = /* @__PURE__ */ new Map();
|
|
6052
6576
|
this.successCounters = /* @__PURE__ */ new Map();
|
|
6053
|
-
this.restartAttempts = /* @__PURE__ */ new Map();
|
|
6054
6577
|
this.logger = logger.child({ component: "HealthMonitor" });
|
|
6055
6578
|
}
|
|
6056
6579
|
/**
|
|
6057
6580
|
* Register a plugin for health monitoring
|
|
6058
6581
|
*/
|
|
6059
6582
|
registerPlugin(pluginName, config) {
|
|
6583
|
+
assertNoRetiredKeys(pluginName, config);
|
|
6060
6584
|
this.healthChecks.set(pluginName, config);
|
|
6061
6585
|
this.healthStatus.set(pluginName, "unknown");
|
|
6062
6586
|
this.failureCounters.set(pluginName, 0);
|
|
6063
6587
|
this.successCounters.set(pluginName, 0);
|
|
6064
|
-
this.restartAttempts.set(pluginName, 0);
|
|
6065
6588
|
this.logger.info("Plugin registered for health monitoring", {
|
|
6066
6589
|
plugin: pluginName,
|
|
6067
6590
|
interval: config.interval
|
|
@@ -6113,6 +6636,7 @@ var PluginHealthMonitor = class {
|
|
|
6113
6636
|
let status = "healthy";
|
|
6114
6637
|
let message;
|
|
6115
6638
|
const checks = [];
|
|
6639
|
+
let failureRoute;
|
|
6116
6640
|
try {
|
|
6117
6641
|
if (config.checkMethod && typeof plugin[config.checkMethod] === "function") {
|
|
6118
6642
|
const checkResult = await this.raceCheckTimeout(
|
|
@@ -6133,8 +6657,8 @@ var PluginHealthMonitor = class {
|
|
|
6133
6657
|
if (status === "healthy") {
|
|
6134
6658
|
this.successCounters.set(pluginName, (this.successCounters.get(pluginName) || 0) + 1);
|
|
6135
6659
|
this.failureCounters.set(pluginName, 0);
|
|
6136
|
-
const currentStatus = this.healthStatus.get(pluginName);
|
|
6137
|
-
if (currentStatus
|
|
6660
|
+
const currentStatus = this.healthStatus.get(pluginName) ?? "unknown";
|
|
6661
|
+
if (RECOVERY_IS_THRESHOLD_GATED[currentStatus]) {
|
|
6138
6662
|
const successCount = this.successCounters.get(pluginName) || 0;
|
|
6139
6663
|
if (successCount >= config.successThreshold) {
|
|
6140
6664
|
this.healthStatus.set(pluginName, "healthy");
|
|
@@ -6146,27 +6670,11 @@ var PluginHealthMonitor = class {
|
|
|
6146
6670
|
this.healthStatus.set(pluginName, "healthy");
|
|
6147
6671
|
}
|
|
6148
6672
|
} 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
|
-
}
|
|
6673
|
+
failureRoute = "returned";
|
|
6164
6674
|
}
|
|
6165
6675
|
} catch (error) {
|
|
6166
6676
|
status = "failed";
|
|
6167
6677
|
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
6678
|
checks.push({
|
|
6171
6679
|
name: "health-check",
|
|
6172
6680
|
status: "failed",
|
|
@@ -6176,69 +6684,59 @@ var PluginHealthMonitor = class {
|
|
|
6176
6684
|
plugin: pluginName,
|
|
6177
6685
|
error
|
|
6178
6686
|
});
|
|
6687
|
+
failureRoute = "thrown";
|
|
6688
|
+
}
|
|
6689
|
+
if (failureRoute) {
|
|
6690
|
+
this.recordFailedRound(pluginName, config, failureRoute);
|
|
6179
6691
|
}
|
|
6180
6692
|
const report = {
|
|
6181
6693
|
status: this.healthStatus.get(pluginName) || "unknown",
|
|
6182
6694
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
6183
6695
|
message,
|
|
6184
6696
|
metrics: {
|
|
6185
|
-
|
|
6697
|
+
uptimeMs: Date.now() - startTime
|
|
6186
6698
|
},
|
|
6187
6699
|
checks: checks.length > 0 ? checks : void 0
|
|
6188
6700
|
};
|
|
6189
6701
|
this.healthReports.set(pluginName, report);
|
|
6190
6702
|
}
|
|
6191
6703
|
/**
|
|
6192
|
-
*
|
|
6704
|
+
* Handle one failed round — the single path BOTH failure routes take.
|
|
6705
|
+
*
|
|
6706
|
+
* `performHealthCheck` can fail two disjoint ways: the check *returns* a
|
|
6707
|
+
* failure (`false` or `{ status: 'unhealthy' }`), or it *throws* — which by
|
|
6708
|
+
* `raceCheckTimeout` includes every `timeout` overrun, the severest case of
|
|
6709
|
+
* the two. The routes used to be handled in separate blocks, and only the
|
|
6710
|
+
* returned one cleared `successCounters`, so the counters a declared
|
|
6711
|
+
* `failureThreshold` / `successThreshold` are counted with depended on which
|
|
6712
|
+
* way the round happened to fail (#11852).
|
|
6713
|
+
*
|
|
6714
|
+
* What stays route-specific is the *status label*, deliberately. A throw is
|
|
6715
|
+
* the separate `failed` status applied immediately with no threshold — that
|
|
6716
|
+
* is the documented contract (`content/docs/protocol/kernel/lifecycle.mdx`,
|
|
6717
|
+
* "Custom Health Checks") and is pinned by the timeout test. Only the
|
|
6718
|
+
* counters are shared, because that is what `failureThreshold` declares, and
|
|
6719
|
+
* it does not name a route.
|
|
6720
|
+
*
|
|
6721
|
+
* This round ENDS here. Nothing is done TO the plugin — see the #12032 note
|
|
6722
|
+
* on the class: a monitor that cannot re-initialise a plugin has no business
|
|
6723
|
+
* destroying one.
|
|
6193
6724
|
*/
|
|
6194
|
-
|
|
6195
|
-
const
|
|
6196
|
-
|
|
6197
|
-
|
|
6198
|
-
|
|
6199
|
-
|
|
6200
|
-
});
|
|
6725
|
+
recordFailedRound(pluginName, config, route) {
|
|
6726
|
+
const failureCount = (this.failureCounters.get(pluginName) || 0) + 1;
|
|
6727
|
+
this.failureCounters.set(pluginName, failureCount);
|
|
6728
|
+
this.successCounters.set(pluginName, 0);
|
|
6729
|
+
const thresholdReached = failureCount >= config.failureThreshold;
|
|
6730
|
+
if (route === "thrown") {
|
|
6201
6731
|
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", {
|
|
6732
|
+
} else if (thresholdReached) {
|
|
6733
|
+
this.healthStatus.set(pluginName, "unhealthy");
|
|
6734
|
+
this.logger.warn("Plugin marked as unhealthy", {
|
|
6222
6735
|
plugin: pluginName,
|
|
6223
|
-
|
|
6736
|
+
failures: failureCount
|
|
6224
6737
|
});
|
|
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;
|
|
6738
|
+
} else {
|
|
6739
|
+
this.healthStatus.set(pluginName, "degraded");
|
|
6242
6740
|
}
|
|
6243
6741
|
}
|
|
6244
6742
|
/**
|
|
@@ -6271,7 +6769,6 @@ var PluginHealthMonitor = class {
|
|
|
6271
6769
|
this.healthReports.clear();
|
|
6272
6770
|
this.failureCounters.clear();
|
|
6273
6771
|
this.successCounters.clear();
|
|
6274
|
-
this.restartAttempts.clear();
|
|
6275
6772
|
this.logger.info("Health monitor shutdown complete");
|
|
6276
6773
|
}
|
|
6277
6774
|
/**
|
|
@@ -6312,7 +6809,7 @@ var PluginHealthMonitor = class {
|
|
|
6312
6809
|
};
|
|
6313
6810
|
|
|
6314
6811
|
// src/hot-reload.ts
|
|
6315
|
-
var
|
|
6812
|
+
var import_node_crypto5 = require("crypto");
|
|
6316
6813
|
var generateUUID = () => {
|
|
6317
6814
|
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
|
6318
6815
|
return crypto.randomUUID();
|
|
@@ -6323,6 +6820,42 @@ var generateUUID = () => {
|
|
|
6323
6820
|
return v.toString(16);
|
|
6324
6821
|
});
|
|
6325
6822
|
};
|
|
6823
|
+
var HONOURED_STATE_STRATEGIES = ["memory", "none"];
|
|
6824
|
+
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.";
|
|
6825
|
+
function hotReloadRefusal(message) {
|
|
6826
|
+
const err = new Error(message);
|
|
6827
|
+
err.code = "VALIDATION_ERROR";
|
|
6828
|
+
err.status = 400;
|
|
6829
|
+
return err;
|
|
6830
|
+
}
|
|
6831
|
+
function assertHonouredStateStrategy(pluginName, strategy) {
|
|
6832
|
+
if (HONOURED_STATE_STRATEGIES.includes(strategy)) {
|
|
6833
|
+
return;
|
|
6834
|
+
}
|
|
6835
|
+
const shown = typeof strategy === "string" ? `'${strategy}'` : String(strategy);
|
|
6836
|
+
const retired = strategy === "disk" || strategy === "distributed";
|
|
6837
|
+
throw hotReloadRefusal(
|
|
6838
|
+
`[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.")
|
|
6839
|
+
);
|
|
6840
|
+
}
|
|
6841
|
+
var RETIRED_HOT_RELOAD_KEYS = [
|
|
6842
|
+
[
|
|
6843
|
+
"distributedConfig",
|
|
6844
|
+
"'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."
|
|
6845
|
+
],
|
|
6846
|
+
[
|
|
6847
|
+
"watchPatterns",
|
|
6848
|
+
"'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."
|
|
6849
|
+
]
|
|
6850
|
+
];
|
|
6851
|
+
function assertNoRetiredKeys2(pluginName, config) {
|
|
6852
|
+
for (const [key, guidance] of RETIRED_HOT_RELOAD_KEYS) {
|
|
6853
|
+
if (!Object.prototype.hasOwnProperty.call(config, key)) {
|
|
6854
|
+
continue;
|
|
6855
|
+
}
|
|
6856
|
+
throw hotReloadRefusal(`[HotReload] Plugin '${pluginName}': ${guidance}`);
|
|
6857
|
+
}
|
|
6858
|
+
}
|
|
6326
6859
|
var PluginStateManager = class {
|
|
6327
6860
|
constructor(logger) {
|
|
6328
6861
|
this.stateSnapshots = /* @__PURE__ */ new Map();
|
|
@@ -6349,17 +6882,6 @@ var PluginStateManager = class {
|
|
|
6349
6882
|
this.memoryStore.set(snapshotId, snapshot);
|
|
6350
6883
|
this.logger.debug("State saved to memory", { pluginId, snapshotId });
|
|
6351
6884
|
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
6885
|
case "none":
|
|
6364
6886
|
this.logger.debug("State persistence disabled", { pluginId });
|
|
6365
6887
|
break;
|
|
@@ -6407,7 +6929,7 @@ var PluginStateManager = class {
|
|
|
6407
6929
|
*/
|
|
6408
6930
|
calculateChecksum(state) {
|
|
6409
6931
|
const stateStr = JSON.stringify(state);
|
|
6410
|
-
return (0,
|
|
6932
|
+
return (0, import_node_crypto5.createHash)("sha256").update(stateStr).digest("hex");
|
|
6411
6933
|
}
|
|
6412
6934
|
/**
|
|
6413
6935
|
* Shutdown state manager
|
|
@@ -6421,7 +6943,6 @@ var PluginStateManager = class {
|
|
|
6421
6943
|
var HotReloadManager = class {
|
|
6422
6944
|
constructor(logger) {
|
|
6423
6945
|
this.reloadConfigs = /* @__PURE__ */ new Map();
|
|
6424
|
-
this.watchHandles = /* @__PURE__ */ new Map();
|
|
6425
6946
|
this.reloadTimers = /* @__PURE__ */ new Map();
|
|
6426
6947
|
this.logger = logger.child({ component: "HotReload" });
|
|
6427
6948
|
this.stateManager = new PluginStateManager(logger);
|
|
@@ -6430,6 +6951,8 @@ var HotReloadManager = class {
|
|
|
6430
6951
|
* Register a plugin for hot reload
|
|
6431
6952
|
*/
|
|
6432
6953
|
registerPlugin(pluginName, config) {
|
|
6954
|
+
assertHonouredStateStrategy(pluginName, config.stateStrategy);
|
|
6955
|
+
assertNoRetiredKeys2(pluginName, config);
|
|
6433
6956
|
if (!config.enabled) {
|
|
6434
6957
|
this.logger.debug("Hot reload disabled for plugin", { plugin: pluginName });
|
|
6435
6958
|
return;
|
|
@@ -6437,32 +6960,45 @@ var HotReloadManager = class {
|
|
|
6437
6960
|
this.reloadConfigs.set(pluginName, config);
|
|
6438
6961
|
this.logger.info("Plugin registered for hot reload", {
|
|
6439
6962
|
plugin: pluginName,
|
|
6440
|
-
watchPatterns: config.watchPatterns,
|
|
6441
6963
|
stateStrategy: config.stateStrategy
|
|
6442
6964
|
});
|
|
6443
6965
|
}
|
|
6444
6966
|
/**
|
|
6445
|
-
*
|
|
6967
|
+
* Refuse the file-watching call this class never implemented (#12428).
|
|
6968
|
+
*
|
|
6969
|
+
* The body used to be a guard plus `logger.info('File watching started')`
|
|
6970
|
+
* over an in-source note saying real watching "would require chokidar or
|
|
6971
|
+
* similar". Nothing was ever watched, so an operator who set
|
|
6972
|
+
* `enabled: true` and read that line at INFO had been told the opposite of
|
|
6973
|
+
* the truth — positive confirmation of a capability that did not exist.
|
|
6974
|
+
* ADR-0049 leaves three states and this surface qualified for none of the
|
|
6975
|
+
* other two: no runtime composes this class, so ENFORCE would build for a
|
|
6976
|
+
* caller that does not exist, and no roadmap entry anywhere claims the
|
|
6977
|
+
* feature, so EXPERIMENTAL would be a promise nobody made.
|
|
6978
|
+
*
|
|
6979
|
+
* Kept as a throwing door rather than deleted: removing the method leaves a
|
|
6980
|
+
* JavaScript host a bare `TypeError: not a function` with no prescription,
|
|
6981
|
+
* and this is the one place a caller of the old placeholder is guaranteed
|
|
6982
|
+
* to arrive. The refusal carries an ADR-0112 envelope so it can be asserted
|
|
6983
|
+
* rather than merely caught.
|
|
6446
6984
|
*/
|
|
6447
6985
|
startWatching(pluginName) {
|
|
6448
|
-
|
|
6449
|
-
|
|
6450
|
-
|
|
6451
|
-
}
|
|
6452
|
-
this.logger.info("File watching started", {
|
|
6453
|
-
plugin: pluginName,
|
|
6454
|
-
patterns: config.watchPatterns
|
|
6455
|
-
});
|
|
6986
|
+
throw hotReloadRefusal(
|
|
6987
|
+
`[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.`
|
|
6988
|
+
);
|
|
6456
6989
|
}
|
|
6457
6990
|
/**
|
|
6458
|
-
*
|
|
6991
|
+
* Cancel a pending debounced reload for a plugin.
|
|
6992
|
+
*
|
|
6993
|
+
* The name is historical (#12428). This never stopped a watcher, because
|
|
6994
|
+
* nothing in this class ever started one: its `watchHandles` cleanup branch
|
|
6995
|
+
* read a Map that had no writer anywhere in the tree, so the branch was
|
|
6996
|
+
* structurally unreachable rather than merely untaken, and it left with
|
|
6997
|
+
* `startWatching`'s placeholder. What survives is the half that always did
|
|
6998
|
+
* something — the debounce timer armed by `scheduleReload` is cleared, so a
|
|
6999
|
+
* reload that was scheduled but has not fired yet is cancelled.
|
|
6459
7000
|
*/
|
|
6460
7001
|
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
7002
|
const timer = this.reloadTimers.get(pluginName);
|
|
6467
7003
|
if (timer) {
|
|
6468
7004
|
clearTimeout(timer);
|
|
@@ -6605,14 +7141,10 @@ var HotReloadManager = class {
|
|
|
6605
7141
|
* Shutdown hot reload manager
|
|
6606
7142
|
*/
|
|
6607
7143
|
shutdown() {
|
|
6608
|
-
for (const pluginName of this.watchHandles.keys()) {
|
|
6609
|
-
this.stopWatching(pluginName);
|
|
6610
|
-
}
|
|
6611
7144
|
for (const timer of this.reloadTimers.values()) {
|
|
6612
7145
|
clearTimeout(timer);
|
|
6613
7146
|
}
|
|
6614
7147
|
this.reloadConfigs.clear();
|
|
6615
|
-
this.watchHandles.clear();
|
|
6616
7148
|
this.reloadTimers.clear();
|
|
6617
7149
|
this.stateManager.shutdown();
|
|
6618
7150
|
this.logger.info("Hot reload manager shutdown complete");
|
|
@@ -7022,7 +7554,9 @@ var NamespaceResolver = class {
|
|
|
7022
7554
|
var import_contracts = require("@objectstack/spec/contracts");
|
|
7023
7555
|
// Annotate the CommonJS export names for ESM import in node:
|
|
7024
7556
|
0 && (module.exports = {
|
|
7557
|
+
ADMIN_STANDING_NON_TABLE_INPUTS,
|
|
7025
7558
|
ADMIN_STANDING_SURFACE,
|
|
7559
|
+
ADVISORY_SAMPLE_ROWS,
|
|
7026
7560
|
ANONYMOUS_DENY_BODY,
|
|
7027
7561
|
ANONYMOUS_DENY_CODE,
|
|
7028
7562
|
ANONYMOUS_DENY_MESSAGE,
|
|
@@ -7030,11 +7564,19 @@ var import_contracts = require("@objectstack/spec/contracts");
|
|
|
7030
7564
|
API_KEY_PREFIX,
|
|
7031
7565
|
AUDIENCE_BINDING_SUGGESTION_STATUSES,
|
|
7032
7566
|
AUDIENCE_BINDING_SUGGESTION_STATUS_VALUES,
|
|
7567
|
+
AUTHZ_GRANTS_CACHE_TTL_ENV,
|
|
7568
|
+
AUTHZ_INVALIDATED_CHANNEL,
|
|
7569
|
+
AUTHZ_STORE_UNAVAILABLE_CODE,
|
|
7570
|
+
AUTHZ_STORE_UNAVAILABLE_MESSAGE,
|
|
7571
|
+
AUTHZ_STORE_UNAVAILABLE_STATUS,
|
|
7572
|
+
AuthzStoreUnavailableError,
|
|
7033
7573
|
CORE_FALLBACK_FACTORIES,
|
|
7034
7574
|
DependencyResolver,
|
|
7035
7575
|
ENTRY_EXECUTION_CONTEXT_FIELDS,
|
|
7036
7576
|
HotReloadManager,
|
|
7577
|
+
InMemoryMetadataActivationStore,
|
|
7037
7578
|
LiteKernel,
|
|
7579
|
+
METADATA_ACTIVATION_TABLE,
|
|
7038
7580
|
MigrationJournalRefusal,
|
|
7039
7581
|
MigrationPlanRegistry,
|
|
7040
7582
|
NamespaceResolver,
|
|
@@ -7042,18 +7584,19 @@ var import_contracts = require("@objectstack/spec/contracts");
|
|
|
7042
7584
|
ObjectKernel,
|
|
7043
7585
|
ObjectKernelBase,
|
|
7044
7586
|
ObjectLogger,
|
|
7587
|
+
ObjectStoreMetadataActivationStore,
|
|
7588
|
+
PLATFORM_ADMIN_EMAIL_SEPARATOR,
|
|
7045
7589
|
POSTURE_INJECTION_RULE,
|
|
7046
7590
|
POSTURE_LADDER,
|
|
7047
7591
|
POSTURE_RANK,
|
|
7048
|
-
PluginConfigValidator,
|
|
7049
7592
|
PluginHealthMonitor,
|
|
7050
7593
|
PluginLoader,
|
|
7051
7594
|
PluginPermissionEnforcer,
|
|
7052
7595
|
PluginPermissionManager,
|
|
7053
7596
|
PluginSandboxRuntime,
|
|
7054
|
-
PluginSecurityScanner,
|
|
7055
7597
|
PluginSignatureVerifier,
|
|
7056
7598
|
QA,
|
|
7599
|
+
SERVICE_NOT_REGISTERED_CODE,
|
|
7057
7600
|
SIGNATURE_ALG,
|
|
7058
7601
|
SecurePluginContext,
|
|
7059
7602
|
SemanticVersionManager,
|
|
@@ -7063,6 +7606,7 @@ var import_contracts = require("@objectstack/spec/contracts");
|
|
|
7063
7606
|
UnresolvedFilterTokenError,
|
|
7064
7607
|
adminStandingColumns,
|
|
7065
7608
|
adminStandingTables,
|
|
7609
|
+
artifactPackageId,
|
|
7066
7610
|
assembleExecutionContext,
|
|
7067
7611
|
assembleExecutionContextOrGuest,
|
|
7068
7612
|
assertInitServiceRequirements,
|
|
@@ -7081,7 +7625,6 @@ var import_contracts = require("@objectstack/spec/contracts");
|
|
|
7081
7625
|
createMemoryJob,
|
|
7082
7626
|
createMemoryMetadata,
|
|
7083
7627
|
createMemoryQueue,
|
|
7084
|
-
createPluginConfigValidator,
|
|
7085
7628
|
createPluginPermissionEnforcer,
|
|
7086
7629
|
deepMerge,
|
|
7087
7630
|
defaultIsTransientError,
|
|
@@ -7093,48 +7636,70 @@ var import_contracts = require("@objectstack/spec/contracts");
|
|
|
7093
7636
|
extractApiKey,
|
|
7094
7637
|
filterTokenContextFrom,
|
|
7095
7638
|
findInterruptedRuns,
|
|
7639
|
+
formatIntegrityViolation,
|
|
7096
7640
|
generateApiKey,
|
|
7097
7641
|
generateEd25519KeyPair,
|
|
7098
7642
|
getEnv,
|
|
7099
7643
|
getMemoryUsage,
|
|
7644
|
+
hasPlatformAdminStanding,
|
|
7100
7645
|
hashApiKey,
|
|
7101
7646
|
hashMigrationPlan,
|
|
7647
|
+
isAggregatingAdvisories,
|
|
7102
7648
|
isAudienceBindingSuggestionStatus,
|
|
7103
7649
|
isAuthGateAllowlisted,
|
|
7650
|
+
isAuthzStoreUnavailableError,
|
|
7651
|
+
isConfiguredPlatformAdminEmail,
|
|
7104
7652
|
isExpired,
|
|
7105
7653
|
isGrantActive,
|
|
7106
7654
|
isGrantExpired,
|
|
7107
7655
|
isNode,
|
|
7108
7656
|
isRowActive,
|
|
7657
|
+
isServiceNotRegisteredError,
|
|
7109
7658
|
isUninterpretableTemporalComparand,
|
|
7659
|
+
matchesConfiguredPlatformAdmin,
|
|
7110
7660
|
nextUtcCalendarDay,
|
|
7111
7661
|
normalizeAuthGate,
|
|
7662
|
+
normalizePlatformAdminEmail,
|
|
7112
7663
|
omitInternalFieldsFromWriteResponse,
|
|
7664
|
+
parsePlatformAdminEmails,
|
|
7113
7665
|
parseScopes,
|
|
7114
7666
|
parseSignature,
|
|
7115
7667
|
planChunks,
|
|
7116
7668
|
postureVisibleRows,
|
|
7117
7669
|
readAuthoredTranslationLayer,
|
|
7670
|
+
readAuthzGrantsCacheTtlMs,
|
|
7118
7671
|
readRunJournal,
|
|
7672
|
+
recordAdvisoryHit,
|
|
7119
7673
|
recordNotFoundError,
|
|
7674
|
+
reportAuthzCachePosture,
|
|
7675
|
+
reportLegacyPlatformAdminGrant,
|
|
7676
|
+
resetLegacyPlatformAdminGrantReport,
|
|
7677
|
+
resetPlatformAdminEmailMemo,
|
|
7120
7678
|
resolveApiKeyAdmission,
|
|
7121
7679
|
resolveApiKeyPrincipal,
|
|
7680
|
+
resolveArtifactPackageOrder,
|
|
7681
|
+
resolveAuthzCachePosture,
|
|
7122
7682
|
resolveAuthzContext,
|
|
7123
7683
|
resolveFilterToken,
|
|
7124
7684
|
resolveFilterTokens,
|
|
7125
7685
|
resolveLocale,
|
|
7126
7686
|
resolveLocalizationContext,
|
|
7687
|
+
resolvePlatformAdminEmails,
|
|
7127
7688
|
resolvePluginOrder,
|
|
7128
7689
|
resolveUserAuthzGrants,
|
|
7129
7690
|
resumeMigrationJournal,
|
|
7691
|
+
rethrowAuthzStoreUnavailable,
|
|
7130
7692
|
runMigrationJournal,
|
|
7693
|
+
runWithAdvisoryAggregation,
|
|
7131
7694
|
safeExit,
|
|
7695
|
+
setPlatformAdminConfigSink,
|
|
7132
7696
|
shouldDenyAnonymous,
|
|
7133
7697
|
signPayload,
|
|
7134
7698
|
temporalComparandKind,
|
|
7135
7699
|
unknownAudienceBindingSuggestionStatusMessage,
|
|
7136
7700
|
utcInstantMs,
|
|
7137
7701
|
validateInitServiceContract,
|
|
7702
|
+
verifyIntegrity,
|
|
7138
7703
|
verifyPayload,
|
|
7139
7704
|
verifyPlatformSignature,
|
|
7140
7705
|
verifyPluginArtifact,
|