@objectstack/core 17.2.0 → 17.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -713,138 +713,6 @@ function createLogger(config) {
713
713
  // src/kernel.ts
714
714
  import { ServiceRequirementDef } from "@objectstack/spec/system";
715
715
 
716
- // src/security/plugin-config-validator.ts
717
- import { z } from "zod";
718
- var PluginConfigValidator = class {
719
- constructor(logger) {
720
- this.logger = logger;
721
- }
722
- /**
723
- * Validate plugin configuration against its Zod schema
724
- *
725
- * @param plugin - Plugin metadata with configSchema
726
- * @param config - User-provided configuration
727
- * @returns Validated and typed configuration
728
- * @throws Error with detailed validation errors
729
- */
730
- validatePluginConfig(plugin, config) {
731
- if (!plugin.configSchema) {
732
- this.logger.debug(`Plugin ${plugin.name} has no config schema - skipping validation`);
733
- return config;
734
- }
735
- try {
736
- const validatedConfig = plugin.configSchema.parse(config);
737
- this.logger.debug(`\u2705 Plugin config validated: ${plugin.name}`, {
738
- plugin: plugin.name,
739
- configKeys: Object.keys(config || {}).length
740
- });
741
- return validatedConfig;
742
- } catch (error) {
743
- if (error instanceof z.ZodError) {
744
- const formattedErrors = this.formatZodErrors(error);
745
- const errorMessage = [
746
- `Plugin ${plugin.name} configuration validation failed:`,
747
- ...formattedErrors.map((e) => ` - ${e.path}: ${e.message}`)
748
- ].join("\n");
749
- this.logger.error(errorMessage, void 0, {
750
- plugin: plugin.name,
751
- errors: formattedErrors
752
- });
753
- throw new Error(errorMessage);
754
- }
755
- throw error;
756
- }
757
- }
758
- /**
759
- * Validate partial configuration (for incremental updates)
760
- *
761
- * @param plugin - Plugin metadata
762
- * @param partialConfig - Partial configuration to validate
763
- * @returns Validated partial configuration
764
- */
765
- validatePartialConfig(plugin, partialConfig) {
766
- if (!plugin.configSchema) {
767
- return partialConfig;
768
- }
769
- try {
770
- const partialSchema = plugin.configSchema.partial();
771
- const validatedConfig = partialSchema.parse(partialConfig);
772
- this.logger.debug(`\u2705 Partial config validated: ${plugin.name}`);
773
- return validatedConfig;
774
- } catch (error) {
775
- if (error instanceof z.ZodError) {
776
- const formattedErrors = this.formatZodErrors(error);
777
- const errorMessage = [
778
- `Plugin ${plugin.name} partial configuration validation failed:`,
779
- ...formattedErrors.map((e) => ` - ${e.path}: ${e.message}`)
780
- ].join("\n");
781
- throw new Error(errorMessage);
782
- }
783
- throw error;
784
- }
785
- }
786
- /**
787
- * Get default configuration from schema
788
- *
789
- * @param plugin - Plugin metadata
790
- * @returns Default configuration object
791
- */
792
- getDefaultConfig(plugin) {
793
- if (!plugin.configSchema) {
794
- return void 0;
795
- }
796
- try {
797
- const defaults = plugin.configSchema.parse({});
798
- this.logger.debug(`Default config extracted: ${plugin.name}`);
799
- return defaults;
800
- } catch (error) {
801
- this.logger.debug(`No default config available: ${plugin.name}`);
802
- return void 0;
803
- }
804
- }
805
- /**
806
- * Check if configuration is valid without throwing
807
- *
808
- * @param plugin - Plugin metadata
809
- * @param config - Configuration to check
810
- * @returns True if valid, false otherwise
811
- */
812
- isConfigValid(plugin, config) {
813
- if (!plugin.configSchema) {
814
- return true;
815
- }
816
- const result = plugin.configSchema.safeParse(config);
817
- return result.success;
818
- }
819
- /**
820
- * Get configuration errors without throwing
821
- *
822
- * @param plugin - Plugin metadata
823
- * @param config - Configuration to check
824
- * @returns Array of validation errors, or empty array if valid
825
- */
826
- getConfigErrors(plugin, config) {
827
- if (!plugin.configSchema) {
828
- return [];
829
- }
830
- const result = plugin.configSchema.safeParse(config);
831
- if (result.success) {
832
- return [];
833
- }
834
- return this.formatZodErrors(result.error);
835
- }
836
- // Private methods
837
- formatZodErrors(error) {
838
- return error.issues.map((e) => ({
839
- path: e.path.join(".") || "root",
840
- message: e.message
841
- }));
842
- }
843
- };
844
- function createPluginConfigValidator(logger) {
845
- return new PluginConfigValidator(logger);
846
- }
847
-
848
716
  // src/security/plugin-artifact-signature.ts
849
717
  import {
850
718
  sign as cryptoSign,
@@ -947,6 +815,20 @@ async function verifyPluginArtifact(input, keys) {
947
815
  return { ok: true, publisherVerified: publisher.verified, platformVerified };
948
816
  }
949
817
 
818
+ // src/service-not-registered.ts
819
+ var SERVICE_NOT_REGISTERED_CODE = "SERVICE_NOT_REGISTERED";
820
+ var SERVICE_NOT_REGISTERED_BRAND = "__objectstackServiceNotRegistered";
821
+ function serviceNotRegisteredError(name) {
822
+ const err = new Error(`Service '${name}' not found`);
823
+ err[SERVICE_NOT_REGISTERED_BRAND] = true;
824
+ err.code = SERVICE_NOT_REGISTERED_CODE;
825
+ err.serviceName = name;
826
+ return err;
827
+ }
828
+ function isServiceNotRegisteredError(err) {
829
+ return typeof err === "object" && err !== null && err[SERVICE_NOT_REGISTERED_BRAND] === true;
830
+ }
831
+
950
832
  // src/plugin-loader.ts
951
833
  var ServiceLifecycle = /* @__PURE__ */ ((ServiceLifecycle2) => {
952
834
  ServiceLifecycle2["SINGLETON"] = "singleton";
@@ -962,7 +844,6 @@ var PluginLoader = class {
962
844
  this.scopedServices = /* @__PURE__ */ new Map();
963
845
  this.creating = /* @__PURE__ */ new Set();
964
846
  this.logger = logger;
965
- this.configValidator = new PluginConfigValidator(logger);
966
847
  }
967
848
  /**
968
849
  * Set the plugin context for service factories
@@ -989,9 +870,6 @@ var PluginLoader = class {
989
870
  if (!versionCheck.compatible) {
990
871
  throw new Error(`Version incompatible: ${versionCheck.message}`);
991
872
  }
992
- if (metadata.configSchema) {
993
- this.validatePluginConfig(metadata);
994
- }
995
873
  if (metadata.signature) {
996
874
  await this.verifyPluginSignature(metadata);
997
875
  }
@@ -1030,7 +908,7 @@ var PluginLoader = class {
1030
908
  if (!registration) {
1031
909
  const instance = this.serviceInstances.get(name);
1032
910
  if (!instance) {
1033
- throw new Error(`Service '${name}' not found`);
911
+ throw serviceNotRegisteredError(name);
1034
912
  }
1035
913
  return instance;
1036
914
  }
@@ -1190,16 +1068,6 @@ var PluginLoader = class {
1190
1068
  const semverRegex = /^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/;
1191
1069
  return semverRegex.test(version);
1192
1070
  }
1193
- validatePluginConfig(plugin, config) {
1194
- if (!plugin.configSchema) {
1195
- return;
1196
- }
1197
- if (config === void 0) {
1198
- this.logger.debug(`Plugin ${plugin.name} has configuration schema (config validation postponed)`);
1199
- return;
1200
- }
1201
- this.configValidator.validatePluginConfig(plugin, config);
1202
- }
1203
1071
  async verifyPluginSignature(plugin) {
1204
1072
  if (!plugin.signature) {
1205
1073
  return;
@@ -1489,7 +1357,7 @@ function createMemoryI18n() {
1489
1357
  }
1490
1358
 
1491
1359
  // src/metadata-service-contract.ts
1492
- import { pluralToSingular } from "@objectstack/spec/shared";
1360
+ import { pluralToSingular } from "@objectstack/spec/meta-spelling";
1493
1361
  var REGISTER_REFUSAL_CODE = "VALIDATION_ERROR";
1494
1362
  function canonicalMetadataServiceType(type) {
1495
1363
  return pluralToSingular(type);
@@ -2373,6 +2241,67 @@ var ObjectKernel = class {
2373
2241
  }
2374
2242
  };
2375
2243
 
2244
+ // src/artifact-packages.ts
2245
+ import { ArtifactPackageSchema } from "@objectstack/spec";
2246
+ var MAX_REPORTED_ENTRY_ISSUES = 5;
2247
+ function refuse(code, message) {
2248
+ const err = new Error(message);
2249
+ err.code = code;
2250
+ err.status = 422;
2251
+ return err;
2252
+ }
2253
+ function artifactPackageId(manifest) {
2254
+ const id = manifest?.id || manifest?.name;
2255
+ return typeof id === "string" && id !== "" ? id : void 0;
2256
+ }
2257
+ function resolveArtifactPackageOrder(artifact) {
2258
+ const declared = artifact?.packages;
2259
+ if (declared === void 0 || declared === null) return [artifact];
2260
+ if (!Array.isArray(declared)) {
2261
+ throw refuse(
2262
+ "INVALID_ARTIFACT_PACKAGES",
2263
+ `A release artifact's \`packages\` must be an array of package entries (ADR-0130 D4, \`ArtifactPackageEntrySchema\`), but this artifact carries \`packages\` of type ${typeof declared}. Omit the key entirely for a single-package artifact \u2014 \`manifest\` is retained, not replaced.`
2264
+ );
2265
+ }
2266
+ const nodes = /* @__PURE__ */ new Map();
2267
+ declared.forEach((entry, index) => {
2268
+ const verdict = ArtifactPackageSchema.safeParse(entry);
2269
+ if (!verdict.success) {
2270
+ const issues = verdict.error.issues;
2271
+ const shown = issues.slice(0, MAX_REPORTED_ENTRY_ISSUES);
2272
+ throw refuse(
2273
+ "INVALID_ARTIFACT_PACKAGE_ENTRY",
2274
+ `Release artifact \`packages[${index}]\` is not a package entry (ADR-0130 D4): ` + shown.map((i) => `${i.path.join(".") || "<entry>"}: ${i.message}`).join("; ") + (issues.length > shown.length ? ` (+${issues.length - shown.length} more)` : "") + ". Each entry is a WRAPPER object carrying its package under `manifest:` \u2014 wrap an inlined body as `{ manifest: { \u2026 } }`. The key position is reserved so a future external-segment form is an additive key rather than a reshape. The body under `manifest:` is the ASSEMBLED package body (`AssembledPackageBodySchema`): its `objects` / `datasources` are DEFINITIONS, not the authoring manifest's glob patterns \u2014 a compiled artifact has no files left to glob."
2275
+ );
2276
+ }
2277
+ const manifest = entry.manifest;
2278
+ const id = artifactPackageId(manifest);
2279
+ if (id === void 0) {
2280
+ throw refuse(
2281
+ "INVALID_ARTIFACT_PACKAGE_ENTRY",
2282
+ `Release artifact \`packages[${index}]\` carries a manifest with no usable package id: \`registerApp\` keys the installed package on \`id || name\`, so an entry without either cannot be ordered against its siblings or addressed after install.`
2283
+ );
2284
+ }
2285
+ if (nodes.has(id)) {
2286
+ throw refuse(
2287
+ "DUPLICATE_ARTIFACT_PACKAGE",
2288
+ `Release artifact declares package "${id}" more than once (\`packages[${index}]\` repeats an earlier entry). One artifact carries each package once \u2014 an artifact is one atomic delivery (ADR-0130 D1/D6), not a list with last-writer-wins.`
2289
+ );
2290
+ }
2291
+ nodes.set(id, {
2292
+ // `name` is what `resolvePluginOrder` puts in its diagnostics; the MAP KEY
2293
+ // is what its edges resolve against. Both are the package id, so an error
2294
+ // it raises names the same string the artifact author wrote.
2295
+ name: id,
2296
+ optionalDependencies: Object.keys(
2297
+ manifest.dependencies ?? {}
2298
+ ),
2299
+ manifest
2300
+ });
2301
+ });
2302
+ return resolvePluginOrder(nodes).map((node) => node.manifest);
2303
+ }
2304
+
2376
2305
  // src/lite-kernel.ts
2377
2306
  var LiteKernel = class extends ObjectKernelBase {
2378
2307
  constructor(config) {
@@ -3102,6 +3031,55 @@ var PluginSignatureVerifier = class {
3102
3031
  }
3103
3032
  };
3104
3033
 
3034
+ // src/security/plugin-artifact-integrity.ts
3035
+ import { createHash } from "crypto";
3036
+ var SRI_ALGORITHMS = /* @__PURE__ */ new Set(["sha256", "sha384", "sha512"]);
3037
+ function sriDigestFor(declared, data) {
3038
+ const dash = declared.indexOf("-");
3039
+ const alg = dash > 0 && SRI_ALGORITHMS.has(declared.slice(0, dash)) ? declared.slice(0, dash) : "sha256";
3040
+ return `${alg}-${createHash(alg).update(data).digest("base64")}`;
3041
+ }
3042
+ function verifyIntegrity(files, integrity, options = {}) {
3043
+ if (integrity === null || integrity === void 0) {
3044
+ return { ok: true, skipped: true, checked: 0, violations: [] };
3045
+ }
3046
+ const exempt = new Set(options.exempt ?? []);
3047
+ const byPath = /* @__PURE__ */ new Map();
3048
+ for (const f of files) {
3049
+ if (!exempt.has(f.path)) byPath.set(f.path, f.data);
3050
+ }
3051
+ const violations = [];
3052
+ let checked = 0;
3053
+ for (const [path, declaredRaw] of Object.entries(integrity)) {
3054
+ if (exempt.has(path)) continue;
3055
+ const declared = typeof declaredRaw === "string" ? declaredRaw : String(declaredRaw);
3056
+ const data = byPath.get(path);
3057
+ if (data === void 0) {
3058
+ violations.push({ kind: "missing_file", path, declared });
3059
+ continue;
3060
+ }
3061
+ checked++;
3062
+ const actual = sriDigestFor(declared, data);
3063
+ if (actual !== declared) violations.push({ kind: "digest_mismatch", path, declared, actual });
3064
+ }
3065
+ for (const path of [...byPath.keys()].sort()) {
3066
+ if (!Object.prototype.hasOwnProperty.call(integrity, path)) {
3067
+ violations.push({ kind: "extra_file", path });
3068
+ }
3069
+ }
3070
+ return { ok: violations.length === 0, skipped: false, checked, violations };
3071
+ }
3072
+ function formatIntegrityViolation(v) {
3073
+ switch (v.kind) {
3074
+ case "digest_mismatch":
3075
+ return `${v.path}: digest mismatch \u2014 manifest declares ${v.declared}, artifact bytes hash to ${v.actual}`;
3076
+ case "missing_file":
3077
+ return `${v.path}: declared in the integrity map but absent from the artifact`;
3078
+ case "extra_file":
3079
+ return `${v.path}: present in the artifact but not in the integrity map`;
3080
+ }
3081
+ }
3082
+
3105
3083
  // src/security/plugin-permission-enforcer.ts
3106
3084
  var PluginPermissionEnforcer = class {
3107
3085
  constructor(logger) {
@@ -4196,13 +4174,13 @@ var PluginSecurityScanner = class {
4196
4174
  };
4197
4175
 
4198
4176
  // src/security/api-key.ts
4199
- import { createHash, randomBytes } from "crypto";
4177
+ import { createHash as createHash2, randomBytes } from "crypto";
4200
4178
  import { postureEnforcesWall, postureUsesUnionScope, normalizeTenancyPosture } from "@objectstack/spec/security";
4201
4179
  var API_KEY_PREFIX = "osk_";
4202
4180
  var API_KEY_ENTROPY_BYTES = 32;
4203
4181
  var VISIBLE_PREFIX_LEN = 12;
4204
4182
  function hashApiKey(raw) {
4205
- return createHash("sha256").update(raw, "utf8").digest("hex");
4183
+ return createHash2("sha256").update(raw, "utf8").digest("hex");
4206
4184
  }
4207
4185
  function generateApiKey(prefix = API_KEY_PREFIX) {
4208
4186
  const secret = randomBytes(API_KEY_ENTROPY_BYTES).toString("base64url");
@@ -4319,11 +4297,41 @@ function safeJsonParse(s, fallback) {
4319
4297
  }
4320
4298
  }
4321
4299
 
4300
+ // src/security/authz-store-unavailable.ts
4301
+ var AUTHZ_STORE_UNAVAILABLE_STATUS = 503;
4302
+ var AUTHZ_STORE_UNAVAILABLE_CODE = "SERVICE_UNAVAILABLE";
4303
+ var AUTHZ_STORE_UNAVAILABLE_MESSAGE = "The authorization store could not be read, so this request's permissions were never determined. This is a server-side outage, not a permission denial.";
4304
+ var AUTHZ_STORE_UNAVAILABLE_BRAND = "__objectstackAuthzStoreUnavailable";
4305
+ var _a, _b;
4306
+ var AuthzStoreUnavailableError = class extends (_b = Error, _a = AUTHZ_STORE_UNAVAILABLE_BRAND, _b) {
4307
+ constructor(object, cause) {
4308
+ super(`${AUTHZ_STORE_UNAVAILABLE_MESSAGE} (failed read: \`${object}\`)`);
4309
+ /** Brand — see the module doc on why this is not `instanceof`. */
4310
+ this[_a] = true;
4311
+ /** ADR-0112 wire code. */
4312
+ this.code = AUTHZ_STORE_UNAVAILABLE_CODE;
4313
+ /** HTTP status a transport should answer. */
4314
+ this.status = AUTHZ_STORE_UNAVAILABLE_STATUS;
4315
+ this.name = "AuthzStoreUnavailableError";
4316
+ this.object = object;
4317
+ if (cause !== void 0) this.cause = cause;
4318
+ }
4319
+ };
4320
+ function isAuthzStoreUnavailableError(err) {
4321
+ return typeof err === "object" && err !== null && err[AUTHZ_STORE_UNAVAILABLE_BRAND] === true;
4322
+ }
4323
+ function rethrowAuthzStoreUnavailable(err) {
4324
+ if (isAuthzStoreUnavailableError(err)) throw err;
4325
+ return void 0;
4326
+ }
4327
+
4322
4328
  // src/security/resolve-authz-context.ts
4329
+ import { isMissingTableError, resolveTenancyPosture } from "@objectstack/types";
4323
4330
  import {
4324
4331
  mapMembershipRole,
4325
4332
  BUILTIN_IDENTITY_PLATFORM_ADMIN,
4326
4333
  ADMIN_FULL_ACCESS,
4334
+ ADMIN_FULL_ACCESS_CAPABILITIES,
4327
4335
  ORGANIZATION_ADMIN_GRANTS
4328
4336
  } from "@objectstack/spec";
4329
4337
  import { postureEnforcesWall as postureEnforcesWall2 } from "@objectstack/spec/security";
@@ -4346,6 +4354,20 @@ function isGrantActive(row, nowMs) {
4346
4354
  if (until !== void 0 && !(nowMs < until)) return false;
4347
4355
  return true;
4348
4356
  }
4357
+ function nextGrantValidityBoundary(rows, nowMs) {
4358
+ let next;
4359
+ for (const row of rows) {
4360
+ if (!row) continue;
4361
+ const from = toEpochMs(row.valid_from ?? row.validFrom);
4362
+ const until = toEpochMs(row.valid_until ?? row.validUntil);
4363
+ for (const bound of [from, until]) {
4364
+ if (bound !== void 0 && bound > nowMs && (next === void 0 || bound < next)) {
4365
+ next = bound;
4366
+ }
4367
+ }
4368
+ }
4369
+ return next;
4370
+ }
4349
4371
  function isGrantExpired(row, nowMs) {
4350
4372
  if (!row) return false;
4351
4373
  const until = toEpochMs(row.valid_until ?? row.validUntil);
@@ -4353,6 +4375,227 @@ function isGrantExpired(row, nowMs) {
4353
4375
  return !(nowMs < until);
4354
4376
  }
4355
4377
 
4378
+ // src/security/authz-cache-posture.ts
4379
+ var AUTHZ_GRANTS_CACHE_TTL_ENV = "OS_AUTHZ_GRANTS_CACHE_TTL_MS";
4380
+ function resolveAuthzCachePosture(input) {
4381
+ const { ttlMs, bus, driver } = input;
4382
+ if (!(ttlMs > 0)) {
4383
+ return { posture: "disabled", loud: false, message: "" };
4384
+ }
4385
+ if (bus === "bridged") {
4386
+ return {
4387
+ posture: "bus-narrowed",
4388
+ loud: false,
4389
+ message: `[authz-cache] grants cache ENABLED (ttl=${ttlMs}ms) with the "authz.invalidated" bridge attached` + (driver ? ` (cluster driver "${driver}")` : "") + ". The bus narrows the TYPICAL convergence to one network hop; the TTL remains the correctness bound, because no shipped driver delivers better than at-most-once (cluster.mdx \xA74.2)."
4390
+ };
4391
+ }
4392
+ const why = bus === "in-process" ? `the cluster driver "${driver ?? "memory"}" is in-process and does not fan out across replicas` : "no cluster service is registered on this node";
4393
+ return {
4394
+ posture: "ttl-only",
4395
+ loud: true,
4396
+ message: `[authz-cache] grants cache ENABLED (ttl=${ttlMs}ms) with NO "authz.invalidated" invalidation bus \u2014 ${why}. A grant revoked on another replica is honoured by this one for up to ${ttlMs}ms. That is a supported configuration, not an error: the TTL is the correctness bound and it still holds. It is stated because a silently-absent invalidation bridge is how a security control gets disabled without anyone noticing (#4785). To narrow the typical window, configure a remote cluster driver; to remove it entirely, set ${AUTHZ_GRANTS_CACHE_TTL_ENV}=0.`
4397
+ };
4398
+ }
4399
+ function readAuthzGrantsCacheTtlMs(env = typeof process !== "undefined" ? process.env : {}) {
4400
+ const raw = env[AUTHZ_GRANTS_CACHE_TTL_ENV];
4401
+ if (raw === void 0 || raw.trim() === "") {
4402
+ return { ttlMs: 0, malformed: false };
4403
+ }
4404
+ const parsed = Number(raw.trim());
4405
+ if (!Number.isFinite(parsed) || parsed < 0) {
4406
+ return { ttlMs: 0, raw, malformed: true };
4407
+ }
4408
+ return { ttlMs: Math.floor(parsed), raw, malformed: false };
4409
+ }
4410
+ function reportAuthzCachePosture(input, sink2) {
4411
+ if (input.malformedTtl) {
4412
+ sink2.warn(
4413
+ `[authz-cache] ${AUTHZ_GRANTS_CACHE_TTL_ENV}=${JSON.stringify(input.malformedTtl.raw ?? "")} is not a non-negative number; the grants cache is treated as DISABLED. Set a millisecond count, or 0 to disable it deliberately.`
4414
+ );
4415
+ }
4416
+ const statement = resolveAuthzCachePosture(input);
4417
+ if (statement.posture === "disabled") return statement;
4418
+ if (statement.loud) sink2.warn(statement.message);
4419
+ else sink2.info?.(statement.message);
4420
+ return statement;
4421
+ }
4422
+
4423
+ // src/security/resolve-user-grants-cache.ts
4424
+ var GRANTS_CACHE_WATCHED_OBJECTS = /* @__PURE__ */ new Set([
4425
+ "sys_member",
4426
+ "sys_user_position",
4427
+ "sys_user_permission_set",
4428
+ "sys_position",
4429
+ "sys_position_permission_set",
4430
+ "sys_permission_set",
4431
+ "sys_user"
4432
+ ]);
4433
+ var grantsCacheStates = /* @__PURE__ */ new WeakMap();
4434
+ var WRITE_OPERATIONS = /* @__PURE__ */ new Set(["insert", "update", "delete"]);
4435
+ function grantsCacheState(ql) {
4436
+ const existing = grantsCacheStates.get(ql);
4437
+ if (existing !== void 0) return existing ?? void 0;
4438
+ const seamQl = ql;
4439
+ const epoch = seamQl.writeEpoch;
4440
+ const hasEpoch = !!epoch && typeof epoch === "object" && typeof epoch.current === "number" && typeof epoch.bump === "function" && typeof epoch.subscribe === "function";
4441
+ if (!hasEpoch || typeof seamQl.registerMiddleware !== "function") {
4442
+ return void 0;
4443
+ }
4444
+ const state = { gen: 0, entries: /* @__PURE__ */ new Map() };
4445
+ try {
4446
+ seamQl.registerMiddleware(async (ctx, next) => {
4447
+ if (typeof ctx?.operation !== "string" || !WRITE_OPERATIONS.has(ctx.operation) || typeof ctx?.object !== "string" || !GRANTS_CACHE_WATCHED_OBJECTS.has(ctx.object)) {
4448
+ return next();
4449
+ }
4450
+ try {
4451
+ await next();
4452
+ } finally {
4453
+ state.gen += 1;
4454
+ }
4455
+ });
4456
+ epoch.subscribe((_epoch, reason) => {
4457
+ if (reason !== "write") state.gen += 1;
4458
+ });
4459
+ } catch {
4460
+ grantsCacheStates.set(ql, null);
4461
+ return void 0;
4462
+ }
4463
+ grantsCacheStates.set(ql, state);
4464
+ return state;
4465
+ }
4466
+ function grantsCacheKey(userId, opts) {
4467
+ return JSON.stringify([
4468
+ userId,
4469
+ opts.tenantId ?? null,
4470
+ opts.seedEmail ?? null,
4471
+ Array.isArray(opts.seedPermissions) ? opts.seedPermissions : []
4472
+ ]);
4473
+ }
4474
+ var cloneGrants = (grants) => structuredClone(grants);
4475
+ function openUserGrantsCache(ql, userId, opts) {
4476
+ if (opts.bypassGrantsCache) return void 0;
4477
+ const { ttlMs } = readAuthzGrantsCacheTtlMs();
4478
+ if (ttlMs <= 0) return void 0;
4479
+ if (!ql || typeof ql !== "object" || typeof ql.find !== "function") {
4480
+ return void 0;
4481
+ }
4482
+ const state = grantsCacheState(ql);
4483
+ if (!state) return void 0;
4484
+ const key = grantsCacheKey(userId, opts);
4485
+ const now = opts.nowMs ?? Date.now();
4486
+ const genAtOpen = state.gen;
4487
+ const existing = state.entries.get(key);
4488
+ if (existing) {
4489
+ if (existing.gen === state.gen && existing.expiresAt > now) {
4490
+ return { hit: cloneGrants(existing.value), commit: () => {
4491
+ } };
4492
+ }
4493
+ state.entries.delete(key);
4494
+ }
4495
+ return {
4496
+ commit(grants, nextBoundaryMs) {
4497
+ const expiresAt = Math.min(now + ttlMs, nextBoundaryMs ?? Number.POSITIVE_INFINITY);
4498
+ if (expiresAt <= now) return;
4499
+ state.entries.set(key, { value: cloneGrants(grants), gen: genAtOpen, expiresAt });
4500
+ }
4501
+ };
4502
+ }
4503
+
4504
+ // src/security/platform-admin.ts
4505
+ import { isEmailVerifiedUserRow, PLATFORM_OWNER_EMAIL_ENV, resolvePlatformOwnerEmail } from "@objectstack/types";
4506
+ var PLATFORM_ADMIN_EMAIL_SEPARATOR = ",";
4507
+ function normalizePlatformAdminEmail(value) {
4508
+ return typeof value === "string" ? value.trim().toLowerCase() : "";
4509
+ }
4510
+ function isParseableAddress(entry) {
4511
+ if (/\s/.test(entry)) return false;
4512
+ const at = entry.indexOf("@");
4513
+ if (at <= 0) return false;
4514
+ if (entry.indexOf("@", at + 1) !== -1) return false;
4515
+ return at < entry.length - 1;
4516
+ }
4517
+ var EMPTY_CONFIG = Object.freeze({
4518
+ emails: Object.freeze([]),
4519
+ declaredSpellings: Object.freeze([])
4520
+ });
4521
+ function parsePlatformAdminEmails(raw) {
4522
+ if (raw == null) return EMPTY_CONFIG;
4523
+ const text = String(raw);
4524
+ if (text.trim() === "") return EMPTY_CONFIG;
4525
+ const emails = [];
4526
+ const declaredSpellings = [];
4527
+ for (const piece of text.split(PLATFORM_ADMIN_EMAIL_SEPARATOR)) {
4528
+ const entry = normalizePlatformAdminEmail(piece);
4529
+ if (entry === "") continue;
4530
+ if (!isParseableAddress(entry)) {
4531
+ return {
4532
+ emails: Object.freeze([]),
4533
+ declaredSpellings: Object.freeze([]),
4534
+ raw: text,
4535
+ refusal: `${PLATFORM_OWNER_EMAIL_ENV} entry ${JSON.stringify(piece)} is not an email address, so the WHOLE variable is refused and this deployment has ZERO config-derived platform administrators. The entry is not skipped on purpose: silently dropping it would leave a narrower administrator set than the operator declared, with nothing to notice. Fix the entry, or remove it \u2014 ${PLATFORM_OWNER_EMAIL_ENV} takes one address or a comma-separated list of them.`
4536
+ };
4537
+ }
4538
+ if (!emails.includes(entry)) {
4539
+ emails.push(entry);
4540
+ declaredSpellings.push(piece.trim());
4541
+ }
4542
+ }
4543
+ return {
4544
+ emails: Object.freeze(emails),
4545
+ declaredSpellings: Object.freeze(declaredSpellings),
4546
+ raw: text
4547
+ };
4548
+ }
4549
+ var defaultSink = {
4550
+ error: (m) => console.error(m),
4551
+ warn: (m) => console.warn(m)
4552
+ };
4553
+ var sink = defaultSink;
4554
+ function setPlatformAdminConfigSink(next) {
4555
+ const prev = sink;
4556
+ sink = next ?? defaultSink;
4557
+ return prev;
4558
+ }
4559
+ var NOT_MEMOIZED = /* @__PURE__ */ Symbol("platform-admin-config-not-memoized");
4560
+ var memoKey = NOT_MEMOIZED;
4561
+ var memoValue = EMPTY_CONFIG;
4562
+ function resolvePlatformAdminEmails() {
4563
+ const raw = resolvePlatformOwnerEmail();
4564
+ if (memoKey !== NOT_MEMOIZED && memoKey === raw) return memoValue;
4565
+ const parsed = parsePlatformAdminEmails(raw);
4566
+ memoKey = raw;
4567
+ memoValue = parsed;
4568
+ if (parsed.refusal) sink.error(`[authz] ${parsed.refusal}`);
4569
+ return parsed;
4570
+ }
4571
+ function resetPlatformAdminEmailMemo() {
4572
+ memoKey = NOT_MEMOIZED;
4573
+ memoValue = EMPTY_CONFIG;
4574
+ }
4575
+ function matchesConfiguredPlatformAdmin(row, config) {
4576
+ if (config.emails.length === 0) return false;
4577
+ if (!row || typeof row !== "object") return false;
4578
+ if (!isConfiguredPlatformAdminEmail(row.email, config)) return false;
4579
+ return isEmailVerifiedUserRow(row);
4580
+ }
4581
+ function isConfiguredPlatformAdminEmail(email, config) {
4582
+ if (config.emails.length === 0) return false;
4583
+ const candidate = normalizePlatformAdminEmail(email);
4584
+ return candidate !== "" && config.emails.includes(candidate);
4585
+ }
4586
+ var legacyGrantPointerSaid = false;
4587
+ function reportLegacyPlatformAdminGrant(input) {
4588
+ if (legacyGrantPointerSaid) return;
4589
+ legacyGrantPointerSaid = true;
4590
+ const email = normalizePlatformAdminEmail(input.email);
4591
+ sink.warn(
4592
+ `[authz] user ${input.userId} holds PLATFORM_ADMIN through the legacy unscoped 'admin_full_access' grant row, not through ${PLATFORM_OWNER_EMAIL_ENV}. The grant row is the OLD anchor and is honoured for now; it is removed in a later release. Re-anchor this deployment by declaring its administrators in configuration: ${PLATFORM_OWNER_EMAIL_ENV}=${email || "<the administrator's verified email address>"} (comma-separated for several), and make sure each account's email is VERIFIED \u2014 an unverified account holding a configured address is not an administrator. Reported once per process; further holders are not listed.`
4593
+ );
4594
+ }
4595
+ function resetLegacyPlatformAdminGrantReport() {
4596
+ legacyGrantPointerSaid = false;
4597
+ }
4598
+
4356
4599
  // src/security/posture-ladder.ts
4357
4600
  var POSTURE_LADDER = [
4358
4601
  "PLATFORM_ADMIN",
@@ -4433,8 +4676,9 @@ async function tryFind(ql, object, where, limit = 100, organizationId) {
4433
4676
  let rows = await ql.find(object, { where, limit, context });
4434
4677
  if (rows && rows.value) rows = rows.value;
4435
4678
  return Array.isArray(rows) ? rows : [];
4436
- } catch {
4437
- return [];
4679
+ } catch (err) {
4680
+ if (isMissingTableError(err, object)) return [];
4681
+ throw new AuthzStoreUnavailableError(object, err);
4438
4682
  }
4439
4683
  }
4440
4684
  async function resolveAuthzContext(input) {
@@ -4508,6 +4752,8 @@ async function resolveAuthzContext(input) {
4508
4752
  return ctx;
4509
4753
  }
4510
4754
  async function resolveUserAuthzGrants(ql, userId, opts = {}) {
4755
+ const grantsCache = openUserGrantsCache(ql, userId, opts);
4756
+ if (grantsCache?.hit) return grantsCache.hit;
4511
4757
  const { tenantId } = opts;
4512
4758
  const grants = {
4513
4759
  positions: [],
@@ -4528,7 +4774,8 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
4528
4774
  }
4529
4775
  return userRow;
4530
4776
  };
4531
- const needsUserRow = !grants.email || !grants.permissions.includes("ai_seat");
4777
+ const platformAdminConfig = resolvePlatformAdminEmails();
4778
+ const needsUserRow = !grants.email || !grants.permissions.includes("ai_seat") || platformAdminConfig.emails.length > 0;
4532
4779
  const [, members, userPositionRows, orgMembersLeg, upsRowsAll] = await Promise.all([
4533
4780
  needsUserRow ? getUserRow() : Promise.resolve(void 0),
4534
4781
  tryFind(ql, "sys_member", { user_id: userId }, 200),
@@ -4630,6 +4877,18 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
4630
4877
  }
4631
4878
  if (Object.keys(mergedTabs).length > 0) grants.tabPermissions = mergedTabs;
4632
4879
  }
4880
+ const configConfersPlatformAdmin = platformAdminConfig.emails.length > 0 && matchesConfiguredPlatformAdmin(await getUserRow(), platformAdminConfig);
4881
+ if (configConfersPlatformAdmin) {
4882
+ hasPlatformAdminGrant = true;
4883
+ if (!grants.permissions.includes(ADMIN_FULL_ACCESS)) grants.permissions.push(ADMIN_FULL_ACCESS);
4884
+ for (const p of ADMIN_FULL_ACCESS_CAPABILITIES.systemPermissions ?? []) {
4885
+ if (!grants.systemPermissions.includes(p)) grants.systemPermissions.push(p);
4886
+ }
4887
+ } else if (hasPlatformAdminGrant) {
4888
+ if (postureEnforcesWall2(resolveTenancyPosture())) {
4889
+ reportLegacyPlatformAdminGrant({ userId, email: userRow?.email });
4890
+ }
4891
+ }
4633
4892
  if (hasPlatformAdminGrant && !grants.positions.includes(BUILTIN_IDENTITY_PLATFORM_ADMIN)) {
4634
4893
  grants.positions.unshift(BUILTIN_IDENTITY_PLATFORM_ADMIN);
4635
4894
  }
@@ -4643,8 +4902,21 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
4643
4902
  const aiAccess = (await getUserRow())?.ai_access;
4644
4903
  if (aiAccess === true || aiAccess === 1 || aiAccess === "1") grants.permissions.push("ai_seat");
4645
4904
  }
4905
+ grantsCache?.commit(
4906
+ grants,
4907
+ nextGrantValidityBoundary([...members, ...userPositionRows, ...upsRowsAll], nowMs)
4908
+ );
4646
4909
  return grants;
4647
4910
  }
4911
+ async function hasPlatformAdminStanding(ql, userId, opts = {}) {
4912
+ if (!ql || typeof userId !== "string" || userId.length === 0) return false;
4913
+ try {
4914
+ const grants = await resolveUserAuthzGrants(ql, userId, { nowMs: opts.nowMs });
4915
+ return grants.posture === "PLATFORM_ADMIN";
4916
+ } catch {
4917
+ return false;
4918
+ }
4919
+ }
4648
4920
  function isValidTimeZone(tz) {
4649
4921
  try {
4650
4922
  new Intl.DateTimeFormat("en-US", { timeZone: tz });
@@ -4666,25 +4938,98 @@ function coerceCurrency(value) {
4666
4938
  return /^[A-Z]{3}$/.test(s) ? s : void 0;
4667
4939
  }
4668
4940
  var LOCALIZATION_FAILURE_CACHE_TTL_MS = 3e4;
4669
- var localizationFailureCache = /* @__PURE__ */ new WeakMap();
4941
+ var LOCALIZATION_CACHE_TTL_ENV = "OS_LOCALIZATION_CACHE_TTL_MS";
4942
+ var LOCALIZATION_SUCCESS_CACHE_DEFAULT_TTL_MS = 3e4;
4943
+ function localizationSuccessCacheTtlMs(env = typeof process !== "undefined" ? process.env : {}) {
4944
+ const raw = env[LOCALIZATION_CACHE_TTL_ENV];
4945
+ if (raw === void 0 || raw.trim() === "") return LOCALIZATION_SUCCESS_CACHE_DEFAULT_TTL_MS;
4946
+ const parsed = Number(raw.trim());
4947
+ if (!Number.isFinite(parsed) || parsed < 0) return 0;
4948
+ return Math.floor(parsed);
4949
+ }
4950
+ function readWriteEpoch(ql) {
4951
+ if (!ql || typeof ql !== "object") return void 0;
4952
+ const epoch = ql.writeEpoch;
4953
+ if (!epoch || typeof epoch !== "object") return void 0;
4954
+ const seam = epoch;
4955
+ if (typeof seam.current !== "number" || typeof seam.bump !== "function" || typeof seam.subscribe !== "function") {
4956
+ return void 0;
4957
+ }
4958
+ return seam.current;
4959
+ }
4960
+ var localizationSettingsStates = /* @__PURE__ */ new WeakMap();
4961
+ var localizationNoSettingsState = { gen: 0 };
4962
+ function localizationSettingsState(settings) {
4963
+ if (!settings || typeof settings !== "object") return localizationNoSettingsState;
4964
+ const existing = localizationSettingsStates.get(settings);
4965
+ if (existing) return existing;
4966
+ const state = { gen: 0 };
4967
+ localizationSettingsStates.set(settings, state);
4968
+ const subscribe = settings.subscribe;
4969
+ if (typeof subscribe === "function") {
4970
+ try {
4971
+ subscribe.call(
4972
+ settings,
4973
+ "localization",
4974
+ () => {
4975
+ state.gen += 1;
4976
+ }
4977
+ );
4978
+ } catch {
4979
+ }
4980
+ }
4981
+ return state;
4982
+ }
4983
+ var localizationCache = /* @__PURE__ */ new WeakMap();
4984
+ function localizationEntryIsLive(entry, epoch, settings) {
4985
+ if (entry.kind === "failure") return true;
4986
+ return entry.epoch === epoch && entry.settings === settings && entry.settingsGen === settings.gen;
4987
+ }
4988
+ function putLocalizationEntry(ql, key, entry) {
4989
+ const bucket = localizationCache.get(ql) ?? /* @__PURE__ */ new Map();
4990
+ bucket.set(key, entry);
4991
+ localizationCache.set(ql, bucket);
4992
+ }
4670
4993
  async function resolveLocalizationContext(input) {
4671
- const { ql, tenantId, userId } = input;
4994
+ const { ql, settings, tenantId, userId } = input;
4672
4995
  const cacheKey = `${tenantId ?? ""}|${userId ?? ""}`;
4673
- if (ql && typeof ql === "object") {
4674
- const hit = localizationFailureCache.get(ql)?.get(cacheKey);
4675
- if (hit && hit.expiresAt > Date.now()) return hit.value;
4676
- }
4677
- const { value, failed } = await resolveLocalizationContextUncached(input);
4678
- if (failed && ql && typeof ql === "object") {
4679
- const bucket = localizationFailureCache.get(ql) ?? /* @__PURE__ */ new Map();
4680
- bucket.set(cacheKey, { value, expiresAt: Date.now() + LOCALIZATION_FAILURE_CACHE_TTL_MS });
4681
- localizationFailureCache.set(ql, bucket);
4996
+ const cacheable = Boolean(ql) && typeof ql === "object";
4997
+ const epoch = cacheable ? readWriteEpoch(ql) : void 0;
4998
+ const settingsState = localizationSettingsState(settings);
4999
+ if (cacheable) {
5000
+ const hit = localizationCache.get(ql)?.get(cacheKey);
5001
+ if (hit && hit.expiresAt > Date.now() && localizationEntryIsLive(hit, epoch, settingsState)) {
5002
+ return hit.value;
5003
+ }
5004
+ }
5005
+ const { value, backendFailed } = await resolveLocalizationContextUncached(input);
5006
+ if (!cacheable) return value;
5007
+ if (backendFailed) {
5008
+ putLocalizationEntry(ql, cacheKey, {
5009
+ value,
5010
+ expiresAt: Date.now() + LOCALIZATION_FAILURE_CACHE_TTL_MS,
5011
+ kind: "failure"
5012
+ });
5013
+ return value;
5014
+ }
5015
+ const ttlMs = localizationSuccessCacheTtlMs();
5016
+ if (epoch !== void 0 && ttlMs > 0) {
5017
+ putLocalizationEntry(ql, cacheKey, {
5018
+ value,
5019
+ expiresAt: Date.now() + ttlMs,
5020
+ kind: "success",
5021
+ epoch,
5022
+ settings: settingsState,
5023
+ settingsGen: settingsState.gen
5024
+ });
5025
+ } else {
5026
+ localizationCache.get(ql)?.delete(cacheKey);
4682
5027
  }
4683
5028
  return value;
4684
5029
  }
4685
5030
  async function resolveLocalizationContextUncached(input) {
4686
5031
  const { ql, settings, tenantId, userId } = input;
4687
- let failed = false;
5032
+ let backendFailed = false;
4688
5033
  try {
4689
5034
  if (settings && typeof settings.get === "function") {
4690
5035
  const sctx = { tenantId, userId };
@@ -4698,33 +5043,22 @@ async function resolveLocalizationContextUncached(input) {
4698
5043
  localeRes = many.locale;
4699
5044
  currencyRes = many.currency;
4700
5045
  } catch {
4701
- failed = true;
4702
5046
  }
4703
5047
  } else {
4704
5048
  [tzRes, localeRes, currencyRes] = await Promise.all([
4705
- settings.get("localization", "timezone", sctx).catch(() => {
4706
- failed = true;
4707
- return void 0;
4708
- }),
4709
- settings.get("localization", "locale", sctx).catch(() => {
4710
- failed = true;
4711
- return void 0;
4712
- }),
4713
- settings.get("localization", "currency", sctx).catch(() => {
4714
- failed = true;
4715
- return void 0;
4716
- })
5049
+ settings.get("localization", "timezone", sctx).catch(() => void 0),
5050
+ settings.get("localization", "locale", sctx).catch(() => void 0),
5051
+ settings.get("localization", "currency", sctx).catch(() => void 0)
4717
5052
  ]);
4718
5053
  }
4719
5054
  const tz = coerceTimeZone(tzRes?.value);
4720
5055
  const locale = coerceLocale(localeRes?.value);
4721
5056
  const currency = coerceCurrency(currencyRes?.value);
4722
5057
  if (tz || locale || currency) {
4723
- return { value: { timezone: tz ?? "UTC", locale: locale ?? "en-US", currency }, failed: false };
5058
+ return { value: { timezone: tz ?? "UTC", locale: locale ?? "en-US", currency }, backendFailed: false };
4724
5059
  }
4725
5060
  }
4726
5061
  } catch {
4727
- failed = true;
4728
5062
  }
4729
5063
  let rows = [];
4730
5064
  if (ql && typeof ql.find === "function") {
@@ -4737,7 +5071,7 @@ async function resolveLocalizationContextUncached(input) {
4737
5071
  if (result && result.value) result = result.value;
4738
5072
  rows = Array.isArray(result) ? result : [];
4739
5073
  } catch {
4740
- failed = true;
5074
+ backendFailed = true;
4741
5075
  }
4742
5076
  }
4743
5077
  const valueOf = (k) => rows.find((r) => r.key === k)?.value;
@@ -4747,7 +5081,7 @@ async function resolveLocalizationContextUncached(input) {
4747
5081
  locale: coerceLocale(valueOf("locale")) ?? "en-US",
4748
5082
  currency: coerceCurrency(valueOf("currency"))
4749
5083
  },
4750
- failed
5084
+ backendFailed
4751
5085
  };
4752
5086
  }
4753
5087
 
@@ -4909,7 +5243,7 @@ function shouldDenyAnonymous(input) {
4909
5243
  var ADMIN_STANDING_SURFACE = {
4910
5244
  sys_permission_set: {
4911
5245
  role: "derives",
4912
- 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.",
5246
+ reason: "The row `admin_full_access` is resolved BY NAME from (\xA76b) \u2014 `platform_admin` is the POSITION that row derives, not the row's own name. Renaming it, deleting it or switching it off (ADR-0049 `active`, read here since #8613) un-makes every GRANT-derived platform admin at once, with no identity table touched. \u26A0\uFE0F It does NOT un-make a CONFIG-derived one (\xA76b-config, #11970): that route sets the same standing from `ADMIN_FULL_ACCESS_CAPABILITIES` in `@objectstack/spec` and matches the caller's own stored `sys_user` row, so it touches an identity table and never reads this one. With `OS_PLATFORM_OWNER_EMAIL` unset the first sentence is the whole truth; with it declared, this row stops being the single point that un-makes every administrator.",
4913
5247
  columns: [
4914
5248
  "id",
4915
5249
  "name",
@@ -4951,8 +5285,14 @@ var ADMIN_STANDING_SURFACE = {
4951
5285
  ]
4952
5286
  },
4953
5287
  sys_user: {
4954
- role: "reads-only",
4955
- 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."
5288
+ role: "derives",
5289
+ reason: "[#11663 L2] RECLASSIFIED from `reads-only`. This table used to be read only for the `current_user.email` RLS fallback and the ADR-0024 `ai_seat` synthesis (\xA77), and the note here said so: \"Neither confers administrator standing.\" That sentence is now FALSE. The config anchor (\xA76b-config) matches the row's own `email` against the deployment's declared administrator list and requires `email_verified` to read verified, so a write that changes either column takes platform-admin standing away from a config-derived administrator \u2014 an address change and an email_verified reset are both ordinary, reachable writes, and neither touches a grant table. `banned` stays absent from the column list because the resolver still never reads it; the guard watches the ban/delete WRITE SHAPES on this table for its own reasons, which is a different question from what this resolver consumes.",
5290
+ columns: [
5291
+ "id",
5292
+ "email",
5293
+ "email_verified",
5294
+ "ai_access"
5295
+ ]
4956
5296
  },
4957
5297
  sys_user_position: {
4958
5298
  role: "reads-only",
@@ -4967,6 +5307,13 @@ var ADMIN_STANDING_SURFACE = {
4967
5307
  reason: "Position-bound permission sets (\xA76a). Contributes ids to `psIds` \u2014 and therefore names to `permissions` \u2014 but not to `unscopedUserPsIds`, which is the set \xA76b tests for platform-admin standing."
4968
5308
  }
4969
5309
  };
5310
+ var ADMIN_STANDING_NON_TABLE_INPUTS = [
5311
+ {
5312
+ kind: "env",
5313
+ name: "OS_PLATFORM_OWNER_EMAIL",
5314
+ reason: "The deployment's declared platform administrator(s) \u2014 one address or a comma-separated list, matched case-insensitively against `sys_user.email` and conferring standing only when that row's `email_verified` reads verified (\xA76b-config). Read live on every derivation with a per-process memo keyed on the raw string, so a rolled process picks up a change with no special path. Unset, blank, or carrying any unparseable entry means ZERO config-derived administrators, fail closed. No runtime write reaches it, so no break-glass guard can simulate a change to it: revocation is a configuration change plus a process roll, by design."
5315
+ }
5316
+ ];
4970
5317
  function adminStandingTables() {
4971
5318
  return Object.entries(ADMIN_STANDING_SURFACE).filter(([, t]) => t.role === "derives").map(([name]) => name).sort();
4972
5319
  }
@@ -4998,6 +5345,9 @@ function withoutOperationPrivateKeys(exec) {
4998
5345
  return out;
4999
5346
  }
5000
5347
 
5348
+ // src/security/authz-invalidation-channel.ts
5349
+ var AUTHZ_INVALIDATED_CHANNEL = "authz.invalidated";
5350
+
5001
5351
  // src/utils/datetime.ts
5002
5352
  import { nextUtcCalendarDay, utcInstantMs } from "@objectstack/spec/data";
5003
5353
  function calendarPartsInTz(d, tz) {
@@ -5278,7 +5628,7 @@ function omitInternalFieldsFromWriteResponse(schema, records) {
5278
5628
  }
5279
5629
 
5280
5630
  // src/utils/migration-journal.ts
5281
- import { createHash as createHash2, randomUUID } from "crypto";
5631
+ import { createHash as createHash3, randomUUID } from "crypto";
5282
5632
  import {
5283
5633
  MIGRATION_JOURNAL_OBJECT
5284
5634
  } from "@objectstack/spec/system";
@@ -5335,7 +5685,7 @@ function hashMigrationPlan(plan, chunks) {
5335
5685
  steps: plan.steps.map((s) => s.name),
5336
5686
  chunks: chunks.map((c) => [c.stepIndex, c.offset, c.length])
5337
5687
  });
5338
- return createHash2("sha256").update(shape, "utf8").digest("hex").slice(0, 32);
5688
+ return createHash3("sha256").update(shape, "utf8").digest("hex").slice(0, 32);
5339
5689
  }
5340
5690
  async function appendEvent(engine, event, execContext) {
5341
5691
  await engine.insert(
@@ -5903,6 +6253,110 @@ function isUninterpretableTemporalComparand(kind, value) {
5903
6253
  return !(readsAsWallClock(s) || readsAsInstant(s));
5904
6254
  }
5905
6255
 
6256
+ // src/utils/metadata-activation-store.ts
6257
+ var METADATA_ACTIVATION_TABLE = "sys_metadata_activation";
6258
+ var SYSTEM_CTX2 = { isSystem: true, positions: [], permissions: [] };
6259
+ var InMemoryMetadataActivationStore = class {
6260
+ constructor() {
6261
+ this.rows = /* @__PURE__ */ new Map();
6262
+ }
6263
+ async list() {
6264
+ return [...this.rows.values()];
6265
+ }
6266
+ async setActive(row) {
6267
+ this.rows.set(row.name, { ...row });
6268
+ }
6269
+ };
6270
+ var ObjectStoreMetadataActivationStore = class {
6271
+ constructor(engine, metadataType) {
6272
+ this.engine = engine;
6273
+ this.metadataType = metadataType;
6274
+ }
6275
+ /**
6276
+ * Every row of this type. Read once at boot to hydrate the consumer's
6277
+ * projection.
6278
+ *
6279
+ * The only scoping is the `metadata_type` discriminator: the ledger is
6280
+ * deployment-wide and has no tenant column, so there is no second axis to
6281
+ * filter on (see the module header).
6282
+ */
6283
+ async list() {
6284
+ const rows = await this.engine.find(METADATA_ACTIVATION_TABLE, {
6285
+ where: { metadata_type: this.metadataType },
6286
+ context: SYSTEM_CTX2
6287
+ });
6288
+ if (!Array.isArray(rows)) return [];
6289
+ const out = [];
6290
+ for (const row of rows) {
6291
+ const r = row;
6292
+ if (typeof r.name !== "string" || !r.name) continue;
6293
+ out.push({
6294
+ name: r.name,
6295
+ packageId: typeof r.package_id === "string" ? r.package_id : "",
6296
+ // The column defaults to `true`; only an explicit `false`
6297
+ // disarms. A driver that round-trips booleans as 0/1
6298
+ // (SQLite/libsql) is read through the same `=== false || === 0`
6299
+ // test, so a `0` is not mistaken for `true`.
6300
+ active: !(r.active === false || r.active === 0)
6301
+ });
6302
+ }
6303
+ return out;
6304
+ }
6305
+ /**
6306
+ * Insert or update the row for one packaged artifact.
6307
+ *
6308
+ * Read-then-write rather than a blind upsert because the object's
6309
+ * uniqueness is a DECLARED index (`unique: 'global'` over
6310
+ * `(metadata_type, name)`), not a primary key this store controls: there is
6311
+ * no id to collide on, so an insert-and-catch could not tell "already
6312
+ * there" from a real store failure.
6313
+ *
6314
+ * That index is also why taking the FIRST match is taking the only one: the
6315
+ * read below is keyed on exactly the index's two columns, so it can match
6316
+ * at most one row. It used to pick the first row with a NULL organization
6317
+ * out of the result, back when the table carried a reserved tenant column;
6318
+ * with no such column the set it was choosing from can no longer hold more
6319
+ * than one member.
6320
+ */
6321
+ async setActive(row) {
6322
+ const existing = await this.engine.find(METADATA_ACTIVATION_TABLE, {
6323
+ where: { metadata_type: this.metadataType, name: row.name },
6324
+ context: SYSTEM_CTX2
6325
+ });
6326
+ const current = Array.isArray(existing) ? existing[0] : void 0;
6327
+ if (current && current.id != null) {
6328
+ await this.engine.update(
6329
+ METADATA_ACTIVATION_TABLE,
6330
+ { id: current.id, active: row.active, package_id: row.packageId },
6331
+ { context: SYSTEM_CTX2 }
6332
+ );
6333
+ return;
6334
+ }
6335
+ await this.engine.insert(
6336
+ METADATA_ACTIVATION_TABLE,
6337
+ {
6338
+ metadata_type: this.metadataType,
6339
+ name: row.name,
6340
+ package_id: row.packageId,
6341
+ active: row.active
6342
+ },
6343
+ { context: SYSTEM_CTX2 }
6344
+ );
6345
+ }
6346
+ /**
6347
+ * Read the backing table once so a misconfiguration surfaces at BOOT
6348
+ * rather than as a failed toggle later. Throws the driver error verbatim —
6349
+ * `no such table: sys_metadata_activation` means the object was never
6350
+ * registered (or its schema never synced) in this composition.
6351
+ *
6352
+ * ⚠️ Unscoped by design: the question is "does the TABLE read at all",
6353
+ * which is a property of the composition, not of one `metadata_type`.
6354
+ */
6355
+ async probe() {
6356
+ await this.engine.find(METADATA_ACTIVATION_TABLE, { where: {}, limit: 1, context: SYSTEM_CTX2 });
6357
+ }
6358
+ };
6359
+
5906
6360
  // src/utils/record-not-found.ts
5907
6361
  function recordNotFoundError(object, id) {
5908
6362
  const err = new Error(`Record ${id} not found in ${object}`);
@@ -5913,6 +6367,45 @@ function recordNotFoundError(object, id) {
5913
6367
  }
5914
6368
 
5915
6369
  // src/health-monitor.ts
6370
+ var RECOVERY_IS_THRESHOLD_GATED = {
6371
+ degraded: true,
6372
+ unhealthy: true,
6373
+ failed: true,
6374
+ recovering: true,
6375
+ healthy: false,
6376
+ unknown: false
6377
+ };
6378
+ function healthMonitorRefusal(message) {
6379
+ const err = new Error(message);
6380
+ err.code = "VALIDATION_ERROR";
6381
+ err.status = 400;
6382
+ return err;
6383
+ }
6384
+ var RETIRED_HEALTH_CHECK_KEYS = [
6385
+ [
6386
+ "autoRestart",
6387
+ "'autoRestart' was removed from PluginHealthCheck in @objectstack/spec 18 (#12032, ADR-0049 enforce-or-remove) \u2014 it never restarted a plugin. `attemptRestart` called `plugin.destroy()` and stopped there, then logged 'Plugin restarted' and set status `recovering`, and the periodic checks carried on against the destroyed instance \u2014 which the default check (`{ name: 'plugin-loaded', status: 'passed' }`) passes forever, so a destroyed, never-re-initialised plugin ended up reported `healthy`. Delete the key. This monitor no longer destroys anything: a failing plugin is reported `unhealthy` or `failed` and left alone."
6388
+ ],
6389
+ [
6390
+ "maxRestartAttempts",
6391
+ "'maxRestartAttempts' was removed from PluginHealthCheck in @objectstack/spec 18 (#12032, ADR-0049 enforce-or-remove) \u2014 it capped a restart that never happened, so it only counted `destroy()` calls. Delete the key."
6392
+ ],
6393
+ [
6394
+ "restartBackoff",
6395
+ "'restartBackoff' was removed from PluginHealthCheck in @objectstack/spec 18 (#12032, ADR-0049 enforce-or-remove) \u2014 it delayed a restart that never happened, so it only moved when the `destroy()` landed. Delete the key."
6396
+ ]
6397
+ ];
6398
+ var RESTART_IS_THE_HOSTS_JOB = " Restarting a plugin is the HOST's job in this host-driven library: poll `getHealthStatus(pluginName)` / `getHealthReport(pluginName)` and act on `unhealthy` / `failed` at the level that owns the plugin's lifetime \u2014 recreate the kernel, or let your supervisor restart the process.";
6399
+ function assertNoRetiredKeys(pluginName, config) {
6400
+ for (const [key, guidance] of RETIRED_HEALTH_CHECK_KEYS) {
6401
+ if (!Object.prototype.hasOwnProperty.call(config, key)) {
6402
+ continue;
6403
+ }
6404
+ throw healthMonitorRefusal(
6405
+ `[HealthMonitor] Plugin '${pluginName}': ${guidance}${RESTART_IS_THE_HOSTS_JOB}`
6406
+ );
6407
+ }
6408
+ }
5916
6409
  var PluginHealthMonitor = class {
5917
6410
  constructor(logger) {
5918
6411
  this.healthChecks = /* @__PURE__ */ new Map();
@@ -5921,18 +6414,17 @@ var PluginHealthMonitor = class {
5921
6414
  this.checkIntervals = /* @__PURE__ */ new Map();
5922
6415
  this.failureCounters = /* @__PURE__ */ new Map();
5923
6416
  this.successCounters = /* @__PURE__ */ new Map();
5924
- this.restartAttempts = /* @__PURE__ */ new Map();
5925
6417
  this.logger = logger.child({ component: "HealthMonitor" });
5926
6418
  }
5927
6419
  /**
5928
6420
  * Register a plugin for health monitoring
5929
6421
  */
5930
6422
  registerPlugin(pluginName, config) {
6423
+ assertNoRetiredKeys(pluginName, config);
5931
6424
  this.healthChecks.set(pluginName, config);
5932
6425
  this.healthStatus.set(pluginName, "unknown");
5933
6426
  this.failureCounters.set(pluginName, 0);
5934
6427
  this.successCounters.set(pluginName, 0);
5935
- this.restartAttempts.set(pluginName, 0);
5936
6428
  this.logger.info("Plugin registered for health monitoring", {
5937
6429
  plugin: pluginName,
5938
6430
  interval: config.interval
@@ -5984,6 +6476,7 @@ var PluginHealthMonitor = class {
5984
6476
  let status = "healthy";
5985
6477
  let message;
5986
6478
  const checks = [];
6479
+ let failureRoute;
5987
6480
  try {
5988
6481
  if (config.checkMethod && typeof plugin[config.checkMethod] === "function") {
5989
6482
  const checkResult = await this.raceCheckTimeout(
@@ -6004,8 +6497,8 @@ var PluginHealthMonitor = class {
6004
6497
  if (status === "healthy") {
6005
6498
  this.successCounters.set(pluginName, (this.successCounters.get(pluginName) || 0) + 1);
6006
6499
  this.failureCounters.set(pluginName, 0);
6007
- const currentStatus = this.healthStatus.get(pluginName);
6008
- if (currentStatus === "unhealthy" || currentStatus === "degraded") {
6500
+ const currentStatus = this.healthStatus.get(pluginName) ?? "unknown";
6501
+ if (RECOVERY_IS_THRESHOLD_GATED[currentStatus]) {
6009
6502
  const successCount = this.successCounters.get(pluginName) || 0;
6010
6503
  if (successCount >= config.successThreshold) {
6011
6504
  this.healthStatus.set(pluginName, "healthy");
@@ -6017,27 +6510,11 @@ var PluginHealthMonitor = class {
6017
6510
  this.healthStatus.set(pluginName, "healthy");
6018
6511
  }
6019
6512
  } else {
6020
- this.failureCounters.set(pluginName, (this.failureCounters.get(pluginName) || 0) + 1);
6021
- this.successCounters.set(pluginName, 0);
6022
- const failureCount = this.failureCounters.get(pluginName) || 0;
6023
- if (failureCount >= config.failureThreshold) {
6024
- this.healthStatus.set(pluginName, "unhealthy");
6025
- this.logger.warn("Plugin marked as unhealthy", {
6026
- plugin: pluginName,
6027
- failures: failureCount
6028
- });
6029
- if (config.autoRestart) {
6030
- await this.attemptRestart(pluginName, plugin, config);
6031
- }
6032
- } else {
6033
- this.healthStatus.set(pluginName, "degraded");
6034
- }
6513
+ failureRoute = "returned";
6035
6514
  }
6036
6515
  } catch (error) {
6037
6516
  status = "failed";
6038
6517
  message = error instanceof Error ? error.message : "Unknown error";
6039
- this.failureCounters.set(pluginName, (this.failureCounters.get(pluginName) || 0) + 1);
6040
- this.healthStatus.set(pluginName, "failed");
6041
6518
  checks.push({
6042
6519
  name: "health-check",
6043
6520
  status: "failed",
@@ -6047,6 +6524,10 @@ var PluginHealthMonitor = class {
6047
6524
  plugin: pluginName,
6048
6525
  error
6049
6526
  });
6527
+ failureRoute = "thrown";
6528
+ }
6529
+ if (failureRoute) {
6530
+ this.recordFailedRound(pluginName, config, failureRoute);
6050
6531
  }
6051
6532
  const report = {
6052
6533
  status: this.healthStatus.get(pluginName) || "unknown",
@@ -6060,56 +6541,42 @@ var PluginHealthMonitor = class {
6060
6541
  this.healthReports.set(pluginName, report);
6061
6542
  }
6062
6543
  /**
6063
- * Attempt to restart a plugin
6544
+ * Handle one failed round — the single path BOTH failure routes take.
6545
+ *
6546
+ * `performHealthCheck` can fail two disjoint ways: the check *returns* a
6547
+ * failure (`false` or `{ status: 'unhealthy' }`), or it *throws* — which by
6548
+ * `raceCheckTimeout` includes every `timeout` overrun, the severest case of
6549
+ * the two. The routes used to be handled in separate blocks, and only the
6550
+ * returned one cleared `successCounters`, so the counters a declared
6551
+ * `failureThreshold` / `successThreshold` are counted with depended on which
6552
+ * way the round happened to fail (#11852).
6553
+ *
6554
+ * What stays route-specific is the *status label*, deliberately. A throw is
6555
+ * the separate `failed` status applied immediately with no threshold — that
6556
+ * is the documented contract (`content/docs/protocol/kernel/lifecycle.mdx`,
6557
+ * "Custom Health Checks") and is pinned by the timeout test. Only the
6558
+ * counters are shared, because that is what `failureThreshold` declares, and
6559
+ * it does not name a route.
6560
+ *
6561
+ * This round ENDS here. Nothing is done TO the plugin — see the #12032 note
6562
+ * on the class: a monitor that cannot re-initialise a plugin has no business
6563
+ * destroying one.
6064
6564
  */
6065
- async attemptRestart(pluginName, plugin, config) {
6066
- const attempts = this.restartAttempts.get(pluginName) || 0;
6067
- if (attempts >= config.maxRestartAttempts) {
6068
- this.logger.error("Max restart attempts reached, giving up", {
6069
- plugin: pluginName,
6070
- attempts
6071
- });
6565
+ recordFailedRound(pluginName, config, route) {
6566
+ const failureCount = (this.failureCounters.get(pluginName) || 0) + 1;
6567
+ this.failureCounters.set(pluginName, failureCount);
6568
+ this.successCounters.set(pluginName, 0);
6569
+ const thresholdReached = failureCount >= config.failureThreshold;
6570
+ if (route === "thrown") {
6072
6571
  this.healthStatus.set(pluginName, "failed");
6073
- return;
6074
- }
6075
- this.restartAttempts.set(pluginName, attempts + 1);
6076
- const delay = this.calculateBackoff(attempts, config.restartBackoff);
6077
- this.logger.info("Scheduling plugin restart", {
6078
- plugin: pluginName,
6079
- attempt: attempts + 1,
6080
- delay
6081
- });
6082
- await new Promise((resolve) => setTimeout(resolve, delay));
6083
- try {
6084
- if (plugin.destroy) {
6085
- await plugin.destroy();
6086
- }
6087
- this.logger.info("Plugin restarted", { plugin: pluginName });
6088
- this.failureCounters.set(pluginName, 0);
6089
- this.successCounters.set(pluginName, 0);
6090
- this.healthStatus.set(pluginName, "recovering");
6091
- } catch (error) {
6092
- this.logger.error("Plugin restart failed", {
6572
+ } else if (thresholdReached) {
6573
+ this.healthStatus.set(pluginName, "unhealthy");
6574
+ this.logger.warn("Plugin marked as unhealthy", {
6093
6575
  plugin: pluginName,
6094
- error
6576
+ failures: failureCount
6095
6577
  });
6096
- this.healthStatus.set(pluginName, "failed");
6097
- }
6098
- }
6099
- /**
6100
- * Calculate backoff delay for restarts
6101
- */
6102
- calculateBackoff(attempt, strategy) {
6103
- const baseDelay = 1e3;
6104
- switch (strategy) {
6105
- case "fixed":
6106
- return baseDelay;
6107
- case "linear":
6108
- return baseDelay * (attempt + 1);
6109
- case "exponential":
6110
- return baseDelay * Math.pow(2, attempt);
6111
- default:
6112
- return baseDelay;
6578
+ } else {
6579
+ this.healthStatus.set(pluginName, "degraded");
6113
6580
  }
6114
6581
  }
6115
6582
  /**
@@ -6142,7 +6609,6 @@ var PluginHealthMonitor = class {
6142
6609
  this.healthReports.clear();
6143
6610
  this.failureCounters.clear();
6144
6611
  this.successCounters.clear();
6145
- this.restartAttempts.clear();
6146
6612
  this.logger.info("Health monitor shutdown complete");
6147
6613
  }
6148
6614
  /**
@@ -6183,7 +6649,7 @@ var PluginHealthMonitor = class {
6183
6649
  };
6184
6650
 
6185
6651
  // src/hot-reload.ts
6186
- import { createHash as createHash3 } from "crypto";
6652
+ import { createHash as createHash4 } from "crypto";
6187
6653
  var generateUUID = () => {
6188
6654
  if (typeof crypto !== "undefined" && crypto.randomUUID) {
6189
6655
  return crypto.randomUUID();
@@ -6194,6 +6660,42 @@ var generateUUID = () => {
6194
6660
  return v.toString(16);
6195
6661
  });
6196
6662
  };
6663
+ var HONOURED_STATE_STRATEGIES = ["memory", "none"];
6664
+ var RETIRED_STATE_STRATEGY_GUIDANCE = "'disk' and 'distributed' were removed from HotReloadConfig.stateStrategy in @objectstack/spec 18 (ADR-0049 enforce-or-remove) \u2014 neither was ever implemented. Both wrote to the same in-memory Map as 'memory' and reported it only at debug level, so a host that asked for durable or cluster-replicated state got process-local memory and no error. Use 'memory' for in-process state preservation across a reload, or 'none' to disable it. There is no in-tree replacement for durable or distributed plugin state \u2014 persist it in the host, which owns the process lifetime these strategies pretended to outlive.";
6665
+ function hotReloadRefusal(message) {
6666
+ const err = new Error(message);
6667
+ err.code = "VALIDATION_ERROR";
6668
+ err.status = 400;
6669
+ return err;
6670
+ }
6671
+ function assertHonouredStateStrategy(pluginName, strategy) {
6672
+ if (HONOURED_STATE_STRATEGIES.includes(strategy)) {
6673
+ return;
6674
+ }
6675
+ const shown = typeof strategy === "string" ? `'${strategy}'` : String(strategy);
6676
+ const retired = strategy === "disk" || strategy === "distributed";
6677
+ throw hotReloadRefusal(
6678
+ `[HotReload] Plugin '${pluginName}': unsupported stateStrategy ${shown}. Honoured values are ${HONOURED_STATE_STRATEGIES.map((v) => `'${v}'`).join(" and ")}. ` + (retired ? RETIRED_STATE_STRATEGY_GUIDANCE : "This value has never been implemented by PluginStateManager.")
6679
+ );
6680
+ }
6681
+ var RETIRED_HOT_RELOAD_KEYS = [
6682
+ [
6683
+ "distributedConfig",
6684
+ "'distributedConfig' was removed from HotReloadConfig in @objectstack/spec 18 (ADR-0049 enforce-or-remove) \u2014 nothing ever read it. A provider, endpoints, a key prefix, a TTL and a replication factor could all be declared and no connection was ever opened. It left with the stateStrategy: 'distributed' value it was documented as being required for. Delete the key; there is no in-tree replacement for distributed plugin state \u2014 persist it in the host."
6685
+ ],
6686
+ [
6687
+ "watchPatterns",
6688
+ "'watchPatterns' was removed from HotReloadConfig in @objectstack/spec 18 (ADR-0049 enforce-or-remove) \u2014 nothing ever read it. Its only two uses were log lines: no watcher was ever constructed from it, so an author could declare a glob and no file change ever triggered a reload. File watching is the HOST's job in this host-driven library. Delete the key, declare your globs wherever your own watcher reads them, and call `HotReloadManager.scheduleReload(pluginName, reloadFn)` when one matches \u2014 that is the debounced integration point this class does implement."
6689
+ ]
6690
+ ];
6691
+ function assertNoRetiredKeys2(pluginName, config) {
6692
+ for (const [key, guidance] of RETIRED_HOT_RELOAD_KEYS) {
6693
+ if (!Object.prototype.hasOwnProperty.call(config, key)) {
6694
+ continue;
6695
+ }
6696
+ throw hotReloadRefusal(`[HotReload] Plugin '${pluginName}': ${guidance}`);
6697
+ }
6698
+ }
6197
6699
  var PluginStateManager = class {
6198
6700
  constructor(logger) {
6199
6701
  this.stateSnapshots = /* @__PURE__ */ new Map();
@@ -6220,17 +6722,6 @@ var PluginStateManager = class {
6220
6722
  this.memoryStore.set(snapshotId, snapshot);
6221
6723
  this.logger.debug("State saved to memory", { pluginId, snapshotId });
6222
6724
  break;
6223
- case "disk":
6224
- this.memoryStore.set(snapshotId, snapshot);
6225
- this.logger.debug("State saved to disk (memory fallback)", { pluginId, snapshotId });
6226
- break;
6227
- case "distributed":
6228
- this.memoryStore.set(snapshotId, snapshot);
6229
- this.logger.debug("State saved to distributed store (memory fallback)", {
6230
- pluginId,
6231
- snapshotId
6232
- });
6233
- break;
6234
6725
  case "none":
6235
6726
  this.logger.debug("State persistence disabled", { pluginId });
6236
6727
  break;
@@ -6278,7 +6769,7 @@ var PluginStateManager = class {
6278
6769
  */
6279
6770
  calculateChecksum(state) {
6280
6771
  const stateStr = JSON.stringify(state);
6281
- return createHash3("sha256").update(stateStr).digest("hex");
6772
+ return createHash4("sha256").update(stateStr).digest("hex");
6282
6773
  }
6283
6774
  /**
6284
6775
  * Shutdown state manager
@@ -6292,7 +6783,6 @@ var PluginStateManager = class {
6292
6783
  var HotReloadManager = class {
6293
6784
  constructor(logger) {
6294
6785
  this.reloadConfigs = /* @__PURE__ */ new Map();
6295
- this.watchHandles = /* @__PURE__ */ new Map();
6296
6786
  this.reloadTimers = /* @__PURE__ */ new Map();
6297
6787
  this.logger = logger.child({ component: "HotReload" });
6298
6788
  this.stateManager = new PluginStateManager(logger);
@@ -6301,6 +6791,8 @@ var HotReloadManager = class {
6301
6791
  * Register a plugin for hot reload
6302
6792
  */
6303
6793
  registerPlugin(pluginName, config) {
6794
+ assertHonouredStateStrategy(pluginName, config.stateStrategy);
6795
+ assertNoRetiredKeys2(pluginName, config);
6304
6796
  if (!config.enabled) {
6305
6797
  this.logger.debug("Hot reload disabled for plugin", { plugin: pluginName });
6306
6798
  return;
@@ -6308,32 +6800,45 @@ var HotReloadManager = class {
6308
6800
  this.reloadConfigs.set(pluginName, config);
6309
6801
  this.logger.info("Plugin registered for hot reload", {
6310
6802
  plugin: pluginName,
6311
- watchPatterns: config.watchPatterns,
6312
6803
  stateStrategy: config.stateStrategy
6313
6804
  });
6314
6805
  }
6315
6806
  /**
6316
- * Start watching for changes (requires file system integration)
6807
+ * Refuse the file-watching call this class never implemented (#12428).
6808
+ *
6809
+ * The body used to be a guard plus `logger.info('File watching started')`
6810
+ * over an in-source note saying real watching "would require chokidar or
6811
+ * similar". Nothing was ever watched, so an operator who set
6812
+ * `enabled: true` and read that line at INFO had been told the opposite of
6813
+ * the truth — positive confirmation of a capability that did not exist.
6814
+ * ADR-0049 leaves three states and this surface qualified for none of the
6815
+ * other two: no runtime composes this class, so ENFORCE would build for a
6816
+ * caller that does not exist, and no roadmap entry anywhere claims the
6817
+ * feature, so EXPERIMENTAL would be a promise nobody made.
6818
+ *
6819
+ * Kept as a throwing door rather than deleted: removing the method leaves a
6820
+ * JavaScript host a bare `TypeError: not a function` with no prescription,
6821
+ * and this is the one place a caller of the old placeholder is guaranteed
6822
+ * to arrive. The refusal carries an ADR-0112 envelope so it can be asserted
6823
+ * rather than merely caught.
6317
6824
  */
6318
6825
  startWatching(pluginName) {
6319
- const config = this.reloadConfigs.get(pluginName);
6320
- if (!config || !config.enabled) {
6321
- return;
6322
- }
6323
- this.logger.info("File watching started", {
6324
- plugin: pluginName,
6325
- patterns: config.watchPatterns
6326
- });
6826
+ throw hotReloadRefusal(
6827
+ `[HotReload] Plugin '${pluginName}': startWatching() never watched anything and was removed in @objectstack/core 18 (ADR-0049 enforce-or-remove). It logged 'File watching started' at info level while no watcher was ever constructed, so no file change could ever trigger a reload. File watching is the HOST's job in this host-driven library: run your own watcher and call \`HotReloadManager.scheduleReload(pluginName, reloadFn)\` when a file changes \u2014 that is the debounced integration point this class does implement. \`HotReloadConfig.watchPatterns\` was removed in @objectstack/spec 18 for the same reason; declare your globs where your watcher reads them.`
6828
+ );
6327
6829
  }
6328
6830
  /**
6329
- * Stop watching for changes
6831
+ * Cancel a pending debounced reload for a plugin.
6832
+ *
6833
+ * The name is historical (#12428). This never stopped a watcher, because
6834
+ * nothing in this class ever started one: its `watchHandles` cleanup branch
6835
+ * read a Map that had no writer anywhere in the tree, so the branch was
6836
+ * structurally unreachable rather than merely untaken, and it left with
6837
+ * `startWatching`'s placeholder. What survives is the half that always did
6838
+ * something — the debounce timer armed by `scheduleReload` is cleared, so a
6839
+ * reload that was scheduled but has not fired yet is cancelled.
6330
6840
  */
6331
6841
  stopWatching(pluginName) {
6332
- const handle = this.watchHandles.get(pluginName);
6333
- if (handle) {
6334
- this.watchHandles.delete(pluginName);
6335
- this.logger.info("File watching stopped", { plugin: pluginName });
6336
- }
6337
6842
  const timer = this.reloadTimers.get(pluginName);
6338
6843
  if (timer) {
6339
6844
  clearTimeout(timer);
@@ -6476,14 +6981,10 @@ var HotReloadManager = class {
6476
6981
  * Shutdown hot reload manager
6477
6982
  */
6478
6983
  shutdown() {
6479
- for (const pluginName of this.watchHandles.keys()) {
6480
- this.stopWatching(pluginName);
6481
- }
6482
6984
  for (const timer of this.reloadTimers.values()) {
6483
6985
  clearTimeout(timer);
6484
6986
  }
6485
6987
  this.reloadConfigs.clear();
6486
- this.watchHandles.clear();
6487
6988
  this.reloadTimers.clear();
6488
6989
  this.stateManager.shutdown();
6489
6990
  this.logger.info("Hot reload manager shutdown complete");
@@ -6892,6 +7393,7 @@ var NamespaceResolver = class {
6892
7393
  // src/index.ts
6893
7394
  import { UNMATCHED_ROUTE_PATTERN } from "@objectstack/spec/contracts";
6894
7395
  export {
7396
+ ADMIN_STANDING_NON_TABLE_INPUTS,
6895
7397
  ADMIN_STANDING_SURFACE,
6896
7398
  ANONYMOUS_DENY_BODY,
6897
7399
  ANONYMOUS_DENY_CODE,
@@ -6900,11 +7402,19 @@ export {
6900
7402
  API_KEY_PREFIX,
6901
7403
  AUDIENCE_BINDING_SUGGESTION_STATUSES,
6902
7404
  AUDIENCE_BINDING_SUGGESTION_STATUS_VALUES,
7405
+ AUTHZ_GRANTS_CACHE_TTL_ENV,
7406
+ AUTHZ_INVALIDATED_CHANNEL,
7407
+ AUTHZ_STORE_UNAVAILABLE_CODE,
7408
+ AUTHZ_STORE_UNAVAILABLE_MESSAGE,
7409
+ AUTHZ_STORE_UNAVAILABLE_STATUS,
7410
+ AuthzStoreUnavailableError,
6903
7411
  CORE_FALLBACK_FACTORIES,
6904
7412
  DependencyResolver,
6905
7413
  ENTRY_EXECUTION_CONTEXT_FIELDS,
6906
7414
  HotReloadManager,
7415
+ InMemoryMetadataActivationStore,
6907
7416
  LiteKernel,
7417
+ METADATA_ACTIVATION_TABLE,
6908
7418
  MigrationJournalRefusal,
6909
7419
  MigrationPlanRegistry,
6910
7420
  NamespaceResolver,
@@ -6912,10 +7422,11 @@ export {
6912
7422
  ObjectKernel,
6913
7423
  ObjectKernelBase,
6914
7424
  ObjectLogger,
7425
+ ObjectStoreMetadataActivationStore,
7426
+ PLATFORM_ADMIN_EMAIL_SEPARATOR,
6915
7427
  POSTURE_INJECTION_RULE,
6916
7428
  POSTURE_LADDER,
6917
7429
  POSTURE_RANK,
6918
- PluginConfigValidator,
6919
7430
  PluginHealthMonitor,
6920
7431
  PluginLoader,
6921
7432
  PluginPermissionEnforcer,
@@ -6924,6 +7435,7 @@ export {
6924
7435
  PluginSecurityScanner,
6925
7436
  PluginSignatureVerifier,
6926
7437
  qa_exports as QA,
7438
+ SERVICE_NOT_REGISTERED_CODE,
6927
7439
  SIGNATURE_ALG,
6928
7440
  SecurePluginContext,
6929
7441
  SemanticVersionManager,
@@ -6933,6 +7445,7 @@ export {
6933
7445
  UnresolvedFilterTokenError,
6934
7446
  adminStandingColumns,
6935
7447
  adminStandingTables,
7448
+ artifactPackageId,
6936
7449
  assembleExecutionContext,
6937
7450
  assembleExecutionContextOrGuest,
6938
7451
  assertInitServiceRequirements,
@@ -6951,7 +7464,6 @@ export {
6951
7464
  createMemoryJob,
6952
7465
  createMemoryMetadata,
6953
7466
  createMemoryQueue,
6954
- createPluginConfigValidator,
6955
7467
  createPluginPermissionEnforcer,
6956
7468
  deepMerge,
6957
7469
  defaultIsTransientError,
@@ -6963,48 +7475,67 @@ export {
6963
7475
  extractApiKey,
6964
7476
  filterTokenContextFrom,
6965
7477
  findInterruptedRuns,
7478
+ formatIntegrityViolation,
6966
7479
  generateApiKey,
6967
7480
  generateEd25519KeyPair,
6968
7481
  getEnv,
6969
7482
  getMemoryUsage,
7483
+ hasPlatformAdminStanding,
6970
7484
  hashApiKey,
6971
7485
  hashMigrationPlan,
6972
7486
  isAudienceBindingSuggestionStatus,
6973
7487
  isAuthGateAllowlisted,
7488
+ isAuthzStoreUnavailableError,
7489
+ isConfiguredPlatformAdminEmail,
6974
7490
  isExpired,
6975
7491
  isGrantActive,
6976
7492
  isGrantExpired,
6977
7493
  isNode,
6978
7494
  isRowActive,
7495
+ isServiceNotRegisteredError,
6979
7496
  isUninterpretableTemporalComparand,
7497
+ matchesConfiguredPlatformAdmin,
6980
7498
  nextUtcCalendarDay,
6981
7499
  normalizeAuthGate,
7500
+ normalizePlatformAdminEmail,
6982
7501
  omitInternalFieldsFromWriteResponse,
7502
+ parsePlatformAdminEmails,
6983
7503
  parseScopes,
6984
7504
  parseSignature,
6985
7505
  planChunks,
6986
7506
  postureVisibleRows,
6987
7507
  readAuthoredTranslationLayer,
7508
+ readAuthzGrantsCacheTtlMs,
6988
7509
  readRunJournal,
6989
7510
  recordNotFoundError,
7511
+ reportAuthzCachePosture,
7512
+ reportLegacyPlatformAdminGrant,
7513
+ resetLegacyPlatformAdminGrantReport,
7514
+ resetPlatformAdminEmailMemo,
6990
7515
  resolveApiKeyAdmission,
6991
7516
  resolveApiKeyPrincipal,
7517
+ resolveArtifactPackageOrder,
7518
+ resolveAuthzCachePosture,
6992
7519
  resolveAuthzContext,
6993
7520
  resolveFilterToken,
6994
7521
  resolveFilterTokens,
6995
7522
  resolveLocale,
6996
7523
  resolveLocalizationContext,
7524
+ resolvePlatformAdminEmails,
6997
7525
  resolvePluginOrder,
6998
7526
  resolveUserAuthzGrants,
6999
7527
  resumeMigrationJournal,
7528
+ rethrowAuthzStoreUnavailable,
7000
7529
  runMigrationJournal,
7001
7530
  safeExit,
7531
+ setPlatformAdminConfigSink,
7002
7532
  shouldDenyAnonymous,
7003
7533
  signPayload,
7004
7534
  temporalComparandKind,
7005
7535
  unknownAudienceBindingSuggestionStatusMessage,
7006
7536
  utcInstantMs,
7007
7537
  validateInitServiceContract,
7538
+ verifyIntegrity,
7008
7539
  verifyPayload,
7009
7540
  verifyPlatformSignature,
7010
7541
  verifyPluginArtifact,