@objectstack/core 17.1.0 → 17.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -30,6 +30,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
+ ADMIN_STANDING_NON_TABLE_INPUTS: () => ADMIN_STANDING_NON_TABLE_INPUTS,
33
34
  ADMIN_STANDING_SURFACE: () => ADMIN_STANDING_SURFACE,
34
35
  ANONYMOUS_DENY_BODY: () => ANONYMOUS_DENY_BODY,
35
36
  ANONYMOUS_DENY_CODE: () => ANONYMOUS_DENY_CODE,
@@ -38,11 +39,19 @@ __export(index_exports, {
38
39
  API_KEY_PREFIX: () => API_KEY_PREFIX,
39
40
  AUDIENCE_BINDING_SUGGESTION_STATUSES: () => AUDIENCE_BINDING_SUGGESTION_STATUSES,
40
41
  AUDIENCE_BINDING_SUGGESTION_STATUS_VALUES: () => AUDIENCE_BINDING_SUGGESTION_STATUS_VALUES,
42
+ AUTHZ_GRANTS_CACHE_TTL_ENV: () => AUTHZ_GRANTS_CACHE_TTL_ENV,
43
+ AUTHZ_INVALIDATED_CHANNEL: () => AUTHZ_INVALIDATED_CHANNEL,
44
+ AUTHZ_STORE_UNAVAILABLE_CODE: () => AUTHZ_STORE_UNAVAILABLE_CODE,
45
+ AUTHZ_STORE_UNAVAILABLE_MESSAGE: () => AUTHZ_STORE_UNAVAILABLE_MESSAGE,
46
+ AUTHZ_STORE_UNAVAILABLE_STATUS: () => AUTHZ_STORE_UNAVAILABLE_STATUS,
47
+ AuthzStoreUnavailableError: () => AuthzStoreUnavailableError,
41
48
  CORE_FALLBACK_FACTORIES: () => CORE_FALLBACK_FACTORIES,
42
49
  DependencyResolver: () => DependencyResolver,
43
50
  ENTRY_EXECUTION_CONTEXT_FIELDS: () => ENTRY_EXECUTION_CONTEXT_FIELDS,
44
51
  HotReloadManager: () => HotReloadManager,
52
+ InMemoryMetadataActivationStore: () => InMemoryMetadataActivationStore,
45
53
  LiteKernel: () => LiteKernel,
54
+ METADATA_ACTIVATION_TABLE: () => METADATA_ACTIVATION_TABLE,
46
55
  MigrationJournalRefusal: () => MigrationJournalRefusal,
47
56
  MigrationPlanRegistry: () => MigrationPlanRegistry,
48
57
  NamespaceResolver: () => NamespaceResolver,
@@ -50,10 +59,11 @@ __export(index_exports, {
50
59
  ObjectKernel: () => ObjectKernel,
51
60
  ObjectKernelBase: () => ObjectKernelBase,
52
61
  ObjectLogger: () => ObjectLogger,
62
+ ObjectStoreMetadataActivationStore: () => ObjectStoreMetadataActivationStore,
63
+ PLATFORM_ADMIN_EMAIL_SEPARATOR: () => PLATFORM_ADMIN_EMAIL_SEPARATOR,
53
64
  POSTURE_INJECTION_RULE: () => POSTURE_INJECTION_RULE,
54
65
  POSTURE_LADDER: () => POSTURE_LADDER,
55
66
  POSTURE_RANK: () => POSTURE_RANK,
56
- PluginConfigValidator: () => PluginConfigValidator,
57
67
  PluginHealthMonitor: () => PluginHealthMonitor,
58
68
  PluginLoader: () => PluginLoader,
59
69
  PluginPermissionEnforcer: () => PluginPermissionEnforcer,
@@ -62,6 +72,7 @@ __export(index_exports, {
62
72
  PluginSecurityScanner: () => PluginSecurityScanner,
63
73
  PluginSignatureVerifier: () => PluginSignatureVerifier,
64
74
  QA: () => qa_exports,
75
+ SERVICE_NOT_REGISTERED_CODE: () => SERVICE_NOT_REGISTERED_CODE,
65
76
  SIGNATURE_ALG: () => SIGNATURE_ALG,
66
77
  SecurePluginContext: () => SecurePluginContext,
67
78
  SemanticVersionManager: () => SemanticVersionManager,
@@ -71,6 +82,7 @@ __export(index_exports, {
71
82
  UnresolvedFilterTokenError: () => UnresolvedFilterTokenError,
72
83
  adminStandingColumns: () => adminStandingColumns,
73
84
  adminStandingTables: () => adminStandingTables,
85
+ artifactPackageId: () => artifactPackageId,
74
86
  assembleExecutionContext: () => assembleExecutionContext,
75
87
  assembleExecutionContextOrGuest: () => assembleExecutionContextOrGuest,
76
88
  assertInitServiceRequirements: () => assertInitServiceRequirements,
@@ -89,7 +101,6 @@ __export(index_exports, {
89
101
  createMemoryJob: () => createMemoryJob,
90
102
  createMemoryMetadata: () => createMemoryMetadata,
91
103
  createMemoryQueue: () => createMemoryQueue,
92
- createPluginConfigValidator: () => createPluginConfigValidator,
93
104
  createPluginPermissionEnforcer: () => createPluginPermissionEnforcer,
94
105
  deepMerge: () => deepMerge,
95
106
  defaultIsTransientError: () => defaultIsTransientError,
@@ -101,48 +112,67 @@ __export(index_exports, {
101
112
  extractApiKey: () => extractApiKey,
102
113
  filterTokenContextFrom: () => filterTokenContextFrom,
103
114
  findInterruptedRuns: () => findInterruptedRuns,
115
+ formatIntegrityViolation: () => formatIntegrityViolation,
104
116
  generateApiKey: () => generateApiKey,
105
117
  generateEd25519KeyPair: () => generateEd25519KeyPair,
106
118
  getEnv: () => getEnv,
107
119
  getMemoryUsage: () => getMemoryUsage,
120
+ hasPlatformAdminStanding: () => hasPlatformAdminStanding,
108
121
  hashApiKey: () => hashApiKey,
109
122
  hashMigrationPlan: () => hashMigrationPlan,
110
123
  isAudienceBindingSuggestionStatus: () => isAudienceBindingSuggestionStatus,
111
124
  isAuthGateAllowlisted: () => isAuthGateAllowlisted,
125
+ isAuthzStoreUnavailableError: () => isAuthzStoreUnavailableError,
126
+ isConfiguredPlatformAdminEmail: () => isConfiguredPlatformAdminEmail,
112
127
  isExpired: () => isExpired,
113
128
  isGrantActive: () => isGrantActive,
114
129
  isGrantExpired: () => isGrantExpired,
115
130
  isNode: () => isNode,
116
131
  isRowActive: () => isRowActive,
132
+ isServiceNotRegisteredError: () => isServiceNotRegisteredError,
117
133
  isUninterpretableTemporalComparand: () => isUninterpretableTemporalComparand,
134
+ matchesConfiguredPlatformAdmin: () => matchesConfiguredPlatformAdmin,
118
135
  nextUtcCalendarDay: () => import_data.nextUtcCalendarDay,
119
136
  normalizeAuthGate: () => normalizeAuthGate,
137
+ normalizePlatformAdminEmail: () => normalizePlatformAdminEmail,
120
138
  omitInternalFieldsFromWriteResponse: () => omitInternalFieldsFromWriteResponse,
139
+ parsePlatformAdminEmails: () => parsePlatformAdminEmails,
121
140
  parseScopes: () => parseScopes,
122
141
  parseSignature: () => parseSignature,
123
142
  planChunks: () => planChunks,
124
143
  postureVisibleRows: () => postureVisibleRows,
125
144
  readAuthoredTranslationLayer: () => readAuthoredTranslationLayer,
145
+ readAuthzGrantsCacheTtlMs: () => readAuthzGrantsCacheTtlMs,
126
146
  readRunJournal: () => readRunJournal,
127
147
  recordNotFoundError: () => recordNotFoundError,
148
+ reportAuthzCachePosture: () => reportAuthzCachePosture,
149
+ reportLegacyPlatformAdminGrant: () => reportLegacyPlatformAdminGrant,
150
+ resetLegacyPlatformAdminGrantReport: () => resetLegacyPlatformAdminGrantReport,
151
+ resetPlatformAdminEmailMemo: () => resetPlatformAdminEmailMemo,
128
152
  resolveApiKeyAdmission: () => resolveApiKeyAdmission,
129
153
  resolveApiKeyPrincipal: () => resolveApiKeyPrincipal,
154
+ resolveArtifactPackageOrder: () => resolveArtifactPackageOrder,
155
+ resolveAuthzCachePosture: () => resolveAuthzCachePosture,
130
156
  resolveAuthzContext: () => resolveAuthzContext,
131
157
  resolveFilterToken: () => resolveFilterToken,
132
158
  resolveFilterTokens: () => resolveFilterTokens,
133
159
  resolveLocale: () => resolveLocale,
134
160
  resolveLocalizationContext: () => resolveLocalizationContext,
161
+ resolvePlatformAdminEmails: () => resolvePlatformAdminEmails,
135
162
  resolvePluginOrder: () => resolvePluginOrder,
136
163
  resolveUserAuthzGrants: () => resolveUserAuthzGrants,
137
164
  resumeMigrationJournal: () => resumeMigrationJournal,
165
+ rethrowAuthzStoreUnavailable: () => rethrowAuthzStoreUnavailable,
138
166
  runMigrationJournal: () => runMigrationJournal,
139
167
  safeExit: () => safeExit,
168
+ setPlatformAdminConfigSink: () => setPlatformAdminConfigSink,
140
169
  shouldDenyAnonymous: () => shouldDenyAnonymous,
141
170
  signPayload: () => signPayload,
142
171
  temporalComparandKind: () => temporalComparandKind,
143
172
  unknownAudienceBindingSuggestionStatusMessage: () => unknownAudienceBindingSuggestionStatusMessage,
144
173
  utcInstantMs: () => import_data.utcInstantMs,
145
174
  validateInitServiceContract: () => validateInitServiceContract,
175
+ verifyIntegrity: () => verifyIntegrity,
146
176
  verifyPayload: () => verifyPayload,
147
177
  verifyPlatformSignature: () => verifyPlatformSignature,
148
178
  verifyPluginArtifact: () => verifyPluginArtifact,
@@ -858,138 +888,6 @@ function createLogger(config) {
858
888
  // src/kernel.ts
859
889
  var import_system3 = require("@objectstack/spec/system");
860
890
 
861
- // src/security/plugin-config-validator.ts
862
- var import_zod = require("zod");
863
- var PluginConfigValidator = class {
864
- constructor(logger) {
865
- this.logger = logger;
866
- }
867
- /**
868
- * Validate plugin configuration against its Zod schema
869
- *
870
- * @param plugin - Plugin metadata with configSchema
871
- * @param config - User-provided configuration
872
- * @returns Validated and typed configuration
873
- * @throws Error with detailed validation errors
874
- */
875
- validatePluginConfig(plugin, config) {
876
- if (!plugin.configSchema) {
877
- this.logger.debug(`Plugin ${plugin.name} has no config schema - skipping validation`);
878
- return config;
879
- }
880
- try {
881
- const validatedConfig = plugin.configSchema.parse(config);
882
- this.logger.debug(`\u2705 Plugin config validated: ${plugin.name}`, {
883
- plugin: plugin.name,
884
- configKeys: Object.keys(config || {}).length
885
- });
886
- return validatedConfig;
887
- } catch (error) {
888
- if (error instanceof import_zod.z.ZodError) {
889
- const formattedErrors = this.formatZodErrors(error);
890
- const errorMessage = [
891
- `Plugin ${plugin.name} configuration validation failed:`,
892
- ...formattedErrors.map((e) => ` - ${e.path}: ${e.message}`)
893
- ].join("\n");
894
- this.logger.error(errorMessage, void 0, {
895
- plugin: plugin.name,
896
- errors: formattedErrors
897
- });
898
- throw new Error(errorMessage);
899
- }
900
- throw error;
901
- }
902
- }
903
- /**
904
- * Validate partial configuration (for incremental updates)
905
- *
906
- * @param plugin - Plugin metadata
907
- * @param partialConfig - Partial configuration to validate
908
- * @returns Validated partial configuration
909
- */
910
- validatePartialConfig(plugin, partialConfig) {
911
- if (!plugin.configSchema) {
912
- return partialConfig;
913
- }
914
- try {
915
- const partialSchema = plugin.configSchema.partial();
916
- const validatedConfig = partialSchema.parse(partialConfig);
917
- this.logger.debug(`\u2705 Partial config validated: ${plugin.name}`);
918
- return validatedConfig;
919
- } catch (error) {
920
- if (error instanceof import_zod.z.ZodError) {
921
- const formattedErrors = this.formatZodErrors(error);
922
- const errorMessage = [
923
- `Plugin ${plugin.name} partial configuration validation failed:`,
924
- ...formattedErrors.map((e) => ` - ${e.path}: ${e.message}`)
925
- ].join("\n");
926
- throw new Error(errorMessage);
927
- }
928
- throw error;
929
- }
930
- }
931
- /**
932
- * Get default configuration from schema
933
- *
934
- * @param plugin - Plugin metadata
935
- * @returns Default configuration object
936
- */
937
- getDefaultConfig(plugin) {
938
- if (!plugin.configSchema) {
939
- return void 0;
940
- }
941
- try {
942
- const defaults = plugin.configSchema.parse({});
943
- this.logger.debug(`Default config extracted: ${plugin.name}`);
944
- return defaults;
945
- } catch (error) {
946
- this.logger.debug(`No default config available: ${plugin.name}`);
947
- return void 0;
948
- }
949
- }
950
- /**
951
- * Check if configuration is valid without throwing
952
- *
953
- * @param plugin - Plugin metadata
954
- * @param config - Configuration to check
955
- * @returns True if valid, false otherwise
956
- */
957
- isConfigValid(plugin, config) {
958
- if (!plugin.configSchema) {
959
- return true;
960
- }
961
- const result = plugin.configSchema.safeParse(config);
962
- return result.success;
963
- }
964
- /**
965
- * Get configuration errors without throwing
966
- *
967
- * @param plugin - Plugin metadata
968
- * @param config - Configuration to check
969
- * @returns Array of validation errors, or empty array if valid
970
- */
971
- getConfigErrors(plugin, config) {
972
- if (!plugin.configSchema) {
973
- return [];
974
- }
975
- const result = plugin.configSchema.safeParse(config);
976
- if (result.success) {
977
- return [];
978
- }
979
- return this.formatZodErrors(result.error);
980
- }
981
- // Private methods
982
- formatZodErrors(error) {
983
- return error.issues.map((e) => ({
984
- path: e.path.join(".") || "root",
985
- message: e.message
986
- }));
987
- }
988
- };
989
- function createPluginConfigValidator(logger) {
990
- return new PluginConfigValidator(logger);
991
- }
992
-
993
891
  // src/security/plugin-artifact-signature.ts
994
892
  var import_node_crypto = require("crypto");
995
893
  var SIGNATURE_ALG = "ed25519";
@@ -1086,6 +984,20 @@ async function verifyPluginArtifact(input, keys) {
1086
984
  return { ok: true, publisherVerified: publisher.verified, platformVerified };
1087
985
  }
1088
986
 
987
+ // src/service-not-registered.ts
988
+ var SERVICE_NOT_REGISTERED_CODE = "SERVICE_NOT_REGISTERED";
989
+ var SERVICE_NOT_REGISTERED_BRAND = "__objectstackServiceNotRegistered";
990
+ function serviceNotRegisteredError(name) {
991
+ const err = new Error(`Service '${name}' not found`);
992
+ err[SERVICE_NOT_REGISTERED_BRAND] = true;
993
+ err.code = SERVICE_NOT_REGISTERED_CODE;
994
+ err.serviceName = name;
995
+ return err;
996
+ }
997
+ function isServiceNotRegisteredError(err) {
998
+ return typeof err === "object" && err !== null && err[SERVICE_NOT_REGISTERED_BRAND] === true;
999
+ }
1000
+
1089
1001
  // src/plugin-loader.ts
1090
1002
  var ServiceLifecycle = /* @__PURE__ */ ((ServiceLifecycle2) => {
1091
1003
  ServiceLifecycle2["SINGLETON"] = "singleton";
@@ -1101,7 +1013,6 @@ var PluginLoader = class {
1101
1013
  this.scopedServices = /* @__PURE__ */ new Map();
1102
1014
  this.creating = /* @__PURE__ */ new Set();
1103
1015
  this.logger = logger;
1104
- this.configValidator = new PluginConfigValidator(logger);
1105
1016
  }
1106
1017
  /**
1107
1018
  * Set the plugin context for service factories
@@ -1128,9 +1039,6 @@ var PluginLoader = class {
1128
1039
  if (!versionCheck.compatible) {
1129
1040
  throw new Error(`Version incompatible: ${versionCheck.message}`);
1130
1041
  }
1131
- if (metadata.configSchema) {
1132
- this.validatePluginConfig(metadata);
1133
- }
1134
1042
  if (metadata.signature) {
1135
1043
  await this.verifyPluginSignature(metadata);
1136
1044
  }
@@ -1169,7 +1077,7 @@ var PluginLoader = class {
1169
1077
  if (!registration) {
1170
1078
  const instance = this.serviceInstances.get(name);
1171
1079
  if (!instance) {
1172
- throw new Error(`Service '${name}' not found`);
1080
+ throw serviceNotRegisteredError(name);
1173
1081
  }
1174
1082
  return instance;
1175
1083
  }
@@ -1329,16 +1237,6 @@ var PluginLoader = class {
1329
1237
  const semverRegex = /^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/;
1330
1238
  return semverRegex.test(version);
1331
1239
  }
1332
- validatePluginConfig(plugin, config) {
1333
- if (!plugin.configSchema) {
1334
- return;
1335
- }
1336
- if (config === void 0) {
1337
- this.logger.debug(`Plugin ${plugin.name} has configuration schema (config validation postponed)`);
1338
- return;
1339
- }
1340
- this.configValidator.validatePluginConfig(plugin, config);
1341
- }
1342
1240
  async verifyPluginSignature(plugin) {
1343
1241
  if (!plugin.signature) {
1344
1242
  return;
@@ -1494,35 +1392,6 @@ function createMemoryQueue() {
1494
1392
  };
1495
1393
  }
1496
1394
 
1497
- // src/fallbacks/memory-job.ts
1498
- function createMemoryJob() {
1499
- const jobs = /* @__PURE__ */ new Map();
1500
- return {
1501
- __serviceInfo: {
1502
- status: "degraded",
1503
- handlerReady: false,
1504
- message: "In-process job registry \u2014 trigger() runs handlers, but scheduled jobs never fire on their own (no timer). Register a job plugin (e.g. Agenda) for real scheduling."
1505
- },
1506
- _serviceName: "job",
1507
- async schedule(name, schedule, handler) {
1508
- jobs.set(name, { schedule, handler });
1509
- },
1510
- async cancel(name) {
1511
- jobs.delete(name);
1512
- },
1513
- async trigger(name, data) {
1514
- const job = jobs.get(name);
1515
- if (job?.handler) await job.handler({ jobId: name, data });
1516
- },
1517
- async getExecutions() {
1518
- return [];
1519
- },
1520
- async listJobs() {
1521
- return [...jobs.keys()];
1522
- }
1523
- };
1524
- }
1525
-
1526
1395
  // src/fallbacks/memory-i18n.ts
1527
1396
  var import_system = require("@objectstack/spec/system");
1528
1397
  function deepMerge(target, source) {
@@ -1657,10 +1526,10 @@ function createMemoryI18n() {
1657
1526
  }
1658
1527
 
1659
1528
  // src/metadata-service-contract.ts
1660
- var import_shared = require("@objectstack/spec/shared");
1529
+ var import_meta_spelling = require("@objectstack/spec/meta-spelling");
1661
1530
  var REGISTER_REFUSAL_CODE = "VALIDATION_ERROR";
1662
1531
  function canonicalMetadataServiceType(type) {
1663
- return (0, import_shared.pluralToSingular)(type);
1532
+ return (0, import_meta_spelling.pluralToSingular)(type);
1664
1533
  }
1665
1534
  function registerRefusal(message) {
1666
1535
  const err = new Error(message);
@@ -1750,6 +1619,35 @@ function createMemoryMetadata() {
1750
1619
  };
1751
1620
  }
1752
1621
 
1622
+ // src/fallbacks/memory-job.ts
1623
+ function createMemoryJob() {
1624
+ const jobs = /* @__PURE__ */ new Map();
1625
+ return {
1626
+ __serviceInfo: {
1627
+ status: "degraded",
1628
+ handlerReady: false,
1629
+ message: "In-process job registry \u2014 trigger() runs handlers, but scheduled jobs never fire on their own (no timer). Register a job plugin (e.g. Agenda) for real scheduling."
1630
+ },
1631
+ _serviceName: "job",
1632
+ async schedule(name, schedule, handler) {
1633
+ jobs.set(name, { schedule, handler });
1634
+ },
1635
+ async cancel(name) {
1636
+ jobs.delete(name);
1637
+ },
1638
+ async trigger(name, data) {
1639
+ const job = jobs.get(name);
1640
+ if (job?.handler) await job.handler({ jobId: name, data });
1641
+ },
1642
+ async getExecutions() {
1643
+ return [];
1644
+ },
1645
+ async listJobs() {
1646
+ return [...jobs.keys()];
1647
+ }
1648
+ };
1649
+ }
1650
+
1753
1651
  // src/fallbacks/authored-translation-sync.ts
1754
1652
  var import_system2 = require("@objectstack/spec/system");
1755
1653
  var OWNER_PROP = "__authoredTranslationSyncOwner";
@@ -1882,7 +1780,6 @@ var CORE_FALLBACK_FACTORIES = {
1882
1780
  metadata: createMemoryMetadata,
1883
1781
  cache: createMemoryCache,
1884
1782
  queue: createMemoryQueue,
1885
- job: createMemoryJob,
1886
1783
  i18n: createMemoryI18n
1887
1784
  };
1888
1785
 
@@ -1906,6 +1803,48 @@ function registerPluginByName(registry, plugin, logger) {
1906
1803
  return previous;
1907
1804
  }
1908
1805
 
1806
+ // src/timeout-guard.ts
1807
+ var TimeoutGuard = class {
1808
+ constructor(timeoutMs, createTimeoutError) {
1809
+ /**
1810
+ * Settles `expiry` without a value. `Promise<never>` has no resolvable
1811
+ * value in the type system, but settling it is the entire point: it is
1812
+ * only ever called from `reclaim()`, i.e. after the race it guarded has
1813
+ * already been decided, so the resolution is discarded by construction and
1814
+ * can never become a race winner. The cast localises that argument here
1815
+ * rather than pushing a lie into every caller's return type.
1816
+ */
1817
+ this.settleExpiry = () => {
1818
+ };
1819
+ this.expiry = new Promise((resolve, reject) => {
1820
+ this.settleExpiry = resolve;
1821
+ this.timer = setTimeout(() => reject(createTimeoutError()), timeoutMs);
1822
+ });
1823
+ }
1824
+ /**
1825
+ * Reclaim the guard once the race it protects has been decided. Both
1826
+ * halves, always: the timer is cleared so it cannot fire against a
1827
+ * lifecycle phase that is already over, and `expiry` is settled so neither
1828
+ * it nor the race's reaction on it is retained.
1829
+ *
1830
+ * Idempotent — `clearTimeout` on a cleared handle and a second resolve on
1831
+ * a settled promise are both no-ops.
1832
+ */
1833
+ reclaim() {
1834
+ clearTimeout(this.timer);
1835
+ this.timer = void 0;
1836
+ this.settleExpiry();
1837
+ }
1838
+ };
1839
+ async function raceWithTimeout(operation, timeoutMs, createTimeoutError) {
1840
+ const guard = new TimeoutGuard(timeoutMs, createTimeoutError);
1841
+ try {
1842
+ return await Promise.race([operation, guard.expiry]);
1843
+ } finally {
1844
+ guard.reclaim();
1845
+ }
1846
+ }
1847
+
1909
1848
  // src/kernel.ts
1910
1849
  var ObjectKernel = class {
1911
1850
  constructor(config = {}) {
@@ -2176,14 +2115,11 @@ var ObjectKernel = class {
2176
2115
  this.logger.info("Graceful shutdown started");
2177
2116
  const shutdownTimeoutError = new Error("Shutdown timeout exceeded");
2178
2117
  try {
2179
- const shutdownPromise = this.performShutdown();
2180
- const timeoutPromise = new Promise((_, reject) => {
2181
- const t = setTimeout(() => {
2182
- reject(shutdownTimeoutError);
2183
- }, this.config.shutdownTimeout);
2184
- if (t.unref) t.unref();
2185
- });
2186
- await Promise.race([shutdownPromise, timeoutPromise]);
2118
+ await raceWithTimeout(
2119
+ this.performShutdown(),
2120
+ this.config.shutdownTimeout,
2121
+ () => shutdownTimeoutError
2122
+ );
2187
2123
  this.state = "stopped";
2188
2124
  this.logger.info("\u2705 Graceful shutdown complete");
2189
2125
  } catch (error) {
@@ -2301,25 +2237,17 @@ var ObjectKernel = class {
2301
2237
  * as well: if the hook never settles and nothing else keeps the loop alive,
2302
2238
  * Node exits before the timer can fire and the timeout is never reported.
2303
2239
  * The guard has to stay ref'd exactly as long as the race is undecided,
2304
- * which is what `clearTimeout` in a `finally` expresses.
2240
+ * which is what clearing on settle expresses.
2305
2241
  *
2306
- * `operation` is widened to `T | PromiseLike<T>` because the Plugin
2307
- * contract permits a synchronous hook (`init`/`start` return
2308
- * `void | Promise<void>`); such a hook wins the race immediately and the
2309
- * guard is reclaimed on the same turn.
2242
+ * Clearing the timer was only half of it, though (#10604): the promise the
2243
+ * race still holds a reaction on has to SETTLE, or it and that reaction are
2244
+ * retained past the end of the run two leaking promises per boot, which
2245
+ * is what `vitest --detectAsyncLeaks` names here. Both halves now live in
2246
+ * `TimeoutGuard.reclaim()`, shared with `shutdown()`, so the two sites
2247
+ * cannot drift into doing one half each again.
2310
2248
  */
2311
2249
  async raceStartupTimeout(operation, timeout, message) {
2312
- let guard;
2313
- const timeoutPromise = new Promise((_, reject) => {
2314
- guard = setTimeout(() => {
2315
- reject(new Error(message));
2316
- }, timeout);
2317
- });
2318
- try {
2319
- return await Promise.race([operation, timeoutPromise]);
2320
- } finally {
2321
- clearTimeout(guard);
2322
- }
2250
+ return raceWithTimeout(operation, timeout, () => new Error(message));
2323
2251
  }
2324
2252
  /**
2325
2253
  * Whether a service is resolvable on this kernel right now — direct
@@ -2482,6 +2410,67 @@ var ObjectKernel = class {
2482
2410
  }
2483
2411
  };
2484
2412
 
2413
+ // src/artifact-packages.ts
2414
+ var import_spec = require("@objectstack/spec");
2415
+ var MAX_REPORTED_ENTRY_ISSUES = 5;
2416
+ function refuse(code, message) {
2417
+ const err = new Error(message);
2418
+ err.code = code;
2419
+ err.status = 422;
2420
+ return err;
2421
+ }
2422
+ function artifactPackageId(manifest) {
2423
+ const id = manifest?.id || manifest?.name;
2424
+ return typeof id === "string" && id !== "" ? id : void 0;
2425
+ }
2426
+ function resolveArtifactPackageOrder(artifact) {
2427
+ const declared = artifact?.packages;
2428
+ if (declared === void 0 || declared === null) return [artifact];
2429
+ if (!Array.isArray(declared)) {
2430
+ throw refuse(
2431
+ "INVALID_ARTIFACT_PACKAGES",
2432
+ `A release artifact's \`packages\` must be an array of package entries (ADR-0130 D4, \`ArtifactPackageEntrySchema\`), but this artifact carries \`packages\` of type ${typeof declared}. Omit the key entirely for a single-package artifact \u2014 \`manifest\` is retained, not replaced.`
2433
+ );
2434
+ }
2435
+ const nodes = /* @__PURE__ */ new Map();
2436
+ declared.forEach((entry, index) => {
2437
+ const verdict = import_spec.ArtifactPackageSchema.safeParse(entry);
2438
+ if (!verdict.success) {
2439
+ const issues = verdict.error.issues;
2440
+ const shown = issues.slice(0, MAX_REPORTED_ENTRY_ISSUES);
2441
+ throw refuse(
2442
+ "INVALID_ARTIFACT_PACKAGE_ENTRY",
2443
+ `Release artifact \`packages[${index}]\` is not a package entry (ADR-0130 D4): ` + shown.map((i) => `${i.path.join(".") || "<entry>"}: ${i.message}`).join("; ") + (issues.length > shown.length ? ` (+${issues.length - shown.length} more)` : "") + ". Each entry is a WRAPPER object carrying its package under `manifest:` \u2014 wrap an inlined body as `{ manifest: { \u2026 } }`. The key position is reserved so a future external-segment form is an additive key rather than a reshape. The body under `manifest:` is the ASSEMBLED package body (`AssembledPackageBodySchema`): its `objects` / `datasources` are DEFINITIONS, not the authoring manifest's glob patterns \u2014 a compiled artifact has no files left to glob."
2444
+ );
2445
+ }
2446
+ const manifest = entry.manifest;
2447
+ const id = artifactPackageId(manifest);
2448
+ if (id === void 0) {
2449
+ throw refuse(
2450
+ "INVALID_ARTIFACT_PACKAGE_ENTRY",
2451
+ `Release artifact \`packages[${index}]\` carries a manifest with no usable package id: \`registerApp\` keys the installed package on \`id || name\`, so an entry without either cannot be ordered against its siblings or addressed after install.`
2452
+ );
2453
+ }
2454
+ if (nodes.has(id)) {
2455
+ throw refuse(
2456
+ "DUPLICATE_ARTIFACT_PACKAGE",
2457
+ `Release artifact declares package "${id}" more than once (\`packages[${index}]\` repeats an earlier entry). One artifact carries each package once \u2014 an artifact is one atomic delivery (ADR-0130 D1/D6), not a list with last-writer-wins.`
2458
+ );
2459
+ }
2460
+ nodes.set(id, {
2461
+ // `name` is what `resolvePluginOrder` puts in its diagnostics; the MAP KEY
2462
+ // is what its edges resolve against. Both are the package id, so an error
2463
+ // it raises names the same string the artifact author wrote.
2464
+ name: id,
2465
+ optionalDependencies: Object.keys(
2466
+ manifest.dependencies ?? {}
2467
+ ),
2468
+ manifest
2469
+ });
2470
+ });
2471
+ return resolvePluginOrder(nodes).map((node) => node.manifest);
2472
+ }
2473
+
2485
2474
  // src/lite-kernel.ts
2486
2475
  var LiteKernel = class extends ObjectKernelBase {
2487
2476
  constructor(config) {
@@ -3211,6 +3200,55 @@ var PluginSignatureVerifier = class {
3211
3200
  }
3212
3201
  };
3213
3202
 
3203
+ // src/security/plugin-artifact-integrity.ts
3204
+ var import_node_crypto2 = require("crypto");
3205
+ var SRI_ALGORITHMS = /* @__PURE__ */ new Set(["sha256", "sha384", "sha512"]);
3206
+ function sriDigestFor(declared, data) {
3207
+ const dash = declared.indexOf("-");
3208
+ const alg = dash > 0 && SRI_ALGORITHMS.has(declared.slice(0, dash)) ? declared.slice(0, dash) : "sha256";
3209
+ return `${alg}-${(0, import_node_crypto2.createHash)(alg).update(data).digest("base64")}`;
3210
+ }
3211
+ function verifyIntegrity(files, integrity, options = {}) {
3212
+ if (integrity === null || integrity === void 0) {
3213
+ return { ok: true, skipped: true, checked: 0, violations: [] };
3214
+ }
3215
+ const exempt = new Set(options.exempt ?? []);
3216
+ const byPath = /* @__PURE__ */ new Map();
3217
+ for (const f of files) {
3218
+ if (!exempt.has(f.path)) byPath.set(f.path, f.data);
3219
+ }
3220
+ const violations = [];
3221
+ let checked = 0;
3222
+ for (const [path, declaredRaw] of Object.entries(integrity)) {
3223
+ if (exempt.has(path)) continue;
3224
+ const declared = typeof declaredRaw === "string" ? declaredRaw : String(declaredRaw);
3225
+ const data = byPath.get(path);
3226
+ if (data === void 0) {
3227
+ violations.push({ kind: "missing_file", path, declared });
3228
+ continue;
3229
+ }
3230
+ checked++;
3231
+ const actual = sriDigestFor(declared, data);
3232
+ if (actual !== declared) violations.push({ kind: "digest_mismatch", path, declared, actual });
3233
+ }
3234
+ for (const path of [...byPath.keys()].sort()) {
3235
+ if (!Object.prototype.hasOwnProperty.call(integrity, path)) {
3236
+ violations.push({ kind: "extra_file", path });
3237
+ }
3238
+ }
3239
+ return { ok: violations.length === 0, skipped: false, checked, violations };
3240
+ }
3241
+ function formatIntegrityViolation(v) {
3242
+ switch (v.kind) {
3243
+ case "digest_mismatch":
3244
+ return `${v.path}: digest mismatch \u2014 manifest declares ${v.declared}, artifact bytes hash to ${v.actual}`;
3245
+ case "missing_file":
3246
+ return `${v.path}: declared in the integrity map but absent from the artifact`;
3247
+ case "extra_file":
3248
+ return `${v.path}: present in the artifact but not in the integrity map`;
3249
+ }
3250
+ }
3251
+
3214
3252
  // src/security/plugin-permission-enforcer.ts
3215
3253
  var PluginPermissionEnforcer = class {
3216
3254
  constructor(logger) {
@@ -4305,16 +4343,16 @@ var PluginSecurityScanner = class {
4305
4343
  };
4306
4344
 
4307
4345
  // src/security/api-key.ts
4308
- var import_node_crypto2 = require("crypto");
4346
+ var import_node_crypto3 = require("crypto");
4309
4347
  var import_security = require("@objectstack/spec/security");
4310
4348
  var API_KEY_PREFIX = "osk_";
4311
4349
  var API_KEY_ENTROPY_BYTES = 32;
4312
4350
  var VISIBLE_PREFIX_LEN = 12;
4313
4351
  function hashApiKey(raw) {
4314
- return (0, import_node_crypto2.createHash)("sha256").update(raw, "utf8").digest("hex");
4352
+ return (0, import_node_crypto3.createHash)("sha256").update(raw, "utf8").digest("hex");
4315
4353
  }
4316
4354
  function generateApiKey(prefix = API_KEY_PREFIX) {
4317
- const secret = (0, import_node_crypto2.randomBytes)(API_KEY_ENTROPY_BYTES).toString("base64url");
4355
+ const secret = (0, import_node_crypto3.randomBytes)(API_KEY_ENTROPY_BYTES).toString("base64url");
4318
4356
  const raw = `${prefix}${secret}`;
4319
4357
  return {
4320
4358
  raw,
@@ -4428,8 +4466,37 @@ function safeJsonParse(s, fallback) {
4428
4466
  }
4429
4467
  }
4430
4468
 
4469
+ // src/security/authz-store-unavailable.ts
4470
+ var AUTHZ_STORE_UNAVAILABLE_STATUS = 503;
4471
+ var AUTHZ_STORE_UNAVAILABLE_CODE = "SERVICE_UNAVAILABLE";
4472
+ var AUTHZ_STORE_UNAVAILABLE_MESSAGE = "The authorization store could not be read, so this request's permissions were never determined. This is a server-side outage, not a permission denial.";
4473
+ var AUTHZ_STORE_UNAVAILABLE_BRAND = "__objectstackAuthzStoreUnavailable";
4474
+ var _a, _b;
4475
+ var AuthzStoreUnavailableError = class extends (_b = Error, _a = AUTHZ_STORE_UNAVAILABLE_BRAND, _b) {
4476
+ constructor(object, cause) {
4477
+ super(`${AUTHZ_STORE_UNAVAILABLE_MESSAGE} (failed read: \`${object}\`)`);
4478
+ /** Brand — see the module doc on why this is not `instanceof`. */
4479
+ this[_a] = true;
4480
+ /** ADR-0112 wire code. */
4481
+ this.code = AUTHZ_STORE_UNAVAILABLE_CODE;
4482
+ /** HTTP status a transport should answer. */
4483
+ this.status = AUTHZ_STORE_UNAVAILABLE_STATUS;
4484
+ this.name = "AuthzStoreUnavailableError";
4485
+ this.object = object;
4486
+ if (cause !== void 0) this.cause = cause;
4487
+ }
4488
+ };
4489
+ function isAuthzStoreUnavailableError(err) {
4490
+ return typeof err === "object" && err !== null && err[AUTHZ_STORE_UNAVAILABLE_BRAND] === true;
4491
+ }
4492
+ function rethrowAuthzStoreUnavailable(err) {
4493
+ if (isAuthzStoreUnavailableError(err)) throw err;
4494
+ return void 0;
4495
+ }
4496
+
4431
4497
  // src/security/resolve-authz-context.ts
4432
- var import_spec = require("@objectstack/spec");
4498
+ var import_types2 = require("@objectstack/types");
4499
+ var import_spec2 = require("@objectstack/spec");
4433
4500
  var import_security2 = require("@objectstack/spec/security");
4434
4501
 
4435
4502
  // src/security/grant-validity.ts
@@ -4450,6 +4517,20 @@ function isGrantActive(row, nowMs) {
4450
4517
  if (until !== void 0 && !(nowMs < until)) return false;
4451
4518
  return true;
4452
4519
  }
4520
+ function nextGrantValidityBoundary(rows, nowMs) {
4521
+ let next;
4522
+ for (const row of rows) {
4523
+ if (!row) continue;
4524
+ const from = toEpochMs(row.valid_from ?? row.validFrom);
4525
+ const until = toEpochMs(row.valid_until ?? row.validUntil);
4526
+ for (const bound of [from, until]) {
4527
+ if (bound !== void 0 && bound > nowMs && (next === void 0 || bound < next)) {
4528
+ next = bound;
4529
+ }
4530
+ }
4531
+ }
4532
+ return next;
4533
+ }
4453
4534
  function isGrantExpired(row, nowMs) {
4454
4535
  if (!row) return false;
4455
4536
  const until = toEpochMs(row.valid_until ?? row.validUntil);
@@ -4457,6 +4538,227 @@ function isGrantExpired(row, nowMs) {
4457
4538
  return !(nowMs < until);
4458
4539
  }
4459
4540
 
4541
+ // src/security/authz-cache-posture.ts
4542
+ var AUTHZ_GRANTS_CACHE_TTL_ENV = "OS_AUTHZ_GRANTS_CACHE_TTL_MS";
4543
+ function resolveAuthzCachePosture(input) {
4544
+ const { ttlMs, bus, driver } = input;
4545
+ if (!(ttlMs > 0)) {
4546
+ return { posture: "disabled", loud: false, message: "" };
4547
+ }
4548
+ if (bus === "bridged") {
4549
+ return {
4550
+ posture: "bus-narrowed",
4551
+ loud: false,
4552
+ message: `[authz-cache] grants cache ENABLED (ttl=${ttlMs}ms) with the "authz.invalidated" bridge attached` + (driver ? ` (cluster driver "${driver}")` : "") + ". The bus narrows the TYPICAL convergence to one network hop; the TTL remains the correctness bound, because no shipped driver delivers better than at-most-once (cluster.mdx \xA74.2)."
4553
+ };
4554
+ }
4555
+ const why = bus === "in-process" ? `the cluster driver "${driver ?? "memory"}" is in-process and does not fan out across replicas` : "no cluster service is registered on this node";
4556
+ return {
4557
+ posture: "ttl-only",
4558
+ loud: true,
4559
+ message: `[authz-cache] grants cache ENABLED (ttl=${ttlMs}ms) with NO "authz.invalidated" invalidation bus \u2014 ${why}. A grant revoked on another replica is honoured by this one for up to ${ttlMs}ms. That is a supported configuration, not an error: the TTL is the correctness bound and it still holds. It is stated because a silently-absent invalidation bridge is how a security control gets disabled without anyone noticing (#4785). To narrow the typical window, configure a remote cluster driver; to remove it entirely, set ${AUTHZ_GRANTS_CACHE_TTL_ENV}=0.`
4560
+ };
4561
+ }
4562
+ function readAuthzGrantsCacheTtlMs(env = typeof process !== "undefined" ? process.env : {}) {
4563
+ const raw = env[AUTHZ_GRANTS_CACHE_TTL_ENV];
4564
+ if (raw === void 0 || raw.trim() === "") {
4565
+ return { ttlMs: 0, malformed: false };
4566
+ }
4567
+ const parsed = Number(raw.trim());
4568
+ if (!Number.isFinite(parsed) || parsed < 0) {
4569
+ return { ttlMs: 0, raw, malformed: true };
4570
+ }
4571
+ return { ttlMs: Math.floor(parsed), raw, malformed: false };
4572
+ }
4573
+ function reportAuthzCachePosture(input, sink2) {
4574
+ if (input.malformedTtl) {
4575
+ sink2.warn(
4576
+ `[authz-cache] ${AUTHZ_GRANTS_CACHE_TTL_ENV}=${JSON.stringify(input.malformedTtl.raw ?? "")} is not a non-negative number; the grants cache is treated as DISABLED. Set a millisecond count, or 0 to disable it deliberately.`
4577
+ );
4578
+ }
4579
+ const statement = resolveAuthzCachePosture(input);
4580
+ if (statement.posture === "disabled") return statement;
4581
+ if (statement.loud) sink2.warn(statement.message);
4582
+ else sink2.info?.(statement.message);
4583
+ return statement;
4584
+ }
4585
+
4586
+ // src/security/resolve-user-grants-cache.ts
4587
+ var GRANTS_CACHE_WATCHED_OBJECTS = /* @__PURE__ */ new Set([
4588
+ "sys_member",
4589
+ "sys_user_position",
4590
+ "sys_user_permission_set",
4591
+ "sys_position",
4592
+ "sys_position_permission_set",
4593
+ "sys_permission_set",
4594
+ "sys_user"
4595
+ ]);
4596
+ var grantsCacheStates = /* @__PURE__ */ new WeakMap();
4597
+ var WRITE_OPERATIONS = /* @__PURE__ */ new Set(["insert", "update", "delete"]);
4598
+ function grantsCacheState(ql) {
4599
+ const existing = grantsCacheStates.get(ql);
4600
+ if (existing !== void 0) return existing ?? void 0;
4601
+ const seamQl = ql;
4602
+ const epoch = seamQl.writeEpoch;
4603
+ const hasEpoch = !!epoch && typeof epoch === "object" && typeof epoch.current === "number" && typeof epoch.bump === "function" && typeof epoch.subscribe === "function";
4604
+ if (!hasEpoch || typeof seamQl.registerMiddleware !== "function") {
4605
+ return void 0;
4606
+ }
4607
+ const state = { gen: 0, entries: /* @__PURE__ */ new Map() };
4608
+ try {
4609
+ seamQl.registerMiddleware(async (ctx, next) => {
4610
+ if (typeof ctx?.operation !== "string" || !WRITE_OPERATIONS.has(ctx.operation) || typeof ctx?.object !== "string" || !GRANTS_CACHE_WATCHED_OBJECTS.has(ctx.object)) {
4611
+ return next();
4612
+ }
4613
+ try {
4614
+ await next();
4615
+ } finally {
4616
+ state.gen += 1;
4617
+ }
4618
+ });
4619
+ epoch.subscribe((_epoch, reason) => {
4620
+ if (reason !== "write") state.gen += 1;
4621
+ });
4622
+ } catch {
4623
+ grantsCacheStates.set(ql, null);
4624
+ return void 0;
4625
+ }
4626
+ grantsCacheStates.set(ql, state);
4627
+ return state;
4628
+ }
4629
+ function grantsCacheKey(userId, opts) {
4630
+ return JSON.stringify([
4631
+ userId,
4632
+ opts.tenantId ?? null,
4633
+ opts.seedEmail ?? null,
4634
+ Array.isArray(opts.seedPermissions) ? opts.seedPermissions : []
4635
+ ]);
4636
+ }
4637
+ var cloneGrants = (grants) => structuredClone(grants);
4638
+ function openUserGrantsCache(ql, userId, opts) {
4639
+ if (opts.bypassGrantsCache) return void 0;
4640
+ const { ttlMs } = readAuthzGrantsCacheTtlMs();
4641
+ if (ttlMs <= 0) return void 0;
4642
+ if (!ql || typeof ql !== "object" || typeof ql.find !== "function") {
4643
+ return void 0;
4644
+ }
4645
+ const state = grantsCacheState(ql);
4646
+ if (!state) return void 0;
4647
+ const key = grantsCacheKey(userId, opts);
4648
+ const now = opts.nowMs ?? Date.now();
4649
+ const genAtOpen = state.gen;
4650
+ const existing = state.entries.get(key);
4651
+ if (existing) {
4652
+ if (existing.gen === state.gen && existing.expiresAt > now) {
4653
+ return { hit: cloneGrants(existing.value), commit: () => {
4654
+ } };
4655
+ }
4656
+ state.entries.delete(key);
4657
+ }
4658
+ return {
4659
+ commit(grants, nextBoundaryMs) {
4660
+ const expiresAt = Math.min(now + ttlMs, nextBoundaryMs ?? Number.POSITIVE_INFINITY);
4661
+ if (expiresAt <= now) return;
4662
+ state.entries.set(key, { value: cloneGrants(grants), gen: genAtOpen, expiresAt });
4663
+ }
4664
+ };
4665
+ }
4666
+
4667
+ // src/security/platform-admin.ts
4668
+ var import_types = require("@objectstack/types");
4669
+ var PLATFORM_ADMIN_EMAIL_SEPARATOR = ",";
4670
+ function normalizePlatformAdminEmail(value) {
4671
+ return typeof value === "string" ? value.trim().toLowerCase() : "";
4672
+ }
4673
+ function isParseableAddress(entry) {
4674
+ if (/\s/.test(entry)) return false;
4675
+ const at = entry.indexOf("@");
4676
+ if (at <= 0) return false;
4677
+ if (entry.indexOf("@", at + 1) !== -1) return false;
4678
+ return at < entry.length - 1;
4679
+ }
4680
+ var EMPTY_CONFIG = Object.freeze({
4681
+ emails: Object.freeze([]),
4682
+ declaredSpellings: Object.freeze([])
4683
+ });
4684
+ function parsePlatformAdminEmails(raw) {
4685
+ if (raw == null) return EMPTY_CONFIG;
4686
+ const text = String(raw);
4687
+ if (text.trim() === "") return EMPTY_CONFIG;
4688
+ const emails = [];
4689
+ const declaredSpellings = [];
4690
+ for (const piece of text.split(PLATFORM_ADMIN_EMAIL_SEPARATOR)) {
4691
+ const entry = normalizePlatformAdminEmail(piece);
4692
+ if (entry === "") continue;
4693
+ if (!isParseableAddress(entry)) {
4694
+ return {
4695
+ emails: Object.freeze([]),
4696
+ declaredSpellings: Object.freeze([]),
4697
+ raw: text,
4698
+ refusal: `${import_types.PLATFORM_OWNER_EMAIL_ENV} entry ${JSON.stringify(piece)} is not an email address, so the WHOLE variable is refused and this deployment has ZERO config-derived platform administrators. The entry is not skipped on purpose: silently dropping it would leave a narrower administrator set than the operator declared, with nothing to notice. Fix the entry, or remove it \u2014 ${import_types.PLATFORM_OWNER_EMAIL_ENV} takes one address or a comma-separated list of them.`
4699
+ };
4700
+ }
4701
+ if (!emails.includes(entry)) {
4702
+ emails.push(entry);
4703
+ declaredSpellings.push(piece.trim());
4704
+ }
4705
+ }
4706
+ return {
4707
+ emails: Object.freeze(emails),
4708
+ declaredSpellings: Object.freeze(declaredSpellings),
4709
+ raw: text
4710
+ };
4711
+ }
4712
+ var defaultSink = {
4713
+ error: (m) => console.error(m),
4714
+ warn: (m) => console.warn(m)
4715
+ };
4716
+ var sink = defaultSink;
4717
+ function setPlatformAdminConfigSink(next) {
4718
+ const prev = sink;
4719
+ sink = next ?? defaultSink;
4720
+ return prev;
4721
+ }
4722
+ var NOT_MEMOIZED = /* @__PURE__ */ Symbol("platform-admin-config-not-memoized");
4723
+ var memoKey = NOT_MEMOIZED;
4724
+ var memoValue = EMPTY_CONFIG;
4725
+ function resolvePlatformAdminEmails() {
4726
+ const raw = (0, import_types.resolvePlatformOwnerEmail)();
4727
+ if (memoKey !== NOT_MEMOIZED && memoKey === raw) return memoValue;
4728
+ const parsed = parsePlatformAdminEmails(raw);
4729
+ memoKey = raw;
4730
+ memoValue = parsed;
4731
+ if (parsed.refusal) sink.error(`[authz] ${parsed.refusal}`);
4732
+ return parsed;
4733
+ }
4734
+ function resetPlatformAdminEmailMemo() {
4735
+ memoKey = NOT_MEMOIZED;
4736
+ memoValue = EMPTY_CONFIG;
4737
+ }
4738
+ function matchesConfiguredPlatformAdmin(row, config) {
4739
+ if (config.emails.length === 0) return false;
4740
+ if (!row || typeof row !== "object") return false;
4741
+ if (!isConfiguredPlatformAdminEmail(row.email, config)) return false;
4742
+ return (0, import_types.isEmailVerifiedUserRow)(row);
4743
+ }
4744
+ function isConfiguredPlatformAdminEmail(email, config) {
4745
+ if (config.emails.length === 0) return false;
4746
+ const candidate = normalizePlatformAdminEmail(email);
4747
+ return candidate !== "" && config.emails.includes(candidate);
4748
+ }
4749
+ var legacyGrantPointerSaid = false;
4750
+ function reportLegacyPlatformAdminGrant(input) {
4751
+ if (legacyGrantPointerSaid) return;
4752
+ legacyGrantPointerSaid = true;
4753
+ const email = normalizePlatformAdminEmail(input.email);
4754
+ sink.warn(
4755
+ `[authz] user ${input.userId} holds PLATFORM_ADMIN through the legacy unscoped 'admin_full_access' grant row, not through ${import_types.PLATFORM_OWNER_EMAIL_ENV}. The grant row is the OLD anchor and is honoured for now; it is removed in a later release. Re-anchor this deployment by declaring its administrators in configuration: ${import_types.PLATFORM_OWNER_EMAIL_ENV}=${email || "<the administrator's verified email address>"} (comma-separated for several), and make sure each account's email is VERIFIED \u2014 an unverified account holding a configured address is not an administrator. Reported once per process; further holders are not listed.`
4756
+ );
4757
+ }
4758
+ function resetLegacyPlatformAdminGrantReport() {
4759
+ legacyGrantPointerSaid = false;
4760
+ }
4761
+
4460
4762
  // src/security/posture-ladder.ts
4461
4763
  var POSTURE_LADDER = [
4462
4764
  "PLATFORM_ADMIN",
@@ -4530,14 +4832,16 @@ function safeJsonParse2(s, fallback) {
4530
4832
  return fallback;
4531
4833
  }
4532
4834
  }
4533
- async function tryFind(ql, object, where, limit = 100) {
4835
+ async function tryFind(ql, object, where, limit = 100, organizationId) {
4534
4836
  if (!ql || typeof ql.find !== "function") return [];
4535
4837
  try {
4536
- let rows = await ql.find(object, { where, limit, context: { isSystem: true } });
4838
+ const context = organizationId ? { isSystem: true, tenantId: organizationId } : { isSystem: true };
4839
+ let rows = await ql.find(object, { where, limit, context });
4537
4840
  if (rows && rows.value) rows = rows.value;
4538
4841
  return Array.isArray(rows) ? rows : [];
4539
- } catch {
4540
- return [];
4842
+ } catch (err) {
4843
+ if ((0, import_types2.isMissingTableError)(err, object)) return [];
4844
+ throw new AuthzStoreUnavailableError(object, err);
4541
4845
  }
4542
4846
  }
4543
4847
  async function resolveAuthzContext(input) {
@@ -4611,6 +4915,8 @@ async function resolveAuthzContext(input) {
4611
4915
  return ctx;
4612
4916
  }
4613
4917
  async function resolveUserAuthzGrants(ql, userId, opts = {}) {
4918
+ const grantsCache = openUserGrantsCache(ql, userId, opts);
4919
+ if (grantsCache?.hit) return grantsCache.hit;
4614
4920
  const { tenantId } = opts;
4615
4921
  const grants = {
4616
4922
  positions: [],
@@ -4631,12 +4937,20 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
4631
4937
  }
4632
4938
  return userRow;
4633
4939
  };
4940
+ const platformAdminConfig = resolvePlatformAdminEmails();
4941
+ const needsUserRow = !grants.email || !grants.permissions.includes("ai_seat") || platformAdminConfig.emails.length > 0;
4942
+ const [, members, userPositionRows, orgMembersLeg, upsRowsAll] = await Promise.all([
4943
+ needsUserRow ? getUserRow() : Promise.resolve(void 0),
4944
+ tryFind(ql, "sys_member", { user_id: userId }, 200),
4945
+ tryFind(ql, "sys_user_position", { user_id: userId }, 200),
4946
+ tenantId ? tryFind(ql, "sys_member", { organization_id: tenantId }, 1e3) : Promise.resolve([]),
4947
+ tryFind(ql, "sys_user_permission_set", { user_id: userId }, 100)
4948
+ ]);
4634
4949
  if (!grants.email) {
4635
4950
  const u = await getUserRow();
4636
4951
  if (u?.email) grants.email = String(u.email);
4637
4952
  }
4638
4953
  const nowMs = opts.nowMs ?? Date.now();
4639
- const members = await tryFind(ql, "sys_member", { user_id: userId }, 200);
4640
4954
  const accessibleOrgIds = /* @__PURE__ */ new Set();
4641
4955
  for (const m of members) {
4642
4956
  if (!isGrantActive(m, nowMs)) continue;
@@ -4644,16 +4958,17 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
4644
4958
  if (typeof org === "string" && org) accessibleOrgIds.add(org);
4645
4959
  }
4646
4960
  grants.accessible_org_ids = Array.from(accessibleOrgIds);
4647
- const activeMembers = tenantId ? members.filter((m) => (m.organization_id ?? m.organizationId) === tenantId) : members;
4961
+ const activeMembers = members.filter(
4962
+ (m) => isGrantActive(m, nowMs) && (!tenantId || (m.organization_id ?? m.organizationId) === tenantId)
4963
+ );
4648
4964
  for (const m of activeMembers) {
4649
4965
  if (m.role && typeof m.role === "string") {
4650
4966
  for (const raw of m.role.split(",").map((s) => s.trim()).filter(Boolean)) {
4651
- const r = (0, import_spec.mapMembershipRole)(raw);
4967
+ const r = (0, import_spec2.mapMembershipRole)(raw);
4652
4968
  if (!grants.positions.includes(r)) grants.positions.push(r);
4653
4969
  }
4654
4970
  }
4655
4971
  }
4656
- const userPositionRows = await tryFind(ql, "sys_user_position", { user_id: userId }, 200);
4657
4972
  for (const ur of userPositionRows) {
4658
4973
  const org = ur.organization_id ?? null;
4659
4974
  if (org && tenantId && org !== tenantId) continue;
@@ -4662,14 +4977,13 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
4662
4977
  if (typeof r === "string" && r && !grants.positions.includes(r)) grants.positions.push(r);
4663
4978
  }
4664
4979
  if (tenantId) {
4665
- const orgMembers = await tryFind(ql, "sys_member", { organization_id: tenantId }, 1e3);
4980
+ const orgMembers = orgMembersLeg;
4666
4981
  const ids = new Set(
4667
4982
  orgMembers.map((m) => m.user_id ?? m.userId).filter((v) => typeof v === "string" && v.length > 0)
4668
4983
  );
4669
4984
  ids.add(userId);
4670
4985
  grants.org_user_ids = Array.from(ids);
4671
4986
  }
4672
- const upsRowsAll = await tryFind(ql, "sys_user_permission_set", { user_id: userId }, 100);
4673
4987
  const upsRows = upsRowsAll.filter((r) => isGrantActive(r, nowMs));
4674
4988
  const psIds = new Set(
4675
4989
  upsRows.filter((r) => {
@@ -4683,7 +4997,7 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
4683
4997
  let hasPlatformAdminGrant = false;
4684
4998
  if (!grants.positions.includes("everyone")) grants.positions.push("everyone");
4685
4999
  if (grants.positions.length > 0) {
4686
- const positionRows = await tryFind(ql, "sys_position", { name: { $in: grants.positions } }, 100);
5000
+ const positionRows = await tryFind(ql, "sys_position", { name: { $in: grants.positions } }, 200, tenantId);
4687
5001
  const deactivatedNames = new Set(
4688
5002
  positionRows.filter((r) => !isRowActive(r)).map((r) => r.name).filter(Boolean)
4689
5003
  );
@@ -4706,7 +5020,7 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
4706
5020
  const mergedTabs = {};
4707
5021
  for (const ps of psRows) {
4708
5022
  if (ps.name && !grants.permissions.includes(ps.name)) grants.permissions.push(ps.name);
4709
- if (ps.name === import_spec.ADMIN_FULL_ACCESS && unscopedUserPsIds.has(ps.id)) hasPlatformAdminGrant = true;
5023
+ if (ps.name === import_spec2.ADMIN_FULL_ACCESS && unscopedUserPsIds.has(ps.id)) hasPlatformAdminGrant = true;
4710
5024
  const sysPerms = typeof ps.system_permissions === "string" ? safeJsonParse2(ps.system_permissions, []) : ps.system_permissions ?? ps.systemPermissions;
4711
5025
  if (Array.isArray(sysPerms)) {
4712
5026
  for (const p of sysPerms) {
@@ -4726,21 +5040,46 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
4726
5040
  }
4727
5041
  if (Object.keys(mergedTabs).length > 0) grants.tabPermissions = mergedTabs;
4728
5042
  }
4729
- if (hasPlatformAdminGrant && !grants.positions.includes(import_spec.BUILTIN_IDENTITY_PLATFORM_ADMIN)) {
4730
- grants.positions.unshift(import_spec.BUILTIN_IDENTITY_PLATFORM_ADMIN);
5043
+ const configConfersPlatformAdmin = platformAdminConfig.emails.length > 0 && matchesConfiguredPlatformAdmin(await getUserRow(), platformAdminConfig);
5044
+ if (configConfersPlatformAdmin) {
5045
+ hasPlatformAdminGrant = true;
5046
+ if (!grants.permissions.includes(import_spec2.ADMIN_FULL_ACCESS)) grants.permissions.push(import_spec2.ADMIN_FULL_ACCESS);
5047
+ for (const p of import_spec2.ADMIN_FULL_ACCESS_CAPABILITIES.systemPermissions ?? []) {
5048
+ if (!grants.systemPermissions.includes(p)) grants.systemPermissions.push(p);
5049
+ }
5050
+ } else if (hasPlatformAdminGrant) {
5051
+ if ((0, import_security2.postureEnforcesWall)((0, import_types2.resolveTenancyPosture)())) {
5052
+ reportLegacyPlatformAdminGrant({ userId, email: userRow?.email });
5053
+ }
5054
+ }
5055
+ if (hasPlatformAdminGrant && !grants.positions.includes(import_spec2.BUILTIN_IDENTITY_PLATFORM_ADMIN)) {
5056
+ grants.positions.unshift(import_spec2.BUILTIN_IDENTITY_PLATFORM_ADMIN);
4731
5057
  }
4732
5058
  grants.posture = derivePosture({
4733
5059
  isPlatformAdmin: hasPlatformAdminGrant,
4734
5060
  // [ADR-0105 D4] Either org-admin capability set resolves the rung — the
4735
5061
  // wall-less variant differs only by withholding the superuser bits.
4736
- isTenantAdmin: import_spec.ORGANIZATION_ADMIN_GRANTS.some((n) => grants.permissions.includes(n))
5062
+ isTenantAdmin: import_spec2.ORGANIZATION_ADMIN_GRANTS.some((n) => grants.permissions.includes(n))
4737
5063
  });
4738
5064
  if (!grants.permissions.includes("ai_seat")) {
4739
5065
  const aiAccess = (await getUserRow())?.ai_access;
4740
5066
  if (aiAccess === true || aiAccess === 1 || aiAccess === "1") grants.permissions.push("ai_seat");
4741
5067
  }
5068
+ grantsCache?.commit(
5069
+ grants,
5070
+ nextGrantValidityBoundary([...members, ...userPositionRows, ...upsRowsAll], nowMs)
5071
+ );
4742
5072
  return grants;
4743
5073
  }
5074
+ async function hasPlatformAdminStanding(ql, userId, opts = {}) {
5075
+ if (!ql || typeof userId !== "string" || userId.length === 0) return false;
5076
+ try {
5077
+ const grants = await resolveUserAuthzGrants(ql, userId, { nowMs: opts.nowMs });
5078
+ return grants.posture === "PLATFORM_ADMIN";
5079
+ } catch {
5080
+ return false;
5081
+ }
5082
+ }
4744
5083
  function isValidTimeZone(tz) {
4745
5084
  try {
4746
5085
  new Intl.DateTimeFormat("en-US", { timeZone: tz });
@@ -4761,34 +5100,151 @@ function coerceCurrency(value) {
4761
5100
  const s = typeof value === "string" ? value.trim().toUpperCase() : "";
4762
5101
  return /^[A-Z]{3}$/.test(s) ? s : void 0;
4763
5102
  }
5103
+ var LOCALIZATION_FAILURE_CACHE_TTL_MS = 3e4;
5104
+ var LOCALIZATION_CACHE_TTL_ENV = "OS_LOCALIZATION_CACHE_TTL_MS";
5105
+ var LOCALIZATION_SUCCESS_CACHE_DEFAULT_TTL_MS = 3e4;
5106
+ function localizationSuccessCacheTtlMs(env = typeof process !== "undefined" ? process.env : {}) {
5107
+ const raw = env[LOCALIZATION_CACHE_TTL_ENV];
5108
+ if (raw === void 0 || raw.trim() === "") return LOCALIZATION_SUCCESS_CACHE_DEFAULT_TTL_MS;
5109
+ const parsed = Number(raw.trim());
5110
+ if (!Number.isFinite(parsed) || parsed < 0) return 0;
5111
+ return Math.floor(parsed);
5112
+ }
5113
+ function readWriteEpoch(ql) {
5114
+ if (!ql || typeof ql !== "object") return void 0;
5115
+ const epoch = ql.writeEpoch;
5116
+ if (!epoch || typeof epoch !== "object") return void 0;
5117
+ const seam = epoch;
5118
+ if (typeof seam.current !== "number" || typeof seam.bump !== "function" || typeof seam.subscribe !== "function") {
5119
+ return void 0;
5120
+ }
5121
+ return seam.current;
5122
+ }
5123
+ var localizationSettingsStates = /* @__PURE__ */ new WeakMap();
5124
+ var localizationNoSettingsState = { gen: 0 };
5125
+ function localizationSettingsState(settings) {
5126
+ if (!settings || typeof settings !== "object") return localizationNoSettingsState;
5127
+ const existing = localizationSettingsStates.get(settings);
5128
+ if (existing) return existing;
5129
+ const state = { gen: 0 };
5130
+ localizationSettingsStates.set(settings, state);
5131
+ const subscribe = settings.subscribe;
5132
+ if (typeof subscribe === "function") {
5133
+ try {
5134
+ subscribe.call(
5135
+ settings,
5136
+ "localization",
5137
+ () => {
5138
+ state.gen += 1;
5139
+ }
5140
+ );
5141
+ } catch {
5142
+ }
5143
+ }
5144
+ return state;
5145
+ }
5146
+ var localizationCache = /* @__PURE__ */ new WeakMap();
5147
+ function localizationEntryIsLive(entry, epoch, settings) {
5148
+ if (entry.kind === "failure") return true;
5149
+ return entry.epoch === epoch && entry.settings === settings && entry.settingsGen === settings.gen;
5150
+ }
5151
+ function putLocalizationEntry(ql, key, entry) {
5152
+ const bucket = localizationCache.get(ql) ?? /* @__PURE__ */ new Map();
5153
+ bucket.set(key, entry);
5154
+ localizationCache.set(ql, bucket);
5155
+ }
4764
5156
  async function resolveLocalizationContext(input) {
4765
5157
  const { ql, settings, tenantId, userId } = input;
5158
+ const cacheKey = `${tenantId ?? ""}|${userId ?? ""}`;
5159
+ const cacheable = Boolean(ql) && typeof ql === "object";
5160
+ const epoch = cacheable ? readWriteEpoch(ql) : void 0;
5161
+ const settingsState = localizationSettingsState(settings);
5162
+ if (cacheable) {
5163
+ const hit = localizationCache.get(ql)?.get(cacheKey);
5164
+ if (hit && hit.expiresAt > Date.now() && localizationEntryIsLive(hit, epoch, settingsState)) {
5165
+ return hit.value;
5166
+ }
5167
+ }
5168
+ const { value, backendFailed } = await resolveLocalizationContextUncached(input);
5169
+ if (!cacheable) return value;
5170
+ if (backendFailed) {
5171
+ putLocalizationEntry(ql, cacheKey, {
5172
+ value,
5173
+ expiresAt: Date.now() + LOCALIZATION_FAILURE_CACHE_TTL_MS,
5174
+ kind: "failure"
5175
+ });
5176
+ return value;
5177
+ }
5178
+ const ttlMs = localizationSuccessCacheTtlMs();
5179
+ if (epoch !== void 0 && ttlMs > 0) {
5180
+ putLocalizationEntry(ql, cacheKey, {
5181
+ value,
5182
+ expiresAt: Date.now() + ttlMs,
5183
+ kind: "success",
5184
+ epoch,
5185
+ settings: settingsState,
5186
+ settingsGen: settingsState.gen
5187
+ });
5188
+ } else {
5189
+ localizationCache.get(ql)?.delete(cacheKey);
5190
+ }
5191
+ return value;
5192
+ }
5193
+ async function resolveLocalizationContextUncached(input) {
5194
+ const { ql, settings, tenantId, userId } = input;
5195
+ let backendFailed = false;
4766
5196
  try {
4767
5197
  if (settings && typeof settings.get === "function") {
4768
5198
  const sctx = { tenantId, userId };
4769
- const [tzRes, localeRes, currencyRes] = await Promise.all([
4770
- settings.get("localization", "timezone", sctx).catch(() => void 0),
4771
- settings.get("localization", "locale", sctx).catch(() => void 0),
4772
- settings.get("localization", "currency", sctx).catch(() => void 0)
4773
- ]);
5199
+ let tzRes;
5200
+ let localeRes;
5201
+ let currencyRes;
5202
+ if (typeof settings.getMany === "function") {
5203
+ try {
5204
+ const many = await settings.getMany("localization", ["timezone", "locale", "currency"], sctx);
5205
+ tzRes = many.timezone;
5206
+ localeRes = many.locale;
5207
+ currencyRes = many.currency;
5208
+ } catch {
5209
+ }
5210
+ } else {
5211
+ [tzRes, localeRes, currencyRes] = await Promise.all([
5212
+ settings.get("localization", "timezone", sctx).catch(() => void 0),
5213
+ settings.get("localization", "locale", sctx).catch(() => void 0),
5214
+ settings.get("localization", "currency", sctx).catch(() => void 0)
5215
+ ]);
5216
+ }
4774
5217
  const tz = coerceTimeZone(tzRes?.value);
4775
5218
  const locale = coerceLocale(localeRes?.value);
4776
5219
  const currency = coerceCurrency(currencyRes?.value);
4777
- if (tz || locale || currency) return { timezone: tz ?? "UTC", locale: locale ?? "en-US", currency };
5220
+ if (tz || locale || currency) {
5221
+ return { value: { timezone: tz ?? "UTC", locale: locale ?? "en-US", currency }, backendFailed: false };
5222
+ }
4778
5223
  }
4779
5224
  } catch {
4780
5225
  }
4781
- const rows = await tryFind(
4782
- ql,
4783
- "sys_setting",
4784
- { namespace: "localization", key: { $in: ["timezone", "locale", "currency"] }, scope: "tenant" },
4785
- 10
4786
- );
5226
+ let rows = [];
5227
+ if (ql && typeof ql.find === "function") {
5228
+ try {
5229
+ let result = await ql.find("sys_setting", {
5230
+ where: { namespace: "localization", key: { $in: ["timezone", "locale", "currency"] }, scope: "tenant" },
5231
+ limit: 10,
5232
+ context: { isSystem: true }
5233
+ });
5234
+ if (result && result.value) result = result.value;
5235
+ rows = Array.isArray(result) ? result : [];
5236
+ } catch {
5237
+ backendFailed = true;
5238
+ }
5239
+ }
4787
5240
  const valueOf = (k) => rows.find((r) => r.key === k)?.value;
4788
5241
  return {
4789
- timezone: coerceTimeZone(valueOf("timezone")) ?? "UTC",
4790
- locale: coerceLocale(valueOf("locale")) ?? "en-US",
4791
- currency: coerceCurrency(valueOf("currency"))
5242
+ value: {
5243
+ timezone: coerceTimeZone(valueOf("timezone")) ?? "UTC",
5244
+ locale: coerceLocale(valueOf("locale")) ?? "en-US",
5245
+ currency: coerceCurrency(valueOf("currency"))
5246
+ },
5247
+ backendFailed
4792
5248
  };
4793
5249
  }
4794
5250
 
@@ -4950,7 +5406,7 @@ function shouldDenyAnonymous(input) {
4950
5406
  var ADMIN_STANDING_SURFACE = {
4951
5407
  sys_permission_set: {
4952
5408
  role: "derives",
4953
- reason: "The row `platform_admin` is resolved BY NAME from (\xA76b). Renaming it, deleting it or switching it off (ADR-0049 `active`, read here since #8613) un-makes every platform admin at once, with no identity table touched.",
5409
+ reason: "The row `admin_full_access` is resolved BY NAME from (\xA76b) \u2014 `platform_admin` is the POSITION that row derives, not the row's own name. Renaming it, deleting it or switching it off (ADR-0049 `active`, read here since #8613) un-makes every GRANT-derived platform admin at once, with no identity table touched. \u26A0\uFE0F It does NOT un-make a CONFIG-derived one (\xA76b-config, #11970): that route sets the same standing from `ADMIN_FULL_ACCESS_CAPABILITIES` in `@objectstack/spec` and matches the caller's own stored `sys_user` row, so it touches an identity table and never reads this one. With `OS_PLATFORM_OWNER_EMAIL` unset the first sentence is the whole truth; with it declared, this row stops being the single point that un-makes every administrator.",
4954
5410
  columns: [
4955
5411
  "id",
4956
5412
  "name",
@@ -4992,8 +5448,14 @@ var ADMIN_STANDING_SURFACE = {
4992
5448
  ]
4993
5449
  },
4994
5450
  sys_user: {
4995
- role: "reads-only",
4996
- reason: "Read for the `current_user.email` RLS fallback and the ADR-0024 `ai_seat` synthesis (\xA77). Neither confers administrator standing. The guard does watch this table, but for the ban/delete WRITE SHAPES \u2014 `banned` is never read here, so it is not a derivation column and carries no standing-key list."
5451
+ role: "derives",
5452
+ reason: "[#11663 L2] RECLASSIFIED from `reads-only`. This table used to be read only for the `current_user.email` RLS fallback and the ADR-0024 `ai_seat` synthesis (\xA77), and the note here said so: \"Neither confers administrator standing.\" That sentence is now FALSE. The config anchor (\xA76b-config) matches the row's own `email` against the deployment's declared administrator list and requires `email_verified` to read verified, so a write that changes either column takes platform-admin standing away from a config-derived administrator \u2014 an address change and an email_verified reset are both ordinary, reachable writes, and neither touches a grant table. `banned` stays absent from the column list because the resolver still never reads it; the guard watches the ban/delete WRITE SHAPES on this table for its own reasons, which is a different question from what this resolver consumes.",
5453
+ columns: [
5454
+ "id",
5455
+ "email",
5456
+ "email_verified",
5457
+ "ai_access"
5458
+ ]
4997
5459
  },
4998
5460
  sys_user_position: {
4999
5461
  role: "reads-only",
@@ -5008,6 +5470,13 @@ var ADMIN_STANDING_SURFACE = {
5008
5470
  reason: "Position-bound permission sets (\xA76a). Contributes ids to `psIds` \u2014 and therefore names to `permissions` \u2014 but not to `unscopedUserPsIds`, which is the set \xA76b tests for platform-admin standing."
5009
5471
  }
5010
5472
  };
5473
+ var ADMIN_STANDING_NON_TABLE_INPUTS = [
5474
+ {
5475
+ kind: "env",
5476
+ name: "OS_PLATFORM_OWNER_EMAIL",
5477
+ reason: "The deployment's declared platform administrator(s) \u2014 one address or a comma-separated list, matched case-insensitively against `sys_user.email` and conferring standing only when that row's `email_verified` reads verified (\xA76b-config). Read live on every derivation with a per-process memo keyed on the raw string, so a rolled process picks up a change with no special path. Unset, blank, or carrying any unparseable entry means ZERO config-derived administrators, fail closed. No runtime write reaches it, so no break-glass guard can simulate a change to it: revocation is a configuration change plus a process roll, by design."
5478
+ }
5479
+ ];
5011
5480
  function adminStandingTables() {
5012
5481
  return Object.entries(ADMIN_STANDING_SURFACE).filter(([, t]) => t.role === "derives").map(([name]) => name).sort();
5013
5482
  }
@@ -5039,6 +5508,9 @@ function withoutOperationPrivateKeys(exec) {
5039
5508
  return out;
5040
5509
  }
5041
5510
 
5511
+ // src/security/authz-invalidation-channel.ts
5512
+ var AUTHZ_INVALIDATED_CHANNEL = "authz.invalidated";
5513
+
5042
5514
  // src/utils/datetime.ts
5043
5515
  var import_data = require("@objectstack/spec/data");
5044
5516
  function calendarPartsInTz(d, tz) {
@@ -5319,7 +5791,7 @@ function omitInternalFieldsFromWriteResponse(schema, records) {
5319
5791
  }
5320
5792
 
5321
5793
  // src/utils/migration-journal.ts
5322
- var import_node_crypto3 = require("crypto");
5794
+ var import_node_crypto4 = require("crypto");
5323
5795
  var import_system4 = require("@objectstack/spec/system");
5324
5796
  var SYSTEM_CTX = { isSystem: true };
5325
5797
  var DEFAULT_CHUNK_SIZE = 200;
@@ -5374,7 +5846,7 @@ function hashMigrationPlan(plan, chunks) {
5374
5846
  steps: plan.steps.map((s) => s.name),
5375
5847
  chunks: chunks.map((c) => [c.stepIndex, c.offset, c.length])
5376
5848
  });
5377
- return (0, import_node_crypto3.createHash)("sha256").update(shape, "utf8").digest("hex").slice(0, 32);
5849
+ return (0, import_node_crypto4.createHash)("sha256").update(shape, "utf8").digest("hex").slice(0, 32);
5378
5850
  }
5379
5851
  async function appendEvent(engine, event, execContext) {
5380
5852
  await engine.insert(
@@ -5447,7 +5919,7 @@ async function runMigrationJournal(engine, plan, options = {}) {
5447
5919
  }
5448
5920
  const { chunks, planHash, rowsByStep } = await loadPlan(engine, plan, options.chunkSize);
5449
5921
  const resuming = Boolean(options.runId);
5450
- const runId = options.runId ?? (0, import_node_crypto3.randomUUID)();
5922
+ const runId = options.runId ?? (0, import_node_crypto4.randomUUID)();
5451
5923
  let events = [];
5452
5924
  let seq = 0;
5453
5925
  let committed = /* @__PURE__ */ new Set();
@@ -5939,6 +6411,110 @@ function isUninterpretableTemporalComparand(kind, value) {
5939
6411
  return !(readsAsWallClock(s) || readsAsInstant(s));
5940
6412
  }
5941
6413
 
6414
+ // src/utils/metadata-activation-store.ts
6415
+ var METADATA_ACTIVATION_TABLE = "sys_metadata_activation";
6416
+ var SYSTEM_CTX2 = { isSystem: true, positions: [], permissions: [] };
6417
+ var InMemoryMetadataActivationStore = class {
6418
+ constructor() {
6419
+ this.rows = /* @__PURE__ */ new Map();
6420
+ }
6421
+ async list() {
6422
+ return [...this.rows.values()];
6423
+ }
6424
+ async setActive(row) {
6425
+ this.rows.set(row.name, { ...row });
6426
+ }
6427
+ };
6428
+ var ObjectStoreMetadataActivationStore = class {
6429
+ constructor(engine, metadataType) {
6430
+ this.engine = engine;
6431
+ this.metadataType = metadataType;
6432
+ }
6433
+ /**
6434
+ * Every row of this type. Read once at boot to hydrate the consumer's
6435
+ * projection.
6436
+ *
6437
+ * The only scoping is the `metadata_type` discriminator: the ledger is
6438
+ * deployment-wide and has no tenant column, so there is no second axis to
6439
+ * filter on (see the module header).
6440
+ */
6441
+ async list() {
6442
+ const rows = await this.engine.find(METADATA_ACTIVATION_TABLE, {
6443
+ where: { metadata_type: this.metadataType },
6444
+ context: SYSTEM_CTX2
6445
+ });
6446
+ if (!Array.isArray(rows)) return [];
6447
+ const out = [];
6448
+ for (const row of rows) {
6449
+ const r = row;
6450
+ if (typeof r.name !== "string" || !r.name) continue;
6451
+ out.push({
6452
+ name: r.name,
6453
+ packageId: typeof r.package_id === "string" ? r.package_id : "",
6454
+ // The column defaults to `true`; only an explicit `false`
6455
+ // disarms. A driver that round-trips booleans as 0/1
6456
+ // (SQLite/libsql) is read through the same `=== false || === 0`
6457
+ // test, so a `0` is not mistaken for `true`.
6458
+ active: !(r.active === false || r.active === 0)
6459
+ });
6460
+ }
6461
+ return out;
6462
+ }
6463
+ /**
6464
+ * Insert or update the row for one packaged artifact.
6465
+ *
6466
+ * Read-then-write rather than a blind upsert because the object's
6467
+ * uniqueness is a DECLARED index (`unique: 'global'` over
6468
+ * `(metadata_type, name)`), not a primary key this store controls: there is
6469
+ * no id to collide on, so an insert-and-catch could not tell "already
6470
+ * there" from a real store failure.
6471
+ *
6472
+ * That index is also why taking the FIRST match is taking the only one: the
6473
+ * read below is keyed on exactly the index's two columns, so it can match
6474
+ * at most one row. It used to pick the first row with a NULL organization
6475
+ * out of the result, back when the table carried a reserved tenant column;
6476
+ * with no such column the set it was choosing from can no longer hold more
6477
+ * than one member.
6478
+ */
6479
+ async setActive(row) {
6480
+ const existing = await this.engine.find(METADATA_ACTIVATION_TABLE, {
6481
+ where: { metadata_type: this.metadataType, name: row.name },
6482
+ context: SYSTEM_CTX2
6483
+ });
6484
+ const current = Array.isArray(existing) ? existing[0] : void 0;
6485
+ if (current && current.id != null) {
6486
+ await this.engine.update(
6487
+ METADATA_ACTIVATION_TABLE,
6488
+ { id: current.id, active: row.active, package_id: row.packageId },
6489
+ { context: SYSTEM_CTX2 }
6490
+ );
6491
+ return;
6492
+ }
6493
+ await this.engine.insert(
6494
+ METADATA_ACTIVATION_TABLE,
6495
+ {
6496
+ metadata_type: this.metadataType,
6497
+ name: row.name,
6498
+ package_id: row.packageId,
6499
+ active: row.active
6500
+ },
6501
+ { context: SYSTEM_CTX2 }
6502
+ );
6503
+ }
6504
+ /**
6505
+ * Read the backing table once so a misconfiguration surfaces at BOOT
6506
+ * rather than as a failed toggle later. Throws the driver error verbatim —
6507
+ * `no such table: sys_metadata_activation` means the object was never
6508
+ * registered (or its schema never synced) in this composition.
6509
+ *
6510
+ * ⚠️ Unscoped by design: the question is "does the TABLE read at all",
6511
+ * which is a property of the composition, not of one `metadata_type`.
6512
+ */
6513
+ async probe() {
6514
+ await this.engine.find(METADATA_ACTIVATION_TABLE, { where: {}, limit: 1, context: SYSTEM_CTX2 });
6515
+ }
6516
+ };
6517
+
5942
6518
  // src/utils/record-not-found.ts
5943
6519
  function recordNotFoundError(object, id) {
5944
6520
  const err = new Error(`Record ${id} not found in ${object}`);
@@ -5949,6 +6525,45 @@ function recordNotFoundError(object, id) {
5949
6525
  }
5950
6526
 
5951
6527
  // src/health-monitor.ts
6528
+ var RECOVERY_IS_THRESHOLD_GATED = {
6529
+ degraded: true,
6530
+ unhealthy: true,
6531
+ failed: true,
6532
+ recovering: true,
6533
+ healthy: false,
6534
+ unknown: false
6535
+ };
6536
+ function healthMonitorRefusal(message) {
6537
+ const err = new Error(message);
6538
+ err.code = "VALIDATION_ERROR";
6539
+ err.status = 400;
6540
+ return err;
6541
+ }
6542
+ var RETIRED_HEALTH_CHECK_KEYS = [
6543
+ [
6544
+ "autoRestart",
6545
+ "'autoRestart' was removed from PluginHealthCheck in @objectstack/spec 18 (#12032, ADR-0049 enforce-or-remove) \u2014 it never restarted a plugin. `attemptRestart` called `plugin.destroy()` and stopped there, then logged 'Plugin restarted' and set status `recovering`, and the periodic checks carried on against the destroyed instance \u2014 which the default check (`{ name: 'plugin-loaded', status: 'passed' }`) passes forever, so a destroyed, never-re-initialised plugin ended up reported `healthy`. Delete the key. This monitor no longer destroys anything: a failing plugin is reported `unhealthy` or `failed` and left alone."
6546
+ ],
6547
+ [
6548
+ "maxRestartAttempts",
6549
+ "'maxRestartAttempts' was removed from PluginHealthCheck in @objectstack/spec 18 (#12032, ADR-0049 enforce-or-remove) \u2014 it capped a restart that never happened, so it only counted `destroy()` calls. Delete the key."
6550
+ ],
6551
+ [
6552
+ "restartBackoff",
6553
+ "'restartBackoff' was removed from PluginHealthCheck in @objectstack/spec 18 (#12032, ADR-0049 enforce-or-remove) \u2014 it delayed a restart that never happened, so it only moved when the `destroy()` landed. Delete the key."
6554
+ ]
6555
+ ];
6556
+ var RESTART_IS_THE_HOSTS_JOB = " Restarting a plugin is the HOST's job in this host-driven library: poll `getHealthStatus(pluginName)` / `getHealthReport(pluginName)` and act on `unhealthy` / `failed` at the level that owns the plugin's lifetime \u2014 recreate the kernel, or let your supervisor restart the process.";
6557
+ function assertNoRetiredKeys(pluginName, config) {
6558
+ for (const [key, guidance] of RETIRED_HEALTH_CHECK_KEYS) {
6559
+ if (!Object.prototype.hasOwnProperty.call(config, key)) {
6560
+ continue;
6561
+ }
6562
+ throw healthMonitorRefusal(
6563
+ `[HealthMonitor] Plugin '${pluginName}': ${guidance}${RESTART_IS_THE_HOSTS_JOB}`
6564
+ );
6565
+ }
6566
+ }
5952
6567
  var PluginHealthMonitor = class {
5953
6568
  constructor(logger) {
5954
6569
  this.healthChecks = /* @__PURE__ */ new Map();
@@ -5957,18 +6572,17 @@ var PluginHealthMonitor = class {
5957
6572
  this.checkIntervals = /* @__PURE__ */ new Map();
5958
6573
  this.failureCounters = /* @__PURE__ */ new Map();
5959
6574
  this.successCounters = /* @__PURE__ */ new Map();
5960
- this.restartAttempts = /* @__PURE__ */ new Map();
5961
6575
  this.logger = logger.child({ component: "HealthMonitor" });
5962
6576
  }
5963
6577
  /**
5964
6578
  * Register a plugin for health monitoring
5965
6579
  */
5966
6580
  registerPlugin(pluginName, config) {
6581
+ assertNoRetiredKeys(pluginName, config);
5967
6582
  this.healthChecks.set(pluginName, config);
5968
6583
  this.healthStatus.set(pluginName, "unknown");
5969
6584
  this.failureCounters.set(pluginName, 0);
5970
6585
  this.successCounters.set(pluginName, 0);
5971
- this.restartAttempts.set(pluginName, 0);
5972
6586
  this.logger.info("Plugin registered for health monitoring", {
5973
6587
  plugin: pluginName,
5974
6588
  interval: config.interval
@@ -6020,6 +6634,7 @@ var PluginHealthMonitor = class {
6020
6634
  let status = "healthy";
6021
6635
  let message;
6022
6636
  const checks = [];
6637
+ let failureRoute;
6023
6638
  try {
6024
6639
  if (config.checkMethod && typeof plugin[config.checkMethod] === "function") {
6025
6640
  const checkResult = await this.raceCheckTimeout(
@@ -6040,8 +6655,8 @@ var PluginHealthMonitor = class {
6040
6655
  if (status === "healthy") {
6041
6656
  this.successCounters.set(pluginName, (this.successCounters.get(pluginName) || 0) + 1);
6042
6657
  this.failureCounters.set(pluginName, 0);
6043
- const currentStatus = this.healthStatus.get(pluginName);
6044
- if (currentStatus === "unhealthy" || currentStatus === "degraded") {
6658
+ const currentStatus = this.healthStatus.get(pluginName) ?? "unknown";
6659
+ if (RECOVERY_IS_THRESHOLD_GATED[currentStatus]) {
6045
6660
  const successCount = this.successCounters.get(pluginName) || 0;
6046
6661
  if (successCount >= config.successThreshold) {
6047
6662
  this.healthStatus.set(pluginName, "healthy");
@@ -6053,27 +6668,11 @@ var PluginHealthMonitor = class {
6053
6668
  this.healthStatus.set(pluginName, "healthy");
6054
6669
  }
6055
6670
  } else {
6056
- this.failureCounters.set(pluginName, (this.failureCounters.get(pluginName) || 0) + 1);
6057
- this.successCounters.set(pluginName, 0);
6058
- const failureCount = this.failureCounters.get(pluginName) || 0;
6059
- if (failureCount >= config.failureThreshold) {
6060
- this.healthStatus.set(pluginName, "unhealthy");
6061
- this.logger.warn("Plugin marked as unhealthy", {
6062
- plugin: pluginName,
6063
- failures: failureCount
6064
- });
6065
- if (config.autoRestart) {
6066
- await this.attemptRestart(pluginName, plugin, config);
6067
- }
6068
- } else {
6069
- this.healthStatus.set(pluginName, "degraded");
6070
- }
6671
+ failureRoute = "returned";
6071
6672
  }
6072
6673
  } catch (error) {
6073
6674
  status = "failed";
6074
6675
  message = error instanceof Error ? error.message : "Unknown error";
6075
- this.failureCounters.set(pluginName, (this.failureCounters.get(pluginName) || 0) + 1);
6076
- this.healthStatus.set(pluginName, "failed");
6077
6676
  checks.push({
6078
6677
  name: "health-check",
6079
6678
  status: "failed",
@@ -6083,6 +6682,10 @@ var PluginHealthMonitor = class {
6083
6682
  plugin: pluginName,
6084
6683
  error
6085
6684
  });
6685
+ failureRoute = "thrown";
6686
+ }
6687
+ if (failureRoute) {
6688
+ this.recordFailedRound(pluginName, config, failureRoute);
6086
6689
  }
6087
6690
  const report = {
6088
6691
  status: this.healthStatus.get(pluginName) || "unknown",
@@ -6096,56 +6699,42 @@ var PluginHealthMonitor = class {
6096
6699
  this.healthReports.set(pluginName, report);
6097
6700
  }
6098
6701
  /**
6099
- * Attempt to restart a plugin
6702
+ * Handle one failed round — the single path BOTH failure routes take.
6703
+ *
6704
+ * `performHealthCheck` can fail two disjoint ways: the check *returns* a
6705
+ * failure (`false` or `{ status: 'unhealthy' }`), or it *throws* — which by
6706
+ * `raceCheckTimeout` includes every `timeout` overrun, the severest case of
6707
+ * the two. The routes used to be handled in separate blocks, and only the
6708
+ * returned one cleared `successCounters`, so the counters a declared
6709
+ * `failureThreshold` / `successThreshold` are counted with depended on which
6710
+ * way the round happened to fail (#11852).
6711
+ *
6712
+ * What stays route-specific is the *status label*, deliberately. A throw is
6713
+ * the separate `failed` status applied immediately with no threshold — that
6714
+ * is the documented contract (`content/docs/protocol/kernel/lifecycle.mdx`,
6715
+ * "Custom Health Checks") and is pinned by the timeout test. Only the
6716
+ * counters are shared, because that is what `failureThreshold` declares, and
6717
+ * it does not name a route.
6718
+ *
6719
+ * This round ENDS here. Nothing is done TO the plugin — see the #12032 note
6720
+ * on the class: a monitor that cannot re-initialise a plugin has no business
6721
+ * destroying one.
6100
6722
  */
6101
- async attemptRestart(pluginName, plugin, config) {
6102
- const attempts = this.restartAttempts.get(pluginName) || 0;
6103
- if (attempts >= config.maxRestartAttempts) {
6104
- this.logger.error("Max restart attempts reached, giving up", {
6105
- plugin: pluginName,
6106
- attempts
6107
- });
6723
+ recordFailedRound(pluginName, config, route) {
6724
+ const failureCount = (this.failureCounters.get(pluginName) || 0) + 1;
6725
+ this.failureCounters.set(pluginName, failureCount);
6726
+ this.successCounters.set(pluginName, 0);
6727
+ const thresholdReached = failureCount >= config.failureThreshold;
6728
+ if (route === "thrown") {
6108
6729
  this.healthStatus.set(pluginName, "failed");
6109
- return;
6110
- }
6111
- this.restartAttempts.set(pluginName, attempts + 1);
6112
- const delay = this.calculateBackoff(attempts, config.restartBackoff);
6113
- this.logger.info("Scheduling plugin restart", {
6114
- plugin: pluginName,
6115
- attempt: attempts + 1,
6116
- delay
6117
- });
6118
- await new Promise((resolve) => setTimeout(resolve, delay));
6119
- try {
6120
- if (plugin.destroy) {
6121
- await plugin.destroy();
6122
- }
6123
- this.logger.info("Plugin restarted", { plugin: pluginName });
6124
- this.failureCounters.set(pluginName, 0);
6125
- this.successCounters.set(pluginName, 0);
6126
- this.healthStatus.set(pluginName, "recovering");
6127
- } catch (error) {
6128
- this.logger.error("Plugin restart failed", {
6730
+ } else if (thresholdReached) {
6731
+ this.healthStatus.set(pluginName, "unhealthy");
6732
+ this.logger.warn("Plugin marked as unhealthy", {
6129
6733
  plugin: pluginName,
6130
- error
6734
+ failures: failureCount
6131
6735
  });
6132
- this.healthStatus.set(pluginName, "failed");
6133
- }
6134
- }
6135
- /**
6136
- * Calculate backoff delay for restarts
6137
- */
6138
- calculateBackoff(attempt, strategy) {
6139
- const baseDelay = 1e3;
6140
- switch (strategy) {
6141
- case "fixed":
6142
- return baseDelay;
6143
- case "linear":
6144
- return baseDelay * (attempt + 1);
6145
- case "exponential":
6146
- return baseDelay * Math.pow(2, attempt);
6147
- default:
6148
- return baseDelay;
6736
+ } else {
6737
+ this.healthStatus.set(pluginName, "degraded");
6149
6738
  }
6150
6739
  }
6151
6740
  /**
@@ -6178,7 +6767,6 @@ var PluginHealthMonitor = class {
6178
6767
  this.healthReports.clear();
6179
6768
  this.failureCounters.clear();
6180
6769
  this.successCounters.clear();
6181
- this.restartAttempts.clear();
6182
6770
  this.logger.info("Health monitor shutdown complete");
6183
6771
  }
6184
6772
  /**
@@ -6219,7 +6807,7 @@ var PluginHealthMonitor = class {
6219
6807
  };
6220
6808
 
6221
6809
  // src/hot-reload.ts
6222
- var import_node_crypto4 = require("crypto");
6810
+ var import_node_crypto5 = require("crypto");
6223
6811
  var generateUUID = () => {
6224
6812
  if (typeof crypto !== "undefined" && crypto.randomUUID) {
6225
6813
  return crypto.randomUUID();
@@ -6230,6 +6818,42 @@ var generateUUID = () => {
6230
6818
  return v.toString(16);
6231
6819
  });
6232
6820
  };
6821
+ var HONOURED_STATE_STRATEGIES = ["memory", "none"];
6822
+ var RETIRED_STATE_STRATEGY_GUIDANCE = "'disk' and 'distributed' were removed from HotReloadConfig.stateStrategy in @objectstack/spec 18 (ADR-0049 enforce-or-remove) \u2014 neither was ever implemented. Both wrote to the same in-memory Map as 'memory' and reported it only at debug level, so a host that asked for durable or cluster-replicated state got process-local memory and no error. Use 'memory' for in-process state preservation across a reload, or 'none' to disable it. There is no in-tree replacement for durable or distributed plugin state \u2014 persist it in the host, which owns the process lifetime these strategies pretended to outlive.";
6823
+ function hotReloadRefusal(message) {
6824
+ const err = new Error(message);
6825
+ err.code = "VALIDATION_ERROR";
6826
+ err.status = 400;
6827
+ return err;
6828
+ }
6829
+ function assertHonouredStateStrategy(pluginName, strategy) {
6830
+ if (HONOURED_STATE_STRATEGIES.includes(strategy)) {
6831
+ return;
6832
+ }
6833
+ const shown = typeof strategy === "string" ? `'${strategy}'` : String(strategy);
6834
+ const retired = strategy === "disk" || strategy === "distributed";
6835
+ throw hotReloadRefusal(
6836
+ `[HotReload] Plugin '${pluginName}': unsupported stateStrategy ${shown}. Honoured values are ${HONOURED_STATE_STRATEGIES.map((v) => `'${v}'`).join(" and ")}. ` + (retired ? RETIRED_STATE_STRATEGY_GUIDANCE : "This value has never been implemented by PluginStateManager.")
6837
+ );
6838
+ }
6839
+ var RETIRED_HOT_RELOAD_KEYS = [
6840
+ [
6841
+ "distributedConfig",
6842
+ "'distributedConfig' was removed from HotReloadConfig in @objectstack/spec 18 (ADR-0049 enforce-or-remove) \u2014 nothing ever read it. A provider, endpoints, a key prefix, a TTL and a replication factor could all be declared and no connection was ever opened. It left with the stateStrategy: 'distributed' value it was documented as being required for. Delete the key; there is no in-tree replacement for distributed plugin state \u2014 persist it in the host."
6843
+ ],
6844
+ [
6845
+ "watchPatterns",
6846
+ "'watchPatterns' was removed from HotReloadConfig in @objectstack/spec 18 (ADR-0049 enforce-or-remove) \u2014 nothing ever read it. Its only two uses were log lines: no watcher was ever constructed from it, so an author could declare a glob and no file change ever triggered a reload. File watching is the HOST's job in this host-driven library. Delete the key, declare your globs wherever your own watcher reads them, and call `HotReloadManager.scheduleReload(pluginName, reloadFn)` when one matches \u2014 that is the debounced integration point this class does implement."
6847
+ ]
6848
+ ];
6849
+ function assertNoRetiredKeys2(pluginName, config) {
6850
+ for (const [key, guidance] of RETIRED_HOT_RELOAD_KEYS) {
6851
+ if (!Object.prototype.hasOwnProperty.call(config, key)) {
6852
+ continue;
6853
+ }
6854
+ throw hotReloadRefusal(`[HotReload] Plugin '${pluginName}': ${guidance}`);
6855
+ }
6856
+ }
6233
6857
  var PluginStateManager = class {
6234
6858
  constructor(logger) {
6235
6859
  this.stateSnapshots = /* @__PURE__ */ new Map();
@@ -6256,17 +6880,6 @@ var PluginStateManager = class {
6256
6880
  this.memoryStore.set(snapshotId, snapshot);
6257
6881
  this.logger.debug("State saved to memory", { pluginId, snapshotId });
6258
6882
  break;
6259
- case "disk":
6260
- this.memoryStore.set(snapshotId, snapshot);
6261
- this.logger.debug("State saved to disk (memory fallback)", { pluginId, snapshotId });
6262
- break;
6263
- case "distributed":
6264
- this.memoryStore.set(snapshotId, snapshot);
6265
- this.logger.debug("State saved to distributed store (memory fallback)", {
6266
- pluginId,
6267
- snapshotId
6268
- });
6269
- break;
6270
6883
  case "none":
6271
6884
  this.logger.debug("State persistence disabled", { pluginId });
6272
6885
  break;
@@ -6314,7 +6927,7 @@ var PluginStateManager = class {
6314
6927
  */
6315
6928
  calculateChecksum(state) {
6316
6929
  const stateStr = JSON.stringify(state);
6317
- return (0, import_node_crypto4.createHash)("sha256").update(stateStr).digest("hex");
6930
+ return (0, import_node_crypto5.createHash)("sha256").update(stateStr).digest("hex");
6318
6931
  }
6319
6932
  /**
6320
6933
  * Shutdown state manager
@@ -6328,7 +6941,6 @@ var PluginStateManager = class {
6328
6941
  var HotReloadManager = class {
6329
6942
  constructor(logger) {
6330
6943
  this.reloadConfigs = /* @__PURE__ */ new Map();
6331
- this.watchHandles = /* @__PURE__ */ new Map();
6332
6944
  this.reloadTimers = /* @__PURE__ */ new Map();
6333
6945
  this.logger = logger.child({ component: "HotReload" });
6334
6946
  this.stateManager = new PluginStateManager(logger);
@@ -6337,6 +6949,8 @@ var HotReloadManager = class {
6337
6949
  * Register a plugin for hot reload
6338
6950
  */
6339
6951
  registerPlugin(pluginName, config) {
6952
+ assertHonouredStateStrategy(pluginName, config.stateStrategy);
6953
+ assertNoRetiredKeys2(pluginName, config);
6340
6954
  if (!config.enabled) {
6341
6955
  this.logger.debug("Hot reload disabled for plugin", { plugin: pluginName });
6342
6956
  return;
@@ -6344,32 +6958,45 @@ var HotReloadManager = class {
6344
6958
  this.reloadConfigs.set(pluginName, config);
6345
6959
  this.logger.info("Plugin registered for hot reload", {
6346
6960
  plugin: pluginName,
6347
- watchPatterns: config.watchPatterns,
6348
6961
  stateStrategy: config.stateStrategy
6349
6962
  });
6350
6963
  }
6351
6964
  /**
6352
- * Start watching for changes (requires file system integration)
6965
+ * Refuse the file-watching call this class never implemented (#12428).
6966
+ *
6967
+ * The body used to be a guard plus `logger.info('File watching started')`
6968
+ * over an in-source note saying real watching "would require chokidar or
6969
+ * similar". Nothing was ever watched, so an operator who set
6970
+ * `enabled: true` and read that line at INFO had been told the opposite of
6971
+ * the truth — positive confirmation of a capability that did not exist.
6972
+ * ADR-0049 leaves three states and this surface qualified for none of the
6973
+ * other two: no runtime composes this class, so ENFORCE would build for a
6974
+ * caller that does not exist, and no roadmap entry anywhere claims the
6975
+ * feature, so EXPERIMENTAL would be a promise nobody made.
6976
+ *
6977
+ * Kept as a throwing door rather than deleted: removing the method leaves a
6978
+ * JavaScript host a bare `TypeError: not a function` with no prescription,
6979
+ * and this is the one place a caller of the old placeholder is guaranteed
6980
+ * to arrive. The refusal carries an ADR-0112 envelope so it can be asserted
6981
+ * rather than merely caught.
6353
6982
  */
6354
6983
  startWatching(pluginName) {
6355
- const config = this.reloadConfigs.get(pluginName);
6356
- if (!config || !config.enabled) {
6357
- return;
6358
- }
6359
- this.logger.info("File watching started", {
6360
- plugin: pluginName,
6361
- patterns: config.watchPatterns
6362
- });
6984
+ throw hotReloadRefusal(
6985
+ `[HotReload] Plugin '${pluginName}': startWatching() never watched anything and was removed in @objectstack/core 18 (ADR-0049 enforce-or-remove). It logged 'File watching started' at info level while no watcher was ever constructed, so no file change could ever trigger a reload. File watching is the HOST's job in this host-driven library: run your own watcher and call \`HotReloadManager.scheduleReload(pluginName, reloadFn)\` when a file changes \u2014 that is the debounced integration point this class does implement. \`HotReloadConfig.watchPatterns\` was removed in @objectstack/spec 18 for the same reason; declare your globs where your watcher reads them.`
6986
+ );
6363
6987
  }
6364
6988
  /**
6365
- * Stop watching for changes
6989
+ * Cancel a pending debounced reload for a plugin.
6990
+ *
6991
+ * The name is historical (#12428). This never stopped a watcher, because
6992
+ * nothing in this class ever started one: its `watchHandles` cleanup branch
6993
+ * read a Map that had no writer anywhere in the tree, so the branch was
6994
+ * structurally unreachable rather than merely untaken, and it left with
6995
+ * `startWatching`'s placeholder. What survives is the half that always did
6996
+ * something — the debounce timer armed by `scheduleReload` is cleared, so a
6997
+ * reload that was scheduled but has not fired yet is cancelled.
6366
6998
  */
6367
6999
  stopWatching(pluginName) {
6368
- const handle = this.watchHandles.get(pluginName);
6369
- if (handle) {
6370
- this.watchHandles.delete(pluginName);
6371
- this.logger.info("File watching stopped", { plugin: pluginName });
6372
- }
6373
7000
  const timer = this.reloadTimers.get(pluginName);
6374
7001
  if (timer) {
6375
7002
  clearTimeout(timer);
@@ -6512,14 +7139,10 @@ var HotReloadManager = class {
6512
7139
  * Shutdown hot reload manager
6513
7140
  */
6514
7141
  shutdown() {
6515
- for (const pluginName of this.watchHandles.keys()) {
6516
- this.stopWatching(pluginName);
6517
- }
6518
7142
  for (const timer of this.reloadTimers.values()) {
6519
7143
  clearTimeout(timer);
6520
7144
  }
6521
7145
  this.reloadConfigs.clear();
6522
- this.watchHandles.clear();
6523
7146
  this.reloadTimers.clear();
6524
7147
  this.stateManager.shutdown();
6525
7148
  this.logger.info("Hot reload manager shutdown complete");
@@ -6929,6 +7552,7 @@ var NamespaceResolver = class {
6929
7552
  var import_contracts = require("@objectstack/spec/contracts");
6930
7553
  // Annotate the CommonJS export names for ESM import in node:
6931
7554
  0 && (module.exports = {
7555
+ ADMIN_STANDING_NON_TABLE_INPUTS,
6932
7556
  ADMIN_STANDING_SURFACE,
6933
7557
  ANONYMOUS_DENY_BODY,
6934
7558
  ANONYMOUS_DENY_CODE,
@@ -6937,11 +7561,19 @@ var import_contracts = require("@objectstack/spec/contracts");
6937
7561
  API_KEY_PREFIX,
6938
7562
  AUDIENCE_BINDING_SUGGESTION_STATUSES,
6939
7563
  AUDIENCE_BINDING_SUGGESTION_STATUS_VALUES,
7564
+ AUTHZ_GRANTS_CACHE_TTL_ENV,
7565
+ AUTHZ_INVALIDATED_CHANNEL,
7566
+ AUTHZ_STORE_UNAVAILABLE_CODE,
7567
+ AUTHZ_STORE_UNAVAILABLE_MESSAGE,
7568
+ AUTHZ_STORE_UNAVAILABLE_STATUS,
7569
+ AuthzStoreUnavailableError,
6940
7570
  CORE_FALLBACK_FACTORIES,
6941
7571
  DependencyResolver,
6942
7572
  ENTRY_EXECUTION_CONTEXT_FIELDS,
6943
7573
  HotReloadManager,
7574
+ InMemoryMetadataActivationStore,
6944
7575
  LiteKernel,
7576
+ METADATA_ACTIVATION_TABLE,
6945
7577
  MigrationJournalRefusal,
6946
7578
  MigrationPlanRegistry,
6947
7579
  NamespaceResolver,
@@ -6949,10 +7581,11 @@ var import_contracts = require("@objectstack/spec/contracts");
6949
7581
  ObjectKernel,
6950
7582
  ObjectKernelBase,
6951
7583
  ObjectLogger,
7584
+ ObjectStoreMetadataActivationStore,
7585
+ PLATFORM_ADMIN_EMAIL_SEPARATOR,
6952
7586
  POSTURE_INJECTION_RULE,
6953
7587
  POSTURE_LADDER,
6954
7588
  POSTURE_RANK,
6955
- PluginConfigValidator,
6956
7589
  PluginHealthMonitor,
6957
7590
  PluginLoader,
6958
7591
  PluginPermissionEnforcer,
@@ -6961,6 +7594,7 @@ var import_contracts = require("@objectstack/spec/contracts");
6961
7594
  PluginSecurityScanner,
6962
7595
  PluginSignatureVerifier,
6963
7596
  QA,
7597
+ SERVICE_NOT_REGISTERED_CODE,
6964
7598
  SIGNATURE_ALG,
6965
7599
  SecurePluginContext,
6966
7600
  SemanticVersionManager,
@@ -6970,6 +7604,7 @@ var import_contracts = require("@objectstack/spec/contracts");
6970
7604
  UnresolvedFilterTokenError,
6971
7605
  adminStandingColumns,
6972
7606
  adminStandingTables,
7607
+ artifactPackageId,
6973
7608
  assembleExecutionContext,
6974
7609
  assembleExecutionContextOrGuest,
6975
7610
  assertInitServiceRequirements,
@@ -6988,7 +7623,6 @@ var import_contracts = require("@objectstack/spec/contracts");
6988
7623
  createMemoryJob,
6989
7624
  createMemoryMetadata,
6990
7625
  createMemoryQueue,
6991
- createPluginConfigValidator,
6992
7626
  createPluginPermissionEnforcer,
6993
7627
  deepMerge,
6994
7628
  defaultIsTransientError,
@@ -7000,48 +7634,67 @@ var import_contracts = require("@objectstack/spec/contracts");
7000
7634
  extractApiKey,
7001
7635
  filterTokenContextFrom,
7002
7636
  findInterruptedRuns,
7637
+ formatIntegrityViolation,
7003
7638
  generateApiKey,
7004
7639
  generateEd25519KeyPair,
7005
7640
  getEnv,
7006
7641
  getMemoryUsage,
7642
+ hasPlatformAdminStanding,
7007
7643
  hashApiKey,
7008
7644
  hashMigrationPlan,
7009
7645
  isAudienceBindingSuggestionStatus,
7010
7646
  isAuthGateAllowlisted,
7647
+ isAuthzStoreUnavailableError,
7648
+ isConfiguredPlatformAdminEmail,
7011
7649
  isExpired,
7012
7650
  isGrantActive,
7013
7651
  isGrantExpired,
7014
7652
  isNode,
7015
7653
  isRowActive,
7654
+ isServiceNotRegisteredError,
7016
7655
  isUninterpretableTemporalComparand,
7656
+ matchesConfiguredPlatformAdmin,
7017
7657
  nextUtcCalendarDay,
7018
7658
  normalizeAuthGate,
7659
+ normalizePlatformAdminEmail,
7019
7660
  omitInternalFieldsFromWriteResponse,
7661
+ parsePlatformAdminEmails,
7020
7662
  parseScopes,
7021
7663
  parseSignature,
7022
7664
  planChunks,
7023
7665
  postureVisibleRows,
7024
7666
  readAuthoredTranslationLayer,
7667
+ readAuthzGrantsCacheTtlMs,
7025
7668
  readRunJournal,
7026
7669
  recordNotFoundError,
7670
+ reportAuthzCachePosture,
7671
+ reportLegacyPlatformAdminGrant,
7672
+ resetLegacyPlatformAdminGrantReport,
7673
+ resetPlatformAdminEmailMemo,
7027
7674
  resolveApiKeyAdmission,
7028
7675
  resolveApiKeyPrincipal,
7676
+ resolveArtifactPackageOrder,
7677
+ resolveAuthzCachePosture,
7029
7678
  resolveAuthzContext,
7030
7679
  resolveFilterToken,
7031
7680
  resolveFilterTokens,
7032
7681
  resolveLocale,
7033
7682
  resolveLocalizationContext,
7683
+ resolvePlatformAdminEmails,
7034
7684
  resolvePluginOrder,
7035
7685
  resolveUserAuthzGrants,
7036
7686
  resumeMigrationJournal,
7687
+ rethrowAuthzStoreUnavailable,
7037
7688
  runMigrationJournal,
7038
7689
  safeExit,
7690
+ setPlatformAdminConfigSink,
7039
7691
  shouldDenyAnonymous,
7040
7692
  signPayload,
7041
7693
  temporalComparandKind,
7042
7694
  unknownAudienceBindingSuggestionStatusMessage,
7043
7695
  utcInstantMs,
7044
7696
  validateInitServiceContract,
7697
+ verifyIntegrity,
7045
7698
  verifyPayload,
7046
7699
  verifyPlatformSignature,
7047
7700
  verifyPluginArtifact,