@objectstack/core 17.3.0 → 17.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -829,6 +829,29 @@ function isServiceNotRegisteredError(err) {
829
829
  return typeof err === "object" && err !== null && err[SERVICE_NOT_REGISTERED_BRAND] === true;
830
830
  }
831
831
 
832
+ // src/plugin-contract.ts
833
+ import { PluginSchema } from "@objectstack/spec/kernel";
834
+ var PLUGIN_CONTRACT_VIOLATION_CODE = "PLUGIN_CONTRACT_VIOLATION";
835
+ function assertPluginContract(plugin) {
836
+ const result = PluginSchema.safeParse(plugin);
837
+ if (result.success) {
838
+ return;
839
+ }
840
+ const issues = result.error.issues.filter((issue) => issue.path[0] !== "version");
841
+ if (issues.length === 0) {
842
+ return;
843
+ }
844
+ const first = issues[0];
845
+ const at = first.path.length > 0 ? first.path.join(".") : "(root)";
846
+ const id = plugin.id;
847
+ const named = typeof id === "string" && id.length > 0 ? `'${plugin.name}' (id: ${id})` : `'${plugin.name}'`;
848
+ const error = new Error(
849
+ `${PLUGIN_CONTRACT_VIOLATION_CODE}: plugin ${named} is refused by the declared plugin contract at '${at}': ${first.message}`
850
+ );
851
+ error.code = PLUGIN_CONTRACT_VIOLATION_CODE;
852
+ throw error;
853
+ }
854
+
832
855
  // src/plugin-loader.ts
833
856
  var ServiceLifecycle = /* @__PURE__ */ ((ServiceLifecycle2) => {
834
857
  ServiceLifecycle2["SINGLETON"] = "singleton";
@@ -866,6 +889,7 @@ var PluginLoader = class {
866
889
  this.logger.info(`Loading plugin: ${plugin.name}`);
867
890
  const metadata = this.toPluginMetadata(plugin);
868
891
  this.validatePluginStructure(metadata);
892
+ this.validatePluginContract(metadata);
869
893
  const versionCheck = this.checkVersionCompatibility(metadata);
870
894
  if (!versionCheck.compatible) {
871
895
  throw new Error(`Version incompatible: ${versionCheck.message}`);
@@ -1050,6 +1074,27 @@ var PluginLoader = class {
1050
1074
  throw new Error(`Invalid semantic version: ${plugin.version}`);
1051
1075
  }
1052
1076
  }
1077
+ /**
1078
+ * Refuse a plugin object the DECLARED plugin contract refuses (#16049,
1079
+ * maintainer ruling 2026-09-06: "the protocol is the baseline; the runtime
1080
+ * aligns to it").
1081
+ *
1082
+ * The check itself — `PluginSchema.safeParse` for validation only, the
1083
+ * eight keys it reaches, the `version` exclusion and the
1084
+ * `PLUGIN_CONTRACT_VIOLATION` envelope — lives in `plugin-contract.ts`,
1085
+ * because since #16721 it is ONE statement run by BOTH kernels:
1086
+ * `LiteKernel.use()` calls it directly, and `ObjectKernel.use()` reaches
1087
+ * it here, through `loadPlugin`. That module's comment is the authority on
1088
+ * what is refused; this method adds nothing to it and subtracts nothing.
1089
+ *
1090
+ * What stays THIS loader's own, and is deliberately not shared: the
1091
+ * structural checks one call up ({@link validatePluginStructure} —
1092
+ * `name`, `init`, semver) and the version-compatibility check below.
1093
+ * The convergence is on the schema, not on the loader.
1094
+ */
1095
+ validatePluginContract(plugin) {
1096
+ assertPluginContract(plugin);
1097
+ }
1053
1098
  checkVersionCompatibility(plugin) {
1054
1099
  const version = plugin.version;
1055
1100
  if (!this.isValidSemanticVersion(version)) {
@@ -1259,6 +1304,7 @@ function createMemoryI18n() {
1259
1304
  const authored = /* @__PURE__ */ new Map();
1260
1305
  let defaultLocale = "en";
1261
1306
  let supportedLocales;
1307
+ let fallbackLocale;
1262
1308
  function resolveKey(data, key) {
1263
1309
  const parts = key.split(".");
1264
1310
  let current = data;
@@ -1295,7 +1341,11 @@ function createMemoryI18n() {
1295
1341
  _serviceName: "i18n",
1296
1342
  t(key, locale, params) {
1297
1343
  const data = resolveTranslations(locale) ?? mergedLocale(defaultLocale);
1298
- const value = data ? resolveKey(data, key) : void 0;
1344
+ let value = data ? resolveKey(data, key) : void 0;
1345
+ if (value === void 0 && fallbackLocale && fallbackLocale !== locale) {
1346
+ const fallbackData = resolveTranslations(fallbackLocale);
1347
+ value = fallbackData ? resolveKey(fallbackData, key) : void 0;
1348
+ }
1299
1349
  if (value == null) return key;
1300
1350
  if (!params) return value;
1301
1351
  return value.replace(/\{\{(\w+)\}\}/g, (_, name) => String(params[name] ?? `{{${name}}}`));
@@ -1352,6 +1402,21 @@ function createMemoryI18n() {
1352
1402
  },
1353
1403
  setDefaultLocale(locale) {
1354
1404
  defaultLocale = locale;
1405
+ },
1406
+ /**
1407
+ * @see II18nService.setFallbackLocale — [#15694]
1408
+ *
1409
+ * ⛔ There is deliberately NO `getFallbackLocale()` beside this. The two
1410
+ * are different questions: this one is what the provider was TOLD, the
1411
+ * accessor is what the serving layer ASKS it in order to build the
1412
+ * metadata-document translators' fallback chain (#14882). Answering the
1413
+ * second from `defaultLocale` — the only value that was always available
1414
+ * here — would settle the default-locale contract question #14882 leaves
1415
+ * deliberately open, from a degraded provider. Without the accessor those
1416
+ * reads keep the resolvers' own default, which is known and intentional.
1417
+ */
1418
+ setFallbackLocale(locale) {
1419
+ fallbackLocale = locale;
1355
1420
  }
1356
1421
  };
1357
1422
  }
@@ -1677,6 +1742,7 @@ async function raceWithTimeout(operation, timeoutMs, createTimeoutError) {
1677
1742
  }
1678
1743
 
1679
1744
  // src/kernel.ts
1745
+ var DEGRADED_CAPABILITIES_SERVICE = "kernel.degraded-capabilities";
1680
1746
  var ObjectKernel = class {
1681
1747
  constructor(config = {}) {
1682
1748
  this.plugins = /* @__PURE__ */ new Map();
@@ -1684,7 +1750,12 @@ var ObjectKernel = class {
1684
1750
  this.hooks = /* @__PURE__ */ new Map();
1685
1751
  this.state = "idle";
1686
1752
  this.startedPlugins = /* @__PURE__ */ new Set();
1687
- this.pluginStartTimes = /* @__PURE__ */ new Map();
1753
+ /**
1754
+ * Plugin name -> elapsed milliseconds that plugin's `start()` took. These
1755
+ * are DURATIONS, never start instants; the old spelling `pluginStartTimes`
1756
+ * said the opposite of what it held.
1757
+ */
1758
+ this.pluginStartupDurations = /* @__PURE__ */ new Map();
1688
1759
  this.shutdownHandlers = [];
1689
1760
  this.config = {
1690
1761
  defaultStartupTimeout: 3e4,
@@ -1871,9 +1942,40 @@ var ObjectKernel = class {
1871
1942
  }
1872
1943
  if (missingCoreServices.length > 0) {
1873
1944
  this.logger.warn(`System started with degraded capabilities. Missing core services: ${missingCoreServices.join(", ")}`);
1945
+ this.publishDegradedCapabilities(missingCoreServices);
1874
1946
  }
1875
1947
  this.logger.info("System requirement check passed");
1876
1948
  }
1949
+ /**
1950
+ * Publish this boot's degraded-capabilities conclusion on
1951
+ * {@link DEGRADED_CAPABILITIES_SERVICE} — the data half of the warning
1952
+ * `validateSystemRequirements()` just logged (#16630).
1953
+ *
1954
+ * ⛔ Best-effort, and silent on failure BY DESIGN: this is a diagnostic
1955
+ * readout, and a readout must never be able to fail a boot that the kernel
1956
+ * has just decided is good enough to run. The one way `registerService`
1957
+ * can throw here is a name collision, which the guard above already
1958
+ * forecloses; the `catch` is there so that stays true if either ever
1959
+ * changes. (`recordSeedOutcome` in `@objectstack/runtime` states the same
1960
+ * rule for the same reason.)
1961
+ *
1962
+ * The value is FROZEN and holds a COPY. `getService` hands out the stored
1963
+ * reference, so an unfrozen live array would let any reader edit the
1964
+ * kernel's own record of what was missing — and this record exists
1965
+ * precisely so that two packages cannot disagree about it.
1966
+ */
1967
+ publishDegradedCapabilities(missingCoreServices) {
1968
+ try {
1969
+ if (this.services.has(DEGRADED_CAPABILITIES_SERVICE) || this.pluginLoader.hasService(DEGRADED_CAPABILITIES_SERVICE)) {
1970
+ return;
1971
+ }
1972
+ this.registerService(
1973
+ DEGRADED_CAPABILITIES_SERVICE,
1974
+ Object.freeze({ missingCoreServices: Object.freeze([...missingCoreServices]) })
1975
+ );
1976
+ } catch {
1977
+ }
1978
+ }
1877
1979
  /**
1878
1980
  * Bootstrap the kernel with enhanced features
1879
1981
  */
@@ -1986,11 +2088,23 @@ var ObjectKernel = class {
1986
2088
  }
1987
2089
  return results;
1988
2090
  }
2091
+ /**
2092
+ * Per-plugin startup durations: plugin name -> elapsed milliseconds that
2093
+ * plugin's `start()` took. Not start instants -- see
2094
+ * {@link PluginStartupResult.durationMs}.
2095
+ */
2096
+ getPluginStartupDurations() {
2097
+ return new Map(this.pluginStartupDurations);
2098
+ }
1989
2099
  /**
1990
2100
  * Get plugin startup metrics
2101
+ *
2102
+ * @deprecated Renamed to {@link ObjectKernel.getPluginStartupDurations},
2103
+ * which states what the values are. Retained as a delegating alias so
2104
+ * nothing has to change on this release; slated for removal.
1991
2105
  */
1992
2106
  getPluginMetrics() {
1993
- return new Map(this.pluginStartTimes);
2107
+ return this.getPluginStartupDurations();
1994
2108
  }
1995
2109
  /**
1996
2110
  * Whether a plugin with the given name has been registered on this kernel.
@@ -2112,11 +2226,14 @@ var ObjectKernel = class {
2112
2226
  );
2113
2227
  const duration = Date.now() - startTime;
2114
2228
  this.startedPlugins.add(plugin.name);
2115
- this.pluginStartTimes.set(plugin.name, duration);
2229
+ this.pluginStartupDurations.set(plugin.name, duration);
2116
2230
  this.logger.debug(`Plugin started: ${plugin.name} (${duration}ms)`);
2117
2231
  return {
2118
2232
  success: true,
2119
2233
  pluginName: plugin.name,
2234
+ durationMs: duration,
2235
+ // Deprecated alias carrying the same elapsed value; see
2236
+ // PluginStartupResult.startTime.
2120
2237
  startTime: duration
2121
2238
  };
2122
2239
  } catch (error) {
@@ -2126,6 +2243,9 @@ var ObjectKernel = class {
2126
2243
  success: false,
2127
2244
  pluginName: plugin.name,
2128
2245
  error,
2246
+ durationMs: duration,
2247
+ // Deprecated alias carrying the same elapsed value; see
2248
+ // PluginStartupResult.startTime.
2129
2249
  startTime: duration,
2130
2250
  timedOut: isTimeout
2131
2251
  };
@@ -2313,6 +2433,26 @@ var LiteKernel = class extends ObjectKernelBase {
2313
2433
  * Register a plugin
2314
2434
  * @param plugin - Plugin instance
2315
2435
  *
2436
+ * A plugin object the DECLARED plugin contract refuses is refused here,
2437
+ * with `PLUGIN_CONTRACT_VIOLATION` — the same check, the same envelope,
2438
+ * that `ObjectKernel.use()` runs through `PluginLoader` (`plugin-contract.ts`
2439
+ * is the one statement both kernels call; #16721, maintainer ruling
2440
+ * 2026-09-08, option A under #9864's precedent that the kernels converge).
2441
+ *
2442
+ * This method used to write the object straight into the registry, so the
2443
+ * same plugin was accepted by this kernel and refused by `ObjectKernel` —
2444
+ * and `AGENTS.md` names THIS kernel for tests, so a plugin could be green
2445
+ * in vitest and refused at production boot. Measured before converging
2446
+ * (#16721 step 1): of 813 `LiteKernel.use()` calls reachable in this
2447
+ * repository's suites, 807 were accepted by the schema unchanged and the
2448
+ * six refusals came from three test-local fixture objects, none of them
2449
+ * product code.
2450
+ *
2451
+ * Ordering, and why it is pinned: state first (`validateIdle`), then the
2452
+ * contract, then registration — a refused plugin never reaches the
2453
+ * registry, so it can neither be booted nor supersede an earlier
2454
+ * registration under its name.
2455
+ *
2316
2456
  * Duplicate names OVERWRITE, with one `warn` naming both versions — the
2317
2457
  * declared contract in `plugin-registration.ts`, applied identically by
2318
2458
  * `ObjectKernel.use()` (#9864, maintainer ruling 2026-08-19).
@@ -2326,6 +2466,7 @@ var LiteKernel = class extends ObjectKernelBase {
2326
2466
  */
2327
2467
  use(plugin) {
2328
2468
  this.validateIdle();
2469
+ assertPluginContract(plugin);
2329
2470
  registerPluginByName(this.plugins, plugin, this.logger);
2330
2471
  return this;
2331
2472
  }
@@ -3951,228 +4092,6 @@ var _PluginSandboxRuntime = class _PluginSandboxRuntime {
3951
4092
  _PluginSandboxRuntime.MONITORING_INTERVAL_MS = 5e3;
3952
4093
  var PluginSandboxRuntime = _PluginSandboxRuntime;
3953
4094
 
3954
- // src/security/security-scanner.ts
3955
- var PluginSecurityScanner = class {
3956
- constructor(logger, config) {
3957
- // Known vulnerabilities database (CVE cache)
3958
- this.vulnerabilityDb = /* @__PURE__ */ new Map();
3959
- // Scan results cache
3960
- this.scanResults = /* @__PURE__ */ new Map();
3961
- this.passThreshold = 70;
3962
- this.logger = logger.child({ component: "SecurityScanner" });
3963
- if (config?.passThreshold !== void 0) {
3964
- this.passThreshold = config.passThreshold;
3965
- }
3966
- }
3967
- /**
3968
- * Perform a comprehensive security scan on a plugin
3969
- */
3970
- async scan(target) {
3971
- this.logger.info("Starting security scan", {
3972
- pluginId: target.pluginId,
3973
- version: target.version
3974
- });
3975
- const issues = [];
3976
- try {
3977
- const codeIssues = await this.scanCode(target);
3978
- issues.push(...codeIssues);
3979
- const depIssues = await this.scanDependencies(target);
3980
- issues.push(...depIssues);
3981
- const malwareIssues = await this.scanMalware(target);
3982
- issues.push(...malwareIssues);
3983
- const licenseIssues = await this.scanLicenses(target);
3984
- issues.push(...licenseIssues);
3985
- const configIssues = await this.scanConfiguration(target);
3986
- issues.push(...configIssues);
3987
- const score = this.calculateSecurityScore(issues);
3988
- const result = {
3989
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3990
- scanner: { name: "ObjectStack Security Scanner", version: "1.0.0" },
3991
- status: score >= this.passThreshold ? "passed" : "failed",
3992
- vulnerabilities: issues.map((issue) => ({
3993
- id: issue.id,
3994
- severity: issue.severity,
3995
- category: issue.category,
3996
- title: issue.title,
3997
- description: issue.description,
3998
- location: issue.location ? `${issue.location.file}:${issue.location.line}` : void 0,
3999
- remediation: issue.remediation,
4000
- affectedVersions: [],
4001
- exploitAvailable: false,
4002
- patchAvailable: false
4003
- })),
4004
- summary: {
4005
- totalVulnerabilities: issues.length,
4006
- criticalCount: issues.filter((i) => i.severity === "critical").length,
4007
- highCount: issues.filter((i) => i.severity === "high").length,
4008
- mediumCount: issues.filter((i) => i.severity === "medium").length,
4009
- lowCount: issues.filter((i) => i.severity === "low").length,
4010
- infoCount: issues.filter((i) => i.severity === "info").length
4011
- }
4012
- };
4013
- this.scanResults.set(`${target.pluginId}:${target.version}`, result);
4014
- this.logger.info("Security scan complete", {
4015
- pluginId: target.pluginId,
4016
- score,
4017
- status: result.status,
4018
- summary: result.summary
4019
- });
4020
- return result;
4021
- } catch (error) {
4022
- this.logger.error("Security scan failed", {
4023
- pluginId: target.pluginId,
4024
- error
4025
- });
4026
- throw error;
4027
- }
4028
- }
4029
- /**
4030
- * Scan code for vulnerabilities
4031
- */
4032
- async scanCode(target) {
4033
- const issues = [];
4034
- this.logger.debug("Code scan complete", {
4035
- pluginId: target.pluginId,
4036
- issuesFound: issues.length
4037
- });
4038
- return issues;
4039
- }
4040
- /**
4041
- * Scan dependencies for known vulnerabilities
4042
- */
4043
- async scanDependencies(target) {
4044
- const issues = [];
4045
- if (!target.dependencies) {
4046
- return issues;
4047
- }
4048
- for (const [depName, version] of Object.entries(target.dependencies)) {
4049
- const vulnKey = `${depName}@${version}`;
4050
- const vulnerability = this.vulnerabilityDb.get(vulnKey);
4051
- if (vulnerability) {
4052
- issues.push({
4053
- id: `vuln-${vulnerability.cve || depName}`,
4054
- severity: vulnerability.severity,
4055
- category: "vulnerability",
4056
- title: `Vulnerable dependency: ${depName}`,
4057
- description: `${depName}@${version} has known security vulnerabilities`,
4058
- remediation: vulnerability.fixedIn ? `Upgrade to ${vulnerability.fixedIn.join(" or ")}` : "No fix available",
4059
- cve: vulnerability.cve
4060
- });
4061
- }
4062
- }
4063
- this.logger.debug("Dependency scan complete", {
4064
- pluginId: target.pluginId,
4065
- dependencies: Object.keys(target.dependencies).length,
4066
- vulnerabilities: issues.length
4067
- });
4068
- return issues;
4069
- }
4070
- /**
4071
- * Scan for malware patterns
4072
- */
4073
- async scanMalware(target) {
4074
- const issues = [];
4075
- this.logger.debug("Malware scan complete", {
4076
- pluginId: target.pluginId,
4077
- issuesFound: issues.length
4078
- });
4079
- return issues;
4080
- }
4081
- /**
4082
- * Check license compliance
4083
- */
4084
- async scanLicenses(target) {
4085
- const issues = [];
4086
- if (!target.dependencies) {
4087
- return issues;
4088
- }
4089
- this.logger.debug("License scan complete", {
4090
- pluginId: target.pluginId,
4091
- issuesFound: issues.length
4092
- });
4093
- return issues;
4094
- }
4095
- /**
4096
- * Check configuration security
4097
- */
4098
- async scanConfiguration(target) {
4099
- const issues = [];
4100
- this.logger.debug("Configuration scan complete", {
4101
- pluginId: target.pluginId,
4102
- issuesFound: issues.length
4103
- });
4104
- return issues;
4105
- }
4106
- /**
4107
- * Calculate security score based on issues
4108
- */
4109
- calculateSecurityScore(issues) {
4110
- let score = 100;
4111
- for (const issue of issues) {
4112
- switch (issue.severity) {
4113
- case "critical":
4114
- score -= 20;
4115
- break;
4116
- case "high":
4117
- score -= 10;
4118
- break;
4119
- case "medium":
4120
- score -= 5;
4121
- break;
4122
- case "low":
4123
- score -= 2;
4124
- break;
4125
- case "info":
4126
- score -= 0;
4127
- break;
4128
- }
4129
- }
4130
- return Math.max(0, score);
4131
- }
4132
- /**
4133
- * Add a vulnerability to the database
4134
- */
4135
- addVulnerability(packageName, version, vulnerability) {
4136
- const key = `${packageName}@${version}`;
4137
- this.vulnerabilityDb.set(key, vulnerability);
4138
- this.logger.debug("Vulnerability added to database", {
4139
- package: packageName,
4140
- version,
4141
- cve: vulnerability.cve
4142
- });
4143
- }
4144
- /**
4145
- * Get scan result from cache
4146
- */
4147
- getScanResult(pluginId, version) {
4148
- return this.scanResults.get(`${pluginId}:${version}`);
4149
- }
4150
- /**
4151
- * Clear scan results cache
4152
- */
4153
- clearCache() {
4154
- this.scanResults.clear();
4155
- this.logger.debug("Scan results cache cleared");
4156
- }
4157
- /**
4158
- * Update vulnerability database from external source
4159
- */
4160
- async updateVulnerabilityDatabase() {
4161
- this.logger.info("Updating vulnerability database");
4162
- this.logger.info("Vulnerability database updated", {
4163
- entries: this.vulnerabilityDb.size
4164
- });
4165
- }
4166
- /**
4167
- * Shutdown security scanner
4168
- */
4169
- shutdown() {
4170
- this.vulnerabilityDb.clear();
4171
- this.scanResults.clear();
4172
- this.logger.info("Security scanner shutdown complete");
4173
- }
4174
- };
4175
-
4176
4095
  // src/security/api-key.ts
4177
4096
  import { createHash as createHash2, randomBytes } from "crypto";
4178
4097
  import { postureEnforcesWall, postureUsesUnionScope, normalizeTenancyPosture } from "@objectstack/spec/security";
@@ -4259,19 +4178,22 @@ async function resolveApiKeyAdmission(ql, headers, nowMs = Date.now(), tenancyPo
4259
4178
  const userId = row.user_id ?? row.userId;
4260
4179
  if (!userId || typeof userId !== "string") return { outcome: "none" };
4261
4180
  const tenantId = typeof row.active_organization_id === "string" && row.active_organization_id ? row.active_organization_id : void 0;
4181
+ const keyId = typeof row.id === "string" && row.id ? row.id : void 0;
4262
4182
  if (!tenantId && tenancyPosture) {
4263
4183
  const posture = tenancyPosture;
4264
4184
  if (postureEnforcesWall(posture) && !postureUsesUnionScope(posture)) {
4265
4185
  return {
4266
4186
  outcome: "refused",
4267
4187
  reason: "organization_required",
4188
+ keyId,
4189
+ userId,
4268
4190
  message: "This API key carries no organization and cannot be used under the `isolated` tenancy posture, where every organization-scoped read is walled to an active organization. Mint a replacement key \u2014 new keys inherit the minter\u2019s active organization."
4269
4191
  };
4270
4192
  }
4271
4193
  }
4272
4194
  return {
4273
4195
  outcome: "admitted",
4274
- principal: { userId, tenantId, scopes: parseScopes(row.scopes) }
4196
+ principal: { userId, keyId, tenantId, scopes: parseScopes(row.scopes) }
4275
4197
  };
4276
4198
  }
4277
4199
  function readHeader(headers, name) {
@@ -4335,6 +4257,7 @@ import {
4335
4257
  ORGANIZATION_ADMIN_GRANTS
4336
4258
  } from "@objectstack/spec";
4337
4259
  import { postureEnforcesWall as postureEnforcesWall2 } from "@objectstack/spec/security";
4260
+ import { isValueDomainMember } from "@objectstack/spec/shared";
4338
4261
 
4339
4262
  // src/security/grant-validity.ts
4340
4263
  function toEpochMs(value) {
@@ -4669,6 +4592,18 @@ function safeJsonParse2(s, fallback) {
4669
4592
  return fallback;
4670
4593
  }
4671
4594
  }
4595
+ function warnApiKeyRefusal(details) {
4596
+ const { reason, keyId, userId, organizationId } = details;
4597
+ console.warn(
4598
+ `[security] API key refused (${reason}): key=${keyId ?? "<unknown>"} principal=${userId ?? "<unknown>"} organization=${organizationId ?? "<none>"}. The caller received the generic 401 UNAUTHENTICATED \u2014 this reason is server-side only.`
4599
+ );
4600
+ }
4601
+ function warnSessionOrganizationClaimDropped(details) {
4602
+ const { reason, sessionId, userId, organizationId } = details;
4603
+ console.warn(
4604
+ `[security] Session organization claim dropped (${reason}): session=${sessionId ?? "<unknown>"} principal=${userId ?? "<unknown>"} organization=${organizationId ?? "<none>"}. The session stays authenticated with NO active organization \u2014 the wire is unchanged.`
4605
+ );
4606
+ }
4672
4607
  async function tryFind(ql, object, where, limit = 100, organizationId) {
4673
4608
  if (!ql || typeof ql.find !== "function") return [];
4674
4609
  try {
@@ -4692,9 +4627,15 @@ async function resolveAuthzContext(input) {
4692
4627
  };
4693
4628
  let userId;
4694
4629
  let tenantId;
4630
+ let sessionId;
4695
4631
  const admission = await resolveApiKeyAdmission(ql, headers, input.nowMs, input.tenancyPosture);
4696
4632
  if (admission.outcome === "refused") {
4697
- ctx.authRefusal = { reason: admission.reason, message: admission.message };
4633
+ warnApiKeyRefusal({
4634
+ reason: admission.reason,
4635
+ keyId: admission.keyId,
4636
+ userId: admission.userId,
4637
+ organizationId: admission.organizationId
4638
+ });
4698
4639
  return ctx;
4699
4640
  }
4700
4641
  const keyPrincipal = admission.outcome === "admitted" ? admission.principal : void 0;
@@ -4710,6 +4651,8 @@ async function resolveAuthzContext(input) {
4710
4651
  const sessionData = await input.getSession(headers);
4711
4652
  userId = sessionData?.user?.id ?? sessionData?.session?.userId;
4712
4653
  tenantId = tenantId ?? sessionData?.session?.activeOrganizationId;
4654
+ const rawSessionId = sessionData?.session?.id;
4655
+ sessionId = typeof rawSessionId === "string" && rawSessionId ? rawSessionId : void 0;
4713
4656
  ctx.accessToken = sessionData?.session?.token ?? ctx.accessToken;
4714
4657
  if (sessionData?.user?.email) ctx.email = String(sessionData.user.email);
4715
4658
  } catch {
@@ -4719,7 +4662,7 @@ async function resolveAuthzContext(input) {
4719
4662
  ctx.userId = userId;
4720
4663
  if (tenantId) ctx.tenantId = tenantId;
4721
4664
  if (!ql || typeof ql.find !== "function") return ctx;
4722
- const grants = await resolveUserAuthzGrants(ql, userId, {
4665
+ let grants = await resolveUserAuthzGrants(ql, userId, {
4723
4666
  tenantId,
4724
4667
  nowMs: input.nowMs,
4725
4668
  seedPermissions: ctx.permissions,
@@ -4728,19 +4671,37 @@ async function resolveAuthzContext(input) {
4728
4671
  if (keyPrincipal?.tenantId && input.tenancyPosture) {
4729
4672
  const posture = input.tenancyPosture;
4730
4673
  if (postureEnforcesWall2(posture) && !grants.accessible_org_ids.includes(keyPrincipal.tenantId)) {
4674
+ warnApiKeyRefusal({
4675
+ reason: "organization_membership_ended",
4676
+ keyId: keyPrincipal.keyId,
4677
+ userId: keyPrincipal.userId,
4678
+ organizationId: keyPrincipal.tenantId
4679
+ });
4731
4680
  return {
4732
4681
  positions: [],
4733
4682
  permissions: [],
4734
4683
  systemPermissions: [],
4735
4684
  org_user_ids: [],
4736
- accessible_org_ids: [],
4737
- authRefusal: {
4738
- reason: "organization_membership_ended",
4739
- message: "This API key authenticates into an organization its owner is no longer a member of. The key was not revoked \u2014 the membership that backed it ended."
4740
- }
4685
+ accessible_org_ids: []
4741
4686
  };
4742
4687
  }
4743
4688
  }
4689
+ if (!keyPrincipal && tenantId && input.tenancyPosture && postureEnforcesWall2(input.tenancyPosture) && !grants.accessible_org_ids.includes(tenantId)) {
4690
+ warnSessionOrganizationClaimDropped({
4691
+ reason: "organization_membership_ended",
4692
+ sessionId,
4693
+ userId,
4694
+ organizationId: tenantId
4695
+ });
4696
+ tenantId = void 0;
4697
+ delete ctx.tenantId;
4698
+ grants = await resolveUserAuthzGrants(ql, userId, {
4699
+ tenantId,
4700
+ nowMs: input.nowMs,
4701
+ seedPermissions: ctx.permissions,
4702
+ seedEmail: ctx.email
4703
+ });
4704
+ }
4744
4705
  ctx.positions = grants.positions;
4745
4706
  ctx.permissions = grants.permissions;
4746
4707
  ctx.systemPermissions = grants.systemPermissions;
@@ -4917,17 +4878,9 @@ async function hasPlatformAdminStanding(ql, userId, opts = {}) {
4917
4878
  return false;
4918
4879
  }
4919
4880
  }
4920
- function isValidTimeZone(tz) {
4921
- try {
4922
- new Intl.DateTimeFormat("en-US", { timeZone: tz });
4923
- return true;
4924
- } catch {
4925
- return false;
4926
- }
4927
- }
4928
4881
  function coerceTimeZone(value) {
4929
4882
  const s = typeof value === "string" ? value.trim() : value != null ? String(value).trim() : "";
4930
- return s && isValidTimeZone(s) ? s : void 0;
4883
+ return s && isValueDomainMember("iana_time_zone", s) ? s : void 0;
4931
4884
  }
4932
4885
  function coerceLocale(value) {
4933
4886
  const s = typeof value === "string" ? value.trim() : value != null ? String(value).trim() : "";
@@ -6016,6 +5969,52 @@ function errText(err) {
6016
5969
  }
6017
5970
  }
6018
5971
 
5972
+ // src/utils/advisory-aggregation.ts
5973
+ import { AsyncLocalStorage } from "async_hooks";
5974
+ var ADVISORY_SAMPLE_ROWS = 5;
5975
+ var storage = new AsyncLocalStorage();
5976
+ function keyOf(object, rule) {
5977
+ return JSON.stringify([object, rule]);
5978
+ }
5979
+ function recordAdvisoryHit(hit) {
5980
+ const collector = storage.getStore();
5981
+ if (!collector) return false;
5982
+ const key = keyOf(hit.object, hit.rule);
5983
+ const existing = collector.groups.get(key);
5984
+ if (existing) {
5985
+ existing.rows += 1;
5986
+ if (hit.recordRef != null && existing.sampleRows.length < ADVISORY_SAMPLE_ROWS) {
5987
+ existing.sampleRows.push(hit.recordRef);
5988
+ }
5989
+ return true;
5990
+ }
5991
+ collector.groups.set(key, {
5992
+ object: hit.object,
5993
+ rule: hit.rule,
5994
+ severity: hit.severity,
5995
+ message: hit.message,
5996
+ rows: 1,
5997
+ sampleRows: hit.recordRef != null ? [hit.recordRef] : []
5998
+ });
5999
+ return true;
6000
+ }
6001
+ function isAggregatingAdvisories() {
6002
+ return storage.getStore() !== void 0;
6003
+ }
6004
+ async function runWithAdvisoryAggregation(fn, report) {
6005
+ const collector = { groups: /* @__PURE__ */ new Map() };
6006
+ try {
6007
+ return await storage.run(collector, fn);
6008
+ } finally {
6009
+ if (collector.groups.size > 0) {
6010
+ try {
6011
+ report([...collector.groups.values()]);
6012
+ } catch {
6013
+ }
6014
+ }
6015
+ }
6016
+ }
6017
+
6019
6018
  // src/utils/filter-tokens.ts
6020
6019
  import {
6021
6020
  classifyFilterToken,
@@ -6534,7 +6533,7 @@ var PluginHealthMonitor = class {
6534
6533
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
6535
6534
  message,
6536
6535
  metrics: {
6537
- uptime: Date.now() - startTime
6536
+ uptimeMs: Date.now() - startTime
6538
6537
  },
6539
6538
  checks: checks.length > 0 ? checks : void 0
6540
6539
  };
@@ -7395,6 +7394,7 @@ import { UNMATCHED_ROUTE_PATTERN } from "@objectstack/spec/contracts";
7395
7394
  export {
7396
7395
  ADMIN_STANDING_NON_TABLE_INPUTS,
7397
7396
  ADMIN_STANDING_SURFACE,
7397
+ ADVISORY_SAMPLE_ROWS,
7398
7398
  ANONYMOUS_DENY_BODY,
7399
7399
  ANONYMOUS_DENY_CODE,
7400
7400
  ANONYMOUS_DENY_MESSAGE,
@@ -7432,7 +7432,6 @@ export {
7432
7432
  PluginPermissionEnforcer,
7433
7433
  PluginPermissionManager,
7434
7434
  PluginSandboxRuntime,
7435
- PluginSecurityScanner,
7436
7435
  PluginSignatureVerifier,
7437
7436
  qa_exports as QA,
7438
7437
  SERVICE_NOT_REGISTERED_CODE,
@@ -7483,6 +7482,7 @@ export {
7483
7482
  hasPlatformAdminStanding,
7484
7483
  hashApiKey,
7485
7484
  hashMigrationPlan,
7485
+ isAggregatingAdvisories,
7486
7486
  isAudienceBindingSuggestionStatus,
7487
7487
  isAuthGateAllowlisted,
7488
7488
  isAuthzStoreUnavailableError,
@@ -7507,6 +7507,7 @@ export {
7507
7507
  readAuthoredTranslationLayer,
7508
7508
  readAuthzGrantsCacheTtlMs,
7509
7509
  readRunJournal,
7510
+ recordAdvisoryHit,
7510
7511
  recordNotFoundError,
7511
7512
  reportAuthzCachePosture,
7512
7513
  reportLegacyPlatformAdminGrant,
@@ -7527,6 +7528,7 @@ export {
7527
7528
  resumeMigrationJournal,
7528
7529
  rethrowAuthzStoreUnavailable,
7529
7530
  runMigrationJournal,
7531
+ runWithAdvisoryAggregation,
7530
7532
  safeExit,
7531
7533
  setPlatformAdminConfigSink,
7532
7534
  shouldDenyAnonymous,