@objectstack/core 17.2.0 → 17.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2099 -0
- package/dist/index.cjs +1108 -543
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1445 -233
- package/dist/index.d.ts +1445 -233
- package/dist/index.js +1064 -531
- package/dist/index.js.map +1 -1
- package/dist/logger.cjs.map +1 -1
- package/dist/logger.js.map +1 -1
- package/package.json +11 -6
package/dist/index.d.cts
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 { PluginDefinition, CORE_PLUGIN_TYPES, PluginCapability, PluginPermissions as PluginPermissions$1, PluginPermissionSet, ResourceType, PermissionAction, PluginPermission, SandboxConfig, ExecutionContext, PluginHealthCheckParsed, PluginHealthStatus as PluginHealthStatus$1, PluginHealthReport, HotReloadConfigParsed, VersionConstraint, DependencyConflict, SemanticVersion, CompatibilityLevel } from '@objectstack/spec/kernel';
|
|
5
5
|
import { ObjectLogger } from './logger.cjs';
|
|
6
6
|
export { createLogger } from './logger.cjs';
|
|
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
|
|
@@ -77,6 +72,27 @@ interface PluginLoadResult {
|
|
|
77
72
|
interface PluginStartupResult {
|
|
78
73
|
success: boolean;
|
|
79
74
|
pluginName: string;
|
|
75
|
+
/**
|
|
76
|
+
* Elapsed milliseconds the plugin's `start()` took.
|
|
77
|
+
*
|
|
78
|
+
* Named for the member `packages/spec` declares for the same measure --
|
|
79
|
+
* `PluginStartupResultSchema.durationMs` in
|
|
80
|
+
* `packages/spec/src/kernel/startup-orchestrator.zod.ts` ("Time taken to
|
|
81
|
+
* start the plugin in milliseconds"), where the bare `duration` spelling is
|
|
82
|
+
* retired: a duration-shaped number carries its unit in its key name. Like
|
|
83
|
+
* `PluginLoadResult.loadTime` above, it is the same `Date.now() - startTime`
|
|
84
|
+
* computation under a name that does not lie.
|
|
85
|
+
*/
|
|
86
|
+
durationMs?: number;
|
|
87
|
+
/**
|
|
88
|
+
* The same elapsed milliseconds as {@link PluginStartupResult.durationMs}.
|
|
89
|
+
*
|
|
90
|
+
* @deprecated Misnamed: this has never held an instant, so a reader who
|
|
91
|
+
* correctly takes `startTime` for one and writes `Date.now() - result.startTime`
|
|
92
|
+
* gets an age near the epoch instead of a wait. Read `durationMs` instead.
|
|
93
|
+
* Still populated so nothing has to change on this release (ADR-0087 L1 --
|
|
94
|
+
* the old shape keeps working while the fleet moves); slated for removal.
|
|
95
|
+
*/
|
|
80
96
|
startTime?: number;
|
|
81
97
|
error?: Error;
|
|
82
98
|
timedOut?: boolean;
|
|
@@ -97,7 +113,6 @@ interface VersionCompatibility {
|
|
|
97
113
|
declare class PluginLoader {
|
|
98
114
|
private logger;
|
|
99
115
|
private context?;
|
|
100
|
-
private configValidator;
|
|
101
116
|
private loadedPlugins;
|
|
102
117
|
private serviceFactories;
|
|
103
118
|
private serviceInstances;
|
|
@@ -158,9 +173,27 @@ declare class PluginLoader {
|
|
|
158
173
|
getLoadedPlugins(): Map<string, PluginMetadata>;
|
|
159
174
|
private toPluginMetadata;
|
|
160
175
|
private validatePluginStructure;
|
|
176
|
+
/**
|
|
177
|
+
* Refuse a plugin object the DECLARED plugin contract refuses (#16049,
|
|
178
|
+
* maintainer ruling 2026-09-06: "the protocol is the baseline; the runtime
|
|
179
|
+
* aligns to it").
|
|
180
|
+
*
|
|
181
|
+
* The check itself — `PluginSchema.safeParse` for validation only, the
|
|
182
|
+
* eight keys it reaches, the `version` exclusion and the
|
|
183
|
+
* `PLUGIN_CONTRACT_VIOLATION` envelope — lives in `plugin-contract.ts`,
|
|
184
|
+
* because since #16721 it is ONE statement run by BOTH kernels:
|
|
185
|
+
* `LiteKernel.use()` calls it directly, and `ObjectKernel.use()` reaches
|
|
186
|
+
* it here, through `loadPlugin`. That module's comment is the authority on
|
|
187
|
+
* what is refused; this method adds nothing to it and subtracts nothing.
|
|
188
|
+
*
|
|
189
|
+
* What stays THIS loader's own, and is deliberately not shared: the
|
|
190
|
+
* structural checks one call up ({@link validatePluginStructure} —
|
|
191
|
+
* `name`, `init`, semver) and the version-compatibility check below.
|
|
192
|
+
* The convergence is on the schema, not on the loader.
|
|
193
|
+
*/
|
|
194
|
+
private validatePluginContract;
|
|
161
195
|
private checkVersionCompatibility;
|
|
162
196
|
private isValidSemanticVersion;
|
|
163
|
-
private validatePluginConfig;
|
|
164
197
|
private verifyPluginSignature;
|
|
165
198
|
private getSingletonService;
|
|
166
199
|
private createTransientService;
|
|
@@ -211,7 +244,12 @@ declare class ObjectKernel {
|
|
|
211
244
|
private pluginLoader;
|
|
212
245
|
private config;
|
|
213
246
|
private startedPlugins;
|
|
214
|
-
|
|
247
|
+
/**
|
|
248
|
+
* Plugin name -> elapsed milliseconds that plugin's `start()` took. These
|
|
249
|
+
* are DURATIONS, never start instants; the old spelling `pluginStartTimes`
|
|
250
|
+
* said the opposite of what it held.
|
|
251
|
+
*/
|
|
252
|
+
private pluginStartupDurations;
|
|
215
253
|
private shutdownHandlers;
|
|
216
254
|
/**
|
|
217
255
|
* Name of the plugin whose init() is currently executing (Phase 1 is
|
|
@@ -251,6 +289,25 @@ declare class ObjectKernel {
|
|
|
251
289
|
* Validate Critical System Requirements
|
|
252
290
|
*/
|
|
253
291
|
private validateSystemRequirements;
|
|
292
|
+
/**
|
|
293
|
+
* Publish this boot's degraded-capabilities conclusion on
|
|
294
|
+
* {@link DEGRADED_CAPABILITIES_SERVICE} — the data half of the warning
|
|
295
|
+
* `validateSystemRequirements()` just logged (#16630).
|
|
296
|
+
*
|
|
297
|
+
* ⛔ Best-effort, and silent on failure BY DESIGN: this is a diagnostic
|
|
298
|
+
* readout, and a readout must never be able to fail a boot that the kernel
|
|
299
|
+
* has just decided is good enough to run. The one way `registerService`
|
|
300
|
+
* can throw here is a name collision, which the guard above already
|
|
301
|
+
* forecloses; the `catch` is there so that stays true if either ever
|
|
302
|
+
* changes. (`recordSeedOutcome` in `@objectstack/runtime` states the same
|
|
303
|
+
* rule for the same reason.)
|
|
304
|
+
*
|
|
305
|
+
* The value is FROZEN and holds a COPY. `getService` hands out the stored
|
|
306
|
+
* reference, so an unfrozen live array would let any reader edit the
|
|
307
|
+
* kernel's own record of what was missing — and this record exists
|
|
308
|
+
* precisely so that two packages cannot disagree about it.
|
|
309
|
+
*/
|
|
310
|
+
private publishDegradedCapabilities;
|
|
254
311
|
/**
|
|
255
312
|
* Bootstrap the kernel with enhanced features
|
|
256
313
|
*/
|
|
@@ -267,8 +324,18 @@ declare class ObjectKernel {
|
|
|
267
324
|
* Check health of all plugins
|
|
268
325
|
*/
|
|
269
326
|
checkAllPluginsHealth(): Promise<Map<string, any>>;
|
|
327
|
+
/**
|
|
328
|
+
* Per-plugin startup durations: plugin name -> elapsed milliseconds that
|
|
329
|
+
* plugin's `start()` took. Not start instants -- see
|
|
330
|
+
* {@link PluginStartupResult.durationMs}.
|
|
331
|
+
*/
|
|
332
|
+
getPluginStartupDurations(): Map<string, number>;
|
|
270
333
|
/**
|
|
271
334
|
* Get plugin startup metrics
|
|
335
|
+
*
|
|
336
|
+
* @deprecated Renamed to {@link ObjectKernel.getPluginStartupDurations},
|
|
337
|
+
* which states what the values are. Retained as a delegating alias so
|
|
338
|
+
* nothing has to change on this release; slated for removal.
|
|
272
339
|
*/
|
|
273
340
|
getPluginMetrics(): Map<string, number>;
|
|
274
341
|
/**
|
|
@@ -466,25 +533,70 @@ interface PluginContext {
|
|
|
466
533
|
*/
|
|
467
534
|
getKernel(): ObjectKernel;
|
|
468
535
|
}
|
|
536
|
+
/**
|
|
537
|
+
* The closed set of plugin types (#13925): `'standard'` plus the seven
|
|
538
|
+
* `CORE_PLUGIN_TYPES` members, in exactly the shape `PluginSchema.type`
|
|
539
|
+
* declares in `@objectstack/spec` (`kernel/plugin.zod.ts`:
|
|
540
|
+
* `z.enum(['standard', ...CORE_PLUGIN_TYPES])`). Derived from the spec's own
|
|
541
|
+
* constant rather than re-spelled here, so the compiler's accept set and the
|
|
542
|
+
* Zod gate's cannot drift apart: `plugin-type-closed-set.test.ts` pins the
|
|
543
|
+
* parity at runtime, and `packages/rest`'s `plugin-type-closed-set.pin.test.ts`
|
|
544
|
+
* pins the published `.d.ts` at compile time.
|
|
545
|
+
*/
|
|
546
|
+
type PluginType = 'standard' | (typeof CORE_PLUGIN_TYPES)[number];
|
|
469
547
|
/**
|
|
470
548
|
* Plugin Interface
|
|
471
549
|
*
|
|
472
550
|
* All ObjectStack plugins must implement this interface.
|
|
473
|
-
|
|
474
|
-
|
|
551
|
+
*
|
|
552
|
+
* ## Two halves, one contract (#16334)
|
|
553
|
+
*
|
|
554
|
+
* **The metadata half is inherited, not restated.** Every key `PluginSchema`
|
|
555
|
+
* declares (`@objectstack/spec`, `kernel/plugin.zod.ts`) — `id`, `type`,
|
|
556
|
+
* `staticPath`, `slug`, `default`, `version`, `description`, `author`,
|
|
557
|
+
* `homepage` — arrives here through `PluginDefinition`
|
|
558
|
+
* (`z.input<typeof PluginSchema>`), so the keys the compiler accepts on a
|
|
559
|
+
* plugin object and the keys `kernel.use()` validates
|
|
560
|
+
* (`PluginLoader.validatePluginContract`, #16049) are ONE declaration. Before
|
|
561
|
+
* this the interface spelled `type` and `version` itself and declared neither
|
|
562
|
+
* `staticPath` nor `slug`, so an in-repo `ui` plugin could not carry the two
|
|
563
|
+
* keys the schema requires of it without widening its own type — two shapes
|
|
564
|
+
* for one contract, free to drift.
|
|
565
|
+
*
|
|
566
|
+
* **The runtime half is declared here and only here**: `name`, the ADR-0116
|
|
567
|
+
* ordering declarations, and the `init` / `start` / `destroy` lifecycle. The
|
|
568
|
+
* spec's schema describes what a plugin OBJECT may say about itself, never
|
|
569
|
+
* what it does.
|
|
570
|
+
*
|
|
571
|
+
* ### `type`
|
|
572
|
+
*
|
|
573
|
+
* The inherited `type` is a {@link PluginType} — the closed set the spec
|
|
574
|
+
* declares (`'standard'` plus `CORE_PLUGIN_TYPES`); `packages/rest`'s
|
|
575
|
+
* `plugin-type-closed-set.pin.test.ts` pins that the inherited key and the
|
|
576
|
+
* exported alias are the same union. Absent means `'standard'` at the schema
|
|
577
|
+
* (`.default('standard')`), and the loader never writes that default back
|
|
578
|
+
* onto the object. A value outside the set no longer type-checks, and since
|
|
579
|
+
* #16049 `kernel.use()` REFUSES it at boot — `assertPluginContract`
|
|
580
|
+
* (`plugin-contract.ts`, run by BOTH `ObjectKernel.use()` and `LiteKernel.use()`
|
|
581
|
+
* since #16721) runs `PluginSchema` over every plugin object and raises
|
|
582
|
+
* `PLUGIN_CONTRACT_VIOLATION` naming the plugin and the first violated key.
|
|
583
|
+
* `type: 'ui'` additionally owes `staticPath` and `slug` (#16334,
|
|
584
|
+
* `PLUGIN_UI_REQUIRED_KEY_MISSING`), refused on the same path.
|
|
585
|
+
*
|
|
586
|
+
* ⚠️ This comment used to say a bad `type` was refused "at parse". It was
|
|
587
|
+
* measured false (#16049, from #15638): `PluginSchema` had no runtime caller,
|
|
588
|
+
* kernel plugin objects were never parsed, and a `type` outside the set was
|
|
589
|
+
* accepted and stored verbatim. The refusal described here is the one that
|
|
590
|
+
* now exists, on the boot path, and the compiler's arm is the second half
|
|
591
|
+
* rather than the only one — `kernel.use(plugin as any)` is a shipped
|
|
592
|
+
* in-repo pattern, and externally authored plugins never meet this compiler
|
|
593
|
+
* at all.
|
|
594
|
+
*/
|
|
595
|
+
interface Plugin extends PluginDefinition {
|
|
475
596
|
/**
|
|
476
597
|
* Unique plugin name (e.g., 'com.objectstack.engine.objectql')
|
|
477
598
|
*/
|
|
478
599
|
name: string;
|
|
479
|
-
/**
|
|
480
|
-
* Plugin version
|
|
481
|
-
*/
|
|
482
|
-
version?: string;
|
|
483
|
-
/**
|
|
484
|
-
* Plugin type (standard, ui, driver, server, app, theme, agent)
|
|
485
|
-
* @default 'standard'
|
|
486
|
-
*/
|
|
487
|
-
type?: string;
|
|
488
600
|
/**
|
|
489
601
|
* List of other plugin names that this plugin depends on.
|
|
490
602
|
* The kernel ensures these plugins are initialized before this one.
|
|
@@ -799,6 +911,41 @@ declare function describeInitOrderFault(currentlyInitializing: string | undefine
|
|
|
799
911
|
*/
|
|
800
912
|
declare function assertInitServiceRequirements(plugin: OrderablePlugin, isServiceRegistered: (name: string) => boolean): void;
|
|
801
913
|
|
|
914
|
+
/**
|
|
915
|
+
* Refusals raised by {@link resolveArtifactPackageOrder}, as ADR-0112 envelopes
|
|
916
|
+
* (`code` + `status`) — the shape this repository's rejection tests assert
|
|
917
|
+
* against, never a bare throw.
|
|
918
|
+
*/
|
|
919
|
+
type ArtifactPackageError = Error & {
|
|
920
|
+
code: string;
|
|
921
|
+
status: number;
|
|
922
|
+
};
|
|
923
|
+
/**
|
|
924
|
+
* The id one artifact package is keyed by.
|
|
925
|
+
*
|
|
926
|
+
* `||`, not `??`, on purpose: `ObjectQL.registerApp` keys the installed package
|
|
927
|
+
* on `manifest.id || manifest.name`, so an empty-string `id` falls back to
|
|
928
|
+
* `name` there. Every seam that has to name a package — this module's ordering
|
|
929
|
+
* map, and the install gate's co-ownership set (ADR-0130 D1) — reads the id
|
|
930
|
+
* through THIS function, so none of them can order or admit a package under a
|
|
931
|
+
* key the registry never stores it by.
|
|
932
|
+
*
|
|
933
|
+
* @returns The package id, or `undefined` when the manifest carries neither a
|
|
934
|
+
* usable `id` nor a usable `name`.
|
|
935
|
+
*/
|
|
936
|
+
declare function artifactPackageId(manifest: unknown): string | undefined;
|
|
937
|
+
/**
|
|
938
|
+
* Resolve an artifact into the manifests to register, in dependency-topological
|
|
939
|
+
* order (ADR-0130 D4 + D5).
|
|
940
|
+
*
|
|
941
|
+
* @param artifact - A release artifact (`{ packages: [...] }`), or a bare
|
|
942
|
+
* manifest / single-`manifest` artifact — both shapes are read.
|
|
943
|
+
* @returns The manifest bodies to register, in the order to register them.
|
|
944
|
+
* @throws An ADR-0112 envelope (`code` + `status: 422`) for a malformed entry or
|
|
945
|
+
* a duplicate package id, and `resolvePluginOrder`'s own error for a cycle.
|
|
946
|
+
*/
|
|
947
|
+
declare function resolveArtifactPackageOrder(artifact: unknown): unknown[];
|
|
948
|
+
|
|
802
949
|
/**
|
|
803
950
|
* ObjectKernel - MiniKernel Architecture
|
|
804
951
|
*
|
|
@@ -822,6 +969,26 @@ declare class LiteKernel extends ObjectKernelBase {
|
|
|
822
969
|
* Register a plugin
|
|
823
970
|
* @param plugin - Plugin instance
|
|
824
971
|
*
|
|
972
|
+
* A plugin object the DECLARED plugin contract refuses is refused here,
|
|
973
|
+
* with `PLUGIN_CONTRACT_VIOLATION` — the same check, the same envelope,
|
|
974
|
+
* that `ObjectKernel.use()` runs through `PluginLoader` (`plugin-contract.ts`
|
|
975
|
+
* is the one statement both kernels call; #16721, maintainer ruling
|
|
976
|
+
* 2026-09-08, option A under #9864's precedent that the kernels converge).
|
|
977
|
+
*
|
|
978
|
+
* This method used to write the object straight into the registry, so the
|
|
979
|
+
* same plugin was accepted by this kernel and refused by `ObjectKernel` —
|
|
980
|
+
* and `AGENTS.md` names THIS kernel for tests, so a plugin could be green
|
|
981
|
+
* in vitest and refused at production boot. Measured before converging
|
|
982
|
+
* (#16721 step 1): of 813 `LiteKernel.use()` calls reachable in this
|
|
983
|
+
* repository's suites, 807 were accepted by the schema unchanged and the
|
|
984
|
+
* six refusals came from three test-local fixture objects, none of them
|
|
985
|
+
* product code.
|
|
986
|
+
*
|
|
987
|
+
* Ordering, and why it is pinned: state first (`validateIdle`), then the
|
|
988
|
+
* contract, then registration — a refused plugin never reaches the
|
|
989
|
+
* registry, so it can neither be booted nor supersede an earlier
|
|
990
|
+
* registration under its name.
|
|
991
|
+
*
|
|
825
992
|
* Duplicate names OVERWRITE, with one `warn` naming both versions — the
|
|
826
993
|
* declared contract in `plugin-registration.ts`, applied identically by
|
|
827
994
|
* `ObjectKernel.use()` (#9864, maintainer ruling 2026-08-19).
|
|
@@ -862,6 +1029,100 @@ declare class LiteKernel extends ObjectKernelBase {
|
|
|
862
1029
|
isRunning(): boolean;
|
|
863
1030
|
}
|
|
864
1031
|
|
|
1032
|
+
/**
|
|
1033
|
+
* [#13905] The discriminator that tells **"nothing ever registered this
|
|
1034
|
+
* service"** apart from **"the service IS registered and could not be built"**
|
|
1035
|
+
* on the ASYNCHRONOUS resolution path.
|
|
1036
|
+
*
|
|
1037
|
+
* ## The fault
|
|
1038
|
+
*
|
|
1039
|
+
* `PluginLoader.getService` (reached through `Kernel.getServiceAsync`) answered
|
|
1040
|
+
* both facts with the same bare `Error`. A caller that holds only the rejection
|
|
1041
|
+
* therefore could not tell an UNWIRED embedder from a BROKEN one, and the only
|
|
1042
|
+
* thing separating them was message text.
|
|
1043
|
+
*
|
|
1044
|
+
* That mattered one layer out. `RestServer.computeExecCtx`'s kernel branch
|
|
1045
|
+
* absorbs a failed `getServiceAsync('objectql')` and degrades to "no engine is
|
|
1046
|
+
* wired". It must keep doing so — a kernel with no data plane is a SUPPORTED
|
|
1047
|
+
* configuration (`rest-api-plugin.ts` declares
|
|
1048
|
+
* `optionalDependencies: ['com.objectstack.engine.objectql']`) — but a
|
|
1049
|
+
* multi-tenant host whose engine FAILED TO CONSTRUCT reached that same resolver
|
|
1050
|
+
* as "no engine is wired", degrading silently where it should have refused
|
|
1051
|
+
* loudly. The branch could not be repaired from the outside, because the fact
|
|
1052
|
+
* it needed had been collapsed before it arrived.
|
|
1053
|
+
*
|
|
1054
|
+
* ## Why a brand, and ⛔ not message text
|
|
1055
|
+
*
|
|
1056
|
+
* The SYNCHRONOUS accessor in `kernel.ts` already draws exactly this line, and
|
|
1057
|
+
* the comment there records what happened the last time someone read the fact
|
|
1058
|
+
* off the wrong surface: reading "not found" off the async path "reported every
|
|
1059
|
+
* missing service as `is async - use await` — the wrong fix, pointing at the
|
|
1060
|
+
* wrong layer". A second text classifier on a resolution path is the failure
|
|
1061
|
+
* mode this module removes, ⛔ not a repair of it.
|
|
1062
|
+
*
|
|
1063
|
+
* The sync side decides from the REGISTRY — synchronous and authoritative — and
|
|
1064
|
+
* raises two different messages. The async side now carries that same
|
|
1065
|
+
* distinction as a branded, `code`-bearing rejection: one fact, spelled for a
|
|
1066
|
+
* caller that only ever sees the rejection.
|
|
1067
|
+
*
|
|
1068
|
+
* ## The test is CLOSED, and its default is LOUD
|
|
1069
|
+
*
|
|
1070
|
+
* Exactly one throw in `PluginLoader.getService` means "never registered", and
|
|
1071
|
+
* it is the one branded here. Every other way that method can reject — a
|
|
1072
|
+
* factory that threw, a missing scope id, an unset loader context, a circular
|
|
1073
|
+
* service dependency — is a service that IS registered and could not be
|
|
1074
|
+
* produced, and stays unbranded. So `false` is the safe answer: a consumer that
|
|
1075
|
+
* absorbs only the branded rejection stays loud about everything else,
|
|
1076
|
+
* including rejections added to that method later.
|
|
1077
|
+
*
|
|
1078
|
+
* ## Two deliberate omissions
|
|
1079
|
+
*
|
|
1080
|
+
* - **No `status`.** An ADR-0112 envelope pairs `code` with a `status`, but
|
|
1081
|
+
* the whole point of this discriminator is that the CONSUMER decides what an
|
|
1082
|
+
* unwired service means — absorb and degrade (the supported no-data-plane
|
|
1083
|
+
* kernel) or refuse. Carrying an HTTP status here would presuppose that
|
|
1084
|
+
* decision at the layer that must not make it.
|
|
1085
|
+
* - **No `name` override.** The rejection stays `name: 'Error'` with a
|
|
1086
|
+
* byte-identical message, so `String(err)`, logs and existing assertions
|
|
1087
|
+
* render exactly as before. The only observable change is two added
|
|
1088
|
+
* own-properties.
|
|
1089
|
+
*
|
|
1090
|
+
* Brand shape follows `AuthzStoreUnavailableError` (2026-08-30): a string-keyed
|
|
1091
|
+
* own property rather than `instanceof`, so the predicate still answers
|
|
1092
|
+
* correctly when two copies of `@objectstack/core` are installed (a duplicated
|
|
1093
|
+
* module makes `instanceof` say "no" to an error it built itself).
|
|
1094
|
+
*
|
|
1095
|
+
* ⚠️ The brand does NOT survive `structuredClone`, and no claim here depends on
|
|
1096
|
+
* it doing so — measured on Node 22: cloning an `Error` keeps `name`, `message`,
|
|
1097
|
+
* `stack` and `cause` and DROPS every other own property, brand and `code`
|
|
1098
|
+
* alike. This discriminator is for an in-process rejection travelling from
|
|
1099
|
+
* `PluginLoader.getService` to a seam that catches it, which is the only path
|
|
1100
|
+
* it is used on.
|
|
1101
|
+
*/
|
|
1102
|
+
/**
|
|
1103
|
+
* The code carried by the "never registered" rejection.
|
|
1104
|
+
*
|
|
1105
|
+
* ⚠️ Spelled the ADR-0112 way, but deliberately NOT wire vocabulary: this value
|
|
1106
|
+
* is read in-process by the seam that catches the rejection and is never
|
|
1107
|
+
* serialized into an `error.code` envelope. `dispatcher-error-vocabulary.ts`
|
|
1108
|
+
* classifies it `door: 'none'` / `boot-refusal` for exactly that reason — the
|
|
1109
|
+
* same class as the migration-journal runner refusals. If a transport ever
|
|
1110
|
+
* needs to ANSWER with this fact, that is a registration question for #8846's
|
|
1111
|
+
* ledger, ⛔ not something to start doing at a door.
|
|
1112
|
+
*/
|
|
1113
|
+
declare const SERVICE_NOT_REGISTERED_CODE = "SERVICE_NOT_REGISTERED";
|
|
1114
|
+
/**
|
|
1115
|
+
* True when `err` is the rejection meaning **nothing was ever registered under
|
|
1116
|
+
* that service name** — never when a registered service failed to construct.
|
|
1117
|
+
*
|
|
1118
|
+
* The predicate a seam uses to keep absorbing the supported "no data plane"
|
|
1119
|
+
* composition while staying loud about a service that IS wired and broke.
|
|
1120
|
+
*/
|
|
1121
|
+
declare function isServiceNotRegisteredError(err: unknown): err is Error & {
|
|
1122
|
+
readonly code: typeof SERVICE_NOT_REGISTERED_CODE;
|
|
1123
|
+
readonly serviceName: string;
|
|
1124
|
+
};
|
|
1125
|
+
|
|
865
1126
|
/**
|
|
866
1127
|
* Interface for executing test actions against a target system.
|
|
867
1128
|
* The target could be a local Kernel instance or a remote API.
|
|
@@ -1193,82 +1454,45 @@ declare function verifyPluginArtifact(input: {
|
|
|
1193
1454
|
requirePlatform?: boolean;
|
|
1194
1455
|
}): Promise<PluginArtifactVerifyResult>;
|
|
1195
1456
|
|
|
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;
|
|
1457
|
+
/** A single unpacked artifact file. `path` is POSIX, archive-relative. */
|
|
1458
|
+
interface IntegrityFile {
|
|
1459
|
+
path: string;
|
|
1460
|
+
data: Uint8Array;
|
|
1461
|
+
}
|
|
1462
|
+
type IntegrityViolationKind = 'digest_mismatch' | 'missing_file' | 'extra_file';
|
|
1463
|
+
/** One structured integrity finding (the rejection envelope's unit). */
|
|
1464
|
+
interface IntegrityViolation {
|
|
1465
|
+
kind: IntegrityViolationKind;
|
|
1466
|
+
/** Artifact-relative POSIX path the finding is about. */
|
|
1467
|
+
path: string;
|
|
1468
|
+
/** The digest the manifest declares (absent for `extra_file`). */
|
|
1469
|
+
declared?: string;
|
|
1470
|
+
/** The digest computed from the supplied bytes (absent unless comparable). */
|
|
1471
|
+
actual?: string;
|
|
1472
|
+
}
|
|
1473
|
+
interface VerifyIntegrityResult {
|
|
1474
|
+
/** Overall verdict: every declared digest matched and no file was unaccounted for. */
|
|
1475
|
+
ok: boolean;
|
|
1476
|
+
/** True when no integrity map was supplied, so nothing was checked (still `ok`). */
|
|
1477
|
+
skipped: boolean;
|
|
1478
|
+
/** Number of declared entries whose digests were computed and compared. */
|
|
1479
|
+
checked: number;
|
|
1480
|
+
violations: IntegrityViolation[];
|
|
1264
1481
|
}
|
|
1265
1482
|
/**
|
|
1266
|
-
*
|
|
1483
|
+
* Verify `files` against the manifest's declared `integrity` map.
|
|
1267
1484
|
*
|
|
1268
|
-
*
|
|
1269
|
-
*
|
|
1485
|
+
* `options.exempt` names paths outside the map's coverage — the compiled
|
|
1486
|
+
* manifest itself and the signature placeholder, which `computeIntegrity`
|
|
1487
|
+
* excludes at build time (the manifest cannot hash itself, and the
|
|
1488
|
+
* signature signs the manifest) — so their presence is never an
|
|
1489
|
+
* `extra_file` finding.
|
|
1270
1490
|
*/
|
|
1271
|
-
declare function
|
|
1491
|
+
declare function verifyIntegrity(files: readonly IntegrityFile[], integrity: Readonly<Record<string, string>> | null | undefined, options?: {
|
|
1492
|
+
exempt?: readonly string[];
|
|
1493
|
+
}): VerifyIntegrityResult;
|
|
1494
|
+
/** Render one violation as a single human-actionable line. */
|
|
1495
|
+
declare function formatIntegrityViolation(v: IntegrityViolation): string;
|
|
1272
1496
|
|
|
1273
1497
|
/**
|
|
1274
1498
|
* Plugin Permissions
|
|
@@ -1656,96 +1880,6 @@ declare class PluginSandboxRuntime {
|
|
|
1656
1880
|
shutdown(): void;
|
|
1657
1881
|
}
|
|
1658
1882
|
|
|
1659
|
-
/**
|
|
1660
|
-
* Scan Target
|
|
1661
|
-
*/
|
|
1662
|
-
interface ScanTarget {
|
|
1663
|
-
pluginId: string;
|
|
1664
|
-
version: string;
|
|
1665
|
-
files?: string[];
|
|
1666
|
-
dependencies?: Record<string, string>;
|
|
1667
|
-
}
|
|
1668
|
-
/**
|
|
1669
|
-
* Security Issue
|
|
1670
|
-
*/
|
|
1671
|
-
interface SecurityIssue {
|
|
1672
|
-
id: string;
|
|
1673
|
-
severity: 'critical' | 'high' | 'medium' | 'low' | 'info';
|
|
1674
|
-
category: 'vulnerability' | 'malware' | 'license' | 'code-quality' | 'configuration';
|
|
1675
|
-
title: string;
|
|
1676
|
-
description: string;
|
|
1677
|
-
location?: {
|
|
1678
|
-
file?: string;
|
|
1679
|
-
line?: number;
|
|
1680
|
-
column?: number;
|
|
1681
|
-
};
|
|
1682
|
-
remediation?: string;
|
|
1683
|
-
cve?: string;
|
|
1684
|
-
cvss?: number;
|
|
1685
|
-
}
|
|
1686
|
-
/**
|
|
1687
|
-
* Plugin Security Scanner
|
|
1688
|
-
*
|
|
1689
|
-
* Scans plugins for security vulnerabilities, malware, and license issues
|
|
1690
|
-
*/
|
|
1691
|
-
declare class PluginSecurityScanner {
|
|
1692
|
-
private logger;
|
|
1693
|
-
private vulnerabilityDb;
|
|
1694
|
-
private scanResults;
|
|
1695
|
-
private passThreshold;
|
|
1696
|
-
constructor(logger: ObjectLogger, config?: {
|
|
1697
|
-
passThreshold?: number;
|
|
1698
|
-
});
|
|
1699
|
-
/**
|
|
1700
|
-
* Perform a comprehensive security scan on a plugin
|
|
1701
|
-
*/
|
|
1702
|
-
scan(target: ScanTarget): Promise<KernelSecurityScanResult>;
|
|
1703
|
-
/**
|
|
1704
|
-
* Scan code for vulnerabilities
|
|
1705
|
-
*/
|
|
1706
|
-
private scanCode;
|
|
1707
|
-
/**
|
|
1708
|
-
* Scan dependencies for known vulnerabilities
|
|
1709
|
-
*/
|
|
1710
|
-
private scanDependencies;
|
|
1711
|
-
/**
|
|
1712
|
-
* Scan for malware patterns
|
|
1713
|
-
*/
|
|
1714
|
-
private scanMalware;
|
|
1715
|
-
/**
|
|
1716
|
-
* Check license compliance
|
|
1717
|
-
*/
|
|
1718
|
-
private scanLicenses;
|
|
1719
|
-
/**
|
|
1720
|
-
* Check configuration security
|
|
1721
|
-
*/
|
|
1722
|
-
private scanConfiguration;
|
|
1723
|
-
/**
|
|
1724
|
-
* Calculate security score based on issues
|
|
1725
|
-
*/
|
|
1726
|
-
private calculateSecurityScore;
|
|
1727
|
-
/**
|
|
1728
|
-
* Add a vulnerability to the database
|
|
1729
|
-
*/
|
|
1730
|
-
addVulnerability(packageName: string, version: string, vulnerability: KernelSecurityVulnerability): void;
|
|
1731
|
-
/**
|
|
1732
|
-
* Get scan result from cache
|
|
1733
|
-
*/
|
|
1734
|
-
getScanResult(pluginId: string, version: string): KernelSecurityScanResult | undefined;
|
|
1735
|
-
/**
|
|
1736
|
-
* Clear scan results cache
|
|
1737
|
-
*/
|
|
1738
|
-
clearCache(): void;
|
|
1739
|
-
/**
|
|
1740
|
-
* Update vulnerability database from external source
|
|
1741
|
-
*/
|
|
1742
|
-
updateVulnerabilityDatabase(): Promise<void>;
|
|
1743
|
-
/**
|
|
1744
|
-
* Shutdown security scanner
|
|
1745
|
-
*/
|
|
1746
|
-
shutdown(): void;
|
|
1747
|
-
}
|
|
1748
|
-
|
|
1749
1883
|
/** Default visible prefix for generated keys (helps users identify a key). */
|
|
1750
1884
|
declare const API_KEY_PREFIX = "osk_";
|
|
1751
1885
|
/**
|
|
@@ -1789,6 +1923,21 @@ declare function isExpired(value: unknown, nowMs: number): boolean;
|
|
|
1789
1923
|
/** The principal resolved from a valid `sys_api_key`. */
|
|
1790
1924
|
interface ApiKeyPrincipal {
|
|
1791
1925
|
userId: string;
|
|
1926
|
+
/**
|
|
1927
|
+
* [#15256 / 2A] The `sys_api_key` ROW id — a non-secret handle an operator
|
|
1928
|
+
* can look the credential up by. Carried so the posture-conditional refusal
|
|
1929
|
+
* log in `resolve-authz-context.ts` can name WHICH key was refused without
|
|
1930
|
+
* naming the credential.
|
|
1931
|
+
*
|
|
1932
|
+
* ⛔ Never the raw key and never its hash: the raw key is returned exactly
|
|
1933
|
+
* once by {@link generateApiKey} and only `sha256(raw)` is ever stored, and
|
|
1934
|
+
* neither may enter a log line (see this module's SECURITY header). The row
|
|
1935
|
+
* id is not derived from either.
|
|
1936
|
+
*
|
|
1937
|
+
* Optional because a row is only required to identify its owner; a store
|
|
1938
|
+
* that answers without an `id` still yields a usable principal.
|
|
1939
|
+
*/
|
|
1940
|
+
keyId?: string;
|
|
1792
1941
|
/**
|
|
1793
1942
|
* The organization this key authenticates INTO — read from the row's
|
|
1794
1943
|
* `active_organization_id` and adopted by `resolveAuthzContext` as the
|
|
@@ -1827,6 +1976,19 @@ type ApiKeyAdmission = {
|
|
|
1827
1976
|
outcome: 'refused';
|
|
1828
1977
|
reason: ApiKeyRefusalReason;
|
|
1829
1978
|
message: string;
|
|
1979
|
+
/**
|
|
1980
|
+
* [#15256 / 2A] The refused key's `sys_api_key` row id — same non-secret
|
|
1981
|
+
* handle as {@link ApiKeyPrincipal.keyId}, carried on this arm too so the
|
|
1982
|
+
* refusal log can name the credential the operator must go look at. ⛔
|
|
1983
|
+
* Never the raw key or its hash. The WIRE answer is unchanged (a generic
|
|
1984
|
+
* `401 UNAUTHENTICATED`, no reason and no id), so nothing here reaches a
|
|
1985
|
+
* caller holding someone else's key.
|
|
1986
|
+
*/
|
|
1987
|
+
keyId?: string;
|
|
1988
|
+
/** The owner this refused key authenticates as — for the same log line. */
|
|
1989
|
+
userId?: string;
|
|
1990
|
+
/** The organization the refusal is about, when the key names one. */
|
|
1991
|
+
organizationId?: string;
|
|
1830
1992
|
};
|
|
1831
1993
|
/**
|
|
1832
1994
|
* The shape of the kernel's `tenancy` service this module reads a posture from.
|
|
@@ -1884,9 +2046,200 @@ declare function resolveApiKeyPrincipal(ql: any, headers: any, nowMs?: number, t
|
|
|
1884
2046
|
*/
|
|
1885
2047
|
declare function resolveApiKeyAdmission(ql: any, headers: any, nowMs?: number, tenancyPosture?: TenancyPosture): Promise<ApiKeyAdmission>;
|
|
1886
2048
|
|
|
2049
|
+
/**
|
|
2050
|
+
* [#13279] The LOUD failure an unreachable permission store raises.
|
|
2051
|
+
*
|
|
2052
|
+
* ## The defect this exists to end
|
|
2053
|
+
*
|
|
2054
|
+
* `resolveAuthzContext`'s per-read helper `tryFind` used to answer a THROWN
|
|
2055
|
+
* read the same way it answers an EMPTY one: `[]`. So a permission-store
|
|
2056
|
+
* outage resolved as a well-formed context for an AUTHENTICATED principal
|
|
2057
|
+
* holding ZERO capabilities, and the package-management door answered
|
|
2058
|
+
* `403 FORBIDDEN` — "Reading packages requires the `studio.access` or
|
|
2059
|
+
* `setup.access` capability." That answer was measured BYTE-IDENTICAL
|
|
2060
|
+
* (`JSON.stringify` equal, against a positive control that separates two
|
|
2061
|
+
* answers which differ) to the answer a caller who genuinely holds nothing
|
|
2062
|
+
* gets. An administrator was told they lack a capability, during an outage of
|
|
2063
|
+
* the store that holds the capability.
|
|
2064
|
+
*
|
|
2065
|
+
* The resolver was asserting a fact it did not have. "No rows came back"
|
|
2066
|
+
* and "the read failed" are different facts, and only one of them licenses
|
|
2067
|
+
* the sentence "this user holds nothing".
|
|
2068
|
+
*
|
|
2069
|
+
* ## …and "the read failed" turned out to be TWO facts (ruled 2026-08-30, A)
|
|
2070
|
+
*
|
|
2071
|
+
* A read ALSO throws when the table was never PROVISIONED — a real engine,
|
|
2072
|
+
* wired and reachable, whose `sys_*` tables were never created. There "this
|
|
2073
|
+
* user holds nothing" is TRUE, not invented, so failing loud would refuse
|
|
2074
|
+
* service to a correctly-configured deployment. The first implementation of
|
|
2075
|
+
* this card did exactly that and four CI suites measured it.
|
|
2076
|
+
*
|
|
2077
|
+
* So `tryFind` raises this error only for a read failure that is NOT
|
|
2078
|
+
* positively identified as an unprovisioned table, asking the one relocated
|
|
2079
|
+
* `isMissingTableError` predicate (`@objectstack/types`) rather than a second
|
|
2080
|
+
* copy. The boundary, the ruling's verbatim text and the false-positive risk
|
|
2081
|
+
* signed off with it are written beside that call in `resolve-authz-context.ts`;
|
|
2082
|
+
* both directions are pinned in `authz-store-unavailable.test.ts` §4.
|
|
2083
|
+
*
|
|
2084
|
+
* ## Maintainer ruling, 2026-08-30, verbatim 「第一批其余同意」
|
|
2085
|
+
*
|
|
2086
|
+
* > `tryFind` 区分「无行」与「读失败」,读失败 fail-loud —— 权限库不可达时
|
|
2087
|
+
* > 不再解析为「已认证零能力」,而是响亮拒绝(与真实能力拒绝的 403 可区分),
|
|
2088
|
+
* > 让宕机不再伪装成一次逐字节相同的能力否决。
|
|
2089
|
+
*
|
|
2090
|
+
* The ruling fixes the DIRECTION and leaves the spelling to the implementation.
|
|
2091
|
+
*
|
|
2092
|
+
* ## Why a THROW, and not a field on the envelope
|
|
2093
|
+
*
|
|
2094
|
+
* The alternative was a discriminator field on `ResolvedAuthzContext` — the
|
|
2095
|
+
* shape `authRefusal` had (#8287). That was rejected on a MEASUREMENT, not a
|
|
2096
|
+
* preference: from #8287 until #14273 removed it, `authRefusal` had **zero**
|
|
2097
|
+
* consumers anywhere in the repo outside this module and test assertions — a
|
|
2098
|
+
* reading #14273 acted on by deleting the field. Every transport reads `userId`
|
|
2099
|
+
* and `systemPermissions`; a new sibling field would have to be taught to eight
|
|
2100
|
+
* separate call sites before it made a single door louder, and would answer
|
|
2101
|
+
* the old quiet 403 at every site that was missed.
|
|
2102
|
+
*
|
|
2103
|
+
* A field is quiet by default and must be deliberately made loud. A throw is
|
|
2104
|
+
* loud by default and must be deliberately silenced. On a security surface
|
|
2105
|
+
* whose whole defect is a silence, the default is the entire decision.
|
|
2106
|
+
*
|
|
2107
|
+
* It is also the idiom this platform already uses for unresolvable authority:
|
|
2108
|
+
* `packages/mcp`'s stdio entry throws and refuses to start rather than run with
|
|
2109
|
+
* an authority it could not resolve.
|
|
2110
|
+
*
|
|
2111
|
+
* ## Why `SERVICE_UNAVAILABLE` / 503, and why that is not a new wire shape
|
|
2112
|
+
*
|
|
2113
|
+
* `SERVICE_UNAVAILABLE` is an EXISTING member of the closed ADR-0112 wire
|
|
2114
|
+
* vocabulary (`StandardErrorCode`, `packages/spec/src/api/errors.zod.ts`), and
|
|
2115
|
+
* `HttpStatusErrorCodeMap` already maps it to 503 — "service exists but is
|
|
2116
|
+
* temporarily down". Nothing is added to the vocabulary and no envelope gains
|
|
2117
|
+
* or loses a key: a door that already renders `{ code, message }` renders this
|
|
2118
|
+
* one the same way. What changes is WHICH declared code an outage selects —
|
|
2119
|
+
* from the caller's `FORBIDDEN` to the operator's `SERVICE_UNAVAILABLE`.
|
|
2120
|
+
*
|
|
2121
|
+
* That is the ruling's own test, stated on the wire: 503 is not 403, so an
|
|
2122
|
+
* outage is no longer answerable as a capability denial.
|
|
2123
|
+
*
|
|
2124
|
+
* ## Recognise by BRAND, never by `instanceof`
|
|
2125
|
+
*
|
|
2126
|
+
* {@link isAuthzStoreUnavailableError} tests a own-property brand rather than
|
|
2127
|
+
* `instanceof`. This error crosses package boundaries (`@objectstack/core` →
|
|
2128
|
+
* rest / runtime / mcp / services / plugins) and a monorepo resolves the same
|
|
2129
|
+
* module through more than one path (`src` under vitest aliases, `dist` under
|
|
2130
|
+
* the published `exports`). Two copies of this class make `instanceof` answer
|
|
2131
|
+
* FALSE for a genuine instance — which, here, silently restores the exact
|
|
2132
|
+
* quiet 403 this module exists to remove. The brand survives duplication.
|
|
2133
|
+
*/
|
|
2134
|
+
/** HTTP status an unreachable authorization store answers with. */
|
|
2135
|
+
declare const AUTHZ_STORE_UNAVAILABLE_STATUS: 503;
|
|
2136
|
+
/**
|
|
2137
|
+
* Machine code — an EXISTING `StandardErrorCode` member (ADR-0112: SCREAMING).
|
|
2138
|
+
* Deliberately NOT a new code: the wire vocabulary is closed.
|
|
2139
|
+
*/
|
|
2140
|
+
declare const AUTHZ_STORE_UNAVAILABLE_CODE: "SERVICE_UNAVAILABLE";
|
|
2141
|
+
/**
|
|
2142
|
+
* Human-facing message. States the OUTAGE, and says explicitly that no
|
|
2143
|
+
* capability judgement was reached — so neither the caller nor the operator
|
|
2144
|
+
* reads it as a permission verdict.
|
|
2145
|
+
*/
|
|
2146
|
+
declare const AUTHZ_STORE_UNAVAILABLE_MESSAGE: string;
|
|
2147
|
+
/**
|
|
2148
|
+
* The own-property brand {@link isAuthzStoreUnavailableError} tests for.
|
|
2149
|
+
* A string-keyed own property (not a `Symbol.for` registry key), so a
|
|
2150
|
+
* duplicated copy of this module still brands identically — which is exactly
|
|
2151
|
+
* what `instanceof` cannot do (module doc above).
|
|
2152
|
+
*
|
|
2153
|
+
* ⚠️ The brand does NOT survive `structuredClone`, and no claim here depends
|
|
2154
|
+
* on it doing so — the same measured behaviour `service-not-registered.ts`
|
|
2155
|
+
* records for its own brand. Reproduce on Node 22.22.2:
|
|
2156
|
+
*
|
|
2157
|
+
* ```js
|
|
2158
|
+
* const e = new Error('x'); e.__brand = true; e.code = 'C';
|
|
2159
|
+
* const c = structuredClone(e);
|
|
2160
|
+
* // c.__brand === undefined c.code === undefined c.message === 'x'
|
|
2161
|
+
* // control: structuredClone({ __brand: true, code: 'C' }) keeps BOTH keys
|
|
2162
|
+
* ```
|
|
2163
|
+
*
|
|
2164
|
+
* `Error` has a dedicated serialization carrying `message`, `stack` and
|
|
2165
|
+
* `cause` only, so it DROPS every other own property — this brand, the
|
|
2166
|
+
* ADR-0112 `code`, `status` and `object` alike (and a subclass's own `name`
|
|
2167
|
+
* returns as `'Error'`). The plain-object control is the half that proves the
|
|
2168
|
+
* loss is specific to `Error`, not general to `structuredClone`.
|
|
2169
|
+
*
|
|
2170
|
+
* ⛔ So never branch on this brand across a worker or `postMessage` boundary:
|
|
2171
|
+
* it would answer `false` and fail OPEN. Every call site today is in-process —
|
|
2172
|
+
* `rethrowAuthzStoreUnavailable` on the rest rethrow paths and
|
|
2173
|
+
* `isAuthzStoreUnavailableError` inside service `catch` blocks.
|
|
2174
|
+
*/
|
|
2175
|
+
declare const AUTHZ_STORE_UNAVAILABLE_BRAND: "__objectstackAuthzStoreUnavailable";
|
|
2176
|
+
/**
|
|
2177
|
+
* Raised when a permission-store read FAILED — never when it legitimately
|
|
2178
|
+
* returned no rows.
|
|
2179
|
+
*
|
|
2180
|
+
* Carries the `object` whose read failed so an operator sees WHICH table was
|
|
2181
|
+
* unreachable, and the originating error as `cause` so the driver's own
|
|
2182
|
+
* diagnostic is not lost behind this one.
|
|
2183
|
+
*/
|
|
2184
|
+
declare class AuthzStoreUnavailableError extends Error {
|
|
2185
|
+
/** Brand — see the module doc on why this is not `instanceof`. */
|
|
2186
|
+
readonly [AUTHZ_STORE_UNAVAILABLE_BRAND]: true;
|
|
2187
|
+
/** ADR-0112 wire code. */
|
|
2188
|
+
readonly code: "SERVICE_UNAVAILABLE";
|
|
2189
|
+
/** HTTP status a transport should answer. */
|
|
2190
|
+
readonly status: 503;
|
|
2191
|
+
/** The object/table whose read failed (e.g. `sys_user_permission_set`). */
|
|
2192
|
+
readonly object: string;
|
|
2193
|
+
/** The driver's originating failure, kept so its diagnostic is not lost. */
|
|
2194
|
+
readonly cause?: unknown;
|
|
2195
|
+
constructor(object: string, cause?: unknown);
|
|
2196
|
+
}
|
|
2197
|
+
/**
|
|
2198
|
+
* True when `err` is the loud authorization-store failure above.
|
|
2199
|
+
*
|
|
2200
|
+
* The predicate every transport uses to tell "the store was unreachable" apart
|
|
2201
|
+
* from every other throw, so a fail-closed `catch` can re-raise THIS one
|
|
2202
|
+
* without loosening its handling of anything else.
|
|
2203
|
+
*/
|
|
2204
|
+
declare function isAuthzStoreUnavailableError(err: unknown): err is AuthzStoreUnavailableError;
|
|
2205
|
+
/**
|
|
2206
|
+
* The `.catch` argument every fail-closed seam between `resolveAuthzContext`
|
|
2207
|
+
* and a door should use in place of `() => undefined`.
|
|
2208
|
+
*
|
|
2209
|
+
* Re-raises {@link AuthzStoreUnavailableError} and swallows everything else to
|
|
2210
|
+
* `undefined`, so a seam keeps its existing fail-closed behaviour for every
|
|
2211
|
+
* fault EXCEPT the one the 2026-08-30 ruling requires to stay loud.
|
|
2212
|
+
*
|
|
2213
|
+
* ## Why the seams need this at all
|
|
2214
|
+
*
|
|
2215
|
+
* Making `tryFind` throw is necessary but NOT sufficient, and that was
|
|
2216
|
+
* MEASURED rather than assumed. Between the resolver and the package door sit
|
|
2217
|
+
* three independent nets — `computeExecCtx`'s `try { … } catch { return
|
|
2218
|
+
* undefined; }`, `resolvePackageRouteExecutionContext`'s `.catch(() =>
|
|
2219
|
+
* undefined)`, and `refusePackageRequest`'s own — and with the throw in place
|
|
2220
|
+
* but the nets untouched, the door answered **401**: the outage had simply
|
|
2221
|
+
* changed disguises, from "you hold no capability" (403) into "you are not
|
|
2222
|
+
* authenticated" (401), which is byte-identical to a genuine anonymous caller.
|
|
2223
|
+
* Distinguishable from a capability denial, yes — but still not LOUD, and now
|
|
2224
|
+
* wearing the costume of a different card's defect.
|
|
2225
|
+
*
|
|
2226
|
+
* A blanket `catch` cannot tell a fault from a refusal, so each net has to be
|
|
2227
|
+
* told once, in one shape. This is that shape.
|
|
2228
|
+
*/
|
|
2229
|
+
declare function rethrowAuthzStoreUnavailable(err: unknown): undefined;
|
|
2230
|
+
|
|
1887
2231
|
/** The transport-agnostic authorization envelope produced from a request. */
|
|
1888
2232
|
interface ResolvedAuthzContext {
|
|
1889
2233
|
userId?: string;
|
|
2234
|
+
/**
|
|
2235
|
+
* The ACTIVE organization this request operates in.
|
|
2236
|
+
*
|
|
2237
|
+
* ⚠️ [#15409] For a session principal under a wall-enforcing posture this is
|
|
2238
|
+
* a VETTED value, never the stored `activeOrganizationId` as read: a claim
|
|
2239
|
+
* that is not in {@link accessible_org_ids} is dropped and the context
|
|
2240
|
+
* resolves with no active organization at all. Absent here is the fail-closed
|
|
2241
|
+
* state, not a missing lookup — Layer 0 denies on it.
|
|
2242
|
+
*/
|
|
1890
2243
|
tenantId?: string;
|
|
1891
2244
|
email?: string;
|
|
1892
2245
|
accessToken?: string;
|
|
@@ -1907,31 +2260,15 @@ interface ResolvedAuthzContext {
|
|
|
1907
2260
|
/**
|
|
1908
2261
|
* [ADR-0095 D2/D3] The monotonic posture rung this principal resolves to,
|
|
1909
2262
|
* DERIVED once here from held capability grants (never a better-auth role):
|
|
1910
|
-
* `PLATFORM_ADMIN` (unscoped `admin_full_access`
|
|
2263
|
+
* `PLATFORM_ADMIN` (an unscoped `admin_full_access` grant, or — since #11663
|
|
2264
|
+
* L2 — a VERIFIED `sys_user.email` on the deployment's declared
|
|
2265
|
+
* administrator list) > `TENANT_ADMIN`
|
|
1911
2266
|
* (`organization_admin`) > `MEMBER` (the authenticated floor). `EXTERNAL` is
|
|
1912
2267
|
* defined/test-locked but never resolved yet (no external principal type —
|
|
1913
2268
|
* see `posture-ladder.ts`). Present only for an authenticated principal;
|
|
1914
2269
|
* anonymous requests carry no rung.
|
|
1915
2270
|
*/
|
|
1916
2271
|
posture?: AuthzPosture;
|
|
1917
|
-
/**
|
|
1918
|
-
* [#8287] Set when an inbound API key was REFUSED — a real, intact
|
|
1919
|
-
* credential this deployment's tenancy posture cannot admit. The context is
|
|
1920
|
-
* otherwise EMPTY (no `userId`), so every transport already fails it closed
|
|
1921
|
-
* to 401 with no change; this field only lets a transport that wants to say
|
|
1922
|
-
* WHY do so, instead of answering the operator with a bare "unauthenticated"
|
|
1923
|
-
* for a key they can see is neither revoked nor expired.
|
|
1924
|
-
*
|
|
1925
|
-
* ⚠️ `reason` is NOT an `error.code`. The wire vocabulary is closed
|
|
1926
|
-
* (ADR-0112: `StandardErrorCode ∪ ERROR_CODE_LEDGER`, both in `packages/spec`)
|
|
1927
|
-
* and a refused credential's standard member is `UNAUTHENTICATED`. This is a
|
|
1928
|
-
* diagnostic discriminator for the message, deliberately lowercase so it can
|
|
1929
|
-
* never be mistaken for one.
|
|
1930
|
-
*/
|
|
1931
|
-
authRefusal?: {
|
|
1932
|
-
reason: ApiKeyRefusalReason;
|
|
1933
|
-
message: string;
|
|
1934
|
-
};
|
|
1935
2272
|
}
|
|
1936
2273
|
interface ResolveAuthzInput {
|
|
1937
2274
|
/** Data engine (ObjectQL) exposing `find(object, { where, limit, context })`. */
|
|
@@ -1960,8 +2297,19 @@ interface ResolveAuthzInput {
|
|
|
1960
2297
|
tenancyPosture?: TenancyPosture;
|
|
1961
2298
|
}
|
|
1962
2299
|
/**
|
|
1963
|
-
* Resolve the authorization context for an inbound request.
|
|
1964
|
-
*
|
|
2300
|
+
* Resolve the authorization context for an inbound request. Anonymous requests
|
|
2301
|
+
* yield `{ positions: [], permissions: [], ... }`.
|
|
2302
|
+
*
|
|
2303
|
+
* ⚠️ [#13279] This function used to document itself as "Always resolves — never
|
|
2304
|
+
* throws", and that total guarantee WAS the defect: the only way to always
|
|
2305
|
+
* resolve across a permission-store outage is to report a capability set the
|
|
2306
|
+
* resolver never actually read. It now throws exactly one error —
|
|
2307
|
+
* {@link AuthzStoreUnavailableError}, when a permission-store read was issued
|
|
2308
|
+
* and failed FOR A REASON THAT IS NOT AN UNPROVISIONED TABLE. Every other path
|
|
2309
|
+
* still resolves, including every MISSING-service path and every
|
|
2310
|
+
* never-provisioned one. A transport that fails closed on unexpected throws should re-raise this
|
|
2311
|
+
* one ({@link isAuthzStoreUnavailableError}) rather than degrade it to a
|
|
2312
|
+
* refusal — degrading it restores the disguise the ruling removed.
|
|
1965
2313
|
*/
|
|
1966
2314
|
declare function resolveAuthzContext(input: ResolveAuthzInput): Promise<ResolvedAuthzContext>;
|
|
1967
2315
|
/** The authorization grants a KNOWN user holds — a subset of {@link ResolvedAuthzContext}. */
|
|
@@ -1992,6 +2340,25 @@ interface ResolveUserAuthzGrantsOptions {
|
|
|
1992
2340
|
seedPermissions?: string[];
|
|
1993
2341
|
/** A caller-supplied email (e.g. from the session) that wins over the `sys_user` read. */
|
|
1994
2342
|
seedEmail?: string;
|
|
2343
|
+
/**
|
|
2344
|
+
* ⭐ Force a FRESH resolution even when the #11971 grants cache is enabled
|
|
2345
|
+
* — the ruled bypass list of #11633 (leg B, maintainer acceptance
|
|
2346
|
+
* 2026-08-25). Two call sites carry it, for two ruled reasons:
|
|
2347
|
+
*
|
|
2348
|
+
* - `plugin-security/src/explain-engine.ts` (`buildContextForUser`): the
|
|
2349
|
+
* permission explainer is the tool an administrator uses to VERIFY that
|
|
2350
|
+
* a revocation took effect. An explainer answering from cache would
|
|
2351
|
+
* explain a state that no longer exists, and would do it at exactly the
|
|
2352
|
+
* moment someone is checking.
|
|
2353
|
+
* - `service-automation/src/plugin.ts` (`runAs:'user'` runs): automation
|
|
2354
|
+
* runs are not request-shaped and can be long-lived; they must not pin
|
|
2355
|
+
* an envelope.
|
|
2356
|
+
*
|
|
2357
|
+
* Bypassing reads NOTHING from the cache and writes NOTHING into it — a
|
|
2358
|
+
* bypassed resolution must not repopulate an entry the next cached caller
|
|
2359
|
+
* would then trust.
|
|
2360
|
+
*/
|
|
2361
|
+
bypassGrantsCache?: boolean;
|
|
1995
2362
|
}
|
|
1996
2363
|
/**
|
|
1997
2364
|
* resolveUserAuthzGrants — the userId-driven core of {@link resolveAuthzContext}.
|
|
@@ -2012,13 +2379,84 @@ interface ResolveUserAuthzGrantsOptions {
|
|
|
2012
2379
|
* automation engine calls this to run the flow's data ops exactly as that user
|
|
2013
2380
|
* — not the bare member/everyone fallback the missing grants used to leave it.
|
|
2014
2381
|
*
|
|
2015
|
-
* Fail-closed like its parent:
|
|
2016
|
-
*
|
|
2382
|
+
* Fail-closed like its parent: a missing engine yields an empty-but-valid
|
|
2383
|
+
* envelope.
|
|
2384
|
+
*
|
|
2385
|
+
* ⚠️ [#13279] "and it never throws" was removed from this sentence deliberately.
|
|
2386
|
+
* A permission-store read that is issued and FAILS now raises
|
|
2387
|
+
* {@link AuthzStoreUnavailableError} rather than contributing an empty grant
|
|
2388
|
+
* set, so a `runAs:'user'` automation cannot silently run with the authority of
|
|
2389
|
+
* a user whose grants were never read.
|
|
2017
2390
|
*/
|
|
2018
2391
|
declare function resolveUserAuthzGrants(ql: any, userId: string, opts?: ResolveUserAuthzGrantsOptions): Promise<UserAuthzGrants>;
|
|
2392
|
+
/**
|
|
2393
|
+
* hasPlatformAdminStanding — the ID-SHAPED platform-admin question, asked in
|
|
2394
|
+
* exactly one place.
|
|
2395
|
+
*
|
|
2396
|
+
* ADR-0068 D2 defined PLATFORM standing as one thing: an UNSCOPED
|
|
2397
|
+
* (`organization_id = null`) `sys_user_permission_set` grant on the
|
|
2398
|
+
* `admin_full_access` set, held **now**. Since #11663 L2 there is a SECOND
|
|
2399
|
+
* anchor beside it — a `sys_user` row whose VERIFIED email is on the
|
|
2400
|
+
* deployment's declared administrator list (`OS_PLATFORM_OWNER_EMAIL`) — and
|
|
2401
|
+
* this predicate answers for both, for free, because it is a projection rather
|
|
2402
|
+
* than a copy (see below). A surface that only knows a user id —
|
|
2403
|
+
* a session-payload derivation, a platform-operator route gate, an
|
|
2404
|
+
* impersonation oracle — asks here, so it never has to re-read the grant tables
|
|
2405
|
+
* itself, which is the prohibition this module's header states.
|
|
2406
|
+
*
|
|
2407
|
+
* It is a PROJECTION of {@link resolveUserAuthzGrants}, never a second
|
|
2408
|
+
* derivation: the answer is the `PLATFORM_ADMIN` rung of the posture ladder,
|
|
2409
|
+
* and that rung is derived from the unscoped-grant evidence and nothing else.
|
|
2410
|
+
* Everything that governs those grants therefore applies here by construction
|
|
2411
|
+
* and cannot drift from it — the ADR-0091 validity window (§6), the ADR-0049
|
|
2412
|
+
* `active` flag on the catalogue row (§6b), the system-identity read, and the
|
|
2413
|
+
* resolution of `admin_full_access` BY ID rather than by scanning a page of the
|
|
2414
|
+
* catalogue. Each of those was missing from a hand-written copy of this
|
|
2415
|
+
* predicate; none of them can be missing from a projection.
|
|
2416
|
+
*
|
|
2417
|
+
* ⛔ Read the RUNG — never `positions.includes(BUILTIN_IDENTITY_PLATFORM_ADMIN)`.
|
|
2418
|
+
* The positions list is wider on purpose: an ADR-0057 D4 `sys_user_position`
|
|
2419
|
+
* row may spell that very name, and a platform-RBAC assignment is not the D2
|
|
2420
|
+
* capability grant. The two readings genuinely differ, so the narrow one is the
|
|
2421
|
+
* one that gets a name here.
|
|
2422
|
+
*
|
|
2423
|
+
* ⛔ The options are deliberately NOT {@link ResolveUserAuthzGrantsOptions}.
|
|
2424
|
+
* That type carries caller-supplied seeds (`seedEmail`, `seedPermissions`) for
|
|
2425
|
+
* transports that already resolved part of a principal; an authorization
|
|
2426
|
+
* predicate that accepted them would let a caller supply part of its own
|
|
2427
|
+
* verdict. Clock injection is the only thing a caller may pass, so this
|
|
2428
|
+
* function's answer is a function of `(ql, userId)` and the stored rows alone.
|
|
2429
|
+
*
|
|
2430
|
+
* ⚠️ This is the PER-USER predicate. The POPULATION question ("which user is
|
|
2431
|
+
* the platform admin?" — `ensure-default-organization.ts`) is a different kind
|
|
2432
|
+
* and is deliberately not expressible through it; do not widen this to serve
|
|
2433
|
+
* it.
|
|
2434
|
+
*
|
|
2435
|
+
* Fail-CLOSED: an empty id, a missing engine, or any unreadable lookup answers
|
|
2436
|
+
* `false`. This backs security gates, and an unverifiable actor never passes.
|
|
2437
|
+
*/
|
|
2438
|
+
declare function hasPlatformAdminStanding(ql: any, userId: string, opts?: {
|
|
2439
|
+
nowMs?: number;
|
|
2440
|
+
}): Promise<boolean>;
|
|
2019
2441
|
interface ResolveLocalizationInput {
|
|
2020
2442
|
ql: any;
|
|
2021
|
-
/**
|
|
2443
|
+
/**
|
|
2444
|
+
* Settings service occupant. Two methods are consumed, in this order:
|
|
2445
|
+
*
|
|
2446
|
+
* - `getMany(namespace, keys, { tenantId, userId })` — PREFERRED since
|
|
2447
|
+
* #10826, and what this resolver calls for all three localization keys in
|
|
2448
|
+
* ONE grouped read.
|
|
2449
|
+
* - `get(namespace, key, { tenantId, userId })` — the per-key fallback,
|
|
2450
|
+
* taken only when the occupant does not expose `getMany` (three parallel
|
|
2451
|
+
* reads; see the feature-detect below).
|
|
2452
|
+
*
|
|
2453
|
+
* `getMany` is OPTIONAL for an occupant: the branch is feature-detected, so
|
|
2454
|
+
* a service that predates it still resolves — at three reads instead of one.
|
|
2455
|
+
* Typed `any` deliberately (the occupant's shape varies by host); the
|
|
2456
|
+
* declaration above is the contract this resolver actually relies on, and it
|
|
2457
|
+
* is prose precisely because nothing type-checks it — `getService` is a cast
|
|
2458
|
+
* and `rest-server.ts` widens the provider's return to a bare promise.
|
|
2459
|
+
*/
|
|
2022
2460
|
settings?: any;
|
|
2023
2461
|
tenantId?: string;
|
|
2024
2462
|
userId?: string;
|
|
@@ -2034,14 +2472,22 @@ type LocalizationResult = {
|
|
|
2034
2472
|
* platform default → global → tenant); falls back to direct tenant-scoped
|
|
2035
2473
|
* `sys_setting` rows, then the built-ins `UTC` / `en-US`. Never throws.
|
|
2036
2474
|
*
|
|
2037
|
-
*
|
|
2038
|
-
* refused, etc.) is memoized for
|
|
2039
|
-
* per `(ql, tenantId, userId)` so
|
|
2040
|
-
* line for it — does not repeat
|
|
2041
|
-
*
|
|
2042
|
-
*
|
|
2043
|
-
*
|
|
2044
|
-
*
|
|
2475
|
+
* The DIRECT `sys_setting` read failing outright (backend fault — table
|
|
2476
|
+
* missing, connection refused, etc.) is memoized for
|
|
2477
|
+
* {@link LOCALIZATION_FAILURE_CACHE_TTL_MS} per `(ql, tenantId, userId)` so
|
|
2478
|
+
* the failing query — and the driver's log line for it — does not repeat
|
|
2479
|
+
* every request (#10221). A settings-service refusal is not a backend fault
|
|
2480
|
+
* and never populates that memo (#11877).
|
|
2481
|
+
*
|
|
2482
|
+
* A SUCCESSFUL read is cached too, since #11966 (leg C of #11633) — but only
|
|
2483
|
+
* when the engine carries the write-epoch seam, and only until the first of:
|
|
2484
|
+
* a `localization` settings change, an engine write, or
|
|
2485
|
+
* `OS_LOCALIZATION_CACHE_TTL_MS`. Both invalidations are synchronous and
|
|
2486
|
+
* in-process, which is what lets the success cache exist at all: the
|
|
2487
|
+
* dogfood analytics-bucketing test writes a new org timezone and reads it back
|
|
2488
|
+
* on the very next request, and it is kept unweakened as this leg's acceptance
|
|
2489
|
+
* criterion. See the leg-C docblock above for the full contract, including why
|
|
2490
|
+
* a `ql` with no seam declines to cache rather than falling back to the TTL.
|
|
2045
2491
|
*/
|
|
2046
2492
|
declare function resolveLocalizationContext(input: ResolveLocalizationInput): Promise<LocalizationResult>;
|
|
2047
2493
|
|
|
@@ -2592,6 +3038,20 @@ declare function isRowActive(row: ActivatableRow | null | undefined): boolean;
|
|
|
2592
3038
|
* the table-level half of the same guarantee: a resolver that starts deriving
|
|
2593
3039
|
* administrator standing from a new table would otherwise be invisible to a
|
|
2594
3040
|
* column-set comparison, because the new table appears in neither side's list.
|
|
3041
|
+
*
|
|
3042
|
+
* ## ⚠️ Tables are no longer the whole surface (#11663 L2)
|
|
3043
|
+
*
|
|
3044
|
+
* Since the platform-admin re-anchor's core leg, one input to the administrator
|
|
3045
|
+
* derivation is NOT a table at all: the deployment's declared administrator
|
|
3046
|
+
* list, read from the environment on every resolution
|
|
3047
|
+
* (`security/platform-admin.ts`). A file that listed only tables would go on
|
|
3048
|
+
* being perfectly accurate about the tables while silently claiming the
|
|
3049
|
+
* derivation reads nothing else — the same shape as the stale comment this file
|
|
3050
|
+
* replaced, one level up. {@link ADMIN_STANDING_NON_TABLE_INPUTS} is the place
|
|
3051
|
+
* that says so, and it is deliberately a SEPARATE export rather than a
|
|
3052
|
+
* pseudo-row in the table map: the map is compared for equality against
|
|
3053
|
+
* observed table reads, and a pseudo-row would have to be excluded from that
|
|
3054
|
+
* comparison by name, which is exactly the kind of special case that rots.
|
|
2595
3055
|
*/
|
|
2596
3056
|
/** How a table this resolver reads relates to "who is an administrator". */
|
|
2597
3057
|
interface AdminStandingTable {
|
|
@@ -2618,11 +3078,37 @@ interface AdminStandingTable {
|
|
|
2618
3078
|
* principal, and therefore all of `resolveUserAuthzGrants`. The API-key
|
|
2619
3079
|
* ADMISSION path (`resolveApiKeyAdmission`) is outside it on purpose: it
|
|
2620
3080
|
* 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
|
|
3081
|
+
* confers no administrator standing of its own — `hasPlatformAdminGrant` is set
|
|
3082
|
+
* from a `sys_permission_set` row reached through an UNSCOPED
|
|
3083
|
+
* `sys_user_permission_set` grant (§6b) or from the deployment config matched
|
|
3084
|
+
* against the caller's own STORED `sys_user` row (§6b-config), never from a
|
|
3085
|
+
* scope string and never from the caller-seedable `grants.email`.
|
|
2624
3086
|
*/
|
|
2625
3087
|
declare const ADMIN_STANDING_SURFACE: Readonly<Record<string, AdminStandingTable>>;
|
|
3088
|
+
/** A derivation input that is not a table — see {@link ADMIN_STANDING_NON_TABLE_INPUTS}. */
|
|
3089
|
+
interface AdminStandingNonTableInput {
|
|
3090
|
+
/** How the value reaches the resolver, e.g. `env` for a process environment variable. */
|
|
3091
|
+
readonly kind: 'env';
|
|
3092
|
+
/** The exact spelling an operator sets — quotable verbatim in a refusal message. */
|
|
3093
|
+
readonly name: string;
|
|
3094
|
+
/** What it decides, and what a break-glass guard can and cannot do about it. */
|
|
3095
|
+
readonly reason: string;
|
|
3096
|
+
}
|
|
3097
|
+
/**
|
|
3098
|
+
* [#11663 L2] Inputs to the administrator derivation that no table write can
|
|
3099
|
+
* reach — declared here so this file's silence about them cannot be read as
|
|
3100
|
+
* "the derivation reads only tables".
|
|
3101
|
+
*
|
|
3102
|
+
* The practical consequence is the one worth writing down: a break-glass guard
|
|
3103
|
+
* simulates a pending WRITE, and there is no write to simulate for any of
|
|
3104
|
+
* these. Standing that rests on one of them is taken away by changing the
|
|
3105
|
+
* deployment's configuration and rolling the process, which is deliberately
|
|
3106
|
+
* outside every in-product path — including every path an agent could be talked
|
|
3107
|
+
* into calling. That is the whole point of the config anchor, and it is also
|
|
3108
|
+
* the reason a guard cannot promise to prevent this class of lockout: it can
|
|
3109
|
+
* only refuse the writes it can see.
|
|
3110
|
+
*/
|
|
3111
|
+
declare const ADMIN_STANDING_NON_TABLE_INPUTS: readonly AdminStandingNonTableInput[];
|
|
2626
3112
|
/** The tables a write to which can change who is an administrator. */
|
|
2627
3113
|
declare function adminStandingTables(): string[];
|
|
2628
3114
|
/**
|
|
@@ -2631,6 +3117,147 @@ declare function adminStandingTables(): string[];
|
|
|
2631
3117
|
*/
|
|
2632
3118
|
declare function adminStandingColumns(table: string): readonly string[] | undefined;
|
|
2633
3119
|
|
|
3120
|
+
/**
|
|
3121
|
+
* The one separator {@link parsePlatformAdminEmails} splits on (Choice 2B).
|
|
3122
|
+
* Same shape as `OS_CORS_ORIGIN`, the existing comma-separated precedent.
|
|
3123
|
+
*/
|
|
3124
|
+
declare const PLATFORM_ADMIN_EMAIL_SEPARATOR = ",";
|
|
3125
|
+
/**
|
|
3126
|
+
* The ONE normalization, applied to both sides of every comparison: trim, then
|
|
3127
|
+
* lowercase. Email domains are case-insensitive and every mailbox this platform
|
|
3128
|
+
* issues is too, so an operator who types `Ada@Example.com` and a row storing
|
|
3129
|
+
* `ada@example.com` must be one administrator, not two half-matches.
|
|
3130
|
+
*/
|
|
3131
|
+
declare function normalizePlatformAdminEmail(value: unknown): string;
|
|
3132
|
+
/** The parsed state of `OS_PLATFORM_OWNER_EMAIL` for one raw value. */
|
|
3133
|
+
interface PlatformAdminEmailConfig {
|
|
3134
|
+
/**
|
|
3135
|
+
* Normalized, de-duplicated administrator addresses in the order the operator
|
|
3136
|
+
* declared them. EMPTY when the variable is unset, blank, or refused — those
|
|
3137
|
+
* three are one outcome by design (zero config-derived administrators), and
|
|
3138
|
+
* they are told apart by {@link refusal} rather than by a second empty value.
|
|
3139
|
+
*/
|
|
3140
|
+
readonly emails: readonly string[];
|
|
3141
|
+
/**
|
|
3142
|
+
* The SAME administrators as {@link emails} and index-aligned with it, each
|
|
3143
|
+
* spelled as the operator typed it — trimmed only, never lowercased (exactly
|
|
3144
|
+
* what `resolvePlatformOwnerEmail()` used to hand a single-value reader).
|
|
3145
|
+
*
|
|
3146
|
+
* It exists so that no consumer ever has a reason to split {@link raw} a
|
|
3147
|
+
* second time. Two readers need the as-typed form and neither may re-parse
|
|
3148
|
+
* to get it: the platform-admin STANDING surface's by-email `sys_user`
|
|
3149
|
+
* lookup queries the verbatim spelling alongside the normalized one (an
|
|
3150
|
+
* imported/legacy row may not be stored lowercased, and a driver `where` is
|
|
3151
|
+
* an exact match) — that lookup is `resolvePlatformAdminStanding` in
|
|
3152
|
+
* plugin-security's `platform-admin-service.ts`, which inherited the
|
|
3153
|
+
* two-spelling discipline from the elevation gate the #11663 re-anchor
|
|
3154
|
+
* (leg L4) retired — and the walled boot diagnostic quotes the addresses
|
|
3155
|
+
* back to the operator, who should see what they wrote.
|
|
3156
|
+
*/
|
|
3157
|
+
readonly declaredSpellings: readonly string[];
|
|
3158
|
+
/** What the operator actually typed, when the variable was set to anything. */
|
|
3159
|
+
readonly raw?: string;
|
|
3160
|
+
/**
|
|
3161
|
+
* Set when the variable was DECLARED but refused, naming the offending entry.
|
|
3162
|
+
* `emails` is empty in that case: the whole variable fails closed, never the
|
|
3163
|
+
* one entry (Choice 2B).
|
|
3164
|
+
*/
|
|
3165
|
+
readonly refusal?: string;
|
|
3166
|
+
}
|
|
3167
|
+
/**
|
|
3168
|
+
* Parse one raw `OS_PLATFORM_OWNER_EMAIL` value into the administrator list.
|
|
3169
|
+
*
|
|
3170
|
+
* Pure — no env read, no logging — so the whole parse is testable as a
|
|
3171
|
+
* function of its input. {@link resolvePlatformAdminEmails} is the env-reading,
|
|
3172
|
+
* memoizing, once-per-value-loud wrapper around it.
|
|
3173
|
+
*/
|
|
3174
|
+
declare function parsePlatformAdminEmails(raw: string | undefined): PlatformAdminEmailConfig;
|
|
3175
|
+
/**
|
|
3176
|
+
* Sink for the refusal notice. `console` by default so the loudness does not
|
|
3177
|
+
* depend on any host wiring it up — a deployment that declared administrators
|
|
3178
|
+
* and got none must never find that out silently. Swappable for tests.
|
|
3179
|
+
*/
|
|
3180
|
+
interface PlatformAdminConfigSink {
|
|
3181
|
+
error(message: string): void;
|
|
3182
|
+
warn(message: string): void;
|
|
3183
|
+
}
|
|
3184
|
+
/** Redirect this module's notices (tests). Returns the previous sink. */
|
|
3185
|
+
declare function setPlatformAdminConfigSink(next: PlatformAdminConfigSink | undefined): PlatformAdminConfigSink;
|
|
3186
|
+
/**
|
|
3187
|
+
* Resolve the deployment's declared platform administrators — live from the
|
|
3188
|
+
* environment, memoized on the raw string, and LOUD exactly once per distinct
|
|
3189
|
+
* refused value.
|
|
3190
|
+
*
|
|
3191
|
+
* Silence for an UNSET variable is deliberate and is not the same decision:
|
|
3192
|
+
* every `single`-posture deployment runs that way by design (Choice 4A leaves
|
|
3193
|
+
* first-user promotion in place there), and warning on the shipped default is
|
|
3194
|
+
* how a log people read becomes a log people skim. A walled posture with the
|
|
3195
|
+
* variable unset already REFUSES BOOT one layer up, in plugin-auth.
|
|
3196
|
+
*/
|
|
3197
|
+
declare function resolvePlatformAdminEmails(): PlatformAdminEmailConfig;
|
|
3198
|
+
/** Drop the memo — for tests that drive several values through one process. */
|
|
3199
|
+
declare function resetPlatformAdminEmailMemo(): void;
|
|
3200
|
+
/**
|
|
3201
|
+
* Does this stored `sys_user` row belong to a declared platform administrator?
|
|
3202
|
+
*
|
|
3203
|
+
* Fail-closed on every axis: an empty/refused config answers `false` without
|
|
3204
|
+
* looking at the row at all, an address that is not on the list answers
|
|
3205
|
+
* `false`, and an address that IS on the list but whose `email_verified`
|
|
3206
|
+
* column does not read verified answers `false` too. The last one is the point
|
|
3207
|
+
* of the whole leg — an unverified account holding a configured address confers
|
|
3208
|
+
* nothing, so an attacker who registers the operator's address before the
|
|
3209
|
+
* operator does gains no standing by it.
|
|
3210
|
+
*
|
|
3211
|
+
* ⚠️ `row` MUST be the caller's own stored `sys_user` row. See this module's
|
|
3212
|
+
* header: `grants.email` is caller-seedable and reading it here would be an
|
|
3213
|
+
* escalation channel.
|
|
3214
|
+
*/
|
|
3215
|
+
declare function matchesConfiguredPlatformAdmin(row: unknown, config: PlatformAdminEmailConfig): boolean;
|
|
3216
|
+
/**
|
|
3217
|
+
* [#13147] Is this bare ADDRESS one of the declared administrators?
|
|
3218
|
+
*
|
|
3219
|
+
* The membership half of {@link matchesConfiguredPlatformAdmin}, spelled once
|
|
3220
|
+
* and exported, because the row-and-verified predicate above is not the shape
|
|
3221
|
+
* every reader of `OS_PLATFORM_OWNER_EMAIL` needs:
|
|
3222
|
+
*
|
|
3223
|
+
* - the walled platform-admin STANDING surface must keep the two halves
|
|
3224
|
+
* SEPARATE — `resolvePlatformAdminStanding`
|
|
3225
|
+
* (`plugin-security/platform-admin-service.ts`, reported at boot by
|
|
3226
|
+
* `bootstrap-platform-admin.ts`) answers `registered` and `verified` as two
|
|
3227
|
+
* independent per-entry fields, so the operator's log can tell "not
|
|
3228
|
+
* registered yet" apart from "registered, NOT verified". ⚠️ That reason
|
|
3229
|
+
* predates the #11663 re-anchor and survives it: the pair used to be the
|
|
3230
|
+
* retired elevation gate's `walled_owner_not_registered` /
|
|
3231
|
+
* `walled_owner_not_verified` reasons — the mechanism moved, the need to
|
|
3232
|
+
* keep the halves apart did not;
|
|
3233
|
+
* - the creation-time operator stamp (`plugin-auth`) is handed an email
|
|
3234
|
+
* STRING by better-auth, before any row exists to read;
|
|
3235
|
+
* - the Layer 0 wall bypass takes a fast negative on the session's
|
|
3236
|
+
* server-resolved email before it spends a `sys_user` read.
|
|
3237
|
+
*
|
|
3238
|
+
* ⛔ Those readers must NOT hand-roll `config.emails.includes(x.toLowerCase())`
|
|
3239
|
+
* instead. That expression is where a seventh dialect gets born: it silently
|
|
3240
|
+
* drops the trim, and a stray space in one list entry then makes an
|
|
3241
|
+
* administrator vanish with nothing to notice. One membership expression, one
|
|
3242
|
+
* normalization ({@link normalizePlatformAdminEmail}), one place to fix.
|
|
3243
|
+
*
|
|
3244
|
+
* Fail-closed like everything else here: an empty or refused config answers
|
|
3245
|
+
* `false` without looking at the candidate, and a blank/non-string candidate
|
|
3246
|
+
* answers `false` against any config.
|
|
3247
|
+
*
|
|
3248
|
+
* ⚠️ This is a match against CONFIGURATION only — it says nothing about whether
|
|
3249
|
+
* the address is verified, or whether the caller actually holds it. Standing
|
|
3250
|
+
* still requires {@link matchesConfiguredPlatformAdmin} over the caller's own
|
|
3251
|
+
* stored row; see this module's header for why `grants.email` is never it.
|
|
3252
|
+
*/
|
|
3253
|
+
declare function isConfiguredPlatformAdminEmail(email: unknown, config: PlatformAdminEmailConfig): boolean;
|
|
3254
|
+
declare function reportLegacyPlatformAdminGrant(input: {
|
|
3255
|
+
userId: string;
|
|
3256
|
+
email?: unknown;
|
|
3257
|
+
}): void;
|
|
3258
|
+
/** Drop the once-per-process latch — for tests. */
|
|
3259
|
+
declare function resetLegacyPlatformAdminGrantReport(): void;
|
|
3260
|
+
|
|
2634
3261
|
/**
|
|
2635
3262
|
* [#7678] The `?status=` vocabulary of the audience-binding suggestion list
|
|
2636
3263
|
* (ADR-0090 D5/D9) — ONE owner for a rule that had exactly one implementation
|
|
@@ -2697,7 +3324,12 @@ declare const unknownAudienceBindingSuggestionStatusMessage: (value: string) =>
|
|
|
2697
3324
|
* `__expandRead` marks a read as a lookup EXPANSION sub-read (it no longer
|
|
2698
3325
|
* relaxes any gate — #7626 removed that waiver — but it still travels with
|
|
2699
3326
|
* one operation and must not be inherited by another), `__referentialFieldClear`
|
|
2700
|
-
* authorizes the referential-clear write.
|
|
3327
|
+
* authorizes the referential-clear write. [#13644] The latter also has a
|
|
3328
|
+
* DECLARED, read-only projection — `HookContext.referentialFieldClear`
|
|
3329
|
+
* (`@objectstack/spec/data`), populated by objectql's `update()` assembly
|
|
3330
|
+
* and carried across the sandbox boundary by contract — which is what an
|
|
3331
|
+
* APP reads; the `__` key here remains the engine/middleware authorization
|
|
3332
|
+
* channel, and this file's stripping rule is unchanged by the projection.
|
|
2701
3333
|
*
|
|
2702
3334
|
* plugin-security is the PRODUCER of that vocabulary and would be the most
|
|
2703
3335
|
* honest owner of the rule for consuming it, but none of the three consumers
|
|
@@ -2786,6 +3418,244 @@ declare const OPERATION_PRIVATE_KEY_PREFIX = "__";
|
|
|
2786
3418
|
*/
|
|
2787
3419
|
declare function withoutOperationPrivateKeys(exec: Record<string, unknown>): ExecutionContext;
|
|
2788
3420
|
|
|
3421
|
+
/**
|
|
3422
|
+
* ── The `authz.invalidated` cluster channel (#11968, #11633 §3) ─────────────
|
|
3423
|
+
*
|
|
3424
|
+
* The cross-node half of the authorization invalidation substrate: one channel
|
|
3425
|
+
* name, one payload shape, and the contract statement that governs how both may
|
|
3426
|
+
* be read. It carries no cache and no consumer — leg B (#11967) is the first.
|
|
3427
|
+
*
|
|
3428
|
+
* ## ⭐ THE TTL IS THE CORRECTNESS CONTRACT. THIS CHANNEL IS NOT.
|
|
3429
|
+
*
|
|
3430
|
+
* A message on this channel is a **hint**, and **a missed message is EXPECTED**.
|
|
3431
|
+
* That is not a caveat about an unreliable network; it is the shipped
|
|
3432
|
+
* guarantee, measured rather than assumed:
|
|
3433
|
+
*
|
|
3434
|
+
* - `content/docs/kernel/cluster.mdx` §4.2, on `at-least-once`:
|
|
3435
|
+
* *"**No shipped driver provides this yet.** The `redis` driver publishes
|
|
3436
|
+
* over plain Redis pub/sub, which is *at-most-once* — fire-and-forget, no
|
|
3437
|
+
* persistence, no replay for a node that was down at publish time."*
|
|
3438
|
+
* - `@objectstack/service-cluster-redis`'s own `publish` docblock:
|
|
3439
|
+
* *"there is no delivery guarantee to subscribers and no replay for a node
|
|
3440
|
+
* that was down or slow at publish time. This is acceptable **only** for
|
|
3441
|
+
* events that are pure cache-invalidation hints, never the source of
|
|
3442
|
+
* truth."*
|
|
3443
|
+
* - The `memory` driver does not cross a process boundary at all
|
|
3444
|
+
* (`service-cluster/src/memory/pubsub.ts`, and the split-brain guard's
|
|
3445
|
+
* `IN_PROCESS_DRIVERS`).
|
|
3446
|
+
*
|
|
3447
|
+
* So a dropped message on an at-most-once transport is a staleness window with
|
|
3448
|
+
* **no upper bound**, and no amount of care at the publish site changes that.
|
|
3449
|
+
* What bounds it is the **TTL** every cached authorization answer must carry:
|
|
3450
|
+
* a peer that never hears the message still converges when its entry expires.
|
|
3451
|
+
*
|
|
3452
|
+
* ⇒ **A consumer that would be incorrect if a message were lost is misusing
|
|
3453
|
+
* this channel.** The channel exists for one thing: moving the *typical*
|
|
3454
|
+
* convergence from "one TTL" down to "one network hop". It never moves the
|
|
3455
|
+
* worst case, and it is never the mechanism that makes a cached authorization
|
|
3456
|
+
* answer safe.
|
|
3457
|
+
*
|
|
3458
|
+
* ⚠️ For the same reason this channel is **best-effort at the publish site
|
|
3459
|
+
* too**: a publish failure is logged and swallowed, never propagated into the
|
|
3460
|
+
* write that triggered it. A grant revocation must not fail because a cache
|
|
3461
|
+
* hint could not be delivered — the TTL already covers exactly that case.
|
|
3462
|
+
*
|
|
3463
|
+
* `IPubSub`'s own interface docblock (`@objectstack/spec/contracts`) states the
|
|
3464
|
+
* same thing from the contract side — delivery is whatever the configured
|
|
3465
|
+
* driver declares, no shipped driver exceeds at-most-once, and handlers must be
|
|
3466
|
+
* idempotent **and** tolerate loss. That docblock, `cluster.mdx` §4.2 and the
|
|
3467
|
+
* redis driver agree; there is no disagreement here for a later reader to go
|
|
3468
|
+
* looking for.
|
|
3469
|
+
*
|
|
3470
|
+
* ## Why a new channel on the existing bus, and not a new transport
|
|
3471
|
+
*
|
|
3472
|
+
* `MetadataClusterBridgePlugin` already shows the whole shape — a channel on
|
|
3473
|
+
* `IPubSub`, bridged by a plugin that late-binds at `kernel:ready` and does
|
|
3474
|
+
* nothing when the services it needs are absent. Reusing it adds no dependency
|
|
3475
|
+
* and no new failure mode. The one thing metadata's channel does NOT have to
|
|
3476
|
+
* carry is what makes this one different: a missed `metadata.changed` costs a
|
|
3477
|
+
* stale schema until reload and loses no data, while a missed
|
|
3478
|
+
* `authz.invalidated` would cost a permission honoured past its revocation —
|
|
3479
|
+
* which is why the bound lives in the TTL and why the absence of this bridge is
|
|
3480
|
+
* stated out loud at boot ({@link ../security/authz-cache-posture.js}).
|
|
3481
|
+
*/
|
|
3482
|
+
/**
|
|
3483
|
+
* The cluster channel authorization-cache invalidation hints travel on.
|
|
3484
|
+
*
|
|
3485
|
+
* Named as a fact about authorization, not about any one cache, because a
|
|
3486
|
+
* second consumer must reuse this channel rather than mint a parallel one —
|
|
3487
|
+
* two channels would be two chances to miss a bridge.
|
|
3488
|
+
*/
|
|
3489
|
+
declare const AUTHZ_INVALIDATED_CHANNEL = "authz.invalidated";
|
|
3490
|
+
/**
|
|
3491
|
+
* Why an authorization epoch advanced. Coarse on purpose (#11633 §2.2, Fork 1 →
|
|
3492
|
+
* A): the engine seam sees `update`/`delete` expressed as a `where`, from which
|
|
3493
|
+
* the affected user or organization is frequently **not derivable without
|
|
3494
|
+
* reading the row back**. So the substrate carries "something authorization-
|
|
3495
|
+
* relevant changed", never "whose entry to drop", and a consumer retires its
|
|
3496
|
+
* whole bucket. Keyed invalidation is gated behind a measurement of a
|
|
3497
|
+
* write-heavy tenant and is explicitly not the starting point.
|
|
3498
|
+
*/
|
|
3499
|
+
type AuthzInvalidationReason =
|
|
3500
|
+
/** A write (`insert` / `update` / `delete`) passed the engine middleware seam. */
|
|
3501
|
+
'write'
|
|
3502
|
+
/** A metadata change — a permission set can be DECLARED, so no row is written. */
|
|
3503
|
+
| 'metadata'
|
|
3504
|
+
/** A hint received from a peer node on this channel. */
|
|
3505
|
+
| 'remote'
|
|
3506
|
+
/** An explicit bump by a host that knows something the seam cannot see. */
|
|
3507
|
+
| 'manual';
|
|
3508
|
+
/**
|
|
3509
|
+
* The payload on {@link AUTHZ_INVALIDATED_CHANNEL}.
|
|
3510
|
+
*
|
|
3511
|
+
* Deliberately tiny and deliberately NOT a description of what to invalidate:
|
|
3512
|
+
* see {@link AuthzInvalidationReason} for why the seam cannot supply that. A
|
|
3513
|
+
* receiver's only correct response is to retire its authorization cache
|
|
3514
|
+
* wholesale — and to remain correct if this message never arrives.
|
|
3515
|
+
*
|
|
3516
|
+
* ⛔ Not a `packages/spec` contract type. #11633 §5 reserves a declared shape
|
|
3517
|
+
* for the invalidation event to the spec seat and does not pre-commit it; this
|
|
3518
|
+
* is the runtime shape the substrate publishes today.
|
|
3519
|
+
*/
|
|
3520
|
+
interface AuthzInvalidatedPayload {
|
|
3521
|
+
/**
|
|
3522
|
+
* Publishing node, for loopback suppression — a node must not act on its own
|
|
3523
|
+
* hint. Mirrors `ClusterMetadataChangedPayload.originNode`.
|
|
3524
|
+
*/
|
|
3525
|
+
originNode?: string;
|
|
3526
|
+
/** The publisher's local epoch after the bump. Diagnostic only. */
|
|
3527
|
+
epoch: number;
|
|
3528
|
+
/** What advanced the epoch. Diagnostic only — see the type's doc. */
|
|
3529
|
+
reason: AuthzInvalidationReason;
|
|
3530
|
+
/** Wall-clock publish time, ms since epoch. Best-effort. */
|
|
3531
|
+
at: number;
|
|
3532
|
+
}
|
|
3533
|
+
|
|
3534
|
+
/**
|
|
3535
|
+
* ── The boot-time authorization-cache posture statement (#11968, #11633 §3) ──
|
|
3536
|
+
*
|
|
3537
|
+
* ⭐ **Non-optional**, by the 2026-08-25 ruling on #11633 (Fork 2 → B): whenever
|
|
3538
|
+
* a grants cache is enabled and there is **no** cross-node invalidation bus, the
|
|
3539
|
+
* deployment is told so, **out loud**, at boot.
|
|
3540
|
+
*
|
|
3541
|
+
* ## Why a statement and not a refusal
|
|
3542
|
+
*
|
|
3543
|
+
* A per-process cache bounded only by its TTL is a legitimate configuration —
|
|
3544
|
+
* #11633 §3 rules the TTL, not the bus, as the correctness contract, so a
|
|
3545
|
+
* single-node deployment (or one that simply accepts the window) is correct
|
|
3546
|
+
* with no bus at all. What is NOT acceptable is arriving there **without
|
|
3547
|
+
* noticing**: that is the shape of #4785, where a control was silently disabled
|
|
3548
|
+
* by configuration and nothing said so. The metadata bridge logs its own
|
|
3549
|
+
* absence at `debug` and that is right for metadata — a missed
|
|
3550
|
+
* `metadata.changed` costs a stale schema until reload and loses no data. Here
|
|
3551
|
+
* the same silence would cost a permission honoured past its revocation.
|
|
3552
|
+
*
|
|
3553
|
+
* ⇒ Enabled cache + no bus is a `warn`, every boot, naming the window it just
|
|
3554
|
+
* accepted. Not a refusal — a statement.
|
|
3555
|
+
*
|
|
3556
|
+
* ## The three postures, and the reason `disabled` is silent
|
|
3557
|
+
*
|
|
3558
|
+
* - `disabled` — no cache is enabled. **Silent.** There is no window to
|
|
3559
|
+
* state, and a line every boot on the shipped default
|
|
3560
|
+
* (TTL `0`, Fork 4) would train operators to ignore it —
|
|
3561
|
+
* which is how the loud line stops being loud.
|
|
3562
|
+
* - `ttl-only` — cache enabled, no cross-node bus. **LOUD (`warn`).**
|
|
3563
|
+
* - `bus-narrowed` — cache enabled, bus bridged. `info`, so the bridge's
|
|
3564
|
+
* presence is on the record next to its absence.
|
|
3565
|
+
*
|
|
3566
|
+
* Both arms are pinned in `authz-cache-posture.test.ts`: a statement that
|
|
3567
|
+
* appears always is no more useful than one that never appears.
|
|
3568
|
+
*/
|
|
3569
|
+
/** Deployment variable that turns the grants cache on. `0` (default) = off. */
|
|
3570
|
+
declare const AUTHZ_GRANTS_CACHE_TTL_ENV = "OS_AUTHZ_GRANTS_CACHE_TTL_MS";
|
|
3571
|
+
/**
|
|
3572
|
+
* What the local node has, in cross-node terms, for delivering
|
|
3573
|
+
* `authz.invalidated`.
|
|
3574
|
+
*
|
|
3575
|
+
* ⚠️ `in-process` is a distinct state on purpose, and it is the one that would
|
|
3576
|
+
* otherwise go unnoticed: `Runtime` auto-registers a **memory** cluster service
|
|
3577
|
+
* by default, so "is a `cluster` service registered?" answers *yes* on the
|
|
3578
|
+
* shipped default while the bus fans out to exactly nobody
|
|
3579
|
+
* (`service-cluster/src/memory/pubsub.ts`: *"No cross-process delivery"*; the
|
|
3580
|
+
* split-brain guard calls the same set `IN_PROCESS_DRIVERS`). A posture check
|
|
3581
|
+
* that asked only whether a service exists would therefore stay silent in
|
|
3582
|
+
* precisely the multi-replica deployment it exists to warn.
|
|
3583
|
+
*/
|
|
3584
|
+
type AuthzInvalidationBusState =
|
|
3585
|
+
/** A cross-node transport is attached and carrying the channel. */
|
|
3586
|
+
'bridged'
|
|
3587
|
+
/** A cluster service exists, but its driver does not cross a process boundary. */
|
|
3588
|
+
| 'in-process'
|
|
3589
|
+
/** No cluster service, or no engine seam to attach one to. */
|
|
3590
|
+
| 'absent';
|
|
3591
|
+
/** The posture a boot resolves to. */
|
|
3592
|
+
type AuthzCachePosture = 'disabled' | 'ttl-only' | 'bus-narrowed';
|
|
3593
|
+
interface AuthzCachePostureInput {
|
|
3594
|
+
/** Configured grants-cache TTL in ms. `<= 0` means the cache is off. */
|
|
3595
|
+
ttlMs: number;
|
|
3596
|
+
/** What the node has for cross-node invalidation. */
|
|
3597
|
+
bus: AuthzInvalidationBusState;
|
|
3598
|
+
/** Cluster driver name, when one is registered. Surfaced in the message. */
|
|
3599
|
+
driver?: string;
|
|
3600
|
+
}
|
|
3601
|
+
interface AuthzCachePostureStatement {
|
|
3602
|
+
posture: AuthzCachePosture;
|
|
3603
|
+
/** True when this must be said at `warn`. See the module doc. */
|
|
3604
|
+
loud: boolean;
|
|
3605
|
+
/** The statement itself. Empty only for the silent `disabled` posture. */
|
|
3606
|
+
message: string;
|
|
3607
|
+
}
|
|
3608
|
+
/**
|
|
3609
|
+
* Resolve the posture. Pure — it reads its inputs and nothing else, so both
|
|
3610
|
+
* arms of the acceptance criterion ("appears exactly when a cache flag is on
|
|
3611
|
+
* without a bus, and not otherwise") are testable without a boot.
|
|
3612
|
+
*/
|
|
3613
|
+
declare function resolveAuthzCachePosture(input: AuthzCachePostureInput): AuthzCachePostureStatement;
|
|
3614
|
+
/** The reading of {@link AUTHZ_GRANTS_CACHE_TTL_ENV}, malformed input included. */
|
|
3615
|
+
interface AuthzGrantsCacheTtlReading {
|
|
3616
|
+
/** The effective TTL. `0` whenever the cache is off — including malformed. */
|
|
3617
|
+
ttlMs: number;
|
|
3618
|
+
/** The raw value read, when one was set. */
|
|
3619
|
+
raw?: string;
|
|
3620
|
+
/** True when a value was set but could not be read as a non-negative number. */
|
|
3621
|
+
malformed: boolean;
|
|
3622
|
+
}
|
|
3623
|
+
/**
|
|
3624
|
+
* Read the grants-cache TTL from deployment config.
|
|
3625
|
+
*
|
|
3626
|
+
* Deployment config, never a settings row (#11633 §5): the knob that bounds a
|
|
3627
|
+
* cache must not itself be served through a cached path, and operator-level
|
|
3628
|
+
* configuration comes from the environment.
|
|
3629
|
+
*
|
|
3630
|
+
* Default `0` — the grants cache is **off** unless a deployment turns it on and
|
|
3631
|
+
* accepts the staleness window explicitly (#11633 Fork 4, ruled 2026-08-25).
|
|
3632
|
+
*
|
|
3633
|
+
* ⚠️ A malformed value resolves to `0` but is reported as malformed rather than
|
|
3634
|
+
* folded into "off": `OS_AUTHZ_GRANTS_CACHE_TTL_MS=5OOO` (letter O) silently
|
|
3635
|
+
* meaning "disabled" is the same silent-disable class the posture statement
|
|
3636
|
+
* exists to prevent.
|
|
3637
|
+
*/
|
|
3638
|
+
declare function readAuthzGrantsCacheTtlMs(env?: Record<string, string | undefined>): AuthzGrantsCacheTtlReading;
|
|
3639
|
+
/** Minimal sink shape — `warn` is the member every logger in this repo has. */
|
|
3640
|
+
interface AuthzPostureSink {
|
|
3641
|
+
warn(message: string, meta?: Record<string, unknown>): void;
|
|
3642
|
+
info?(message: string, meta?: Record<string, unknown>): void;
|
|
3643
|
+
debug?(message: string, meta?: Record<string, unknown>): void;
|
|
3644
|
+
}
|
|
3645
|
+
/**
|
|
3646
|
+
* State the posture at boot. `warn` for the loud arm, `info` for the bridged
|
|
3647
|
+
* one, and nothing at all when no cache is enabled (see the module doc for why
|
|
3648
|
+
* silence is the right default rather than a courtesy line).
|
|
3649
|
+
*
|
|
3650
|
+
* A malformed TTL value is warned about on its own, because "we read your
|
|
3651
|
+
* setting as off" is exactly what a deployment must not have to infer.
|
|
3652
|
+
*/
|
|
3653
|
+
declare function reportAuthzCachePosture(input: AuthzCachePostureInput & {
|
|
3654
|
+
malformedTtl?: {
|
|
3655
|
+
raw?: string;
|
|
3656
|
+
};
|
|
3657
|
+
}, sink: AuthzPostureSink): AuthzCachePostureStatement;
|
|
3658
|
+
|
|
2789
3659
|
/**
|
|
2790
3660
|
* Environment utilities for universal (Node/Browser) compatibility.
|
|
2791
3661
|
*/
|
|
@@ -3292,6 +4162,71 @@ declare function runMigrationJournal(engine: IObjectQLEngine, plan: MigrationPla
|
|
|
3292
4162
|
/** Resume a run the journal says was interrupted. Thin alias for intent at call sites. */
|
|
3293
4163
|
declare function resumeMigrationJournal(engine: IObjectQLEngine, plan: MigrationPlan, runId: string, options?: Omit<RunMigrationJournalOptions, 'runId'>): Promise<MigrationRunResult>;
|
|
3294
4164
|
|
|
4165
|
+
/** Example row references a group carries, so the summary can point at real rows. */
|
|
4166
|
+
declare const ADVISORY_SAMPLE_ROWS = 5;
|
|
4167
|
+
/** One advisory rule hit, as the evaluator reports it. */
|
|
4168
|
+
interface AdvisoryHit {
|
|
4169
|
+
/** Object the row belongs to. */
|
|
4170
|
+
object: string;
|
|
4171
|
+
/** The declared rule's `name`. */
|
|
4172
|
+
rule: string;
|
|
4173
|
+
/** The rule's declared severity — `'warning'` or `'info'`; never `'error'`. */
|
|
4174
|
+
severity: string;
|
|
4175
|
+
/** The rule's author-written message, in the caller's locale. */
|
|
4176
|
+
message: string;
|
|
4177
|
+
/**
|
|
4178
|
+
* A reference to the row, when the write carries one.
|
|
4179
|
+
*
|
|
4180
|
+
* NOT necessarily an id: on the path this exists for — a seed INSERT — the
|
|
4181
|
+
* driver has not issued an id yet at validation time, so an id-only reference
|
|
4182
|
+
* would be empty for exactly the case the aggregation was built for. The
|
|
4183
|
+
* producer sends the best stable handle it has (`id`, else `name=<value>`,
|
|
4184
|
+
* the same way the seed loader names a row in its own errors).
|
|
4185
|
+
*/
|
|
4186
|
+
recordRef?: string;
|
|
4187
|
+
}
|
|
4188
|
+
/** Every hit for one `(object, rule)` pair, folded. */
|
|
4189
|
+
interface AdvisoryGroup {
|
|
4190
|
+
object: string;
|
|
4191
|
+
rule: string;
|
|
4192
|
+
severity: string;
|
|
4193
|
+
/** The first message seen for this group (they differ only by interpolation). */
|
|
4194
|
+
message: string;
|
|
4195
|
+
/** How many ROWS tripped this rule during the scope. */
|
|
4196
|
+
rows: number;
|
|
4197
|
+
/** Up to {@link ADVISORY_SAMPLE_ROWS} example row references. */
|
|
4198
|
+
sampleRows: string[];
|
|
4199
|
+
}
|
|
4200
|
+
/**
|
|
4201
|
+
* Offer one advisory hit to the active aggregation scope.
|
|
4202
|
+
*
|
|
4203
|
+
* @returns `true` when a scope captured it — the caller must then NOT log its
|
|
4204
|
+
* own per-row line, because the scope owner reports the whole group. `false`
|
|
4205
|
+
* when no scope is active, which is the ordinary interactive case: the caller
|
|
4206
|
+
* logs exactly as it always did. A caller that ignores the return value
|
|
4207
|
+
* degrades to today's behaviour rather than losing the report.
|
|
4208
|
+
*/
|
|
4209
|
+
declare function recordAdvisoryHit(hit: AdvisoryHit): boolean;
|
|
4210
|
+
/**
|
|
4211
|
+
* Whether an advisory aggregation scope is active on this async context.
|
|
4212
|
+
*
|
|
4213
|
+
* Exported for tests and for a caller that wants to skip building a message it
|
|
4214
|
+
* is about to discard; {@link recordAdvisoryHit}'s return value is the one that
|
|
4215
|
+
* decides.
|
|
4216
|
+
*/
|
|
4217
|
+
declare function isAggregatingAdvisories(): boolean;
|
|
4218
|
+
/**
|
|
4219
|
+
* Run `fn` with advisory hits aggregated, then hand the folded groups to
|
|
4220
|
+
* `report`.
|
|
4221
|
+
*
|
|
4222
|
+
* `report` runs in a `finally`, so a load that throws still reports what it
|
|
4223
|
+
* tripped before failing — the diagnostics of a half-finished seed are the ones
|
|
4224
|
+
* most worth having. It is called only when there is something to report, and
|
|
4225
|
+
* its own failure is never allowed to replace the caller's outcome: a reporting
|
|
4226
|
+
* bug must not turn a successful seed load into a failed one.
|
|
4227
|
+
*/
|
|
4228
|
+
declare function runWithAdvisoryAggregation<T>(fn: () => Promise<T>, report: (groups: AdvisoryGroup[]) => void): Promise<T>;
|
|
4229
|
+
|
|
3295
4230
|
/**
|
|
3296
4231
|
* The slice of an execution context the resolver reads. Structural on purpose —
|
|
3297
4232
|
* see {@link filterTokenContextFrom}.
|
|
@@ -3407,6 +4342,204 @@ declare function temporalComparandKind(fieldType: unknown): TemporalComparandKin
|
|
|
3407
4342
|
*/
|
|
3408
4343
|
declare function isUninterpretableTemporalComparand(kind: TemporalComparandKind, value: unknown): boolean;
|
|
3409
4344
|
|
|
4345
|
+
/**
|
|
4346
|
+
* [ADR-0126 §4] THE activation-ledger row contract — one implementation,
|
|
4347
|
+
* parameterized by `metadata_type`.
|
|
4348
|
+
*
|
|
4349
|
+
* ## Why this file exists (#12350)
|
|
4350
|
+
*
|
|
4351
|
+
* ADR-0126 §4 declares ONE activation ledger for the whole disable+clone
|
|
4352
|
+
* family. It briefly had two implementations of that one row contract:
|
|
4353
|
+
*
|
|
4354
|
+
* | Implementation | Package | Landed |
|
|
4355
|
+
* | :-------------------------------- | :--------------------------------- | :----- |
|
|
4356
|
+
* | `ObjectStoreFlowActivationStore` | `@objectstack/service-automation` | #12296 |
|
|
4357
|
+
* | `ObjectStoreActionActivationStore`| `@objectstack/objectql` | #12348 |
|
|
4358
|
+
*
|
|
4359
|
+
* They agreed on every load-bearing detail because the second was written from
|
|
4360
|
+
* the first — and nothing structurally held them together. ADR-0126 §8
|
|
4361
|
+
* pre-charts `tool`, `skill` and `position` as later consumers, and a third
|
|
4362
|
+
* and fourth copy is where the row semantics start drifting: the org-row skip
|
|
4363
|
+
* and the `0`-is-false read are exactly the kind of detail a copy loses
|
|
4364
|
+
* quietly, in a direction (an artifact silently re-arming) nothing else
|
|
4365
|
+
* measures.
|
|
4366
|
+
*
|
|
4367
|
+
* ## Why the code lives HERE and the object does not
|
|
4368
|
+
*
|
|
4369
|
+
* Neither consumer could import the other: `@objectstack/service-automation`
|
|
4370
|
+
* does not depend on `@objectstack/objectql` (devDependency only), and the
|
|
4371
|
+
* engine must not depend on a service — the dependency arrow points the other
|
|
4372
|
+
* way. `@objectstack/core` is the package BOTH already depend on, so this is
|
|
4373
|
+
* the one home that needs no new edge. ⛔ NOT `@objectstack/platform-objects`,
|
|
4374
|
+
* which declares the OBJECT: `objectql` does not depend on it and adding that
|
|
4375
|
+
* edge would invert the tiering, since platform-objects is a catalog the
|
|
4376
|
+
* engine serves. That is a MODULE-IMPORT question, and it is independent of
|
|
4377
|
+
* where the object's REGISTRATION lives (a composition question, ruled
|
|
4378
|
+
* separately on #12359 — `PlatformObjectsPlugin`).
|
|
4379
|
+
*
|
|
4380
|
+
* The table is reached by NAME, never by importing the declaration, exactly as
|
|
4381
|
+
* the engine already reaches `sys_metadata` / `sys_secret`.
|
|
4382
|
+
*
|
|
4383
|
+
* ## The row shape — ⛔ this module writes COLUMNS, never schema
|
|
4384
|
+
*
|
|
4385
|
+
* `metadata_type` · `name` · `package_id` · `active`, exactly the four
|
|
4386
|
+
* ADR-0126 §4 declares. Three properties are load-bearing and each is pinned on
|
|
4387
|
+
* both consumers' sides (`flow-activation-ledger.test.ts`,
|
|
4388
|
+
* `action-activation.test.ts` — unchanged by the consolidation, which is what
|
|
4389
|
+
* makes them the proof it lost nothing):
|
|
4390
|
+
*
|
|
4391
|
+
* - **The ledger is DEPLOYMENT-level, and carries no tenant column at all.**
|
|
4392
|
+
* A row says "this environment switched this managed item off" — a fact no
|
|
4393
|
+
* organization owns. The table briefly declared a nullable tenant column
|
|
4394
|
+
* marked RESERVED and never written, and this module correspondingly
|
|
4395
|
+
* filtered reads to the NULL ones and skipped any row carrying an
|
|
4396
|
+
* organization. Both are gone: a reserved nullable tenant
|
|
4397
|
+
* column is the shape the total-organization-ownership record proposed in
|
|
4398
|
+
* PR #14976 rules out, so the column was dropped before it ever shipped
|
|
4399
|
+
* (17.2.0 predates the table). There is no filter here any more because
|
|
4400
|
+
* there is no column to filter on — `list()` is simply every activation
|
|
4401
|
+
* row of this type. Should a per-organization dimension ever be wanted, it
|
|
4402
|
+
* returns as a separate org-owned object, never as a column here.
|
|
4403
|
+
* - **Absence of a row means ACTIVE.** Nothing here ever writes a row to say
|
|
4404
|
+
* "active by default", and `list()` returning nothing is the normal
|
|
4405
|
+
* stock-boot state, not an error. Re-enabling UPDATES the row to
|
|
4406
|
+
* `active: true` rather than deleting it, so the ledger records the
|
|
4407
|
+
* administrator's CHOICE instead of erasing it (§6 wall 3) — which is why
|
|
4408
|
+
* {@link MetadataActivationStoreEngine} deliberately has no `delete`.
|
|
4409
|
+
* - **A driver `0` reads as false.** SQLite/libsql round-trip booleans as
|
|
4410
|
+
* 0/1; a `!== false` test alone would read a disabled artifact as armed.
|
|
4411
|
+
*
|
|
4412
|
+
* ## The discriminator is never optional
|
|
4413
|
+
*
|
|
4414
|
+
* The ledger is generic and shared — flow rows and action rows live in the
|
|
4415
|
+
* same table today, and §8 charts more. Every read and write below is scoped
|
|
4416
|
+
* by `metadata_type`, so no consumer can touch a neighbour's state through a
|
|
4417
|
+
* table all of them are told to treat as generic. It is a constructor
|
|
4418
|
+
* argument, not a per-call one, so a caller cannot forget it at a single site.
|
|
4419
|
+
*/
|
|
4420
|
+
/**
|
|
4421
|
+
* The ledger table. A NAME, not an import: the object is declared in
|
|
4422
|
+
* `@objectstack/platform-objects` and this package must not depend on it.
|
|
4423
|
+
*/
|
|
4424
|
+
declare const METADATA_ACTIVATION_TABLE = "sys_metadata_activation";
|
|
4425
|
+
/**
|
|
4426
|
+
* [ADR-0126 §4] One packaged artifact's install-level activation row.
|
|
4427
|
+
*
|
|
4428
|
+
* The ledger's own columns are `metadata_type` / `name` / `package_id` /
|
|
4429
|
+
* `active`; `metadata_type` is fixed by the store, so it never reaches a
|
|
4430
|
+
* consumer's projection.
|
|
4431
|
+
*/
|
|
4432
|
+
interface MetadataActivationRow {
|
|
4433
|
+
/** The packaged artifact's declarative machine name (ADR-0126 §4). */
|
|
4434
|
+
name: string;
|
|
4435
|
+
/** The package that ships the base artifact. */
|
|
4436
|
+
packageId: string;
|
|
4437
|
+
/** Is the packaged artifact armed for this installation. */
|
|
4438
|
+
active: boolean;
|
|
4439
|
+
}
|
|
4440
|
+
/**
|
|
4441
|
+
* [ADR-0126 §4] The durable off-switch for one class of packaged artifact.
|
|
4442
|
+
*
|
|
4443
|
+
* Absence of a row means the packaged default — ACTIVE — so a runtime with no
|
|
4444
|
+
* store attached, or a store with no rows, behaves exactly as a stock boot
|
|
4445
|
+
* always has.
|
|
4446
|
+
*/
|
|
4447
|
+
interface MetadataActivationStore {
|
|
4448
|
+
/** Every activation row for this type — the ledger is deployment-wide. */
|
|
4449
|
+
list(): Promise<MetadataActivationRow[]>;
|
|
4450
|
+
/** Insert or update the row for one packaged artifact. */
|
|
4451
|
+
setActive(row: MetadataActivationRow): Promise<void>;
|
|
4452
|
+
}
|
|
4453
|
+
/**
|
|
4454
|
+
* The exact engine slice this store needs: a keyed read, an insert and an
|
|
4455
|
+
* update. Deliberately WITHOUT `delete` — re-enabling updates the `active`
|
|
4456
|
+
* bit, it never removes the row (see the module header), and demanding only
|
|
4457
|
+
* what is used keeps every test double honest about that.
|
|
4458
|
+
*/
|
|
4459
|
+
interface MetadataActivationStoreEngine {
|
|
4460
|
+
find(object: string, options?: any): Promise<any[]>;
|
|
4461
|
+
insert(object: string, data: any, options?: any): Promise<any>;
|
|
4462
|
+
update(object: string, data: any, options?: any): Promise<any>;
|
|
4463
|
+
}
|
|
4464
|
+
/**
|
|
4465
|
+
* In-memory {@link MetadataActivationStore} — process-lifetime only, for tests
|
|
4466
|
+
* and for hosts with no durable plane. What it lacks versus the ObjectStore
|
|
4467
|
+
* implementation is DURABILITY, which is exactly the property ADR-0126 §6
|
|
4468
|
+
* wall 3 asks for; it is not a sanctioned production off-switch.
|
|
4469
|
+
*
|
|
4470
|
+
* No discriminator: an in-memory map is per-instance, so there is no shared
|
|
4471
|
+
* table for a neighbouring type's rows to be in.
|
|
4472
|
+
*/
|
|
4473
|
+
declare class InMemoryMetadataActivationStore implements MetadataActivationStore {
|
|
4474
|
+
private readonly rows;
|
|
4475
|
+
list(): Promise<MetadataActivationRow[]>;
|
|
4476
|
+
setActive(row: MetadataActivationRow): Promise<void>;
|
|
4477
|
+
}
|
|
4478
|
+
/**
|
|
4479
|
+
* Durable {@link MetadataActivationStore} backed by the
|
|
4480
|
+
* `sys_metadata_activation` object (ADR-0126 §4), scoped to one
|
|
4481
|
+
* `metadata_type`.
|
|
4482
|
+
*
|
|
4483
|
+
* All access uses a system context: the object is `managedBy: 'engine-owned'`
|
|
4484
|
+
* and declares `apiMethods: ['get', 'list']`, i.e. the generic data API cannot
|
|
4485
|
+
* write it at all — these rows are written by the ADR-0126 enable/disable
|
|
4486
|
+
* doors and by nothing else.
|
|
4487
|
+
*/
|
|
4488
|
+
declare class ObjectStoreMetadataActivationStore implements MetadataActivationStore {
|
|
4489
|
+
private readonly engine;
|
|
4490
|
+
/**
|
|
4491
|
+
* The ledger's `metadata_type` discriminator for this consumer —
|
|
4492
|
+
* `'flow'`, `'action'`, … Required, and fixed for the store's
|
|
4493
|
+
* lifetime: see the module header on why it is never a per-call
|
|
4494
|
+
* argument.
|
|
4495
|
+
*/
|
|
4496
|
+
private readonly metadataType;
|
|
4497
|
+
constructor(engine: MetadataActivationStoreEngine,
|
|
4498
|
+
/**
|
|
4499
|
+
* The ledger's `metadata_type` discriminator for this consumer —
|
|
4500
|
+
* `'flow'`, `'action'`, … Required, and fixed for the store's
|
|
4501
|
+
* lifetime: see the module header on why it is never a per-call
|
|
4502
|
+
* argument.
|
|
4503
|
+
*/
|
|
4504
|
+
metadataType: string);
|
|
4505
|
+
/**
|
|
4506
|
+
* Every row of this type. Read once at boot to hydrate the consumer's
|
|
4507
|
+
* projection.
|
|
4508
|
+
*
|
|
4509
|
+
* The only scoping is the `metadata_type` discriminator: the ledger is
|
|
4510
|
+
* deployment-wide and has no tenant column, so there is no second axis to
|
|
4511
|
+
* filter on (see the module header).
|
|
4512
|
+
*/
|
|
4513
|
+
list(): Promise<MetadataActivationRow[]>;
|
|
4514
|
+
/**
|
|
4515
|
+
* Insert or update the row for one packaged artifact.
|
|
4516
|
+
*
|
|
4517
|
+
* Read-then-write rather than a blind upsert because the object's
|
|
4518
|
+
* uniqueness is a DECLARED index (`unique: 'global'` over
|
|
4519
|
+
* `(metadata_type, name)`), not a primary key this store controls: there is
|
|
4520
|
+
* no id to collide on, so an insert-and-catch could not tell "already
|
|
4521
|
+
* there" from a real store failure.
|
|
4522
|
+
*
|
|
4523
|
+
* That index is also why taking the FIRST match is taking the only one: the
|
|
4524
|
+
* read below is keyed on exactly the index's two columns, so it can match
|
|
4525
|
+
* at most one row. It used to pick the first row with a NULL organization
|
|
4526
|
+
* out of the result, back when the table carried a reserved tenant column;
|
|
4527
|
+
* with no such column the set it was choosing from can no longer hold more
|
|
4528
|
+
* than one member.
|
|
4529
|
+
*/
|
|
4530
|
+
setActive(row: MetadataActivationRow): Promise<void>;
|
|
4531
|
+
/**
|
|
4532
|
+
* Read the backing table once so a misconfiguration surfaces at BOOT
|
|
4533
|
+
* rather than as a failed toggle later. Throws the driver error verbatim —
|
|
4534
|
+
* `no such table: sys_metadata_activation` means the object was never
|
|
4535
|
+
* registered (or its schema never synced) in this composition.
|
|
4536
|
+
*
|
|
4537
|
+
* ⚠️ Unscoped by design: the question is "does the TABLE read at all",
|
|
4538
|
+
* which is a property of the composition, not of one `metadata_type`.
|
|
4539
|
+
*/
|
|
4540
|
+
probe(): Promise<void>;
|
|
4541
|
+
}
|
|
4542
|
+
|
|
3410
4543
|
/**
|
|
3411
4544
|
* [#4435] The 404 a single-record operation answers when the id names no row.
|
|
3412
4545
|
*
|
|
@@ -3619,6 +4752,19 @@ declare function createMemoryI18n(): {
|
|
|
3619
4752
|
setSupportedLocales(locales: readonly string[] | undefined): void;
|
|
3620
4753
|
getDefaultLocale(): string;
|
|
3621
4754
|
setDefaultLocale(locale: string): void;
|
|
4755
|
+
/**
|
|
4756
|
+
* @see II18nService.setFallbackLocale — [#15694]
|
|
4757
|
+
*
|
|
4758
|
+
* ⛔ There is deliberately NO `getFallbackLocale()` beside this. The two
|
|
4759
|
+
* are different questions: this one is what the provider was TOLD, the
|
|
4760
|
+
* accessor is what the serving layer ASKS it in order to build the
|
|
4761
|
+
* metadata-document translators' fallback chain (#14882). Answering the
|
|
4762
|
+
* second from `defaultLocale` — the only value that was always available
|
|
4763
|
+
* here — would settle the default-locale contract question #14882 leaves
|
|
4764
|
+
* deliberately open, from a degraded provider. Without the accessor those
|
|
4765
|
+
* reads keep the resolvers' own default, which is known and intentional.
|
|
4766
|
+
*/
|
|
4767
|
+
setFallbackLocale(locale: string): void;
|
|
3622
4768
|
};
|
|
3623
4769
|
|
|
3624
4770
|
/**
|
|
@@ -3710,7 +4856,7 @@ declare const CORE_FALLBACK_FACTORIES: Record<string, () => Record<string, any>>
|
|
|
3710
4856
|
* (#7378 row 2). Folds a plural manifest spelling to the singular metadata
|
|
3711
4857
|
* type name (`'objects'` → `'object'`, `'views'` → `'view'`, …) through the
|
|
3712
4858
|
* platform's one plural↔singular map (`PLURAL_TO_SINGULAR`,
|
|
3713
|
-
* `@objectstack/spec/
|
|
4859
|
+
* `@objectstack/spec/meta-spelling`); a name with no plural mapping — which includes
|
|
3714
4860
|
* every canonical singular type — passes through unchanged.
|
|
3715
4861
|
*/
|
|
3716
4862
|
declare function canonicalMetadataServiceType(type: string): string;
|
|
@@ -3739,8 +4885,37 @@ declare function assertMetadataRegisterContract(type: string, name: string, data
|
|
|
3739
4885
|
/**
|
|
3740
4886
|
* Plugin Health Monitor
|
|
3741
4887
|
*
|
|
3742
|
-
* Monitors plugin health status
|
|
3743
|
-
*
|
|
4888
|
+
* Monitors plugin health status. It REPORTS; it does not act on what it finds.
|
|
4889
|
+
*
|
|
4890
|
+
* ## The monitor no longer "restarts" anything (#12032)
|
|
4891
|
+
*
|
|
4892
|
+
* It used to claim it did. `attemptRestart` called `plugin.destroy()` and
|
|
4893
|
+
* stopped there — the comment above the call read "Call destroy and init to
|
|
4894
|
+
* restart", and `init` appeared in this file ONLY inside that comment. What a
|
|
4895
|
+
* plugin got was: destroy, a log line reading 'Plugin restarted', status
|
|
4896
|
+
* `recovering`, and periodic checks continuing against the destroyed instance.
|
|
4897
|
+
* The default check when no `checkMethod` resolves is
|
|
4898
|
+
* `{ name: 'plugin-loaded', status: 'passed' }`, which a destroyed plugin
|
|
4899
|
+
* passes indefinitely, so the TERMINAL report on a destroyed, never
|
|
4900
|
+
* re-initialised plugin was `healthy` — and #11955 made that MORE convincing
|
|
4901
|
+
* rather than less, because reaching `healthy` now costs `successThreshold`
|
|
4902
|
+
* consecutive passing rounds.
|
|
4903
|
+
*
|
|
4904
|
+
* The restart could not be repaired in place. `Plugin.init(ctx)` needs a
|
|
4905
|
+
* `PluginContext`, and the only two `plugin.init(...)` call sites in the tree
|
|
4906
|
+
* are the kernel's own boot loops, over the full plugin list, with a context
|
|
4907
|
+
* that is `private` on `ObjectKernel` and `protected` on `KernelBase`. No host
|
|
4908
|
+
* can obtain one, so there was nothing for a re-init hook to call. ADR-0049
|
|
4909
|
+
* enforce-or-remove, with no roadmap to point EXPERIMENTAL at, therefore
|
|
4910
|
+
* removed the declaration: `autoRestart`, `maxRestartAttempts` and
|
|
4911
|
+
* `restartBackoff` are tombstoned in `@objectstack/spec` 18, and this class
|
|
4912
|
+
* refuses a config that still carries one instead of accepting it and doing
|
|
4913
|
+
* something else.
|
|
4914
|
+
*
|
|
4915
|
+
* What a failing plugin gets now is the truth: `degraded`, `unhealthy` or
|
|
4916
|
+
* `failed`, and no destroy. Acting on that is the HOST's job — this is a
|
|
4917
|
+
* host-driven library (#11825 route 2), and the host is the only party that
|
|
4918
|
+
* owns the plugin's lifetime.
|
|
3744
4919
|
*/
|
|
3745
4920
|
declare class PluginHealthMonitor {
|
|
3746
4921
|
private logger;
|
|
@@ -3750,7 +4925,6 @@ declare class PluginHealthMonitor {
|
|
|
3750
4925
|
private checkIntervals;
|
|
3751
4926
|
private failureCounters;
|
|
3752
4927
|
private successCounters;
|
|
3753
|
-
private restartAttempts;
|
|
3754
4928
|
constructor(logger: ObjectLogger);
|
|
3755
4929
|
/**
|
|
3756
4930
|
* Register a plugin for health monitoring
|
|
@@ -3769,13 +4943,28 @@ declare class PluginHealthMonitor {
|
|
|
3769
4943
|
*/
|
|
3770
4944
|
private performHealthCheck;
|
|
3771
4945
|
/**
|
|
3772
|
-
*
|
|
3773
|
-
|
|
3774
|
-
|
|
3775
|
-
|
|
3776
|
-
*
|
|
4946
|
+
* Handle one failed round — the single path BOTH failure routes take.
|
|
4947
|
+
*
|
|
4948
|
+
* `performHealthCheck` can fail two disjoint ways: the check *returns* a
|
|
4949
|
+
* failure (`false` or `{ status: 'unhealthy' }`), or it *throws* — which by
|
|
4950
|
+
* `raceCheckTimeout` includes every `timeout` overrun, the severest case of
|
|
4951
|
+
* the two. The routes used to be handled in separate blocks, and only the
|
|
4952
|
+
* returned one cleared `successCounters`, so the counters a declared
|
|
4953
|
+
* `failureThreshold` / `successThreshold` are counted with depended on which
|
|
4954
|
+
* way the round happened to fail (#11852).
|
|
4955
|
+
*
|
|
4956
|
+
* What stays route-specific is the *status label*, deliberately. A throw is
|
|
4957
|
+
* the separate `failed` status applied immediately with no threshold — that
|
|
4958
|
+
* is the documented contract (`content/docs/protocol/kernel/lifecycle.mdx`,
|
|
4959
|
+
* "Custom Health Checks") and is pinned by the timeout test. Only the
|
|
4960
|
+
* counters are shared, because that is what `failureThreshold` declares, and
|
|
4961
|
+
* it does not name a route.
|
|
4962
|
+
*
|
|
4963
|
+
* This round ENDS here. Nothing is done TO the plugin — see the #12032 note
|
|
4964
|
+
* on the class: a monitor that cannot re-initialise a plugin has no business
|
|
4965
|
+
* destroying one.
|
|
3777
4966
|
*/
|
|
3778
|
-
private
|
|
4967
|
+
private recordFailedRound;
|
|
3779
4968
|
/**
|
|
3780
4969
|
* Get current health status of a plugin
|
|
3781
4970
|
*/
|
|
@@ -3857,7 +5046,6 @@ declare class HotReloadManager {
|
|
|
3857
5046
|
private logger;
|
|
3858
5047
|
private stateManager;
|
|
3859
5048
|
private reloadConfigs;
|
|
3860
|
-
private watchHandles;
|
|
3861
5049
|
private reloadTimers;
|
|
3862
5050
|
constructor(logger: ObjectLogger);
|
|
3863
5051
|
/**
|
|
@@ -3865,11 +5053,35 @@ declare class HotReloadManager {
|
|
|
3865
5053
|
*/
|
|
3866
5054
|
registerPlugin(pluginName: string, config: HotReloadConfigParsed): void;
|
|
3867
5055
|
/**
|
|
3868
|
-
*
|
|
5056
|
+
* Refuse the file-watching call this class never implemented (#12428).
|
|
5057
|
+
*
|
|
5058
|
+
* The body used to be a guard plus `logger.info('File watching started')`
|
|
5059
|
+
* over an in-source note saying real watching "would require chokidar or
|
|
5060
|
+
* similar". Nothing was ever watched, so an operator who set
|
|
5061
|
+
* `enabled: true` and read that line at INFO had been told the opposite of
|
|
5062
|
+
* the truth — positive confirmation of a capability that did not exist.
|
|
5063
|
+
* ADR-0049 leaves three states and this surface qualified for none of the
|
|
5064
|
+
* other two: no runtime composes this class, so ENFORCE would build for a
|
|
5065
|
+
* caller that does not exist, and no roadmap entry anywhere claims the
|
|
5066
|
+
* feature, so EXPERIMENTAL would be a promise nobody made.
|
|
5067
|
+
*
|
|
5068
|
+
* Kept as a throwing door rather than deleted: removing the method leaves a
|
|
5069
|
+
* JavaScript host a bare `TypeError: not a function` with no prescription,
|
|
5070
|
+
* and this is the one place a caller of the old placeholder is guaranteed
|
|
5071
|
+
* to arrive. The refusal carries an ADR-0112 envelope so it can be asserted
|
|
5072
|
+
* rather than merely caught.
|
|
3869
5073
|
*/
|
|
3870
|
-
startWatching(pluginName: string):
|
|
5074
|
+
startWatching(pluginName: string): never;
|
|
3871
5075
|
/**
|
|
3872
|
-
*
|
|
5076
|
+
* Cancel a pending debounced reload for a plugin.
|
|
5077
|
+
*
|
|
5078
|
+
* The name is historical (#12428). This never stopped a watcher, because
|
|
5079
|
+
* nothing in this class ever started one: its `watchHandles` cleanup branch
|
|
5080
|
+
* read a Map that had no writer anywhere in the tree, so the branch was
|
|
5081
|
+
* structurally unreachable rather than merely untaken, and it left with
|
|
5082
|
+
* `startWatching`'s placeholder. What survives is the half that always did
|
|
5083
|
+
* something — the debounce timer armed by `scheduleReload` is cleared, so a
|
|
5084
|
+
* reload that was scheduled but has not fired yet is cancelled.
|
|
3873
5085
|
*/
|
|
3874
5086
|
stopWatching(pluginName: string): void;
|
|
3875
5087
|
/**
|
|
@@ -4053,4 +5265,4 @@ declare class NamespaceResolver {
|
|
|
4053
5265
|
private suggestAlternative;
|
|
4054
5266
|
}
|
|
4055
5267
|
|
|
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
|
|
5268
|
+
export { ADMIN_STANDING_NON_TABLE_INPUTS, ADMIN_STANDING_SURFACE, ADVISORY_SAMPLE_ROWS, 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 AdvisoryGroup, type AdvisoryHit, 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, 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, SecurePluginContext, 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, isAggregatingAdvisories, isAudienceBindingSuggestionStatus, isAuthGateAllowlisted, isAuthzStoreUnavailableError, isConfiguredPlatformAdminEmail, isExpired, isGrantActive, isGrantExpired, isNode, isRowActive, isServiceNotRegisteredError, isUninterpretableTemporalComparand, matchesConfiguredPlatformAdmin, normalizeAuthGate, normalizePlatformAdminEmail, omitInternalFieldsFromWriteResponse, parsePlatformAdminEmails, parseScopes, parseSignature, planChunks, postureVisibleRows, readAuthoredTranslationLayer, readAuthzGrantsCacheTtlMs, readRunJournal, recordAdvisoryHit, recordNotFoundError, reportAuthzCachePosture, reportLegacyPlatformAdminGrant, resetLegacyPlatformAdminGrantReport, resetPlatformAdminEmailMemo, resolveApiKeyAdmission, resolveApiKeyPrincipal, resolveArtifactPackageOrder, resolveAuthzCachePosture, resolveAuthzContext, resolveFilterToken, resolveFilterTokens, resolveLocale, resolveLocalizationContext, resolvePlatformAdminEmails, resolvePluginOrder, resolveUserAuthzGrants, resumeMigrationJournal, rethrowAuthzStoreUnavailable, runMigrationJournal, runWithAdvisoryAggregation, safeExit, setPlatformAdminConfigSink, shouldDenyAnonymous, signPayload, temporalComparandKind, unknownAudienceBindingSuggestionStatusMessage, validateInitServiceContract, verifyIntegrity, verifyPayload, verifyPlatformSignature, verifyPluginArtifact, verifyPublisherSignature, wireAuthoredTranslationSync, withTransientRetry, withoutOperationPrivateKeys, zonedDateStartToUtcMs, zonedWallClockToUtcMs };
|