@objectstack/core 17.2.0 → 17.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1450 -0
- package/dist/index.cjs +862 -302
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1195 -115
- package/dist/index.d.ts +1195 -115
- package/dist/index.js +822 -291
- package/dist/index.js.map +1 -1
- package/package.json +8 -4
package/dist/index.d.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { Logger, LifecycleEventName, IServiceRegistry, AudienceBindingSuggestionFilter, IObjectQLEngine } from '@objectstack/spec/contracts';
|
|
2
2
|
export { EngineSchemaRegistryView, EngineTransactionInfo, EngineTransactionOptions, HttpResponseObservation, HttpResponseObserver, IDataDriver, IDataEngine, IHttpRequest, IHttpResponse, IHttpServer, IObjectQLEngine, Logger, Middleware, RouteHandler, UNMATCHED_ROUTE_PATTERN } from '@objectstack/spec/contracts';
|
|
3
|
-
import { z } from 'zod';
|
|
4
3
|
import { LoggerConfig, MigrationOnCrashPolicy, MigrationJournalEvent } from '@objectstack/spec/system';
|
|
4
|
+
import { CORE_PLUGIN_TYPES, PluginCapability, PluginPermissions as PluginPermissions$1, PluginPermissionSet, ResourceType, PermissionAction, PluginPermission, SandboxConfig, KernelSecurityScanResult, KernelSecurityVulnerability, ExecutionContext, PluginHealthCheckParsed, PluginHealthStatus as PluginHealthStatus$1, PluginHealthReport, HotReloadConfigParsed, VersionConstraint, DependencyConflict, SemanticVersion, CompatibilityLevel } from '@objectstack/spec/kernel';
|
|
5
5
|
import { ObjectLogger } from './logger.js';
|
|
6
6
|
export { createLogger } from './logger.js';
|
|
7
7
|
import * as QA from '@objectstack/spec/qa';
|
|
8
8
|
import { KeyObject } from 'node:crypto';
|
|
9
|
-
import { PluginCapability, PluginPermissions as PluginPermissions$1, PluginPermissionSet, ResourceType, PermissionAction, PluginPermission, SandboxConfig, KernelSecurityScanResult, KernelSecurityVulnerability, ExecutionContext, PluginHealthCheckParsed, PluginHealthStatus as PluginHealthStatus$1, PluginHealthReport, HotReloadConfigParsed, VersionConstraint, DependencyConflict, SemanticVersion, CompatibilityLevel } from '@objectstack/spec/kernel';
|
|
10
9
|
import { TenancyPosture, AuthzPosture } from '@objectstack/spec/security';
|
|
11
10
|
export { nextUtcCalendarDay, utcInstantMs } from '@objectstack/spec/data';
|
|
12
11
|
|
|
@@ -42,16 +41,12 @@ interface ServiceRegistration {
|
|
|
42
41
|
interface PluginMetadata extends Plugin {
|
|
43
42
|
/** Semantic version (e.g., "1.0.0") */
|
|
44
43
|
version: string;
|
|
45
|
-
/** Configuration schema for validation */
|
|
46
|
-
configSchema?: z.ZodSchema;
|
|
47
44
|
/** Plugin signature for security verification */
|
|
48
45
|
signature?: string;
|
|
49
46
|
/** Plugin health check function */
|
|
50
47
|
healthCheck?(): Promise<PluginHealthStatus>;
|
|
51
48
|
/** Startup timeout in milliseconds (default: 30000) */
|
|
52
49
|
startupTimeout?: number;
|
|
53
|
-
/** Whether plugin supports hot reload */
|
|
54
|
-
hotReloadable?: boolean;
|
|
55
50
|
}
|
|
56
51
|
/**
|
|
57
52
|
* Plugin Health Status
|
|
@@ -97,7 +92,6 @@ interface VersionCompatibility {
|
|
|
97
92
|
declare class PluginLoader {
|
|
98
93
|
private logger;
|
|
99
94
|
private context?;
|
|
100
|
-
private configValidator;
|
|
101
95
|
private loadedPlugins;
|
|
102
96
|
private serviceFactories;
|
|
103
97
|
private serviceInstances;
|
|
@@ -160,7 +154,6 @@ declare class PluginLoader {
|
|
|
160
154
|
private validatePluginStructure;
|
|
161
155
|
private checkVersionCompatibility;
|
|
162
156
|
private isValidSemanticVersion;
|
|
163
|
-
private validatePluginConfig;
|
|
164
157
|
private verifyPluginSignature;
|
|
165
158
|
private getSingletonService;
|
|
166
159
|
private createTransientService;
|
|
@@ -466,6 +459,17 @@ interface PluginContext {
|
|
|
466
459
|
*/
|
|
467
460
|
getKernel(): ObjectKernel;
|
|
468
461
|
}
|
|
462
|
+
/**
|
|
463
|
+
* The closed set of plugin types (#13925): `'standard'` plus the seven
|
|
464
|
+
* `CORE_PLUGIN_TYPES` members, in exactly the shape `PluginSchema.type`
|
|
465
|
+
* declares in `@objectstack/spec` (`kernel/plugin.zod.ts`:
|
|
466
|
+
* `z.enum(['standard', ...CORE_PLUGIN_TYPES])`). Derived from the spec's own
|
|
467
|
+
* constant rather than re-spelled here, so the compiler's accept set and the
|
|
468
|
+
* Zod gate's cannot drift apart: `plugin-type-closed-set.test.ts` pins the
|
|
469
|
+
* parity at runtime, and `packages/rest`'s `plugin-type-closed-set.pin.test.ts`
|
|
470
|
+
* pins the published `.d.ts` at compile time.
|
|
471
|
+
*/
|
|
472
|
+
type PluginType = 'standard' | (typeof CORE_PLUGIN_TYPES)[number];
|
|
469
473
|
/**
|
|
470
474
|
* Plugin Interface
|
|
471
475
|
*
|
|
@@ -481,10 +485,13 @@ interface Plugin {
|
|
|
481
485
|
*/
|
|
482
486
|
version?: string;
|
|
483
487
|
/**
|
|
484
|
-
* Plugin type
|
|
488
|
+
* Plugin type categorisation for runtime behaviour — a {@link PluginType},
|
|
489
|
+
* the closed set the spec declares. The enumeration lives on that type
|
|
490
|
+
* (derived from `CORE_PLUGIN_TYPES`), not in this comment: a value outside
|
|
491
|
+
* it no longer type-checks, and `PluginSchema.type` refuses it at parse.
|
|
485
492
|
* @default 'standard'
|
|
486
493
|
*/
|
|
487
|
-
type?:
|
|
494
|
+
type?: PluginType;
|
|
488
495
|
/**
|
|
489
496
|
* List of other plugin names that this plugin depends on.
|
|
490
497
|
* The kernel ensures these plugins are initialized before this one.
|
|
@@ -799,6 +806,41 @@ declare function describeInitOrderFault(currentlyInitializing: string | undefine
|
|
|
799
806
|
*/
|
|
800
807
|
declare function assertInitServiceRequirements(plugin: OrderablePlugin, isServiceRegistered: (name: string) => boolean): void;
|
|
801
808
|
|
|
809
|
+
/**
|
|
810
|
+
* Refusals raised by {@link resolveArtifactPackageOrder}, as ADR-0112 envelopes
|
|
811
|
+
* (`code` + `status`) — the shape this repository's rejection tests assert
|
|
812
|
+
* against, never a bare throw.
|
|
813
|
+
*/
|
|
814
|
+
type ArtifactPackageError = Error & {
|
|
815
|
+
code: string;
|
|
816
|
+
status: number;
|
|
817
|
+
};
|
|
818
|
+
/**
|
|
819
|
+
* The id one artifact package is keyed by.
|
|
820
|
+
*
|
|
821
|
+
* `||`, not `??`, on purpose: `ObjectQL.registerApp` keys the installed package
|
|
822
|
+
* on `manifest.id || manifest.name`, so an empty-string `id` falls back to
|
|
823
|
+
* `name` there. Every seam that has to name a package — this module's ordering
|
|
824
|
+
* map, and the install gate's co-ownership set (ADR-0130 D1) — reads the id
|
|
825
|
+
* through THIS function, so none of them can order or admit a package under a
|
|
826
|
+
* key the registry never stores it by.
|
|
827
|
+
*
|
|
828
|
+
* @returns The package id, or `undefined` when the manifest carries neither a
|
|
829
|
+
* usable `id` nor a usable `name`.
|
|
830
|
+
*/
|
|
831
|
+
declare function artifactPackageId(manifest: unknown): string | undefined;
|
|
832
|
+
/**
|
|
833
|
+
* Resolve an artifact into the manifests to register, in dependency-topological
|
|
834
|
+
* order (ADR-0130 D4 + D5).
|
|
835
|
+
*
|
|
836
|
+
* @param artifact - A release artifact (`{ packages: [...] }`), or a bare
|
|
837
|
+
* manifest / single-`manifest` artifact — both shapes are read.
|
|
838
|
+
* @returns The manifest bodies to register, in the order to register them.
|
|
839
|
+
* @throws An ADR-0112 envelope (`code` + `status: 422`) for a malformed entry or
|
|
840
|
+
* a duplicate package id, and `resolvePluginOrder`'s own error for a cycle.
|
|
841
|
+
*/
|
|
842
|
+
declare function resolveArtifactPackageOrder(artifact: unknown): unknown[];
|
|
843
|
+
|
|
802
844
|
/**
|
|
803
845
|
* ObjectKernel - MiniKernel Architecture
|
|
804
846
|
*
|
|
@@ -862,6 +904,100 @@ declare class LiteKernel extends ObjectKernelBase {
|
|
|
862
904
|
isRunning(): boolean;
|
|
863
905
|
}
|
|
864
906
|
|
|
907
|
+
/**
|
|
908
|
+
* [#13905] The discriminator that tells **"nothing ever registered this
|
|
909
|
+
* service"** apart from **"the service IS registered and could not be built"**
|
|
910
|
+
* on the ASYNCHRONOUS resolution path.
|
|
911
|
+
*
|
|
912
|
+
* ## The fault
|
|
913
|
+
*
|
|
914
|
+
* `PluginLoader.getService` (reached through `Kernel.getServiceAsync`) answered
|
|
915
|
+
* both facts with the same bare `Error`. A caller that holds only the rejection
|
|
916
|
+
* therefore could not tell an UNWIRED embedder from a BROKEN one, and the only
|
|
917
|
+
* thing separating them was message text.
|
|
918
|
+
*
|
|
919
|
+
* That mattered one layer out. `RestServer.computeExecCtx`'s kernel branch
|
|
920
|
+
* absorbs a failed `getServiceAsync('objectql')` and degrades to "no engine is
|
|
921
|
+
* wired". It must keep doing so — a kernel with no data plane is a SUPPORTED
|
|
922
|
+
* configuration (`rest-api-plugin.ts` declares
|
|
923
|
+
* `optionalDependencies: ['com.objectstack.engine.objectql']`) — but a
|
|
924
|
+
* multi-tenant host whose engine FAILED TO CONSTRUCT reached that same resolver
|
|
925
|
+
* as "no engine is wired", degrading silently where it should have refused
|
|
926
|
+
* loudly. The branch could not be repaired from the outside, because the fact
|
|
927
|
+
* it needed had been collapsed before it arrived.
|
|
928
|
+
*
|
|
929
|
+
* ## Why a brand, and ⛔ not message text
|
|
930
|
+
*
|
|
931
|
+
* The SYNCHRONOUS accessor in `kernel.ts` already draws exactly this line, and
|
|
932
|
+
* the comment there records what happened the last time someone read the fact
|
|
933
|
+
* off the wrong surface: reading "not found" off the async path "reported every
|
|
934
|
+
* missing service as `is async - use await` — the wrong fix, pointing at the
|
|
935
|
+
* wrong layer". A second text classifier on a resolution path is the failure
|
|
936
|
+
* mode this module removes, ⛔ not a repair of it.
|
|
937
|
+
*
|
|
938
|
+
* The sync side decides from the REGISTRY — synchronous and authoritative — and
|
|
939
|
+
* raises two different messages. The async side now carries that same
|
|
940
|
+
* distinction as a branded, `code`-bearing rejection: one fact, spelled for a
|
|
941
|
+
* caller that only ever sees the rejection.
|
|
942
|
+
*
|
|
943
|
+
* ## The test is CLOSED, and its default is LOUD
|
|
944
|
+
*
|
|
945
|
+
* Exactly one throw in `PluginLoader.getService` means "never registered", and
|
|
946
|
+
* it is the one branded here. Every other way that method can reject — a
|
|
947
|
+
* factory that threw, a missing scope id, an unset loader context, a circular
|
|
948
|
+
* service dependency — is a service that IS registered and could not be
|
|
949
|
+
* produced, and stays unbranded. So `false` is the safe answer: a consumer that
|
|
950
|
+
* absorbs only the branded rejection stays loud about everything else,
|
|
951
|
+
* including rejections added to that method later.
|
|
952
|
+
*
|
|
953
|
+
* ## Two deliberate omissions
|
|
954
|
+
*
|
|
955
|
+
* - **No `status`.** An ADR-0112 envelope pairs `code` with a `status`, but
|
|
956
|
+
* the whole point of this discriminator is that the CONSUMER decides what an
|
|
957
|
+
* unwired service means — absorb and degrade (the supported no-data-plane
|
|
958
|
+
* kernel) or refuse. Carrying an HTTP status here would presuppose that
|
|
959
|
+
* decision at the layer that must not make it.
|
|
960
|
+
* - **No `name` override.** The rejection stays `name: 'Error'` with a
|
|
961
|
+
* byte-identical message, so `String(err)`, logs and existing assertions
|
|
962
|
+
* render exactly as before. The only observable change is two added
|
|
963
|
+
* own-properties.
|
|
964
|
+
*
|
|
965
|
+
* Brand shape follows `AuthzStoreUnavailableError` (2026-08-30): a string-keyed
|
|
966
|
+
* own property rather than `instanceof`, so the predicate still answers
|
|
967
|
+
* correctly when two copies of `@objectstack/core` are installed (a duplicated
|
|
968
|
+
* module makes `instanceof` say "no" to an error it built itself).
|
|
969
|
+
*
|
|
970
|
+
* ⚠️ The brand does NOT survive `structuredClone`, and no claim here depends on
|
|
971
|
+
* it doing so — measured on Node 22: cloning an `Error` keeps `name`, `message`,
|
|
972
|
+
* `stack` and `cause` and DROPS every other own property, brand and `code`
|
|
973
|
+
* alike. This discriminator is for an in-process rejection travelling from
|
|
974
|
+
* `PluginLoader.getService` to a seam that catches it, which is the only path
|
|
975
|
+
* it is used on.
|
|
976
|
+
*/
|
|
977
|
+
/**
|
|
978
|
+
* The code carried by the "never registered" rejection.
|
|
979
|
+
*
|
|
980
|
+
* ⚠️ Spelled the ADR-0112 way, but deliberately NOT wire vocabulary: this value
|
|
981
|
+
* is read in-process by the seam that catches the rejection and is never
|
|
982
|
+
* serialized into an `error.code` envelope. `dispatcher-error-vocabulary.ts`
|
|
983
|
+
* classifies it `door: 'none'` / `boot-refusal` for exactly that reason — the
|
|
984
|
+
* same class as the migration-journal runner refusals. If a transport ever
|
|
985
|
+
* needs to ANSWER with this fact, that is a registration question for #8846's
|
|
986
|
+
* ledger, ⛔ not something to start doing at a door.
|
|
987
|
+
*/
|
|
988
|
+
declare const SERVICE_NOT_REGISTERED_CODE = "SERVICE_NOT_REGISTERED";
|
|
989
|
+
/**
|
|
990
|
+
* True when `err` is the rejection meaning **nothing was ever registered under
|
|
991
|
+
* that service name** — never when a registered service failed to construct.
|
|
992
|
+
*
|
|
993
|
+
* The predicate a seam uses to keep absorbing the supported "no data plane"
|
|
994
|
+
* composition while staying loud about a service that IS wired and broke.
|
|
995
|
+
*/
|
|
996
|
+
declare function isServiceNotRegisteredError(err: unknown): err is Error & {
|
|
997
|
+
readonly code: typeof SERVICE_NOT_REGISTERED_CODE;
|
|
998
|
+
readonly serviceName: string;
|
|
999
|
+
};
|
|
1000
|
+
|
|
865
1001
|
/**
|
|
866
1002
|
* Interface for executing test actions against a target system.
|
|
867
1003
|
* The target could be a local Kernel instance or a remote API.
|
|
@@ -1193,82 +1329,45 @@ declare function verifyPluginArtifact(input: {
|
|
|
1193
1329
|
requirePlatform?: boolean;
|
|
1194
1330
|
}): Promise<PluginArtifactVerifyResult>;
|
|
1195
1331
|
|
|
1196
|
-
/**
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
/**
|
|
1221
|
-
* Validate plugin configuration against its Zod schema
|
|
1222
|
-
*
|
|
1223
|
-
* @param plugin - Plugin metadata with configSchema
|
|
1224
|
-
* @param config - User-provided configuration
|
|
1225
|
-
* @returns Validated and typed configuration
|
|
1226
|
-
* @throws Error with detailed validation errors
|
|
1227
|
-
*/
|
|
1228
|
-
validatePluginConfig<T = any>(plugin: PluginMetadata, config: any): T;
|
|
1229
|
-
/**
|
|
1230
|
-
* Validate partial configuration (for incremental updates)
|
|
1231
|
-
*
|
|
1232
|
-
* @param plugin - Plugin metadata
|
|
1233
|
-
* @param partialConfig - Partial configuration to validate
|
|
1234
|
-
* @returns Validated partial configuration
|
|
1235
|
-
*/
|
|
1236
|
-
validatePartialConfig<T = any>(plugin: PluginMetadata, partialConfig: any): Partial<T>;
|
|
1237
|
-
/**
|
|
1238
|
-
* Get default configuration from schema
|
|
1239
|
-
*
|
|
1240
|
-
* @param plugin - Plugin metadata
|
|
1241
|
-
* @returns Default configuration object
|
|
1242
|
-
*/
|
|
1243
|
-
getDefaultConfig<T = any>(plugin: PluginMetadata): T | undefined;
|
|
1244
|
-
/**
|
|
1245
|
-
* Check if configuration is valid without throwing
|
|
1246
|
-
*
|
|
1247
|
-
* @param plugin - Plugin metadata
|
|
1248
|
-
* @param config - Configuration to check
|
|
1249
|
-
* @returns True if valid, false otherwise
|
|
1250
|
-
*/
|
|
1251
|
-
isConfigValid(plugin: PluginMetadata, config: any): boolean;
|
|
1252
|
-
/**
|
|
1253
|
-
* Get configuration errors without throwing
|
|
1254
|
-
*
|
|
1255
|
-
* @param plugin - Plugin metadata
|
|
1256
|
-
* @param config - Configuration to check
|
|
1257
|
-
* @returns Array of validation errors, or empty array if valid
|
|
1258
|
-
*/
|
|
1259
|
-
getConfigErrors(plugin: PluginMetadata, config: any): Array<{
|
|
1260
|
-
path: string;
|
|
1261
|
-
message: string;
|
|
1262
|
-
}>;
|
|
1263
|
-
private formatZodErrors;
|
|
1332
|
+
/** A single unpacked artifact file. `path` is POSIX, archive-relative. */
|
|
1333
|
+
interface IntegrityFile {
|
|
1334
|
+
path: string;
|
|
1335
|
+
data: Uint8Array;
|
|
1336
|
+
}
|
|
1337
|
+
type IntegrityViolationKind = 'digest_mismatch' | 'missing_file' | 'extra_file';
|
|
1338
|
+
/** One structured integrity finding (the rejection envelope's unit). */
|
|
1339
|
+
interface IntegrityViolation {
|
|
1340
|
+
kind: IntegrityViolationKind;
|
|
1341
|
+
/** Artifact-relative POSIX path the finding is about. */
|
|
1342
|
+
path: string;
|
|
1343
|
+
/** The digest the manifest declares (absent for `extra_file`). */
|
|
1344
|
+
declared?: string;
|
|
1345
|
+
/** The digest computed from the supplied bytes (absent unless comparable). */
|
|
1346
|
+
actual?: string;
|
|
1347
|
+
}
|
|
1348
|
+
interface VerifyIntegrityResult {
|
|
1349
|
+
/** Overall verdict: every declared digest matched and no file was unaccounted for. */
|
|
1350
|
+
ok: boolean;
|
|
1351
|
+
/** True when no integrity map was supplied, so nothing was checked (still `ok`). */
|
|
1352
|
+
skipped: boolean;
|
|
1353
|
+
/** Number of declared entries whose digests were computed and compared. */
|
|
1354
|
+
checked: number;
|
|
1355
|
+
violations: IntegrityViolation[];
|
|
1264
1356
|
}
|
|
1265
1357
|
/**
|
|
1266
|
-
*
|
|
1358
|
+
* Verify `files` against the manifest's declared `integrity` map.
|
|
1267
1359
|
*
|
|
1268
|
-
*
|
|
1269
|
-
*
|
|
1360
|
+
* `options.exempt` names paths outside the map's coverage — the compiled
|
|
1361
|
+
* manifest itself and the signature placeholder, which `computeIntegrity`
|
|
1362
|
+
* excludes at build time (the manifest cannot hash itself, and the
|
|
1363
|
+
* signature signs the manifest) — so their presence is never an
|
|
1364
|
+
* `extra_file` finding.
|
|
1270
1365
|
*/
|
|
1271
|
-
declare function
|
|
1366
|
+
declare function verifyIntegrity(files: readonly IntegrityFile[], integrity: Readonly<Record<string, string>> | null | undefined, options?: {
|
|
1367
|
+
exempt?: readonly string[];
|
|
1368
|
+
}): VerifyIntegrityResult;
|
|
1369
|
+
/** Render one violation as a single human-actionable line. */
|
|
1370
|
+
declare function formatIntegrityViolation(v: IntegrityViolation): string;
|
|
1272
1371
|
|
|
1273
1372
|
/**
|
|
1274
1373
|
* Plugin Permissions
|
|
@@ -1884,6 +1983,188 @@ declare function resolveApiKeyPrincipal(ql: any, headers: any, nowMs?: number, t
|
|
|
1884
1983
|
*/
|
|
1885
1984
|
declare function resolveApiKeyAdmission(ql: any, headers: any, nowMs?: number, tenancyPosture?: TenancyPosture): Promise<ApiKeyAdmission>;
|
|
1886
1985
|
|
|
1986
|
+
/**
|
|
1987
|
+
* [#13279] The LOUD failure an unreachable permission store raises.
|
|
1988
|
+
*
|
|
1989
|
+
* ## The defect this exists to end
|
|
1990
|
+
*
|
|
1991
|
+
* `resolveAuthzContext`'s per-read helper `tryFind` used to answer a THROWN
|
|
1992
|
+
* read the same way it answers an EMPTY one: `[]`. So a permission-store
|
|
1993
|
+
* outage resolved as a well-formed context for an AUTHENTICATED principal
|
|
1994
|
+
* holding ZERO capabilities, and the package-management door answered
|
|
1995
|
+
* `403 FORBIDDEN` — "Reading packages requires the `studio.access` or
|
|
1996
|
+
* `setup.access` capability." That answer was measured BYTE-IDENTICAL
|
|
1997
|
+
* (`JSON.stringify` equal, against a positive control that separates two
|
|
1998
|
+
* answers which differ) to the answer a caller who genuinely holds nothing
|
|
1999
|
+
* gets. An administrator was told they lack a capability, during an outage of
|
|
2000
|
+
* the store that holds the capability.
|
|
2001
|
+
*
|
|
2002
|
+
* The resolver was asserting a fact it did not have. "No rows came back"
|
|
2003
|
+
* and "the read failed" are different facts, and only one of them licenses
|
|
2004
|
+
* the sentence "this user holds nothing".
|
|
2005
|
+
*
|
|
2006
|
+
* ## …and "the read failed" turned out to be TWO facts (ruled 2026-08-30, A)
|
|
2007
|
+
*
|
|
2008
|
+
* A read ALSO throws when the table was never PROVISIONED — a real engine,
|
|
2009
|
+
* wired and reachable, whose `sys_*` tables were never created. There "this
|
|
2010
|
+
* user holds nothing" is TRUE, not invented, so failing loud would refuse
|
|
2011
|
+
* service to a correctly-configured deployment. The first implementation of
|
|
2012
|
+
* this card did exactly that and four CI suites measured it.
|
|
2013
|
+
*
|
|
2014
|
+
* So `tryFind` raises this error only for a read failure that is NOT
|
|
2015
|
+
* positively identified as an unprovisioned table, asking the one relocated
|
|
2016
|
+
* `isMissingTableError` predicate (`@objectstack/types`) rather than a second
|
|
2017
|
+
* copy. The boundary, the ruling's verbatim text and the false-positive risk
|
|
2018
|
+
* signed off with it are written beside that call in `resolve-authz-context.ts`;
|
|
2019
|
+
* both directions are pinned in `authz-store-unavailable.test.ts` §4.
|
|
2020
|
+
*
|
|
2021
|
+
* ## Maintainer ruling, 2026-08-30, verbatim 「第一批其余同意」
|
|
2022
|
+
*
|
|
2023
|
+
* > `tryFind` 区分「无行」与「读失败」,读失败 fail-loud —— 权限库不可达时
|
|
2024
|
+
* > 不再解析为「已认证零能力」,而是响亮拒绝(与真实能力拒绝的 403 可区分),
|
|
2025
|
+
* > 让宕机不再伪装成一次逐字节相同的能力否决。
|
|
2026
|
+
*
|
|
2027
|
+
* The ruling fixes the DIRECTION and leaves the spelling to the implementation.
|
|
2028
|
+
*
|
|
2029
|
+
* ## Why a THROW, and not a field on the envelope
|
|
2030
|
+
*
|
|
2031
|
+
* The alternative was a discriminator field on `ResolvedAuthzContext` — the
|
|
2032
|
+
* shape `authRefusal` already has. That was rejected on a MEASUREMENT, not a
|
|
2033
|
+
* preference: `authRefusal` has existed since #8287 and, outside this module
|
|
2034
|
+
* and its own unit test, has **zero** consumers anywhere in the repo. A
|
|
2035
|
+
* diagnostic field on this envelope is demonstrably not read by any door. Every
|
|
2036
|
+
* transport reads `userId` and `systemPermissions`; a new sibling field would
|
|
2037
|
+
* have to be taught to eight separate call sites before it made a single door
|
|
2038
|
+
* louder, and would answer the old quiet 403 at every site that was missed.
|
|
2039
|
+
*
|
|
2040
|
+
* A field is quiet by default and must be deliberately made loud. A throw is
|
|
2041
|
+
* loud by default and must be deliberately silenced. On a security surface
|
|
2042
|
+
* whose whole defect is a silence, the default is the entire decision.
|
|
2043
|
+
*
|
|
2044
|
+
* It is also the idiom this platform already uses for unresolvable authority:
|
|
2045
|
+
* `packages/mcp`'s stdio entry throws and refuses to start rather than run with
|
|
2046
|
+
* an authority it could not resolve.
|
|
2047
|
+
*
|
|
2048
|
+
* ## Why `SERVICE_UNAVAILABLE` / 503, and why that is not a new wire shape
|
|
2049
|
+
*
|
|
2050
|
+
* `SERVICE_UNAVAILABLE` is an EXISTING member of the closed ADR-0112 wire
|
|
2051
|
+
* vocabulary (`StandardErrorCode`, `packages/spec/src/api/errors.zod.ts`), and
|
|
2052
|
+
* `HttpStatusErrorCodeMap` already maps it to 503 — "service exists but is
|
|
2053
|
+
* temporarily down". Nothing is added to the vocabulary and no envelope gains
|
|
2054
|
+
* or loses a key: a door that already renders `{ code, message }` renders this
|
|
2055
|
+
* one the same way. What changes is WHICH declared code an outage selects —
|
|
2056
|
+
* from the caller's `FORBIDDEN` to the operator's `SERVICE_UNAVAILABLE`.
|
|
2057
|
+
*
|
|
2058
|
+
* That is the ruling's own test, stated on the wire: 503 is not 403, so an
|
|
2059
|
+
* outage is no longer answerable as a capability denial.
|
|
2060
|
+
*
|
|
2061
|
+
* ## Recognise by BRAND, never by `instanceof`
|
|
2062
|
+
*
|
|
2063
|
+
* {@link isAuthzStoreUnavailableError} tests a own-property brand rather than
|
|
2064
|
+
* `instanceof`. This error crosses package boundaries (`@objectstack/core` →
|
|
2065
|
+
* rest / runtime / mcp / services / plugins) and a monorepo resolves the same
|
|
2066
|
+
* module through more than one path (`src` under vitest aliases, `dist` under
|
|
2067
|
+
* the published `exports`). Two copies of this class make `instanceof` answer
|
|
2068
|
+
* FALSE for a genuine instance — which, here, silently restores the exact
|
|
2069
|
+
* quiet 403 this module exists to remove. The brand survives duplication.
|
|
2070
|
+
*/
|
|
2071
|
+
/** HTTP status an unreachable authorization store answers with. */
|
|
2072
|
+
declare const AUTHZ_STORE_UNAVAILABLE_STATUS: 503;
|
|
2073
|
+
/**
|
|
2074
|
+
* Machine code — an EXISTING `StandardErrorCode` member (ADR-0112: SCREAMING).
|
|
2075
|
+
* Deliberately NOT a new code: the wire vocabulary is closed.
|
|
2076
|
+
*/
|
|
2077
|
+
declare const AUTHZ_STORE_UNAVAILABLE_CODE: "SERVICE_UNAVAILABLE";
|
|
2078
|
+
/**
|
|
2079
|
+
* Human-facing message. States the OUTAGE, and says explicitly that no
|
|
2080
|
+
* capability judgement was reached — so neither the caller nor the operator
|
|
2081
|
+
* reads it as a permission verdict.
|
|
2082
|
+
*/
|
|
2083
|
+
declare const AUTHZ_STORE_UNAVAILABLE_MESSAGE: string;
|
|
2084
|
+
/**
|
|
2085
|
+
* The own-property brand {@link isAuthzStoreUnavailableError} tests for.
|
|
2086
|
+
* A string-keyed own property (not a `Symbol.for` registry key), so a
|
|
2087
|
+
* duplicated copy of this module still brands identically — which is exactly
|
|
2088
|
+
* what `instanceof` cannot do (module doc above).
|
|
2089
|
+
*
|
|
2090
|
+
* ⚠️ The brand does NOT survive `structuredClone`, and no claim here depends
|
|
2091
|
+
* on it doing so — the same measured behaviour `service-not-registered.ts`
|
|
2092
|
+
* records for its own brand. Reproduce on Node 22.22.2:
|
|
2093
|
+
*
|
|
2094
|
+
* ```js
|
|
2095
|
+
* const e = new Error('x'); e.__brand = true; e.code = 'C';
|
|
2096
|
+
* const c = structuredClone(e);
|
|
2097
|
+
* // c.__brand === undefined c.code === undefined c.message === 'x'
|
|
2098
|
+
* // control: structuredClone({ __brand: true, code: 'C' }) keeps BOTH keys
|
|
2099
|
+
* ```
|
|
2100
|
+
*
|
|
2101
|
+
* `Error` has a dedicated serialization carrying `message`, `stack` and
|
|
2102
|
+
* `cause` only, so it DROPS every other own property — this brand, the
|
|
2103
|
+
* ADR-0112 `code`, `status` and `object` alike (and a subclass's own `name`
|
|
2104
|
+
* returns as `'Error'`). The plain-object control is the half that proves the
|
|
2105
|
+
* loss is specific to `Error`, not general to `structuredClone`.
|
|
2106
|
+
*
|
|
2107
|
+
* ⛔ So never branch on this brand across a worker or `postMessage` boundary:
|
|
2108
|
+
* it would answer `false` and fail OPEN. Every call site today is in-process —
|
|
2109
|
+
* `rethrowAuthzStoreUnavailable` on the rest rethrow paths and
|
|
2110
|
+
* `isAuthzStoreUnavailableError` inside service `catch` blocks.
|
|
2111
|
+
*/
|
|
2112
|
+
declare const AUTHZ_STORE_UNAVAILABLE_BRAND: "__objectstackAuthzStoreUnavailable";
|
|
2113
|
+
/**
|
|
2114
|
+
* Raised when a permission-store read FAILED — never when it legitimately
|
|
2115
|
+
* returned no rows.
|
|
2116
|
+
*
|
|
2117
|
+
* Carries the `object` whose read failed so an operator sees WHICH table was
|
|
2118
|
+
* unreachable, and the originating error as `cause` so the driver's own
|
|
2119
|
+
* diagnostic is not lost behind this one.
|
|
2120
|
+
*/
|
|
2121
|
+
declare class AuthzStoreUnavailableError extends Error {
|
|
2122
|
+
/** Brand — see the module doc on why this is not `instanceof`. */
|
|
2123
|
+
readonly [AUTHZ_STORE_UNAVAILABLE_BRAND]: true;
|
|
2124
|
+
/** ADR-0112 wire code. */
|
|
2125
|
+
readonly code: "SERVICE_UNAVAILABLE";
|
|
2126
|
+
/** HTTP status a transport should answer. */
|
|
2127
|
+
readonly status: 503;
|
|
2128
|
+
/** The object/table whose read failed (e.g. `sys_user_permission_set`). */
|
|
2129
|
+
readonly object: string;
|
|
2130
|
+
/** The driver's originating failure, kept so its diagnostic is not lost. */
|
|
2131
|
+
readonly cause?: unknown;
|
|
2132
|
+
constructor(object: string, cause?: unknown);
|
|
2133
|
+
}
|
|
2134
|
+
/**
|
|
2135
|
+
* True when `err` is the loud authorization-store failure above.
|
|
2136
|
+
*
|
|
2137
|
+
* The predicate every transport uses to tell "the store was unreachable" apart
|
|
2138
|
+
* from every other throw, so a fail-closed `catch` can re-raise THIS one
|
|
2139
|
+
* without loosening its handling of anything else.
|
|
2140
|
+
*/
|
|
2141
|
+
declare function isAuthzStoreUnavailableError(err: unknown): err is AuthzStoreUnavailableError;
|
|
2142
|
+
/**
|
|
2143
|
+
* The `.catch` argument every fail-closed seam between `resolveAuthzContext`
|
|
2144
|
+
* and a door should use in place of `() => undefined`.
|
|
2145
|
+
*
|
|
2146
|
+
* Re-raises {@link AuthzStoreUnavailableError} and swallows everything else to
|
|
2147
|
+
* `undefined`, so a seam keeps its existing fail-closed behaviour for every
|
|
2148
|
+
* fault EXCEPT the one the 2026-08-30 ruling requires to stay loud.
|
|
2149
|
+
*
|
|
2150
|
+
* ## Why the seams need this at all
|
|
2151
|
+
*
|
|
2152
|
+
* Making `tryFind` throw is necessary but NOT sufficient, and that was
|
|
2153
|
+
* MEASURED rather than assumed. Between the resolver and the package door sit
|
|
2154
|
+
* three independent nets — `computeExecCtx`'s `try { … } catch { return
|
|
2155
|
+
* undefined; }`, `resolvePackageRouteExecutionContext`'s `.catch(() =>
|
|
2156
|
+
* undefined)`, and `refusePackageRequest`'s own — and with the throw in place
|
|
2157
|
+
* but the nets untouched, the door answered **401**: the outage had simply
|
|
2158
|
+
* changed disguises, from "you hold no capability" (403) into "you are not
|
|
2159
|
+
* authenticated" (401), which is byte-identical to a genuine anonymous caller.
|
|
2160
|
+
* Distinguishable from a capability denial, yes — but still not LOUD, and now
|
|
2161
|
+
* wearing the costume of a different card's defect.
|
|
2162
|
+
*
|
|
2163
|
+
* A blanket `catch` cannot tell a fault from a refusal, so each net has to be
|
|
2164
|
+
* told once, in one shape. This is that shape.
|
|
2165
|
+
*/
|
|
2166
|
+
declare function rethrowAuthzStoreUnavailable(err: unknown): undefined;
|
|
2167
|
+
|
|
1887
2168
|
/** The transport-agnostic authorization envelope produced from a request. */
|
|
1888
2169
|
interface ResolvedAuthzContext {
|
|
1889
2170
|
userId?: string;
|
|
@@ -1907,7 +2188,9 @@ interface ResolvedAuthzContext {
|
|
|
1907
2188
|
/**
|
|
1908
2189
|
* [ADR-0095 D2/D3] The monotonic posture rung this principal resolves to,
|
|
1909
2190
|
* DERIVED once here from held capability grants (never a better-auth role):
|
|
1910
|
-
* `PLATFORM_ADMIN` (unscoped `admin_full_access`
|
|
2191
|
+
* `PLATFORM_ADMIN` (an unscoped `admin_full_access` grant, or — since #11663
|
|
2192
|
+
* L2 — a VERIFIED `sys_user.email` on the deployment's declared
|
|
2193
|
+
* administrator list) > `TENANT_ADMIN`
|
|
1911
2194
|
* (`organization_admin`) > `MEMBER` (the authenticated floor). `EXTERNAL` is
|
|
1912
2195
|
* defined/test-locked but never resolved yet (no external principal type —
|
|
1913
2196
|
* see `posture-ladder.ts`). Present only for an authenticated principal;
|
|
@@ -1960,8 +2243,19 @@ interface ResolveAuthzInput {
|
|
|
1960
2243
|
tenancyPosture?: TenancyPosture;
|
|
1961
2244
|
}
|
|
1962
2245
|
/**
|
|
1963
|
-
* Resolve the authorization context for an inbound request.
|
|
1964
|
-
*
|
|
2246
|
+
* Resolve the authorization context for an inbound request. Anonymous requests
|
|
2247
|
+
* yield `{ positions: [], permissions: [], ... }`.
|
|
2248
|
+
*
|
|
2249
|
+
* ⚠️ [#13279] This function used to document itself as "Always resolves — never
|
|
2250
|
+
* throws", and that total guarantee WAS the defect: the only way to always
|
|
2251
|
+
* resolve across a permission-store outage is to report a capability set the
|
|
2252
|
+
* resolver never actually read. It now throws exactly one error —
|
|
2253
|
+
* {@link AuthzStoreUnavailableError}, when a permission-store read was issued
|
|
2254
|
+
* and failed FOR A REASON THAT IS NOT AN UNPROVISIONED TABLE. Every other path
|
|
2255
|
+
* still resolves, including every MISSING-service path and every
|
|
2256
|
+
* never-provisioned one. A transport that fails closed on unexpected throws should re-raise this
|
|
2257
|
+
* one ({@link isAuthzStoreUnavailableError}) rather than degrade it to a
|
|
2258
|
+
* refusal — degrading it restores the disguise the ruling removed.
|
|
1965
2259
|
*/
|
|
1966
2260
|
declare function resolveAuthzContext(input: ResolveAuthzInput): Promise<ResolvedAuthzContext>;
|
|
1967
2261
|
/** The authorization grants a KNOWN user holds — a subset of {@link ResolvedAuthzContext}. */
|
|
@@ -1992,6 +2286,25 @@ interface ResolveUserAuthzGrantsOptions {
|
|
|
1992
2286
|
seedPermissions?: string[];
|
|
1993
2287
|
/** A caller-supplied email (e.g. from the session) that wins over the `sys_user` read. */
|
|
1994
2288
|
seedEmail?: string;
|
|
2289
|
+
/**
|
|
2290
|
+
* ⭐ Force a FRESH resolution even when the #11971 grants cache is enabled
|
|
2291
|
+
* — the ruled bypass list of #11633 (leg B, maintainer acceptance
|
|
2292
|
+
* 2026-08-25). Two call sites carry it, for two ruled reasons:
|
|
2293
|
+
*
|
|
2294
|
+
* - `plugin-security/src/explain-engine.ts` (`buildContextForUser`): the
|
|
2295
|
+
* permission explainer is the tool an administrator uses to VERIFY that
|
|
2296
|
+
* a revocation took effect. An explainer answering from cache would
|
|
2297
|
+
* explain a state that no longer exists, and would do it at exactly the
|
|
2298
|
+
* moment someone is checking.
|
|
2299
|
+
* - `service-automation/src/plugin.ts` (`runAs:'user'` runs): automation
|
|
2300
|
+
* runs are not request-shaped and can be long-lived; they must not pin
|
|
2301
|
+
* an envelope.
|
|
2302
|
+
*
|
|
2303
|
+
* Bypassing reads NOTHING from the cache and writes NOTHING into it — a
|
|
2304
|
+
* bypassed resolution must not repopulate an entry the next cached caller
|
|
2305
|
+
* would then trust.
|
|
2306
|
+
*/
|
|
2307
|
+
bypassGrantsCache?: boolean;
|
|
1995
2308
|
}
|
|
1996
2309
|
/**
|
|
1997
2310
|
* resolveUserAuthzGrants — the userId-driven core of {@link resolveAuthzContext}.
|
|
@@ -2012,13 +2325,84 @@ interface ResolveUserAuthzGrantsOptions {
|
|
|
2012
2325
|
* automation engine calls this to run the flow's data ops exactly as that user
|
|
2013
2326
|
* — not the bare member/everyone fallback the missing grants used to leave it.
|
|
2014
2327
|
*
|
|
2015
|
-
* Fail-closed like its parent:
|
|
2016
|
-
*
|
|
2328
|
+
* Fail-closed like its parent: a missing engine yields an empty-but-valid
|
|
2329
|
+
* envelope.
|
|
2330
|
+
*
|
|
2331
|
+
* ⚠️ [#13279] "and it never throws" was removed from this sentence deliberately.
|
|
2332
|
+
* A permission-store read that is issued and FAILS now raises
|
|
2333
|
+
* {@link AuthzStoreUnavailableError} rather than contributing an empty grant
|
|
2334
|
+
* set, so a `runAs:'user'` automation cannot silently run with the authority of
|
|
2335
|
+
* a user whose grants were never read.
|
|
2017
2336
|
*/
|
|
2018
2337
|
declare function resolveUserAuthzGrants(ql: any, userId: string, opts?: ResolveUserAuthzGrantsOptions): Promise<UserAuthzGrants>;
|
|
2338
|
+
/**
|
|
2339
|
+
* hasPlatformAdminStanding — the ID-SHAPED platform-admin question, asked in
|
|
2340
|
+
* exactly one place.
|
|
2341
|
+
*
|
|
2342
|
+
* ADR-0068 D2 defined PLATFORM standing as one thing: an UNSCOPED
|
|
2343
|
+
* (`organization_id = null`) `sys_user_permission_set` grant on the
|
|
2344
|
+
* `admin_full_access` set, held **now**. Since #11663 L2 there is a SECOND
|
|
2345
|
+
* anchor beside it — a `sys_user` row whose VERIFIED email is on the
|
|
2346
|
+
* deployment's declared administrator list (`OS_PLATFORM_OWNER_EMAIL`) — and
|
|
2347
|
+
* this predicate answers for both, for free, because it is a projection rather
|
|
2348
|
+
* than a copy (see below). A surface that only knows a user id —
|
|
2349
|
+
* a session-payload derivation, a platform-operator route gate, an
|
|
2350
|
+
* impersonation oracle — asks here, so it never has to re-read the grant tables
|
|
2351
|
+
* itself, which is the prohibition this module's header states.
|
|
2352
|
+
*
|
|
2353
|
+
* It is a PROJECTION of {@link resolveUserAuthzGrants}, never a second
|
|
2354
|
+
* derivation: the answer is the `PLATFORM_ADMIN` rung of the posture ladder,
|
|
2355
|
+
* and that rung is derived from the unscoped-grant evidence and nothing else.
|
|
2356
|
+
* Everything that governs those grants therefore applies here by construction
|
|
2357
|
+
* and cannot drift from it — the ADR-0091 validity window (§6), the ADR-0049
|
|
2358
|
+
* `active` flag on the catalogue row (§6b), the system-identity read, and the
|
|
2359
|
+
* resolution of `admin_full_access` BY ID rather than by scanning a page of the
|
|
2360
|
+
* catalogue. Each of those was missing from a hand-written copy of this
|
|
2361
|
+
* predicate; none of them can be missing from a projection.
|
|
2362
|
+
*
|
|
2363
|
+
* ⛔ Read the RUNG — never `positions.includes(BUILTIN_IDENTITY_PLATFORM_ADMIN)`.
|
|
2364
|
+
* The positions list is wider on purpose: an ADR-0057 D4 `sys_user_position`
|
|
2365
|
+
* row may spell that very name, and a platform-RBAC assignment is not the D2
|
|
2366
|
+
* capability grant. The two readings genuinely differ, so the narrow one is the
|
|
2367
|
+
* one that gets a name here.
|
|
2368
|
+
*
|
|
2369
|
+
* ⛔ The options are deliberately NOT {@link ResolveUserAuthzGrantsOptions}.
|
|
2370
|
+
* That type carries caller-supplied seeds (`seedEmail`, `seedPermissions`) for
|
|
2371
|
+
* transports that already resolved part of a principal; an authorization
|
|
2372
|
+
* predicate that accepted them would let a caller supply part of its own
|
|
2373
|
+
* verdict. Clock injection is the only thing a caller may pass, so this
|
|
2374
|
+
* function's answer is a function of `(ql, userId)` and the stored rows alone.
|
|
2375
|
+
*
|
|
2376
|
+
* ⚠️ This is the PER-USER predicate. The POPULATION question ("which user is
|
|
2377
|
+
* the platform admin?" — `ensure-default-organization.ts`) is a different kind
|
|
2378
|
+
* and is deliberately not expressible through it; do not widen this to serve
|
|
2379
|
+
* it.
|
|
2380
|
+
*
|
|
2381
|
+
* Fail-CLOSED: an empty id, a missing engine, or any unreadable lookup answers
|
|
2382
|
+
* `false`. This backs security gates, and an unverifiable actor never passes.
|
|
2383
|
+
*/
|
|
2384
|
+
declare function hasPlatformAdminStanding(ql: any, userId: string, opts?: {
|
|
2385
|
+
nowMs?: number;
|
|
2386
|
+
}): Promise<boolean>;
|
|
2019
2387
|
interface ResolveLocalizationInput {
|
|
2020
2388
|
ql: any;
|
|
2021
|
-
/**
|
|
2389
|
+
/**
|
|
2390
|
+
* Settings service occupant. Two methods are consumed, in this order:
|
|
2391
|
+
*
|
|
2392
|
+
* - `getMany(namespace, keys, { tenantId, userId })` — PREFERRED since
|
|
2393
|
+
* #10826, and what this resolver calls for all three localization keys in
|
|
2394
|
+
* ONE grouped read.
|
|
2395
|
+
* - `get(namespace, key, { tenantId, userId })` — the per-key fallback,
|
|
2396
|
+
* taken only when the occupant does not expose `getMany` (three parallel
|
|
2397
|
+
* reads; see the feature-detect below).
|
|
2398
|
+
*
|
|
2399
|
+
* `getMany` is OPTIONAL for an occupant: the branch is feature-detected, so
|
|
2400
|
+
* a service that predates it still resolves — at three reads instead of one.
|
|
2401
|
+
* Typed `any` deliberately (the occupant's shape varies by host); the
|
|
2402
|
+
* declaration above is the contract this resolver actually relies on, and it
|
|
2403
|
+
* is prose precisely because nothing type-checks it — `getService` is a cast
|
|
2404
|
+
* and `rest-server.ts` widens the provider's return to a bare promise.
|
|
2405
|
+
*/
|
|
2022
2406
|
settings?: any;
|
|
2023
2407
|
tenantId?: string;
|
|
2024
2408
|
userId?: string;
|
|
@@ -2034,14 +2418,22 @@ type LocalizationResult = {
|
|
|
2034
2418
|
* platform default → global → tenant); falls back to direct tenant-scoped
|
|
2035
2419
|
* `sys_setting` rows, then the built-ins `UTC` / `en-US`. Never throws.
|
|
2036
2420
|
*
|
|
2037
|
-
*
|
|
2038
|
-
* refused, etc.) is memoized for
|
|
2039
|
-
* per `(ql, tenantId, userId)` so
|
|
2040
|
-
* line for it — does not repeat
|
|
2041
|
-
*
|
|
2042
|
-
*
|
|
2043
|
-
*
|
|
2044
|
-
*
|
|
2421
|
+
* The DIRECT `sys_setting` read failing outright (backend fault — table
|
|
2422
|
+
* missing, connection refused, etc.) is memoized for
|
|
2423
|
+
* {@link LOCALIZATION_FAILURE_CACHE_TTL_MS} per `(ql, tenantId, userId)` so
|
|
2424
|
+
* the failing query — and the driver's log line for it — does not repeat
|
|
2425
|
+
* every request (#10221). A settings-service refusal is not a backend fault
|
|
2426
|
+
* and never populates that memo (#11877).
|
|
2427
|
+
*
|
|
2428
|
+
* A SUCCESSFUL read is cached too, since #11966 (leg C of #11633) — but only
|
|
2429
|
+
* when the engine carries the write-epoch seam, and only until the first of:
|
|
2430
|
+
* a `localization` settings change, an engine write, or
|
|
2431
|
+
* `OS_LOCALIZATION_CACHE_TTL_MS`. Both invalidations are synchronous and
|
|
2432
|
+
* in-process, which is what lets the success cache exist at all: the
|
|
2433
|
+
* dogfood analytics-bucketing test writes a new org timezone and reads it back
|
|
2434
|
+
* on the very next request, and it is kept unweakened as this leg's acceptance
|
|
2435
|
+
* criterion. See the leg-C docblock above for the full contract, including why
|
|
2436
|
+
* a `ql` with no seam declines to cache rather than falling back to the TTL.
|
|
2045
2437
|
*/
|
|
2046
2438
|
declare function resolveLocalizationContext(input: ResolveLocalizationInput): Promise<LocalizationResult>;
|
|
2047
2439
|
|
|
@@ -2592,6 +2984,20 @@ declare function isRowActive(row: ActivatableRow | null | undefined): boolean;
|
|
|
2592
2984
|
* the table-level half of the same guarantee: a resolver that starts deriving
|
|
2593
2985
|
* administrator standing from a new table would otherwise be invisible to a
|
|
2594
2986
|
* column-set comparison, because the new table appears in neither side's list.
|
|
2987
|
+
*
|
|
2988
|
+
* ## ⚠️ Tables are no longer the whole surface (#11663 L2)
|
|
2989
|
+
*
|
|
2990
|
+
* Since the platform-admin re-anchor's core leg, one input to the administrator
|
|
2991
|
+
* derivation is NOT a table at all: the deployment's declared administrator
|
|
2992
|
+
* list, read from the environment on every resolution
|
|
2993
|
+
* (`security/platform-admin.ts`). A file that listed only tables would go on
|
|
2994
|
+
* being perfectly accurate about the tables while silently claiming the
|
|
2995
|
+
* derivation reads nothing else — the same shape as the stale comment this file
|
|
2996
|
+
* replaced, one level up. {@link ADMIN_STANDING_NON_TABLE_INPUTS} is the place
|
|
2997
|
+
* that says so, and it is deliberately a SEPARATE export rather than a
|
|
2998
|
+
* pseudo-row in the table map: the map is compared for equality against
|
|
2999
|
+
* observed table reads, and a pseudo-row would have to be excluded from that
|
|
3000
|
+
* comparison by name, which is exactly the kind of special case that rots.
|
|
2595
3001
|
*/
|
|
2596
3002
|
/** How a table this resolver reads relates to "who is an administrator". */
|
|
2597
3003
|
interface AdminStandingTable {
|
|
@@ -2618,11 +3024,37 @@ interface AdminStandingTable {
|
|
|
2618
3024
|
* principal, and therefore all of `resolveUserAuthzGrants`. The API-key
|
|
2619
3025
|
* ADMISSION path (`resolveApiKeyAdmission`) is outside it on purpose: it
|
|
2620
3026
|
* authenticates a principal and seeds `permissions` with the key's scopes, and
|
|
2621
|
-
* confers no administrator standing of its own — `hasPlatformAdminGrant`
|
|
2622
|
-
*
|
|
2623
|
-
* `sys_user_permission_set` grant
|
|
3027
|
+
* confers no administrator standing of its own — `hasPlatformAdminGrant` is set
|
|
3028
|
+
* from a `sys_permission_set` row reached through an UNSCOPED
|
|
3029
|
+
* `sys_user_permission_set` grant (§6b) or from the deployment config matched
|
|
3030
|
+
* against the caller's own STORED `sys_user` row (§6b-config), never from a
|
|
3031
|
+
* scope string and never from the caller-seedable `grants.email`.
|
|
2624
3032
|
*/
|
|
2625
3033
|
declare const ADMIN_STANDING_SURFACE: Readonly<Record<string, AdminStandingTable>>;
|
|
3034
|
+
/** A derivation input that is not a table — see {@link ADMIN_STANDING_NON_TABLE_INPUTS}. */
|
|
3035
|
+
interface AdminStandingNonTableInput {
|
|
3036
|
+
/** How the value reaches the resolver, e.g. `env` for a process environment variable. */
|
|
3037
|
+
readonly kind: 'env';
|
|
3038
|
+
/** The exact spelling an operator sets — quotable verbatim in a refusal message. */
|
|
3039
|
+
readonly name: string;
|
|
3040
|
+
/** What it decides, and what a break-glass guard can and cannot do about it. */
|
|
3041
|
+
readonly reason: string;
|
|
3042
|
+
}
|
|
3043
|
+
/**
|
|
3044
|
+
* [#11663 L2] Inputs to the administrator derivation that no table write can
|
|
3045
|
+
* reach — declared here so this file's silence about them cannot be read as
|
|
3046
|
+
* "the derivation reads only tables".
|
|
3047
|
+
*
|
|
3048
|
+
* The practical consequence is the one worth writing down: a break-glass guard
|
|
3049
|
+
* simulates a pending WRITE, and there is no write to simulate for any of
|
|
3050
|
+
* these. Standing that rests on one of them is taken away by changing the
|
|
3051
|
+
* deployment's configuration and rolling the process, which is deliberately
|
|
3052
|
+
* outside every in-product path — including every path an agent could be talked
|
|
3053
|
+
* into calling. That is the whole point of the config anchor, and it is also
|
|
3054
|
+
* the reason a guard cannot promise to prevent this class of lockout: it can
|
|
3055
|
+
* only refuse the writes it can see.
|
|
3056
|
+
*/
|
|
3057
|
+
declare const ADMIN_STANDING_NON_TABLE_INPUTS: readonly AdminStandingNonTableInput[];
|
|
2626
3058
|
/** The tables a write to which can change who is an administrator. */
|
|
2627
3059
|
declare function adminStandingTables(): string[];
|
|
2628
3060
|
/**
|
|
@@ -2631,6 +3063,147 @@ declare function adminStandingTables(): string[];
|
|
|
2631
3063
|
*/
|
|
2632
3064
|
declare function adminStandingColumns(table: string): readonly string[] | undefined;
|
|
2633
3065
|
|
|
3066
|
+
/**
|
|
3067
|
+
* The one separator {@link parsePlatformAdminEmails} splits on (Choice 2B).
|
|
3068
|
+
* Same shape as `OS_CORS_ORIGIN`, the existing comma-separated precedent.
|
|
3069
|
+
*/
|
|
3070
|
+
declare const PLATFORM_ADMIN_EMAIL_SEPARATOR = ",";
|
|
3071
|
+
/**
|
|
3072
|
+
* The ONE normalization, applied to both sides of every comparison: trim, then
|
|
3073
|
+
* lowercase. Email domains are case-insensitive and every mailbox this platform
|
|
3074
|
+
* issues is too, so an operator who types `Ada@Example.com` and a row storing
|
|
3075
|
+
* `ada@example.com` must be one administrator, not two half-matches.
|
|
3076
|
+
*/
|
|
3077
|
+
declare function normalizePlatformAdminEmail(value: unknown): string;
|
|
3078
|
+
/** The parsed state of `OS_PLATFORM_OWNER_EMAIL` for one raw value. */
|
|
3079
|
+
interface PlatformAdminEmailConfig {
|
|
3080
|
+
/**
|
|
3081
|
+
* Normalized, de-duplicated administrator addresses in the order the operator
|
|
3082
|
+
* declared them. EMPTY when the variable is unset, blank, or refused — those
|
|
3083
|
+
* three are one outcome by design (zero config-derived administrators), and
|
|
3084
|
+
* they are told apart by {@link refusal} rather than by a second empty value.
|
|
3085
|
+
*/
|
|
3086
|
+
readonly emails: readonly string[];
|
|
3087
|
+
/**
|
|
3088
|
+
* The SAME administrators as {@link emails} and index-aligned with it, each
|
|
3089
|
+
* spelled as the operator typed it — trimmed only, never lowercased (exactly
|
|
3090
|
+
* what `resolvePlatformOwnerEmail()` used to hand a single-value reader).
|
|
3091
|
+
*
|
|
3092
|
+
* It exists so that no consumer ever has a reason to split {@link raw} a
|
|
3093
|
+
* second time. Two readers need the as-typed form and neither may re-parse
|
|
3094
|
+
* to get it: the platform-admin STANDING surface's by-email `sys_user`
|
|
3095
|
+
* lookup queries the verbatim spelling alongside the normalized one (an
|
|
3096
|
+
* imported/legacy row may not be stored lowercased, and a driver `where` is
|
|
3097
|
+
* an exact match) — that lookup is `resolvePlatformAdminStanding` in
|
|
3098
|
+
* plugin-security's `platform-admin-service.ts`, which inherited the
|
|
3099
|
+
* two-spelling discipline from the elevation gate the #11663 re-anchor
|
|
3100
|
+
* (leg L4) retired — and the walled boot diagnostic quotes the addresses
|
|
3101
|
+
* back to the operator, who should see what they wrote.
|
|
3102
|
+
*/
|
|
3103
|
+
readonly declaredSpellings: readonly string[];
|
|
3104
|
+
/** What the operator actually typed, when the variable was set to anything. */
|
|
3105
|
+
readonly raw?: string;
|
|
3106
|
+
/**
|
|
3107
|
+
* Set when the variable was DECLARED but refused, naming the offending entry.
|
|
3108
|
+
* `emails` is empty in that case: the whole variable fails closed, never the
|
|
3109
|
+
* one entry (Choice 2B).
|
|
3110
|
+
*/
|
|
3111
|
+
readonly refusal?: string;
|
|
3112
|
+
}
|
|
3113
|
+
/**
|
|
3114
|
+
* Parse one raw `OS_PLATFORM_OWNER_EMAIL` value into the administrator list.
|
|
3115
|
+
*
|
|
3116
|
+
* Pure — no env read, no logging — so the whole parse is testable as a
|
|
3117
|
+
* function of its input. {@link resolvePlatformAdminEmails} is the env-reading,
|
|
3118
|
+
* memoizing, once-per-value-loud wrapper around it.
|
|
3119
|
+
*/
|
|
3120
|
+
declare function parsePlatformAdminEmails(raw: string | undefined): PlatformAdminEmailConfig;
|
|
3121
|
+
/**
|
|
3122
|
+
* Sink for the refusal notice. `console` by default so the loudness does not
|
|
3123
|
+
* depend on any host wiring it up — a deployment that declared administrators
|
|
3124
|
+
* and got none must never find that out silently. Swappable for tests.
|
|
3125
|
+
*/
|
|
3126
|
+
interface PlatformAdminConfigSink {
|
|
3127
|
+
error(message: string): void;
|
|
3128
|
+
warn(message: string): void;
|
|
3129
|
+
}
|
|
3130
|
+
/** Redirect this module's notices (tests). Returns the previous sink. */
|
|
3131
|
+
declare function setPlatformAdminConfigSink(next: PlatformAdminConfigSink | undefined): PlatformAdminConfigSink;
|
|
3132
|
+
/**
|
|
3133
|
+
* Resolve the deployment's declared platform administrators — live from the
|
|
3134
|
+
* environment, memoized on the raw string, and LOUD exactly once per distinct
|
|
3135
|
+
* refused value.
|
|
3136
|
+
*
|
|
3137
|
+
* Silence for an UNSET variable is deliberate and is not the same decision:
|
|
3138
|
+
* every `single`-posture deployment runs that way by design (Choice 4A leaves
|
|
3139
|
+
* first-user promotion in place there), and warning on the shipped default is
|
|
3140
|
+
* how a log people read becomes a log people skim. A walled posture with the
|
|
3141
|
+
* variable unset already REFUSES BOOT one layer up, in plugin-auth.
|
|
3142
|
+
*/
|
|
3143
|
+
declare function resolvePlatformAdminEmails(): PlatformAdminEmailConfig;
|
|
3144
|
+
/** Drop the memo — for tests that drive several values through one process. */
|
|
3145
|
+
declare function resetPlatformAdminEmailMemo(): void;
|
|
3146
|
+
/**
|
|
3147
|
+
* Does this stored `sys_user` row belong to a declared platform administrator?
|
|
3148
|
+
*
|
|
3149
|
+
* Fail-closed on every axis: an empty/refused config answers `false` without
|
|
3150
|
+
* looking at the row at all, an address that is not on the list answers
|
|
3151
|
+
* `false`, and an address that IS on the list but whose `email_verified`
|
|
3152
|
+
* column does not read verified answers `false` too. The last one is the point
|
|
3153
|
+
* of the whole leg — an unverified account holding a configured address confers
|
|
3154
|
+
* nothing, so an attacker who registers the operator's address before the
|
|
3155
|
+
* operator does gains no standing by it.
|
|
3156
|
+
*
|
|
3157
|
+
* ⚠️ `row` MUST be the caller's own stored `sys_user` row. See this module's
|
|
3158
|
+
* header: `grants.email` is caller-seedable and reading it here would be an
|
|
3159
|
+
* escalation channel.
|
|
3160
|
+
*/
|
|
3161
|
+
declare function matchesConfiguredPlatformAdmin(row: unknown, config: PlatformAdminEmailConfig): boolean;
|
|
3162
|
+
/**
|
|
3163
|
+
* [#13147] Is this bare ADDRESS one of the declared administrators?
|
|
3164
|
+
*
|
|
3165
|
+
* The membership half of {@link matchesConfiguredPlatformAdmin}, spelled once
|
|
3166
|
+
* and exported, because the row-and-verified predicate above is not the shape
|
|
3167
|
+
* every reader of `OS_PLATFORM_OWNER_EMAIL` needs:
|
|
3168
|
+
*
|
|
3169
|
+
* - the walled platform-admin STANDING surface must keep the two halves
|
|
3170
|
+
* SEPARATE — `resolvePlatformAdminStanding`
|
|
3171
|
+
* (`plugin-security/platform-admin-service.ts`, reported at boot by
|
|
3172
|
+
* `bootstrap-platform-admin.ts`) answers `registered` and `verified` as two
|
|
3173
|
+
* independent per-entry fields, so the operator's log can tell "not
|
|
3174
|
+
* registered yet" apart from "registered, NOT verified". ⚠️ That reason
|
|
3175
|
+
* predates the #11663 re-anchor and survives it: the pair used to be the
|
|
3176
|
+
* retired elevation gate's `walled_owner_not_registered` /
|
|
3177
|
+
* `walled_owner_not_verified` reasons — the mechanism moved, the need to
|
|
3178
|
+
* keep the halves apart did not;
|
|
3179
|
+
* - the creation-time operator stamp (`plugin-auth`) is handed an email
|
|
3180
|
+
* STRING by better-auth, before any row exists to read;
|
|
3181
|
+
* - the Layer 0 wall bypass takes a fast negative on the session's
|
|
3182
|
+
* server-resolved email before it spends a `sys_user` read.
|
|
3183
|
+
*
|
|
3184
|
+
* ⛔ Those readers must NOT hand-roll `config.emails.includes(x.toLowerCase())`
|
|
3185
|
+
* instead. That expression is where a seventh dialect gets born: it silently
|
|
3186
|
+
* drops the trim, and a stray space in one list entry then makes an
|
|
3187
|
+
* administrator vanish with nothing to notice. One membership expression, one
|
|
3188
|
+
* normalization ({@link normalizePlatformAdminEmail}), one place to fix.
|
|
3189
|
+
*
|
|
3190
|
+
* Fail-closed like everything else here: an empty or refused config answers
|
|
3191
|
+
* `false` without looking at the candidate, and a blank/non-string candidate
|
|
3192
|
+
* answers `false` against any config.
|
|
3193
|
+
*
|
|
3194
|
+
* ⚠️ This is a match against CONFIGURATION only — it says nothing about whether
|
|
3195
|
+
* the address is verified, or whether the caller actually holds it. Standing
|
|
3196
|
+
* still requires {@link matchesConfiguredPlatformAdmin} over the caller's own
|
|
3197
|
+
* stored row; see this module's header for why `grants.email` is never it.
|
|
3198
|
+
*/
|
|
3199
|
+
declare function isConfiguredPlatformAdminEmail(email: unknown, config: PlatformAdminEmailConfig): boolean;
|
|
3200
|
+
declare function reportLegacyPlatformAdminGrant(input: {
|
|
3201
|
+
userId: string;
|
|
3202
|
+
email?: unknown;
|
|
3203
|
+
}): void;
|
|
3204
|
+
/** Drop the once-per-process latch — for tests. */
|
|
3205
|
+
declare function resetLegacyPlatformAdminGrantReport(): void;
|
|
3206
|
+
|
|
2634
3207
|
/**
|
|
2635
3208
|
* [#7678] The `?status=` vocabulary of the audience-binding suggestion list
|
|
2636
3209
|
* (ADR-0090 D5/D9) — ONE owner for a rule that had exactly one implementation
|
|
@@ -2697,7 +3270,12 @@ declare const unknownAudienceBindingSuggestionStatusMessage: (value: string) =>
|
|
|
2697
3270
|
* `__expandRead` marks a read as a lookup EXPANSION sub-read (it no longer
|
|
2698
3271
|
* relaxes any gate — #7626 removed that waiver — but it still travels with
|
|
2699
3272
|
* one operation and must not be inherited by another), `__referentialFieldClear`
|
|
2700
|
-
* authorizes the referential-clear write.
|
|
3273
|
+
* authorizes the referential-clear write. [#13644] The latter also has a
|
|
3274
|
+
* DECLARED, read-only projection — `HookContext.referentialFieldClear`
|
|
3275
|
+
* (`@objectstack/spec/data`), populated by objectql's `update()` assembly
|
|
3276
|
+
* and carried across the sandbox boundary by contract — which is what an
|
|
3277
|
+
* APP reads; the `__` key here remains the engine/middleware authorization
|
|
3278
|
+
* channel, and this file's stripping rule is unchanged by the projection.
|
|
2701
3279
|
*
|
|
2702
3280
|
* plugin-security is the PRODUCER of that vocabulary and would be the most
|
|
2703
3281
|
* honest owner of the rule for consuming it, but none of the three consumers
|
|
@@ -2786,6 +3364,244 @@ declare const OPERATION_PRIVATE_KEY_PREFIX = "__";
|
|
|
2786
3364
|
*/
|
|
2787
3365
|
declare function withoutOperationPrivateKeys(exec: Record<string, unknown>): ExecutionContext;
|
|
2788
3366
|
|
|
3367
|
+
/**
|
|
3368
|
+
* ── The `authz.invalidated` cluster channel (#11968, #11633 §3) ─────────────
|
|
3369
|
+
*
|
|
3370
|
+
* The cross-node half of the authorization invalidation substrate: one channel
|
|
3371
|
+
* name, one payload shape, and the contract statement that governs how both may
|
|
3372
|
+
* be read. It carries no cache and no consumer — leg B (#11967) is the first.
|
|
3373
|
+
*
|
|
3374
|
+
* ## ⭐ THE TTL IS THE CORRECTNESS CONTRACT. THIS CHANNEL IS NOT.
|
|
3375
|
+
*
|
|
3376
|
+
* A message on this channel is a **hint**, and **a missed message is EXPECTED**.
|
|
3377
|
+
* That is not a caveat about an unreliable network; it is the shipped
|
|
3378
|
+
* guarantee, measured rather than assumed:
|
|
3379
|
+
*
|
|
3380
|
+
* - `content/docs/kernel/cluster.mdx` §4.2, on `at-least-once`:
|
|
3381
|
+
* *"**No shipped driver provides this yet.** The `redis` driver publishes
|
|
3382
|
+
* over plain Redis pub/sub, which is *at-most-once* — fire-and-forget, no
|
|
3383
|
+
* persistence, no replay for a node that was down at publish time."*
|
|
3384
|
+
* - `@objectstack/service-cluster-redis`'s own `publish` docblock:
|
|
3385
|
+
* *"there is no delivery guarantee to subscribers and no replay for a node
|
|
3386
|
+
* that was down or slow at publish time. This is acceptable **only** for
|
|
3387
|
+
* events that are pure cache-invalidation hints, never the source of
|
|
3388
|
+
* truth."*
|
|
3389
|
+
* - The `memory` driver does not cross a process boundary at all
|
|
3390
|
+
* (`service-cluster/src/memory/pubsub.ts`, and the split-brain guard's
|
|
3391
|
+
* `IN_PROCESS_DRIVERS`).
|
|
3392
|
+
*
|
|
3393
|
+
* So a dropped message on an at-most-once transport is a staleness window with
|
|
3394
|
+
* **no upper bound**, and no amount of care at the publish site changes that.
|
|
3395
|
+
* What bounds it is the **TTL** every cached authorization answer must carry:
|
|
3396
|
+
* a peer that never hears the message still converges when its entry expires.
|
|
3397
|
+
*
|
|
3398
|
+
* ⇒ **A consumer that would be incorrect if a message were lost is misusing
|
|
3399
|
+
* this channel.** The channel exists for one thing: moving the *typical*
|
|
3400
|
+
* convergence from "one TTL" down to "one network hop". It never moves the
|
|
3401
|
+
* worst case, and it is never the mechanism that makes a cached authorization
|
|
3402
|
+
* answer safe.
|
|
3403
|
+
*
|
|
3404
|
+
* ⚠️ For the same reason this channel is **best-effort at the publish site
|
|
3405
|
+
* too**: a publish failure is logged and swallowed, never propagated into the
|
|
3406
|
+
* write that triggered it. A grant revocation must not fail because a cache
|
|
3407
|
+
* hint could not be delivered — the TTL already covers exactly that case.
|
|
3408
|
+
*
|
|
3409
|
+
* `IPubSub`'s own interface docblock (`@objectstack/spec/contracts`) states the
|
|
3410
|
+
* same thing from the contract side — delivery is whatever the configured
|
|
3411
|
+
* driver declares, no shipped driver exceeds at-most-once, and handlers must be
|
|
3412
|
+
* idempotent **and** tolerate loss. That docblock, `cluster.mdx` §4.2 and the
|
|
3413
|
+
* redis driver agree; there is no disagreement here for a later reader to go
|
|
3414
|
+
* looking for.
|
|
3415
|
+
*
|
|
3416
|
+
* ## Why a new channel on the existing bus, and not a new transport
|
|
3417
|
+
*
|
|
3418
|
+
* `MetadataClusterBridgePlugin` already shows the whole shape — a channel on
|
|
3419
|
+
* `IPubSub`, bridged by a plugin that late-binds at `kernel:ready` and does
|
|
3420
|
+
* nothing when the services it needs are absent. Reusing it adds no dependency
|
|
3421
|
+
* and no new failure mode. The one thing metadata's channel does NOT have to
|
|
3422
|
+
* carry is what makes this one different: a missed `metadata.changed` costs a
|
|
3423
|
+
* stale schema until reload and loses no data, while a missed
|
|
3424
|
+
* `authz.invalidated` would cost a permission honoured past its revocation —
|
|
3425
|
+
* which is why the bound lives in the TTL and why the absence of this bridge is
|
|
3426
|
+
* stated out loud at boot ({@link ../security/authz-cache-posture.js}).
|
|
3427
|
+
*/
|
|
3428
|
+
/**
|
|
3429
|
+
* The cluster channel authorization-cache invalidation hints travel on.
|
|
3430
|
+
*
|
|
3431
|
+
* Named as a fact about authorization, not about any one cache, because a
|
|
3432
|
+
* second consumer must reuse this channel rather than mint a parallel one —
|
|
3433
|
+
* two channels would be two chances to miss a bridge.
|
|
3434
|
+
*/
|
|
3435
|
+
declare const AUTHZ_INVALIDATED_CHANNEL = "authz.invalidated";
|
|
3436
|
+
/**
|
|
3437
|
+
* Why an authorization epoch advanced. Coarse on purpose (#11633 §2.2, Fork 1 →
|
|
3438
|
+
* A): the engine seam sees `update`/`delete` expressed as a `where`, from which
|
|
3439
|
+
* the affected user or organization is frequently **not derivable without
|
|
3440
|
+
* reading the row back**. So the substrate carries "something authorization-
|
|
3441
|
+
* relevant changed", never "whose entry to drop", and a consumer retires its
|
|
3442
|
+
* whole bucket. Keyed invalidation is gated behind a measurement of a
|
|
3443
|
+
* write-heavy tenant and is explicitly not the starting point.
|
|
3444
|
+
*/
|
|
3445
|
+
type AuthzInvalidationReason =
|
|
3446
|
+
/** A write (`insert` / `update` / `delete`) passed the engine middleware seam. */
|
|
3447
|
+
'write'
|
|
3448
|
+
/** A metadata change — a permission set can be DECLARED, so no row is written. */
|
|
3449
|
+
| 'metadata'
|
|
3450
|
+
/** A hint received from a peer node on this channel. */
|
|
3451
|
+
| 'remote'
|
|
3452
|
+
/** An explicit bump by a host that knows something the seam cannot see. */
|
|
3453
|
+
| 'manual';
|
|
3454
|
+
/**
|
|
3455
|
+
* The payload on {@link AUTHZ_INVALIDATED_CHANNEL}.
|
|
3456
|
+
*
|
|
3457
|
+
* Deliberately tiny and deliberately NOT a description of what to invalidate:
|
|
3458
|
+
* see {@link AuthzInvalidationReason} for why the seam cannot supply that. A
|
|
3459
|
+
* receiver's only correct response is to retire its authorization cache
|
|
3460
|
+
* wholesale — and to remain correct if this message never arrives.
|
|
3461
|
+
*
|
|
3462
|
+
* ⛔ Not a `packages/spec` contract type. #11633 §5 reserves a declared shape
|
|
3463
|
+
* for the invalidation event to the spec seat and does not pre-commit it; this
|
|
3464
|
+
* is the runtime shape the substrate publishes today.
|
|
3465
|
+
*/
|
|
3466
|
+
interface AuthzInvalidatedPayload {
|
|
3467
|
+
/**
|
|
3468
|
+
* Publishing node, for loopback suppression — a node must not act on its own
|
|
3469
|
+
* hint. Mirrors `ClusterMetadataChangedPayload.originNode`.
|
|
3470
|
+
*/
|
|
3471
|
+
originNode?: string;
|
|
3472
|
+
/** The publisher's local epoch after the bump. Diagnostic only. */
|
|
3473
|
+
epoch: number;
|
|
3474
|
+
/** What advanced the epoch. Diagnostic only — see the type's doc. */
|
|
3475
|
+
reason: AuthzInvalidationReason;
|
|
3476
|
+
/** Wall-clock publish time, ms since epoch. Best-effort. */
|
|
3477
|
+
at: number;
|
|
3478
|
+
}
|
|
3479
|
+
|
|
3480
|
+
/**
|
|
3481
|
+
* ── The boot-time authorization-cache posture statement (#11968, #11633 §3) ──
|
|
3482
|
+
*
|
|
3483
|
+
* ⭐ **Non-optional**, by the 2026-08-25 ruling on #11633 (Fork 2 → B): whenever
|
|
3484
|
+
* a grants cache is enabled and there is **no** cross-node invalidation bus, the
|
|
3485
|
+
* deployment is told so, **out loud**, at boot.
|
|
3486
|
+
*
|
|
3487
|
+
* ## Why a statement and not a refusal
|
|
3488
|
+
*
|
|
3489
|
+
* A per-process cache bounded only by its TTL is a legitimate configuration —
|
|
3490
|
+
* #11633 §3 rules the TTL, not the bus, as the correctness contract, so a
|
|
3491
|
+
* single-node deployment (or one that simply accepts the window) is correct
|
|
3492
|
+
* with no bus at all. What is NOT acceptable is arriving there **without
|
|
3493
|
+
* noticing**: that is the shape of #4785, where a control was silently disabled
|
|
3494
|
+
* by configuration and nothing said so. The metadata bridge logs its own
|
|
3495
|
+
* absence at `debug` and that is right for metadata — a missed
|
|
3496
|
+
* `metadata.changed` costs a stale schema until reload and loses no data. Here
|
|
3497
|
+
* the same silence would cost a permission honoured past its revocation.
|
|
3498
|
+
*
|
|
3499
|
+
* ⇒ Enabled cache + no bus is a `warn`, every boot, naming the window it just
|
|
3500
|
+
* accepted. Not a refusal — a statement.
|
|
3501
|
+
*
|
|
3502
|
+
* ## The three postures, and the reason `disabled` is silent
|
|
3503
|
+
*
|
|
3504
|
+
* - `disabled` — no cache is enabled. **Silent.** There is no window to
|
|
3505
|
+
* state, and a line every boot on the shipped default
|
|
3506
|
+
* (TTL `0`, Fork 4) would train operators to ignore it —
|
|
3507
|
+
* which is how the loud line stops being loud.
|
|
3508
|
+
* - `ttl-only` — cache enabled, no cross-node bus. **LOUD (`warn`).**
|
|
3509
|
+
* - `bus-narrowed` — cache enabled, bus bridged. `info`, so the bridge's
|
|
3510
|
+
* presence is on the record next to its absence.
|
|
3511
|
+
*
|
|
3512
|
+
* Both arms are pinned in `authz-cache-posture.test.ts`: a statement that
|
|
3513
|
+
* appears always is no more useful than one that never appears.
|
|
3514
|
+
*/
|
|
3515
|
+
/** Deployment variable that turns the grants cache on. `0` (default) = off. */
|
|
3516
|
+
declare const AUTHZ_GRANTS_CACHE_TTL_ENV = "OS_AUTHZ_GRANTS_CACHE_TTL_MS";
|
|
3517
|
+
/**
|
|
3518
|
+
* What the local node has, in cross-node terms, for delivering
|
|
3519
|
+
* `authz.invalidated`.
|
|
3520
|
+
*
|
|
3521
|
+
* ⚠️ `in-process` is a distinct state on purpose, and it is the one that would
|
|
3522
|
+
* otherwise go unnoticed: `Runtime` auto-registers a **memory** cluster service
|
|
3523
|
+
* by default, so "is a `cluster` service registered?" answers *yes* on the
|
|
3524
|
+
* shipped default while the bus fans out to exactly nobody
|
|
3525
|
+
* (`service-cluster/src/memory/pubsub.ts`: *"No cross-process delivery"*; the
|
|
3526
|
+
* split-brain guard calls the same set `IN_PROCESS_DRIVERS`). A posture check
|
|
3527
|
+
* that asked only whether a service exists would therefore stay silent in
|
|
3528
|
+
* precisely the multi-replica deployment it exists to warn.
|
|
3529
|
+
*/
|
|
3530
|
+
type AuthzInvalidationBusState =
|
|
3531
|
+
/** A cross-node transport is attached and carrying the channel. */
|
|
3532
|
+
'bridged'
|
|
3533
|
+
/** A cluster service exists, but its driver does not cross a process boundary. */
|
|
3534
|
+
| 'in-process'
|
|
3535
|
+
/** No cluster service, or no engine seam to attach one to. */
|
|
3536
|
+
| 'absent';
|
|
3537
|
+
/** The posture a boot resolves to. */
|
|
3538
|
+
type AuthzCachePosture = 'disabled' | 'ttl-only' | 'bus-narrowed';
|
|
3539
|
+
interface AuthzCachePostureInput {
|
|
3540
|
+
/** Configured grants-cache TTL in ms. `<= 0` means the cache is off. */
|
|
3541
|
+
ttlMs: number;
|
|
3542
|
+
/** What the node has for cross-node invalidation. */
|
|
3543
|
+
bus: AuthzInvalidationBusState;
|
|
3544
|
+
/** Cluster driver name, when one is registered. Surfaced in the message. */
|
|
3545
|
+
driver?: string;
|
|
3546
|
+
}
|
|
3547
|
+
interface AuthzCachePostureStatement {
|
|
3548
|
+
posture: AuthzCachePosture;
|
|
3549
|
+
/** True when this must be said at `warn`. See the module doc. */
|
|
3550
|
+
loud: boolean;
|
|
3551
|
+
/** The statement itself. Empty only for the silent `disabled` posture. */
|
|
3552
|
+
message: string;
|
|
3553
|
+
}
|
|
3554
|
+
/**
|
|
3555
|
+
* Resolve the posture. Pure — it reads its inputs and nothing else, so both
|
|
3556
|
+
* arms of the acceptance criterion ("appears exactly when a cache flag is on
|
|
3557
|
+
* without a bus, and not otherwise") are testable without a boot.
|
|
3558
|
+
*/
|
|
3559
|
+
declare function resolveAuthzCachePosture(input: AuthzCachePostureInput): AuthzCachePostureStatement;
|
|
3560
|
+
/** The reading of {@link AUTHZ_GRANTS_CACHE_TTL_ENV}, malformed input included. */
|
|
3561
|
+
interface AuthzGrantsCacheTtlReading {
|
|
3562
|
+
/** The effective TTL. `0` whenever the cache is off — including malformed. */
|
|
3563
|
+
ttlMs: number;
|
|
3564
|
+
/** The raw value read, when one was set. */
|
|
3565
|
+
raw?: string;
|
|
3566
|
+
/** True when a value was set but could not be read as a non-negative number. */
|
|
3567
|
+
malformed: boolean;
|
|
3568
|
+
}
|
|
3569
|
+
/**
|
|
3570
|
+
* Read the grants-cache TTL from deployment config.
|
|
3571
|
+
*
|
|
3572
|
+
* Deployment config, never a settings row (#11633 §5): the knob that bounds a
|
|
3573
|
+
* cache must not itself be served through a cached path, and operator-level
|
|
3574
|
+
* configuration comes from the environment.
|
|
3575
|
+
*
|
|
3576
|
+
* Default `0` — the grants cache is **off** unless a deployment turns it on and
|
|
3577
|
+
* accepts the staleness window explicitly (#11633 Fork 4, ruled 2026-08-25).
|
|
3578
|
+
*
|
|
3579
|
+
* ⚠️ A malformed value resolves to `0` but is reported as malformed rather than
|
|
3580
|
+
* folded into "off": `OS_AUTHZ_GRANTS_CACHE_TTL_MS=5OOO` (letter O) silently
|
|
3581
|
+
* meaning "disabled" is the same silent-disable class the posture statement
|
|
3582
|
+
* exists to prevent.
|
|
3583
|
+
*/
|
|
3584
|
+
declare function readAuthzGrantsCacheTtlMs(env?: Record<string, string | undefined>): AuthzGrantsCacheTtlReading;
|
|
3585
|
+
/** Minimal sink shape — `warn` is the member every logger in this repo has. */
|
|
3586
|
+
interface AuthzPostureSink {
|
|
3587
|
+
warn(message: string, meta?: Record<string, unknown>): void;
|
|
3588
|
+
info?(message: string, meta?: Record<string, unknown>): void;
|
|
3589
|
+
debug?(message: string, meta?: Record<string, unknown>): void;
|
|
3590
|
+
}
|
|
3591
|
+
/**
|
|
3592
|
+
* State the posture at boot. `warn` for the loud arm, `info` for the bridged
|
|
3593
|
+
* one, and nothing at all when no cache is enabled (see the module doc for why
|
|
3594
|
+
* silence is the right default rather than a courtesy line).
|
|
3595
|
+
*
|
|
3596
|
+
* A malformed TTL value is warned about on its own, because "we read your
|
|
3597
|
+
* setting as off" is exactly what a deployment must not have to infer.
|
|
3598
|
+
*/
|
|
3599
|
+
declare function reportAuthzCachePosture(input: AuthzCachePostureInput & {
|
|
3600
|
+
malformedTtl?: {
|
|
3601
|
+
raw?: string;
|
|
3602
|
+
};
|
|
3603
|
+
}, sink: AuthzPostureSink): AuthzCachePostureStatement;
|
|
3604
|
+
|
|
2789
3605
|
/**
|
|
2790
3606
|
* Environment utilities for universal (Node/Browser) compatibility.
|
|
2791
3607
|
*/
|
|
@@ -3407,6 +4223,204 @@ declare function temporalComparandKind(fieldType: unknown): TemporalComparandKin
|
|
|
3407
4223
|
*/
|
|
3408
4224
|
declare function isUninterpretableTemporalComparand(kind: TemporalComparandKind, value: unknown): boolean;
|
|
3409
4225
|
|
|
4226
|
+
/**
|
|
4227
|
+
* [ADR-0126 §4] THE activation-ledger row contract — one implementation,
|
|
4228
|
+
* parameterized by `metadata_type`.
|
|
4229
|
+
*
|
|
4230
|
+
* ## Why this file exists (#12350)
|
|
4231
|
+
*
|
|
4232
|
+
* ADR-0126 §4 declares ONE activation ledger for the whole disable+clone
|
|
4233
|
+
* family. It briefly had two implementations of that one row contract:
|
|
4234
|
+
*
|
|
4235
|
+
* | Implementation | Package | Landed |
|
|
4236
|
+
* | :-------------------------------- | :--------------------------------- | :----- |
|
|
4237
|
+
* | `ObjectStoreFlowActivationStore` | `@objectstack/service-automation` | #12296 |
|
|
4238
|
+
* | `ObjectStoreActionActivationStore`| `@objectstack/objectql` | #12348 |
|
|
4239
|
+
*
|
|
4240
|
+
* They agreed on every load-bearing detail because the second was written from
|
|
4241
|
+
* the first — and nothing structurally held them together. ADR-0126 §8
|
|
4242
|
+
* pre-charts `tool`, `skill` and `position` as later consumers, and a third
|
|
4243
|
+
* and fourth copy is where the row semantics start drifting: the org-row skip
|
|
4244
|
+
* and the `0`-is-false read are exactly the kind of detail a copy loses
|
|
4245
|
+
* quietly, in a direction (an artifact silently re-arming) nothing else
|
|
4246
|
+
* measures.
|
|
4247
|
+
*
|
|
4248
|
+
* ## Why the code lives HERE and the object does not
|
|
4249
|
+
*
|
|
4250
|
+
* Neither consumer could import the other: `@objectstack/service-automation`
|
|
4251
|
+
* does not depend on `@objectstack/objectql` (devDependency only), and the
|
|
4252
|
+
* engine must not depend on a service — the dependency arrow points the other
|
|
4253
|
+
* way. `@objectstack/core` is the package BOTH already depend on, so this is
|
|
4254
|
+
* the one home that needs no new edge. ⛔ NOT `@objectstack/platform-objects`,
|
|
4255
|
+
* which declares the OBJECT: `objectql` does not depend on it and adding that
|
|
4256
|
+
* edge would invert the tiering, since platform-objects is a catalog the
|
|
4257
|
+
* engine serves. That is a MODULE-IMPORT question, and it is independent of
|
|
4258
|
+
* where the object's REGISTRATION lives (a composition question, ruled
|
|
4259
|
+
* separately on #12359 — `PlatformObjectsPlugin`).
|
|
4260
|
+
*
|
|
4261
|
+
* The table is reached by NAME, never by importing the declaration, exactly as
|
|
4262
|
+
* the engine already reaches `sys_metadata` / `sys_secret`.
|
|
4263
|
+
*
|
|
4264
|
+
* ## The row shape — ⛔ this module writes COLUMNS, never schema
|
|
4265
|
+
*
|
|
4266
|
+
* `metadata_type` · `name` · `package_id` · `active`, exactly the four
|
|
4267
|
+
* ADR-0126 §4 declares. Three properties are load-bearing and each is pinned on
|
|
4268
|
+
* both consumers' sides (`flow-activation-ledger.test.ts`,
|
|
4269
|
+
* `action-activation.test.ts` — unchanged by the consolidation, which is what
|
|
4270
|
+
* makes them the proof it lost nothing):
|
|
4271
|
+
*
|
|
4272
|
+
* - **The ledger is DEPLOYMENT-level, and carries no tenant column at all.**
|
|
4273
|
+
* A row says "this environment switched this managed item off" — a fact no
|
|
4274
|
+
* organization owns. The table briefly declared a nullable tenant column
|
|
4275
|
+
* marked RESERVED and never written, and this module correspondingly
|
|
4276
|
+
* filtered reads to the NULL ones and skipped any row carrying an
|
|
4277
|
+
* organization. Both are gone: a reserved nullable tenant
|
|
4278
|
+
* column is the shape the total-organization-ownership record proposed in
|
|
4279
|
+
* PR #14976 rules out, so the column was dropped before it ever shipped
|
|
4280
|
+
* (17.2.0 predates the table). There is no filter here any more because
|
|
4281
|
+
* there is no column to filter on — `list()` is simply every activation
|
|
4282
|
+
* row of this type. Should a per-organization dimension ever be wanted, it
|
|
4283
|
+
* returns as a separate org-owned object, never as a column here.
|
|
4284
|
+
* - **Absence of a row means ACTIVE.** Nothing here ever writes a row to say
|
|
4285
|
+
* "active by default", and `list()` returning nothing is the normal
|
|
4286
|
+
* stock-boot state, not an error. Re-enabling UPDATES the row to
|
|
4287
|
+
* `active: true` rather than deleting it, so the ledger records the
|
|
4288
|
+
* administrator's CHOICE instead of erasing it (§6 wall 3) — which is why
|
|
4289
|
+
* {@link MetadataActivationStoreEngine} deliberately has no `delete`.
|
|
4290
|
+
* - **A driver `0` reads as false.** SQLite/libsql round-trip booleans as
|
|
4291
|
+
* 0/1; a `!== false` test alone would read a disabled artifact as armed.
|
|
4292
|
+
*
|
|
4293
|
+
* ## The discriminator is never optional
|
|
4294
|
+
*
|
|
4295
|
+
* The ledger is generic and shared — flow rows and action rows live in the
|
|
4296
|
+
* same table today, and §8 charts more. Every read and write below is scoped
|
|
4297
|
+
* by `metadata_type`, so no consumer can touch a neighbour's state through a
|
|
4298
|
+
* table all of them are told to treat as generic. It is a constructor
|
|
4299
|
+
* argument, not a per-call one, so a caller cannot forget it at a single site.
|
|
4300
|
+
*/
|
|
4301
|
+
/**
|
|
4302
|
+
* The ledger table. A NAME, not an import: the object is declared in
|
|
4303
|
+
* `@objectstack/platform-objects` and this package must not depend on it.
|
|
4304
|
+
*/
|
|
4305
|
+
declare const METADATA_ACTIVATION_TABLE = "sys_metadata_activation";
|
|
4306
|
+
/**
|
|
4307
|
+
* [ADR-0126 §4] One packaged artifact's install-level activation row.
|
|
4308
|
+
*
|
|
4309
|
+
* The ledger's own columns are `metadata_type` / `name` / `package_id` /
|
|
4310
|
+
* `active`; `metadata_type` is fixed by the store, so it never reaches a
|
|
4311
|
+
* consumer's projection.
|
|
4312
|
+
*/
|
|
4313
|
+
interface MetadataActivationRow {
|
|
4314
|
+
/** The packaged artifact's declarative machine name (ADR-0126 §4). */
|
|
4315
|
+
name: string;
|
|
4316
|
+
/** The package that ships the base artifact. */
|
|
4317
|
+
packageId: string;
|
|
4318
|
+
/** Is the packaged artifact armed for this installation. */
|
|
4319
|
+
active: boolean;
|
|
4320
|
+
}
|
|
4321
|
+
/**
|
|
4322
|
+
* [ADR-0126 §4] The durable off-switch for one class of packaged artifact.
|
|
4323
|
+
*
|
|
4324
|
+
* Absence of a row means the packaged default — ACTIVE — so a runtime with no
|
|
4325
|
+
* store attached, or a store with no rows, behaves exactly as a stock boot
|
|
4326
|
+
* always has.
|
|
4327
|
+
*/
|
|
4328
|
+
interface MetadataActivationStore {
|
|
4329
|
+
/** Every activation row for this type — the ledger is deployment-wide. */
|
|
4330
|
+
list(): Promise<MetadataActivationRow[]>;
|
|
4331
|
+
/** Insert or update the row for one packaged artifact. */
|
|
4332
|
+
setActive(row: MetadataActivationRow): Promise<void>;
|
|
4333
|
+
}
|
|
4334
|
+
/**
|
|
4335
|
+
* The exact engine slice this store needs: a keyed read, an insert and an
|
|
4336
|
+
* update. Deliberately WITHOUT `delete` — re-enabling updates the `active`
|
|
4337
|
+
* bit, it never removes the row (see the module header), and demanding only
|
|
4338
|
+
* what is used keeps every test double honest about that.
|
|
4339
|
+
*/
|
|
4340
|
+
interface MetadataActivationStoreEngine {
|
|
4341
|
+
find(object: string, options?: any): Promise<any[]>;
|
|
4342
|
+
insert(object: string, data: any, options?: any): Promise<any>;
|
|
4343
|
+
update(object: string, data: any, options?: any): Promise<any>;
|
|
4344
|
+
}
|
|
4345
|
+
/**
|
|
4346
|
+
* In-memory {@link MetadataActivationStore} — process-lifetime only, for tests
|
|
4347
|
+
* and for hosts with no durable plane. What it lacks versus the ObjectStore
|
|
4348
|
+
* implementation is DURABILITY, which is exactly the property ADR-0126 §6
|
|
4349
|
+
* wall 3 asks for; it is not a sanctioned production off-switch.
|
|
4350
|
+
*
|
|
4351
|
+
* No discriminator: an in-memory map is per-instance, so there is no shared
|
|
4352
|
+
* table for a neighbouring type's rows to be in.
|
|
4353
|
+
*/
|
|
4354
|
+
declare class InMemoryMetadataActivationStore implements MetadataActivationStore {
|
|
4355
|
+
private readonly rows;
|
|
4356
|
+
list(): Promise<MetadataActivationRow[]>;
|
|
4357
|
+
setActive(row: MetadataActivationRow): Promise<void>;
|
|
4358
|
+
}
|
|
4359
|
+
/**
|
|
4360
|
+
* Durable {@link MetadataActivationStore} backed by the
|
|
4361
|
+
* `sys_metadata_activation` object (ADR-0126 §4), scoped to one
|
|
4362
|
+
* `metadata_type`.
|
|
4363
|
+
*
|
|
4364
|
+
* All access uses a system context: the object is `managedBy: 'engine-owned'`
|
|
4365
|
+
* and declares `apiMethods: ['get', 'list']`, i.e. the generic data API cannot
|
|
4366
|
+
* write it at all — these rows are written by the ADR-0126 enable/disable
|
|
4367
|
+
* doors and by nothing else.
|
|
4368
|
+
*/
|
|
4369
|
+
declare class ObjectStoreMetadataActivationStore implements MetadataActivationStore {
|
|
4370
|
+
private readonly engine;
|
|
4371
|
+
/**
|
|
4372
|
+
* The ledger's `metadata_type` discriminator for this consumer —
|
|
4373
|
+
* `'flow'`, `'action'`, … Required, and fixed for the store's
|
|
4374
|
+
* lifetime: see the module header on why it is never a per-call
|
|
4375
|
+
* argument.
|
|
4376
|
+
*/
|
|
4377
|
+
private readonly metadataType;
|
|
4378
|
+
constructor(engine: MetadataActivationStoreEngine,
|
|
4379
|
+
/**
|
|
4380
|
+
* The ledger's `metadata_type` discriminator for this consumer —
|
|
4381
|
+
* `'flow'`, `'action'`, … Required, and fixed for the store's
|
|
4382
|
+
* lifetime: see the module header on why it is never a per-call
|
|
4383
|
+
* argument.
|
|
4384
|
+
*/
|
|
4385
|
+
metadataType: string);
|
|
4386
|
+
/**
|
|
4387
|
+
* Every row of this type. Read once at boot to hydrate the consumer's
|
|
4388
|
+
* projection.
|
|
4389
|
+
*
|
|
4390
|
+
* The only scoping is the `metadata_type` discriminator: the ledger is
|
|
4391
|
+
* deployment-wide and has no tenant column, so there is no second axis to
|
|
4392
|
+
* filter on (see the module header).
|
|
4393
|
+
*/
|
|
4394
|
+
list(): Promise<MetadataActivationRow[]>;
|
|
4395
|
+
/**
|
|
4396
|
+
* Insert or update the row for one packaged artifact.
|
|
4397
|
+
*
|
|
4398
|
+
* Read-then-write rather than a blind upsert because the object's
|
|
4399
|
+
* uniqueness is a DECLARED index (`unique: 'global'` over
|
|
4400
|
+
* `(metadata_type, name)`), not a primary key this store controls: there is
|
|
4401
|
+
* no id to collide on, so an insert-and-catch could not tell "already
|
|
4402
|
+
* there" from a real store failure.
|
|
4403
|
+
*
|
|
4404
|
+
* That index is also why taking the FIRST match is taking the only one: the
|
|
4405
|
+
* read below is keyed on exactly the index's two columns, so it can match
|
|
4406
|
+
* at most one row. It used to pick the first row with a NULL organization
|
|
4407
|
+
* out of the result, back when the table carried a reserved tenant column;
|
|
4408
|
+
* with no such column the set it was choosing from can no longer hold more
|
|
4409
|
+
* than one member.
|
|
4410
|
+
*/
|
|
4411
|
+
setActive(row: MetadataActivationRow): Promise<void>;
|
|
4412
|
+
/**
|
|
4413
|
+
* Read the backing table once so a misconfiguration surfaces at BOOT
|
|
4414
|
+
* rather than as a failed toggle later. Throws the driver error verbatim —
|
|
4415
|
+
* `no such table: sys_metadata_activation` means the object was never
|
|
4416
|
+
* registered (or its schema never synced) in this composition.
|
|
4417
|
+
*
|
|
4418
|
+
* ⚠️ Unscoped by design: the question is "does the TABLE read at all",
|
|
4419
|
+
* which is a property of the composition, not of one `metadata_type`.
|
|
4420
|
+
*/
|
|
4421
|
+
probe(): Promise<void>;
|
|
4422
|
+
}
|
|
4423
|
+
|
|
3410
4424
|
/**
|
|
3411
4425
|
* [#4435] The 404 a single-record operation answers when the id names no row.
|
|
3412
4426
|
*
|
|
@@ -3710,7 +4724,7 @@ declare const CORE_FALLBACK_FACTORIES: Record<string, () => Record<string, any>>
|
|
|
3710
4724
|
* (#7378 row 2). Folds a plural manifest spelling to the singular metadata
|
|
3711
4725
|
* type name (`'objects'` → `'object'`, `'views'` → `'view'`, …) through the
|
|
3712
4726
|
* platform's one plural↔singular map (`PLURAL_TO_SINGULAR`,
|
|
3713
|
-
* `@objectstack/spec/
|
|
4727
|
+
* `@objectstack/spec/meta-spelling`); a name with no plural mapping — which includes
|
|
3714
4728
|
* every canonical singular type — passes through unchanged.
|
|
3715
4729
|
*/
|
|
3716
4730
|
declare function canonicalMetadataServiceType(type: string): string;
|
|
@@ -3739,8 +4753,37 @@ declare function assertMetadataRegisterContract(type: string, name: string, data
|
|
|
3739
4753
|
/**
|
|
3740
4754
|
* Plugin Health Monitor
|
|
3741
4755
|
*
|
|
3742
|
-
* Monitors plugin health status
|
|
3743
|
-
*
|
|
4756
|
+
* Monitors plugin health status. It REPORTS; it does not act on what it finds.
|
|
4757
|
+
*
|
|
4758
|
+
* ## The monitor no longer "restarts" anything (#12032)
|
|
4759
|
+
*
|
|
4760
|
+
* It used to claim it did. `attemptRestart` called `plugin.destroy()` and
|
|
4761
|
+
* stopped there — the comment above the call read "Call destroy and init to
|
|
4762
|
+
* restart", and `init` appeared in this file ONLY inside that comment. What a
|
|
4763
|
+
* plugin got was: destroy, a log line reading 'Plugin restarted', status
|
|
4764
|
+
* `recovering`, and periodic checks continuing against the destroyed instance.
|
|
4765
|
+
* The default check when no `checkMethod` resolves is
|
|
4766
|
+
* `{ name: 'plugin-loaded', status: 'passed' }`, which a destroyed plugin
|
|
4767
|
+
* passes indefinitely, so the TERMINAL report on a destroyed, never
|
|
4768
|
+
* re-initialised plugin was `healthy` — and #11955 made that MORE convincing
|
|
4769
|
+
* rather than less, because reaching `healthy` now costs `successThreshold`
|
|
4770
|
+
* consecutive passing rounds.
|
|
4771
|
+
*
|
|
4772
|
+
* The restart could not be repaired in place. `Plugin.init(ctx)` needs a
|
|
4773
|
+
* `PluginContext`, and the only two `plugin.init(...)` call sites in the tree
|
|
4774
|
+
* are the kernel's own boot loops, over the full plugin list, with a context
|
|
4775
|
+
* that is `private` on `ObjectKernel` and `protected` on `KernelBase`. No host
|
|
4776
|
+
* can obtain one, so there was nothing for a re-init hook to call. ADR-0049
|
|
4777
|
+
* enforce-or-remove, with no roadmap to point EXPERIMENTAL at, therefore
|
|
4778
|
+
* removed the declaration: `autoRestart`, `maxRestartAttempts` and
|
|
4779
|
+
* `restartBackoff` are tombstoned in `@objectstack/spec` 18, and this class
|
|
4780
|
+
* refuses a config that still carries one instead of accepting it and doing
|
|
4781
|
+
* something else.
|
|
4782
|
+
*
|
|
4783
|
+
* What a failing plugin gets now is the truth: `degraded`, `unhealthy` or
|
|
4784
|
+
* `failed`, and no destroy. Acting on that is the HOST's job — this is a
|
|
4785
|
+
* host-driven library (#11825 route 2), and the host is the only party that
|
|
4786
|
+
* owns the plugin's lifetime.
|
|
3744
4787
|
*/
|
|
3745
4788
|
declare class PluginHealthMonitor {
|
|
3746
4789
|
private logger;
|
|
@@ -3750,7 +4793,6 @@ declare class PluginHealthMonitor {
|
|
|
3750
4793
|
private checkIntervals;
|
|
3751
4794
|
private failureCounters;
|
|
3752
4795
|
private successCounters;
|
|
3753
|
-
private restartAttempts;
|
|
3754
4796
|
constructor(logger: ObjectLogger);
|
|
3755
4797
|
/**
|
|
3756
4798
|
* Register a plugin for health monitoring
|
|
@@ -3769,13 +4811,28 @@ declare class PluginHealthMonitor {
|
|
|
3769
4811
|
*/
|
|
3770
4812
|
private performHealthCheck;
|
|
3771
4813
|
/**
|
|
3772
|
-
*
|
|
3773
|
-
|
|
3774
|
-
|
|
3775
|
-
|
|
3776
|
-
*
|
|
4814
|
+
* Handle one failed round — the single path BOTH failure routes take.
|
|
4815
|
+
*
|
|
4816
|
+
* `performHealthCheck` can fail two disjoint ways: the check *returns* a
|
|
4817
|
+
* failure (`false` or `{ status: 'unhealthy' }`), or it *throws* — which by
|
|
4818
|
+
* `raceCheckTimeout` includes every `timeout` overrun, the severest case of
|
|
4819
|
+
* the two. The routes used to be handled in separate blocks, and only the
|
|
4820
|
+
* returned one cleared `successCounters`, so the counters a declared
|
|
4821
|
+
* `failureThreshold` / `successThreshold` are counted with depended on which
|
|
4822
|
+
* way the round happened to fail (#11852).
|
|
4823
|
+
*
|
|
4824
|
+
* What stays route-specific is the *status label*, deliberately. A throw is
|
|
4825
|
+
* the separate `failed` status applied immediately with no threshold — that
|
|
4826
|
+
* is the documented contract (`content/docs/protocol/kernel/lifecycle.mdx`,
|
|
4827
|
+
* "Custom Health Checks") and is pinned by the timeout test. Only the
|
|
4828
|
+
* counters are shared, because that is what `failureThreshold` declares, and
|
|
4829
|
+
* it does not name a route.
|
|
4830
|
+
*
|
|
4831
|
+
* This round ENDS here. Nothing is done TO the plugin — see the #12032 note
|
|
4832
|
+
* on the class: a monitor that cannot re-initialise a plugin has no business
|
|
4833
|
+
* destroying one.
|
|
3777
4834
|
*/
|
|
3778
|
-
private
|
|
4835
|
+
private recordFailedRound;
|
|
3779
4836
|
/**
|
|
3780
4837
|
* Get current health status of a plugin
|
|
3781
4838
|
*/
|
|
@@ -3857,7 +4914,6 @@ declare class HotReloadManager {
|
|
|
3857
4914
|
private logger;
|
|
3858
4915
|
private stateManager;
|
|
3859
4916
|
private reloadConfigs;
|
|
3860
|
-
private watchHandles;
|
|
3861
4917
|
private reloadTimers;
|
|
3862
4918
|
constructor(logger: ObjectLogger);
|
|
3863
4919
|
/**
|
|
@@ -3865,11 +4921,35 @@ declare class HotReloadManager {
|
|
|
3865
4921
|
*/
|
|
3866
4922
|
registerPlugin(pluginName: string, config: HotReloadConfigParsed): void;
|
|
3867
4923
|
/**
|
|
3868
|
-
*
|
|
4924
|
+
* Refuse the file-watching call this class never implemented (#12428).
|
|
4925
|
+
*
|
|
4926
|
+
* The body used to be a guard plus `logger.info('File watching started')`
|
|
4927
|
+
* over an in-source note saying real watching "would require chokidar or
|
|
4928
|
+
* similar". Nothing was ever watched, so an operator who set
|
|
4929
|
+
* `enabled: true` and read that line at INFO had been told the opposite of
|
|
4930
|
+
* the truth — positive confirmation of a capability that did not exist.
|
|
4931
|
+
* ADR-0049 leaves three states and this surface qualified for none of the
|
|
4932
|
+
* other two: no runtime composes this class, so ENFORCE would build for a
|
|
4933
|
+
* caller that does not exist, and no roadmap entry anywhere claims the
|
|
4934
|
+
* feature, so EXPERIMENTAL would be a promise nobody made.
|
|
4935
|
+
*
|
|
4936
|
+
* Kept as a throwing door rather than deleted: removing the method leaves a
|
|
4937
|
+
* JavaScript host a bare `TypeError: not a function` with no prescription,
|
|
4938
|
+
* and this is the one place a caller of the old placeholder is guaranteed
|
|
4939
|
+
* to arrive. The refusal carries an ADR-0112 envelope so it can be asserted
|
|
4940
|
+
* rather than merely caught.
|
|
3869
4941
|
*/
|
|
3870
|
-
startWatching(pluginName: string):
|
|
4942
|
+
startWatching(pluginName: string): never;
|
|
3871
4943
|
/**
|
|
3872
|
-
*
|
|
4944
|
+
* Cancel a pending debounced reload for a plugin.
|
|
4945
|
+
*
|
|
4946
|
+
* The name is historical (#12428). This never stopped a watcher, because
|
|
4947
|
+
* nothing in this class ever started one: its `watchHandles` cleanup branch
|
|
4948
|
+
* read a Map that had no writer anywhere in the tree, so the branch was
|
|
4949
|
+
* structurally unreachable rather than merely untaken, and it left with
|
|
4950
|
+
* `startWatching`'s placeholder. What survives is the half that always did
|
|
4951
|
+
* something — the debounce timer armed by `scheduleReload` is cleared, so a
|
|
4952
|
+
* reload that was scheduled but has not fired yet is cancelled.
|
|
3873
4953
|
*/
|
|
3874
4954
|
stopWatching(pluginName: string): void;
|
|
3875
4955
|
/**
|
|
@@ -4053,4 +5133,4 @@ declare class NamespaceResolver {
|
|
|
4053
5133
|
private suggestAlternative;
|
|
4054
5134
|
}
|
|
4055
5135
|
|
|
4056
|
-
export { ADMIN_STANDING_SURFACE, ANONYMOUS_DENY_BODY, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, API_KEY_PREFIX, AUDIENCE_BINDING_SUGGESTION_STATUSES, AUDIENCE_BINDING_SUGGESTION_STATUS_VALUES, type ActivatableRow, type AdminStandingTable, type AnonymousDenyInput, type ApiKeyAdmission, type ApiKeyPrincipal, type ApiKeyRefusalReason, type AudienceBindingSuggestionStatus, type AuthGate, type BucketGranularity, type BulkWriteOptions, type BulkWriteRowResult, CORE_FALLBACK_FACTORIES, type CalendarParts, DependencyResolver, ENTRY_EXECUTION_CONTEXT_FIELDS, type EngineWithTransaction, type EntryExecutionContextField, type EntryLocalization, type ExecutionContextAssemblyInput, type ExecutionContextEntryFields, type ExecutionContextLike, type FilterTokenResolutionContext, type GeneratedApiKey, type GrantValidityWindow, HotReloadManager, type InterruptedRun, type KernelState, type KeyInput, type LadderPrincipal, type LadderRow, LiteKernel, type MigrationChunk, type MigrationChunkContext, MigrationJournalRefusal, type MigrationPlan, type MigrationPlanProvider, MigrationPlanRegistry, type MigrationPlanStep, type MigrationRunResult, type NamespaceCheckResult, type NamespaceConflict, type NamespaceEntry, NamespaceResolver, type OAuthTokenProvenance, OPERATION_PRIVATE_KEY_PREFIX, ObjectKernel, ObjectKernelBase, type ObjectKernelConfig, ObjectLogger, type OrderablePlugin, POSTURE_INJECTION_RULE, POSTURE_LADDER, POSTURE_RANK, type ParsedSignature, type PermissionCheckResult$1 as PermissionCheckResult, type PermissionGrant, type
|
|
5136
|
+
export { ADMIN_STANDING_NON_TABLE_INPUTS, ADMIN_STANDING_SURFACE, ANONYMOUS_DENY_BODY, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, API_KEY_PREFIX, AUDIENCE_BINDING_SUGGESTION_STATUSES, AUDIENCE_BINDING_SUGGESTION_STATUS_VALUES, AUTHZ_GRANTS_CACHE_TTL_ENV, AUTHZ_INVALIDATED_CHANNEL, AUTHZ_STORE_UNAVAILABLE_CODE, AUTHZ_STORE_UNAVAILABLE_MESSAGE, AUTHZ_STORE_UNAVAILABLE_STATUS, type ActivatableRow, type AdminStandingNonTableInput, type AdminStandingTable, type AnonymousDenyInput, type ApiKeyAdmission, type ApiKeyPrincipal, type ApiKeyRefusalReason, type ArtifactPackageError, type AudienceBindingSuggestionStatus, type AuthGate, type AuthzCachePosture, type AuthzCachePostureInput, type AuthzCachePostureStatement, type AuthzGrantsCacheTtlReading, type AuthzInvalidatedPayload, type AuthzInvalidationBusState, type AuthzInvalidationReason, type AuthzPostureSink, AuthzStoreUnavailableError, type BucketGranularity, type BulkWriteOptions, type BulkWriteRowResult, CORE_FALLBACK_FACTORIES, type CalendarParts, DependencyResolver, ENTRY_EXECUTION_CONTEXT_FIELDS, type EngineWithTransaction, type EntryExecutionContextField, type EntryLocalization, type ExecutionContextAssemblyInput, type ExecutionContextEntryFields, type ExecutionContextLike, type FilterTokenResolutionContext, type GeneratedApiKey, type GrantValidityWindow, HotReloadManager, InMemoryMetadataActivationStore, type IntegrityFile, type IntegrityViolation, type IntegrityViolationKind, type InterruptedRun, type KernelState, type KeyInput, type LadderPrincipal, type LadderRow, LiteKernel, METADATA_ACTIVATION_TABLE, type MetadataActivationRow, type MetadataActivationStore, type MetadataActivationStoreEngine, type MigrationChunk, type MigrationChunkContext, MigrationJournalRefusal, type MigrationPlan, type MigrationPlanProvider, MigrationPlanRegistry, type MigrationPlanStep, type MigrationRunResult, type NamespaceCheckResult, type NamespaceConflict, type NamespaceEntry, NamespaceResolver, type OAuthTokenProvenance, OPERATION_PRIVATE_KEY_PREFIX, ObjectKernel, ObjectKernelBase, type ObjectKernelConfig, ObjectLogger, ObjectStoreMetadataActivationStore, type OrderablePlugin, PLATFORM_ADMIN_EMAIL_SEPARATOR, POSTURE_INJECTION_RULE, POSTURE_LADDER, POSTURE_RANK, type ParsedSignature, type PermissionCheckResult$1 as PermissionCheckResult, type PermissionGrant, type PlatformAdminConfigSink, type PlatformAdminEmailConfig, type Plugin, type PluginArtifactVerifyResult, type PluginContext, PluginHealthMonitor, type PluginHealthStatus, type PluginLoadResult, PluginLoader, type PluginMetadata, type PermissionCheckResult as PluginPermissionCheckResult, PluginPermissionEnforcer, PluginPermissionManager, type PluginPermissions, PluginSandboxRuntime, PluginSecurityScanner, type PluginSignatureConfig, PluginSignatureVerifier, type PluginStartupResult, type PluginType, type PostureEvidence, type PublisherVerifyResult, index as QA, type ResolveAuthzInput, type ResolveLocalizationInput, type ResolveUserAuthzGrantsOptions, type ResolvedAuthzContext, type ResourceUsage, type RetryOptions, type RunMigrationJournalOptions, SERVICE_NOT_REGISTERED_CODE, SIGNATURE_ALG, type SandboxContext, type ScanTarget, SecurePluginContext, type SecurityIssue, SemanticVersionManager, type ServiceFactory, ServiceLifecycle, type ServiceRegistration, type SignatureVerificationResult, type TemporalComparandKind, type TenancyPostureSource, UnknownFilterTokenError, UnresolvedFilterTokenError, type UserAuthzGrants, type VerifyIntegrityResult, type VersionCompatibility, type WallClockParts, adminStandingColumns, adminStandingTables, artifactPackageId, assembleExecutionContext, assembleExecutionContextOrGuest, assertInitServiceRequirements, assertMetadataRegisterContract, bucketKeyToCalendarRange, buildPermissionsFromGrants, bulkWrite, calendarPartsInTz, calendarPartsInTzOrUtc, canonicalMetadataServiceType, collectInternalWriteResponseFields, counterSignPayload, createMemoryCache, createMemoryI18n, createMemoryJob, createMemoryMetadata, createMemoryQueue, createPluginPermissionEnforcer, deepMerge, defaultIsTransientError, derivePosture, describeInitOrderFault, effectiveTenancyPosture, engineCanRollBack, evaluateAuthGate, extractApiKey, filterTokenContextFrom, findInterruptedRuns, formatIntegrityViolation, generateApiKey, generateEd25519KeyPair, getEnv, getMemoryUsage, hasPlatformAdminStanding, hashApiKey, hashMigrationPlan, isAudienceBindingSuggestionStatus, isAuthGateAllowlisted, isAuthzStoreUnavailableError, isConfiguredPlatformAdminEmail, isExpired, isGrantActive, isGrantExpired, isNode, isRowActive, isServiceNotRegisteredError, isUninterpretableTemporalComparand, matchesConfiguredPlatformAdmin, normalizeAuthGate, normalizePlatformAdminEmail, omitInternalFieldsFromWriteResponse, parsePlatformAdminEmails, parseScopes, parseSignature, planChunks, postureVisibleRows, readAuthoredTranslationLayer, readAuthzGrantsCacheTtlMs, readRunJournal, recordNotFoundError, reportAuthzCachePosture, reportLegacyPlatformAdminGrant, resetLegacyPlatformAdminGrantReport, resetPlatformAdminEmailMemo, resolveApiKeyAdmission, resolveApiKeyPrincipal, resolveArtifactPackageOrder, resolveAuthzCachePosture, resolveAuthzContext, resolveFilterToken, resolveFilterTokens, resolveLocale, resolveLocalizationContext, resolvePlatformAdminEmails, resolvePluginOrder, resolveUserAuthzGrants, resumeMigrationJournal, rethrowAuthzStoreUnavailable, runMigrationJournal, safeExit, setPlatformAdminConfigSink, shouldDenyAnonymous, signPayload, temporalComparandKind, unknownAudienceBindingSuggestionStatusMessage, validateInitServiceContract, verifyIntegrity, verifyPayload, verifyPlatformSignature, verifyPluginArtifact, verifyPublisherSignature, wireAuthoredTranslationSync, withTransientRetry, withoutOperationPrivateKeys, zonedDateStartToUtcMs, zonedWallClockToUtcMs };
|