@cotal-ai/core 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/acls.d.ts +31 -7
- package/dist/acls.d.ts.map +1 -1
- package/dist/acls.js +105 -17
- package/dist/acls.js.map +1 -1
- package/dist/agent-file.js +1 -1
- package/dist/agent-file.js.map +1 -1
- package/dist/auth-provider.d.ts +8 -0
- package/dist/auth-provider.d.ts.map +1 -1
- package/dist/auth-provider.js.map +1 -1
- package/dist/backup.d.ts.map +1 -1
- package/dist/backup.js +6 -4
- package/dist/backup.js.map +1 -1
- package/dist/broker-floor.d.ts +34 -0
- package/dist/broker-floor.d.ts.map +1 -0
- package/dist/broker-floor.js +39 -0
- package/dist/broker-floor.js.map +1 -0
- package/dist/canonical.d.ts +24 -0
- package/dist/canonical.d.ts.map +1 -0
- package/dist/canonical.js +159 -0
- package/dist/canonical.js.map +1 -0
- package/dist/connector.d.ts +4 -0
- package/dist/connector.d.ts.map +1 -1
- package/dist/endpoint-action.d.ts +424 -0
- package/dist/endpoint-action.d.ts.map +1 -0
- package/dist/endpoint-action.js +910 -0
- package/dist/endpoint-action.js.map +1 -0
- package/dist/endpoint-binding.d.ts +344 -0
- package/dist/endpoint-binding.d.ts.map +1 -0
- package/dist/endpoint-binding.js +753 -0
- package/dist/endpoint-binding.js.map +1 -0
- package/dist/endpoint-checkpoint.d.ts +292 -0
- package/dist/endpoint-checkpoint.d.ts.map +1 -0
- package/dist/endpoint-checkpoint.js +818 -0
- package/dist/endpoint-checkpoint.js.map +1 -0
- package/dist/endpoint-cluster.d.ts +95 -0
- package/dist/endpoint-cluster.d.ts.map +1 -0
- package/dist/endpoint-cluster.js +194 -0
- package/dist/endpoint-cluster.js.map +1 -0
- package/dist/endpoint-contract-store.d.ts +90 -0
- package/dist/endpoint-contract-store.d.ts.map +1 -0
- package/dist/endpoint-contract-store.js +348 -0
- package/dist/endpoint-contract-store.js.map +1 -0
- package/dist/endpoint-envelope.d.ts +165 -0
- package/dist/endpoint-envelope.d.ts.map +1 -0
- package/dist/endpoint-envelope.js +346 -0
- package/dist/endpoint-envelope.js.map +1 -0
- package/dist/endpoint-grants.d.ts +123 -0
- package/dist/endpoint-grants.d.ts.map +1 -0
- package/dist/endpoint-grants.js +215 -0
- package/dist/endpoint-grants.js.map +1 -0
- package/dist/endpoint-guard.d.ts +222 -0
- package/dist/endpoint-guard.d.ts.map +1 -0
- package/dist/endpoint-guard.js +468 -0
- package/dist/endpoint-guard.js.map +1 -0
- package/dist/endpoint-handle.d.ts +176 -0
- package/dist/endpoint-handle.d.ts.map +1 -0
- package/dist/endpoint-handle.js +608 -0
- package/dist/endpoint-handle.js.map +1 -0
- package/dist/endpoint-journal.d.ts +225 -0
- package/dist/endpoint-journal.d.ts.map +1 -0
- package/dist/endpoint-journal.js +431 -0
- package/dist/endpoint-journal.js.map +1 -0
- package/dist/endpoint-receipt.d.ts +225 -0
- package/dist/endpoint-receipt.d.ts.map +1 -0
- package/dist/endpoint-receipt.js +417 -0
- package/dist/endpoint-receipt.js.map +1 -0
- package/dist/endpoint-records.d.ts +243 -0
- package/dist/endpoint-records.d.ts.map +1 -0
- package/dist/endpoint-records.js +591 -0
- package/dist/endpoint-records.js.map +1 -0
- package/dist/endpoint-serve.d.ts +176 -0
- package/dist/endpoint-serve.d.ts.map +1 -0
- package/dist/endpoint-serve.js +446 -0
- package/dist/endpoint-serve.js.map +1 -0
- package/dist/endpoint-service.d.ts +518 -0
- package/dist/endpoint-service.d.ts.map +1 -0
- package/dist/endpoint-service.js +973 -0
- package/dist/endpoint-service.js.map +1 -0
- package/dist/endpoint-session.d.ts +398 -0
- package/dist/endpoint-session.d.ts.map +1 -0
- package/dist/endpoint-session.js +834 -0
- package/dist/endpoint-session.js.map +1 -0
- package/dist/endpoint-signing.d.ts +105 -0
- package/dist/endpoint-signing.d.ts.map +1 -0
- package/dist/endpoint-signing.js +190 -0
- package/dist/endpoint-signing.js.map +1 -0
- package/dist/endpoint-subjects.d.ts +222 -0
- package/dist/endpoint-subjects.d.ts.map +1 -0
- package/dist/endpoint-subjects.js +381 -0
- package/dist/endpoint-subjects.js.map +1 -0
- package/dist/endpoint-supervisor.d.ts +19 -0
- package/dist/endpoint-supervisor.d.ts.map +1 -0
- package/dist/endpoint-supervisor.js +12 -0
- package/dist/endpoint-supervisor.js.map +1 -0
- package/dist/endpoint-traits.d.ts +225 -0
- package/dist/endpoint-traits.d.ts.map +1 -0
- package/dist/endpoint-traits.js +469 -0
- package/dist/endpoint-traits.js.map +1 -0
- package/dist/endpoint-verbs.d.ts +277 -0
- package/dist/endpoint-verbs.d.ts.map +1 -0
- package/dist/endpoint-verbs.js +576 -0
- package/dist/endpoint-verbs.js.map +1 -0
- package/dist/endpoint-virtual.d.ts +308 -0
- package/dist/endpoint-virtual.d.ts.map +1 -0
- package/dist/endpoint-virtual.js +440 -0
- package/dist/endpoint-virtual.js.map +1 -0
- package/dist/endpoint-work.d.ts +304 -0
- package/dist/endpoint-work.d.ts.map +1 -0
- package/dist/endpoint-work.js +728 -0
- package/dist/endpoint-work.js.map +1 -0
- package/dist/endpoint.d.ts +66 -22
- package/dist/endpoint.d.ts.map +1 -1
- package/dist/endpoint.js +289 -89
- package/dist/endpoint.js.map +1 -1
- package/dist/evict.d.ts +95 -32
- package/dist/evict.d.ts.map +1 -1
- package/dist/evict.js +193 -3
- package/dist/evict.js.map +1 -1
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -1
- package/dist/lease.d.ts +4 -0
- package/dist/lease.d.ts.map +1 -1
- package/dist/lease.js.map +1 -1
- package/dist/members.d.ts +4 -4
- package/dist/members.d.ts.map +1 -1
- package/dist/members.js +12 -12
- package/dist/members.js.map +1 -1
- package/dist/provision.d.ts +70 -18
- package/dist/provision.d.ts.map +1 -1
- package/dist/provision.js +194 -33
- package/dist/provision.js.map +1 -1
- package/dist/safe-pattern.d.ts +33 -0
- package/dist/safe-pattern.d.ts.map +1 -0
- package/dist/safe-pattern.js +486 -0
- package/dist/safe-pattern.js.map +1 -0
- package/dist/schema-profile.d.ts +93 -0
- package/dist/schema-profile.d.ts.map +1 -0
- package/dist/schema-profile.js +284 -0
- package/dist/schema-profile.js.map +1 -0
- package/dist/streams.d.ts +13 -8
- package/dist/streams.d.ts.map +1 -1
- package/dist/streams.js +35 -18
- package/dist/streams.js.map +1 -1
- package/dist/subjects.d.ts +100 -40
- package/dist/subjects.d.ts.map +1 -1
- package/dist/subjects.js +136 -60
- package/dist/subjects.js.map +1 -1
- package/dist/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -1
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v0.4 serve/describe machinery (SPEC §13.5 verbs, §13.7 "Descriptor and describe", §13.2
|
|
3
|
+
* rails) — the request-boundary dispatch an endpoint instance serves its registered commands
|
|
4
|
+
* through: queue-grouped class serving, the scatter and stable-instance rails, contract-
|
|
5
|
+
* digest-bound invoke with MANDATORY runtime schema validation, structural reply derivation,
|
|
6
|
+
* and the reserved authorization-scoped `describe`.
|
|
7
|
+
*
|
|
8
|
+
* Only EPHEMERAL commands are rail-served: journal work rides `epj` submissions into the
|
|
9
|
+
* canonicalizer and executes off the effects/pool durables (§13.4/§13.5), so a journal-class
|
|
10
|
+
* command def REFUSES at construction, and a request DECLARING `class: journal` on a rail is
|
|
11
|
+
* refused at the boundary. Incarnation fencing is not a subscription shape (§13.9: the epoch
|
|
12
|
+
* is deliberately absent from serve subscriptions): the §13.1 takeover barrier fences a
|
|
13
|
+
* superseded subscriber, every reply carries the responder's epoch in its SUBJECT (attributably
|
|
14
|
+
* stale when superseded), and commits are epoch-fenced at the record seam
|
|
15
|
+
* ({@link writeServiceStatus}).
|
|
16
|
+
*/
|
|
17
|
+
import type { NatsConnection } from "@nats-io/transport-node";
|
|
18
|
+
import { type EpCaller, type ParsedEpRequest } from "./endpoint-subjects.js";
|
|
19
|
+
import { type EndpointRequest } from "./endpoint-envelope.js";
|
|
20
|
+
import { type CompiledContract } from "./schema-profile.js";
|
|
21
|
+
import { type EpServeGrant } from "./endpoint-service.js";
|
|
22
|
+
import type { DescribeDescriptor } from "./endpoint-cluster.js";
|
|
23
|
+
import { type EpTraitEnforcement } from "./endpoint-traits.js";
|
|
24
|
+
import type { GuardObligation } from "./endpoint-guard.js";
|
|
25
|
+
/** The serving instance's identity: its stable logical instance id and fenced process epoch
|
|
26
|
+
* (§13.1). Both ride every reply SUBJECT (attribution is structural, §13.2). */
|
|
27
|
+
export interface EpServeIdentity {
|
|
28
|
+
endpoint: string;
|
|
29
|
+
instanceId: string;
|
|
30
|
+
epoch: number;
|
|
31
|
+
}
|
|
32
|
+
/** What a handler sees: the broker-authenticated SUBJECT shape (route, caller, target) beside
|
|
33
|
+
* the validated body — provenance never comes from the body (§13.2/§13.3). `obligations` is
|
|
34
|
+
* present exactly when a guard allowed WITH signed attenuations (§13.6), and every entry is
|
|
35
|
+
* VERIFIED by the gate (D28 signature, anchor role/scope, window, space + request binding)
|
|
36
|
+
* before it reaches a handler: the endpoint MUST apply them (monotonic); the applying policy
|
|
37
|
+
* engine is an extension behind the seam. */
|
|
38
|
+
export interface EpServeContext {
|
|
39
|
+
identity: EpServeIdentity;
|
|
40
|
+
subject: ParsedEpRequest;
|
|
41
|
+
request: EndpointRequest;
|
|
42
|
+
obligations?: readonly GuardObligation[];
|
|
43
|
+
}
|
|
44
|
+
/** One served command: its handler plus the COMPILED §13.7 contracts (schema-profile
|
|
45
|
+
* {@link compileContract} — provenance-branded, so a fabricated `{validate, closureDigest}`
|
|
46
|
+
* pair refuses at construction). Everything AUTHORITY-shaped about the command — its class,
|
|
47
|
+
* whether it is targeted and which modes it admits, its schema closure digests — comes from
|
|
48
|
+
* the serve artifact's digest-VERIFIED registered declaration, never from this def: the def
|
|
49
|
+
* only supplies the code, and its compiled contracts must EQUAL the registered digests.
|
|
50
|
+
* `input.validate` gates args before any effect (`bad-request`), `output.validate` gates
|
|
51
|
+
* before the success publish (`internal` — an invalid reply is a server bug, §13.3/§13.7).
|
|
52
|
+
* Runtime validation at the serving boundary is not optional. */
|
|
53
|
+
export interface EpCommandDef {
|
|
54
|
+
command: string;
|
|
55
|
+
contract: {
|
|
56
|
+
input: CompiledContract;
|
|
57
|
+
output: CompiledContract;
|
|
58
|
+
};
|
|
59
|
+
handler: (ctx: EpServeContext) => Promise<unknown> | unknown;
|
|
60
|
+
}
|
|
61
|
+
/** The FRESH target-resolver seam (§13.3/§13.9: targets resolve by `(alias, lifecycleUid)`
|
|
62
|
+
* against the CURRENT mapping immediately before effect; static subject/body agreement is not
|
|
63
|
+
* currency). Returns the alias's current mapping, or `undefined` when the alias has none. The
|
|
64
|
+
* production reader is the D13 lifecycle registry's leader-served mapping read. */
|
|
65
|
+
export type EpTargetResolver = (target: {
|
|
66
|
+
owner: string;
|
|
67
|
+
actor: string;
|
|
68
|
+
}) => Promise<{
|
|
69
|
+
lifecycleUid: string;
|
|
70
|
+
mappingRevision: number;
|
|
71
|
+
} | undefined> | {
|
|
72
|
+
lifecycleUid: string;
|
|
73
|
+
mappingRevision: number;
|
|
74
|
+
} | undefined;
|
|
75
|
+
/** The `child`-mode fresh-authorization seam (§13.2): TRUE iff the DURABLE spawner record of
|
|
76
|
+
* `target` names `caller` as its spawner — read fresh at dispatch, never inferred from the
|
|
77
|
+
* caller's grant alone (the grant pins the owner domain; the spawner relation is per-entity
|
|
78
|
+
* state). The production reader is the D13 lifecycle registry's spawner record. */
|
|
79
|
+
export type EpChildAuthority = (args: {
|
|
80
|
+
caller: EpCaller;
|
|
81
|
+
target: {
|
|
82
|
+
owner: string;
|
|
83
|
+
actor: string;
|
|
84
|
+
lifecycleUid: string;
|
|
85
|
+
};
|
|
86
|
+
}) => Promise<boolean> | boolean;
|
|
87
|
+
/** The `ledger`-mode fresh-authorization seam (§13.2): TRUE iff a FRESH read of the
|
|
88
|
+
* authorization ledger grants `caller` this op on `target`. Fail-closed by construction: no
|
|
89
|
+
* seam, a seam failure, and a false answer all refuse — a ledger row is never cached into a
|
|
90
|
+
* dispatch decision. */
|
|
91
|
+
export type EpLedgerAuthority = (args: {
|
|
92
|
+
caller: EpCaller;
|
|
93
|
+
target: {
|
|
94
|
+
owner: string;
|
|
95
|
+
actor: string;
|
|
96
|
+
lifecycleUid: string;
|
|
97
|
+
};
|
|
98
|
+
op: {
|
|
99
|
+
endpoint: string;
|
|
100
|
+
command: string;
|
|
101
|
+
};
|
|
102
|
+
}) => Promise<boolean> | boolean;
|
|
103
|
+
export interface EpServeHandle {
|
|
104
|
+
/** Drain every serve subscription, then await every in-flight handler: after `stop()`
|
|
105
|
+
* resolves this incarnation performs no further effects and publishes no further replies. */
|
|
106
|
+
stop(): Promise<void>;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Serve an authorized instance's granted commands on the three §13.2 rails, exactly the
|
|
110
|
+
* per-command forms the serve credential grants (§13.9 {@link epServeSubscribeRows}): the class
|
|
111
|
+
* rail queue-qualified under the canonical queue group (`one` = queue-group anycast), the
|
|
112
|
+
* scatter rail plain, and this instance's own `inst` rail.
|
|
113
|
+
*
|
|
114
|
+
* `serve` is the registry-authorized ARTIFACT {@link authorizeServeGrant} returned — the same
|
|
115
|
+
* value the credential minted from. Construction refuses anything else (brand check), refuses
|
|
116
|
+
* a foreign space, and binds every def to the artifact's digest-VERIFIED registered
|
|
117
|
+
* declaration: the def must be a GRANTED command, its provenance-branded compiled contracts
|
|
118
|
+
* must equal the registered schema digests, its class/targeted/modes come from the verified
|
|
119
|
+
* declaration (a journal-class registered command never rail-serves, so it takes no def), and
|
|
120
|
+
* every granted EPHEMERAL command must have a def (a rail nobody serves is a construction bug);
|
|
121
|
+
* journal commands stay in the credential/descriptor surface but ride epj, so a journal-only or
|
|
122
|
+
* mixed endpoint still constructs and serves describe. The reserved `describe` (§13.7: every endpoint MUST serve it) is built
|
|
123
|
+
* HERE over the artifact's DERIVED deep-frozen descriptor — a `describe` def refuses at
|
|
124
|
+
* construction, so the authorization seam cannot be replaced, and no hand-authored or
|
|
125
|
+
* later-mutated descriptor can reach the wire.
|
|
126
|
+
*
|
|
127
|
+
* Boundary discipline per message: subject parse (a non-request subject has no sender and is
|
|
128
|
+
* never handled), body validation with the exact §13.3 catalog codes, body-subject agreement,
|
|
129
|
+
* class match against the REGISTERED class, digest binding, registered admission (targeted
|
|
130
|
+
* commands refuse the untargeted form and vice versa), args schema validation, fresh target
|
|
131
|
+
* currency, the per-mode fresh authorization (`child`/`ledger` seams), then — because those
|
|
132
|
+
* seams await — target currency AGAIN immediately before dispatch (§13.2/§13.3: a mapping
|
|
133
|
+
* rotated during the authority read must fail, never ride a pre-rotation read into the
|
|
134
|
+
* effect), the §13.7 governed pre-effect gate for a command whose REGISTERED declaration
|
|
135
|
+
* carries a governed trait (guard-then-priced, {@link assertGovernedPreEffect}; construction
|
|
136
|
+
* already refused a governed surface/hook gap, so a bypass is structurally impossible), and
|
|
137
|
+
* budgeted output schema validation before the success publish. A call's reply
|
|
138
|
+
* (success OR structured error) is published on the DERIVED reply subject (§13.2: never a
|
|
139
|
+
* body-supplied target); a cast is never replied to, even on error (§13.5: at-most-once, the
|
|
140
|
+
* caller never reads the rail). A request whose body cannot be parsed carries no trustworthy
|
|
141
|
+
* verb; it is answered (the derived subject is nonce-scoped to this caller, and a cast caller
|
|
142
|
+
* simply holds no subscription there). A reply that does not serialize is replaced by a
|
|
143
|
+
* structured `internal` error reply, never dropped.
|
|
144
|
+
*/
|
|
145
|
+
export declare function serveEndpoint(nc: NatsConnection, space: string, serve: EpServeGrant, commands: EpCommandDef[], describe: DescribeAuthorization, opts?: {
|
|
146
|
+
resolveTarget?: EpTargetResolver;
|
|
147
|
+
childAuthority?: EpChildAuthority;
|
|
148
|
+
ledgerAuthority?: EpLedgerAuthority;
|
|
149
|
+
/** The §13.9 trait seam: REQUIRED (with the matching hooks) when any granted command's
|
|
150
|
+
* registered declaration carries a governed trait — construction refuses a governed
|
|
151
|
+
* command it cannot enforce, and refuses an extraneous enforcement bundle on an
|
|
152
|
+
* ungoverned surface (fail loud both ways, never a silent no-op). */
|
|
153
|
+
traits?: EpTraitEnforcement;
|
|
154
|
+
}): EpServeHandle;
|
|
155
|
+
/** A caller's authority view from the TRUSTED source (§13.7): the command set this caller may
|
|
156
|
+
* see. `undefined` = no fresh view (stale beyond its bound, or the source has no answer) —
|
|
157
|
+
* describe then fails CLOSED, never answers from a weaker source. */
|
|
158
|
+
export interface DescribeView {
|
|
159
|
+
commands: string[];
|
|
160
|
+
}
|
|
161
|
+
/** The describe authorization seam: either the deployment declared this descriptor PUBLIC (no
|
|
162
|
+
* view is consulted and the answer says so), or a trusted view provider keyed by the
|
|
163
|
+
* broker-authenticated caller identity (§13.7: payload/slot-asserted scope is ignored — it is
|
|
164
|
+
* not even a parameter here). The provider owns its own freshness bound. */
|
|
165
|
+
export type DescribeAuthorization = {
|
|
166
|
+
public: true;
|
|
167
|
+
} | {
|
|
168
|
+
public?: false;
|
|
169
|
+
view: (caller: EpCaller) => Promise<DescribeView | undefined> | DescribeView | undefined;
|
|
170
|
+
};
|
|
171
|
+
/** The describe answer: `public` says which path produced it (§13.7: the answer says so). */
|
|
172
|
+
export interface DescribeAnswer {
|
|
173
|
+
public: boolean;
|
|
174
|
+
descriptor: DescribeDescriptor;
|
|
175
|
+
}
|
|
176
|
+
//# sourceMappingURL=endpoint-serve.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"endpoint-serve.d.ts","sourceRoot":"","sources":["../src/endpoint-serve.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
import { spacePrefix } from "./subjects.js";
|
|
2
|
+
import { endpointToken, assertCommandToken, assertLifecycleToken, epClassQueueGroup, deriveReplySubject, parseEpSubject, } from "./endpoint-subjects.js";
|
|
3
|
+
import { EpEnvelopeError, parseEndpointRequest, checkRequestSubjectAgreement, assertClassMatches, assertArgsValid, assertOutputValid, } from "./endpoint-envelope.js";
|
|
4
|
+
import { compileContract, assertCompiledContract, VOID_SCHEMA } from "./schema-profile.js";
|
|
5
|
+
import { assertServeGrantAuthorized } from "./endpoint-service.js";
|
|
6
|
+
import { GOVERNED_TRAIT_URNS, TRAIT_GUARDED, TRAIT_PRICED, assertGovernedSurfaceFor, assertGovernedPreEffect, } from "./endpoint-traits.js";
|
|
7
|
+
/** §13.3 target currency at the pre-effect seam, for EVERY body-targeted request (call or
|
|
8
|
+
* cast; a cast has effects too). Fail-closed: no resolver seam means targeted modes are
|
|
9
|
+
* REFUSED (`unavailable`), never dispatched unchecked; a resolver failure is `unavailable`;
|
|
10
|
+
* a missing/superseded mapping, a UID mismatch, or a pinned `mappingRevision` mismatch is
|
|
11
|
+
* `expired` (§13.3). */
|
|
12
|
+
async function assertTargetCurrent(env, resolve) {
|
|
13
|
+
const t = env.target;
|
|
14
|
+
if (t === undefined)
|
|
15
|
+
return;
|
|
16
|
+
if (resolve === undefined)
|
|
17
|
+
throw new EpEnvelopeError("unavailable", "this instance has no trusted target resolver; a targeted request cannot be validated against the current mapping and is refused, never dispatched unchecked (SPEC 13.3/13.9)");
|
|
18
|
+
let mapping;
|
|
19
|
+
try {
|
|
20
|
+
mapping = await resolve({ owner: t.owner, actor: t.actor });
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
throw new EpEnvelopeError("unavailable", `the trusted target resolver failed; refusing the targeted request (SPEC 13.9): ${err?.message ?? String(err)}`);
|
|
24
|
+
}
|
|
25
|
+
if (mapping === undefined)
|
|
26
|
+
throw new EpEnvelopeError("expired", `target ${t.owner}.${t.actor} has no current lifecycle mapping (SPEC 13.3)`);
|
|
27
|
+
if (mapping.lifecycleUid !== t.lifecycleUid)
|
|
28
|
+
throw new EpEnvelopeError("expired", `target ${t.owner}.${t.actor} expected lifecycle ${t.lifecycleUid} but the current mapping is ${mapping.lifecycleUid} (SPEC 13.3: expired on mapping mismatch)`);
|
|
29
|
+
if (t.mappingRevision !== undefined && mapping.mappingRevision !== t.mappingRevision)
|
|
30
|
+
throw new EpEnvelopeError("expired", `target ${t.owner}.${t.actor} pinned mappingRevision ${t.mappingRevision} but the current mapping is at ${mapping.mappingRevision} (SPEC 13.3: the pin is exact)`);
|
|
31
|
+
}
|
|
32
|
+
/** §13.7 invocation binding at the pre-effect seam. `parseEndpointRequest` already enforced
|
|
33
|
+
* digest PRESENCE for every non-describe command; here the pinned values must EQUAL the served
|
|
34
|
+
* contract. `describe` has no contract to pin (`digests` absent exactly there), so a digest
|
|
35
|
+
* carried on it cannot be honored — `contract-mismatch`, never silently ignored. */
|
|
36
|
+
function bindContract(env, def) {
|
|
37
|
+
if (def.digests === undefined) {
|
|
38
|
+
if (env.op.inputDigest !== undefined || env.op.outputDigest !== undefined)
|
|
39
|
+
throw new EpEnvelopeError("contract-mismatch", "describe pins no contract; a digest carried on it cannot be honored (SPEC 13.7)");
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (env.op.inputDigest !== def.digests.input || env.op.outputDigest !== def.digests.output)
|
|
43
|
+
throw new EpEnvelopeError("contract-mismatch", `pinned digests ${env.op.inputDigest}/${env.op.outputDigest} do not match the served contract ${def.digests.input}/${def.digests.output}; a member that cannot honor a pinned digest rejects, never coerces (SPEC 13.7)`);
|
|
44
|
+
}
|
|
45
|
+
/** §13.2/§13.7 registered admission at the pre-effect seam: a command serves ONLY the form its
|
|
46
|
+
* verified registered declaration admits. The subject's mode token is broker-authenticated
|
|
47
|
+
* (the caller's credential pinned it, §13.9), but the GRANT proves what the caller may claim,
|
|
48
|
+
* not what this command supports — default-deny BOTH ways: a targeted command refuses the
|
|
49
|
+
* untargeted form (it would bypass the per-mode fresh authorization entirely), an untargeted
|
|
50
|
+
* command refuses every targeted form, and a targeted request whose mode is not declared is
|
|
51
|
+
* `permission-denied` — all before args validation and before any target resolution. */
|
|
52
|
+
function assertModeAdmitted(parsed, def) {
|
|
53
|
+
if (parsed.target === null) {
|
|
54
|
+
if (def.targeted)
|
|
55
|
+
throw new EpEnvelopeError("permission-denied", `command "${def.command}" is registered TARGETED; the untargeted form is not its surface and would bypass the per-mode fresh authorization (SPEC 13.2/13.7)`);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (!def.targeted)
|
|
59
|
+
throw new EpEnvelopeError("permission-denied", `command "${def.command}" is registered untargeted; a targeted form is not its surface (SPEC 13.2/13.7)`);
|
|
60
|
+
if (!def.targetModes.has(parsed.target.mode))
|
|
61
|
+
throw new EpEnvelopeError("permission-denied", `command "${def.command}" does not admit the "${parsed.target.mode}" authorization mode; a command serves only its registered target modes (SPEC 13.2)`);
|
|
62
|
+
}
|
|
63
|
+
/** §13.2 per-mode FRESH authorization, after target currency: `child` must find the caller in
|
|
64
|
+
* the target's durable spawner record, `ledger` must find a live authorization-ledger grant —
|
|
65
|
+
* both read fresh at dispatch through their seams, both fail CLOSED (`unavailable`) when the
|
|
66
|
+
* seam is absent or fails, `permission-denied` on a false answer. The other modes carry their
|
|
67
|
+
* whole authorization in the minted grant + subject agreement (`self`/`owner`/`handle`) or
|
|
68
|
+
* grant policy alone (`any`) and need no dispatch-time record read. */
|
|
69
|
+
async function assertTargetModeAuthorized(env, parsed, opts) {
|
|
70
|
+
const mode = parsed.target?.mode;
|
|
71
|
+
if (mode !== "child" && mode !== "ledger")
|
|
72
|
+
return;
|
|
73
|
+
const t = env.target; // targeted non-self forms carry a body target (subject agreement, §13.3)
|
|
74
|
+
const target = { owner: t.owner, actor: t.actor, lifecycleUid: t.lifecycleUid };
|
|
75
|
+
const seam = mode === "child" ? opts.childAuthority : opts.ledgerAuthority;
|
|
76
|
+
if (seam === undefined)
|
|
77
|
+
throw new EpEnvelopeError("unavailable", `this instance has no trusted "${mode}"-mode authority seam; a "${mode}"-targeted request cannot be freshly authorized and is refused, never dispatched on the grant alone (SPEC 13.2)`);
|
|
78
|
+
let authorized;
|
|
79
|
+
try {
|
|
80
|
+
authorized = mode === "child"
|
|
81
|
+
? await opts.childAuthority({ caller: parsed.caller, target })
|
|
82
|
+
: await opts.ledgerAuthority({ caller: parsed.caller, target, op: { endpoint: env.op.endpoint, command: env.op.command } });
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
throw new EpEnvelopeError("unavailable", `the trusted "${mode}"-mode authority seam failed; refusing the targeted request (SPEC 13.2): ${err?.message ?? String(err)}`);
|
|
86
|
+
}
|
|
87
|
+
if (!authorized)
|
|
88
|
+
throw new EpEnvelopeError("permission-denied", mode === "child"
|
|
89
|
+
? `the durable spawner record does not name the caller as ${t.owner}.${t.actor}'s spawner (SPEC 13.2: child mode is a fresh spawner check, never the grant alone)`
|
|
90
|
+
: `the authorization ledger holds no live grant for this caller on ${t.owner}.${t.actor} (SPEC 13.2: ledger mode is a fresh ledger read, fail closed)`);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Serve an authorized instance's granted commands on the three §13.2 rails, exactly the
|
|
94
|
+
* per-command forms the serve credential grants (§13.9 {@link epServeSubscribeRows}): the class
|
|
95
|
+
* rail queue-qualified under the canonical queue group (`one` = queue-group anycast), the
|
|
96
|
+
* scatter rail plain, and this instance's own `inst` rail.
|
|
97
|
+
*
|
|
98
|
+
* `serve` is the registry-authorized ARTIFACT {@link authorizeServeGrant} returned — the same
|
|
99
|
+
* value the credential minted from. Construction refuses anything else (brand check), refuses
|
|
100
|
+
* a foreign space, and binds every def to the artifact's digest-VERIFIED registered
|
|
101
|
+
* declaration: the def must be a GRANTED command, its provenance-branded compiled contracts
|
|
102
|
+
* must equal the registered schema digests, its class/targeted/modes come from the verified
|
|
103
|
+
* declaration (a journal-class registered command never rail-serves, so it takes no def), and
|
|
104
|
+
* every granted EPHEMERAL command must have a def (a rail nobody serves is a construction bug);
|
|
105
|
+
* journal commands stay in the credential/descriptor surface but ride epj, so a journal-only or
|
|
106
|
+
* mixed endpoint still constructs and serves describe. The reserved `describe` (§13.7: every endpoint MUST serve it) is built
|
|
107
|
+
* HERE over the artifact's DERIVED deep-frozen descriptor — a `describe` def refuses at
|
|
108
|
+
* construction, so the authorization seam cannot be replaced, and no hand-authored or
|
|
109
|
+
* later-mutated descriptor can reach the wire.
|
|
110
|
+
*
|
|
111
|
+
* Boundary discipline per message: subject parse (a non-request subject has no sender and is
|
|
112
|
+
* never handled), body validation with the exact §13.3 catalog codes, body-subject agreement,
|
|
113
|
+
* class match against the REGISTERED class, digest binding, registered admission (targeted
|
|
114
|
+
* commands refuse the untargeted form and vice versa), args schema validation, fresh target
|
|
115
|
+
* currency, the per-mode fresh authorization (`child`/`ledger` seams), then — because those
|
|
116
|
+
* seams await — target currency AGAIN immediately before dispatch (§13.2/§13.3: a mapping
|
|
117
|
+
* rotated during the authority read must fail, never ride a pre-rotation read into the
|
|
118
|
+
* effect), the §13.7 governed pre-effect gate for a command whose REGISTERED declaration
|
|
119
|
+
* carries a governed trait (guard-then-priced, {@link assertGovernedPreEffect}; construction
|
|
120
|
+
* already refused a governed surface/hook gap, so a bypass is structurally impossible), and
|
|
121
|
+
* budgeted output schema validation before the success publish. A call's reply
|
|
122
|
+
* (success OR structured error) is published on the DERIVED reply subject (§13.2: never a
|
|
123
|
+
* body-supplied target); a cast is never replied to, even on error (§13.5: at-most-once, the
|
|
124
|
+
* caller never reads the rail). A request whose body cannot be parsed carries no trustworthy
|
|
125
|
+
* verb; it is answered (the derived subject is nonce-scoped to this caller, and a cast caller
|
|
126
|
+
* simply holds no subscription there). A reply that does not serialize is replaced by a
|
|
127
|
+
* structured `internal` error reply, never dropped.
|
|
128
|
+
*/
|
|
129
|
+
export function serveEndpoint(nc, space, serve, commands, describe, opts = {}) {
|
|
130
|
+
assertServeGrantAuthorized(serve); // §13.9: the serve table consumes ONLY registry-authorized serve authority
|
|
131
|
+
if (serve.space !== space)
|
|
132
|
+
throw new Error(`the serve artifact was authorized for space "${serve.space}", not "${space}" (SPEC 13.9)`);
|
|
133
|
+
const identity = { endpoint: serve.endpoint, instanceId: serve.instanceId, epoch: serve.epoch };
|
|
134
|
+
const e = endpointToken(identity.endpoint);
|
|
135
|
+
const iId = assertLifecycleToken(identity.instanceId, "instanceId");
|
|
136
|
+
const seen = new Set();
|
|
137
|
+
const defs = [];
|
|
138
|
+
for (const def of commands) {
|
|
139
|
+
assertCommandToken(def.command);
|
|
140
|
+
if (def.command === "describe")
|
|
141
|
+
throw new Error("describe is reserved and built from the serve artifact; a custom describe def would bypass the authorization seam (SPEC 13.7)");
|
|
142
|
+
if (seen.has(def.command))
|
|
143
|
+
throw new Error(`command "${def.command}" is served twice`);
|
|
144
|
+
seen.add(def.command);
|
|
145
|
+
const decl = serve.surface[def.command];
|
|
146
|
+
if (decl === undefined)
|
|
147
|
+
throw new Error(`command "${def.command}" is not granted by the serve artifact; the serve table is exactly the granted registered surface (SPEC 13.9)`);
|
|
148
|
+
if (decl.class !== "ephemeral")
|
|
149
|
+
throw new Error(`command "${def.command}" is registered class "${decl.class}": only ephemeral commands are rail-served, so a journal command takes NO rail def; journal work rides epj submissions (SPEC 13.4/13.5)`);
|
|
150
|
+
// §13.7 digest-bound validators: the def carries provenance-BRANDED compiled contracts
|
|
151
|
+
// (a structural {validate, closureDigest} pair refuses — an arbitrary validator cannot
|
|
152
|
+
// wear a registered digest), and their closure digests must EQUAL the verified registered
|
|
153
|
+
// declaration's, so the schema the caller pinned at describe time is the schema this
|
|
154
|
+
// boundary enforces.
|
|
155
|
+
const input = assertCompiledContract(def.contract?.input, `command "${def.command}" input contract`);
|
|
156
|
+
const output = assertCompiledContract(def.contract?.output, `command "${def.command}" output contract`);
|
|
157
|
+
if (input.closureDigest !== decl.inputDigest || output.closureDigest !== decl.outputDigest)
|
|
158
|
+
throw new Error(`command "${def.command}" compiled contracts ${input.closureDigest}/${output.closureDigest} do not equal the registered declaration ${decl.inputDigest}/${decl.outputDigest} (SPEC 13.7: the registered cluster document is the schema authority)`);
|
|
159
|
+
defs.push({
|
|
160
|
+
command: def.command,
|
|
161
|
+
class: decl.class,
|
|
162
|
+
digests: { input: decl.inputDigest, output: decl.outputDigest },
|
|
163
|
+
validate: { args: input.validate, output: output.validate },
|
|
164
|
+
targeted: decl.targeted,
|
|
165
|
+
targetModes: new Set(decl.modes),
|
|
166
|
+
governed: decl.traits.some((t) => GOVERNED_TRAIT_URNS.includes(t)),
|
|
167
|
+
handler: def.handler,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
// Exact coverage applies to the EPHEMERAL (rail-served) subset only: every ephemeral command
|
|
171
|
+
// needs a def (a rail nobody serves is a construction bug), while journal commands stay in the
|
|
172
|
+
// credential/descriptor surface but ride epj submissions, never a rail def — so a journal-only
|
|
173
|
+
// or mixed endpoint constructs and serves its mandatory `describe` (SPEC 13.7), rather than
|
|
174
|
+
// being impossible because the full-surface rule and the journal-rejection rule contradict.
|
|
175
|
+
for (const cmd of serve.commands) {
|
|
176
|
+
const governedUrns = serve.surface[cmd].traits.filter((t) => GOVERNED_TRAIT_URNS.includes(t));
|
|
177
|
+
if (serve.surface[cmd].class !== "ephemeral") {
|
|
178
|
+
// Journal command: no rail def, by design — but a GOVERNED journal command has no
|
|
179
|
+
// enforcement point in this slice (its pre-effect seam is the epj acceptance→effect
|
|
180
|
+
// path), so refusing to construct beats serving governance unenforced (fail closed).
|
|
181
|
+
if (governedUrns.length > 0)
|
|
182
|
+
throw new Error(`journal-class command "${cmd}" declares governed trait(s) ${governedUrns.join(", ")}; the journal-side pre-effect gate is not built in this slice, and a governed command is never served unenforced (SPEC 13.7: fail closed)`);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (!seen.has(cmd))
|
|
186
|
+
throw new Error(`ephemeral command "${cmd}" has no def in this serve table; the credential subscribes a rail nobody would serve (SPEC 13.9)`);
|
|
187
|
+
}
|
|
188
|
+
// §13.7/§13.9 governed wiring, decided at CONSTRUCTION (a bypass must be structurally
|
|
189
|
+
// impossible, never a first-request surprise): a governed command demands the verified
|
|
190
|
+
// governed surface (branded, bound to exactly THIS grant) plus each trait's hook; an
|
|
191
|
+
// enforcement bundle on an ungoverned surface is a misconfiguration and refuses loudly.
|
|
192
|
+
const governedDefs = defs.filter((d) => d.governed);
|
|
193
|
+
// Capture a CONSTRUCTION-LOCAL enforcement snapshot the dispatch closure binds, so a caller
|
|
194
|
+
// that mutates or swaps `opts.traits` (or its `.governed`) AFTER construction cannot change
|
|
195
|
+
// what the gate enforces — the construction-time validation below is then both necessary AND
|
|
196
|
+
// sufficient (the surface itself is deep-frozen, this closes the whole-object-swap vector).
|
|
197
|
+
let enforcement;
|
|
198
|
+
if (opts.traits === undefined) {
|
|
199
|
+
if (governedDefs.length > 0)
|
|
200
|
+
throw new Error(`command(s) ${governedDefs.map((d) => `"${d.command}"`).join(", ")} declare governed traits and no trait enforcement is wired (opts.traits); missing or unverifiable governed attachments refuse before effect, so construction refuses (SPEC 13.7: fail closed)`);
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
if (governedDefs.length === 0)
|
|
204
|
+
throw new Error("opts.traits is wired but no granted command declares a governed trait; an extraneous enforcement bundle is a misconfiguration, refused loudly rather than silently ignored (SPEC 13.7)");
|
|
205
|
+
// The guard WIRING is snapshot per-field at construction (engineer MEDIUM: the bundle was
|
|
206
|
+
// copied by reference, so a caller swapping `guard.call` after construction would change
|
|
207
|
+
// what the gate enforces at dispatch): single-read the three fields, validate them as
|
|
208
|
+
// functions HERE (never at first request), and freeze a detached bundle. Invoking the
|
|
209
|
+
// captured `now` per request is intentional - only the function REFERENCES are fixed.
|
|
210
|
+
const guardIn = opts.traits.guard;
|
|
211
|
+
let guard;
|
|
212
|
+
if (guardIn !== undefined) {
|
|
213
|
+
const call = guardIn.call, resolveAnchor = guardIn.resolveAnchor, now = guardIn.now;
|
|
214
|
+
if (typeof call !== "function" || typeof resolveAnchor !== "function" || (now !== undefined && typeof now !== "function"))
|
|
215
|
+
throw new Error("opts.traits.guard must wire `call` and `resolveAnchor` functions (plus an optional `now` clock); a garbled guard seam refuses at construction, never as a first-request surprise (SPEC 13.6)");
|
|
216
|
+
guard = Object.freeze({ call, resolveAnchor, ...(now !== undefined ? { now } : {}) });
|
|
217
|
+
}
|
|
218
|
+
enforcement = { governed: opts.traits.governed, guard, verifyPaymentProof: opts.traits.verifyPaymentProof };
|
|
219
|
+
assertGovernedSurfaceFor(enforcement.governed, serve);
|
|
220
|
+
for (const d of governedDefs) {
|
|
221
|
+
const per = enforcement.governed.commands[d.command];
|
|
222
|
+
if (per?.[TRAIT_GUARDED] !== undefined && enforcement.guard === undefined)
|
|
223
|
+
throw new Error(`command "${d.command}" is guarded and no guard seam is wired; an unreachable guard is deny, so construction refuses rather than denying every request (SPEC 13.6)`);
|
|
224
|
+
// priced ⇒ journal-class (§13.10): a priced effect MUST leave a receipt, and a receipt
|
|
225
|
+
// derives from the journaled acceptance fact — the ephemeral rail records no acceptance,
|
|
226
|
+
// so an ephemeral priced command structurally CANNOT satisfy MUST-emit. Every def in this
|
|
227
|
+
// table is rail-served (ephemeral), hence any priced def here refuses at construction.
|
|
228
|
+
if (per?.[TRAIT_PRICED] !== undefined)
|
|
229
|
+
throw new Error(`command "${d.command}" is priced and declared ephemeral; a priced effect MUST leave a receipt, and a receipt derives from the journaled acceptance fact, so priced implies journal-class (SPEC 13.10) - declare the command class journal`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
defs.push({
|
|
233
|
+
command: "describe",
|
|
234
|
+
class: "ephemeral",
|
|
235
|
+
// describe pins no digests (§13.7) but validates like every command: canonical void args
|
|
236
|
+
// (`bad-request` on any payload, BEFORE the authorization-view lookup) and the declared
|
|
237
|
+
// DescribeAnswer output shape.
|
|
238
|
+
validate: describeValidators(),
|
|
239
|
+
targeted: false, // §13.7: describe is reserved UNTARGETED; every targeted form refuses
|
|
240
|
+
targetModes: new Set(),
|
|
241
|
+
governed: false, // §13.7: describe is the discovery bootstrap; it carries no traits
|
|
242
|
+
handler: describeHandler(serve.descriptor, describe),
|
|
243
|
+
});
|
|
244
|
+
const p = spacePrefix(space);
|
|
245
|
+
const subs = [];
|
|
246
|
+
const pending = new Set();
|
|
247
|
+
const enc = new TextEncoder();
|
|
248
|
+
const dec = new TextDecoder();
|
|
249
|
+
const handle = async (def, msg) => {
|
|
250
|
+
const parsed = parseEpSubject(msg.subject);
|
|
251
|
+
if (!parsed || parsed.plane !== "request")
|
|
252
|
+
return; // no sender: MUST NOT be handled (§13.2)
|
|
253
|
+
let env;
|
|
254
|
+
let reply;
|
|
255
|
+
try {
|
|
256
|
+
env = parseEndpointRequest(JSON.parse(dec.decode(msg.data)));
|
|
257
|
+
checkRequestSubjectAgreement(env, parsed);
|
|
258
|
+
assertClassMatches(env, def.class);
|
|
259
|
+
bindContract(env, def);
|
|
260
|
+
// §13.2: the command admits only its REGISTERED target modes — before args validation,
|
|
261
|
+
// so an unadmitted mode learns nothing about the input contract.
|
|
262
|
+
assertModeAdmitted(parsed, def);
|
|
263
|
+
// §13.7: args validate against the input schema BEFORE any effect (bad-request).
|
|
264
|
+
assertArgsValid(def.validate.args, env.args);
|
|
265
|
+
// §13.3/§13.9: target currency resolves against the FRESH mapping immediately before
|
|
266
|
+
// effect — static agreement is not currency; casts have effects too.
|
|
267
|
+
await assertTargetCurrent(env, opts.resolveTarget);
|
|
268
|
+
// §13.2: per-mode fresh authorization (child spawner check / ledger read), fail closed.
|
|
269
|
+
await assertTargetModeAuthorized(env, parsed, opts);
|
|
270
|
+
// §13.2/§13.3: the child/ledger seams AWAIT — the mapping can rotate (delete/recreate,
|
|
271
|
+
// revision advance) during that read, so currency is re-resolved AFTER dynamic
|
|
272
|
+
// authorization, immediately before the effect; a pre-rotation read never rides into
|
|
273
|
+
// the handler.
|
|
274
|
+
if (parsed.target?.mode === "child" || parsed.target?.mode === "ledger")
|
|
275
|
+
await assertTargetCurrent(env, opts.resolveTarget);
|
|
276
|
+
// §13.7/§13.9 governed pre-effect gate, for calls AND casts (casts have effects too):
|
|
277
|
+
// guard-then-priced, every anomalous answer refuses, both seams bounded. Construction
|
|
278
|
+
// refused a governed def without enforcement, so the snapshot is present and total here.
|
|
279
|
+
let obligations;
|
|
280
|
+
if (def.governed) {
|
|
281
|
+
({ obligations } = await assertGovernedPreEffect({
|
|
282
|
+
enforcement: enforcement,
|
|
283
|
+
endpoint: identity.endpoint,
|
|
284
|
+
command: def.command,
|
|
285
|
+
caller: parsed.caller,
|
|
286
|
+
requestId: env.id,
|
|
287
|
+
space,
|
|
288
|
+
...(env.auth !== undefined ? { auth: env.auth } : {}),
|
|
289
|
+
...(env.deadlineMs !== undefined ? { deadlineMs: env.deadlineMs } : {}),
|
|
290
|
+
}));
|
|
291
|
+
// §13.2/§13.3 TOCTOU: the gate AWAITED guard/proof, so a target mapping or child/ledger
|
|
292
|
+
// grant can have rotated since the last currency read — re-resolve currency, re-run the
|
|
293
|
+
// dynamic authorization, and then (because the child/ledger authorization is ITSELF an
|
|
294
|
+
// await the mapping can rotate during) re-resolve currency a FINAL time immediately
|
|
295
|
+
// before the effect. This is the SAME currency → auth → currency discipline the
|
|
296
|
+
// pre-gate path above follows; currency → auth alone leaves the last await unfenced.
|
|
297
|
+
// A one-use priced proof MAY be consumed before this refusal fires: fail-closed, no
|
|
298
|
+
// effect occurs, and the caller retries with a fresh request id and proof.
|
|
299
|
+
await assertTargetCurrent(env, opts.resolveTarget);
|
|
300
|
+
await assertTargetModeAuthorized(env, parsed, opts);
|
|
301
|
+
if (parsed.target?.mode === "child" || parsed.target?.mode === "ledger")
|
|
302
|
+
await assertTargetCurrent(env, opts.resolveTarget);
|
|
303
|
+
}
|
|
304
|
+
const ctx = { identity, subject: parsed, request: env, ...(obligations !== undefined ? { obligations } : {}) };
|
|
305
|
+
if (!env.replyExpected) {
|
|
306
|
+
await def.handler(ctx);
|
|
307
|
+
return; // cast: the responder MUST NOT reply (§13.5)
|
|
308
|
+
}
|
|
309
|
+
const data = await def.handler(ctx);
|
|
310
|
+
// §13.7: the reply validates against the output schema BEFORE it is published, under the
|
|
311
|
+
// same fixed budget as args — an invalid reply is a server bug and fails loud, never
|
|
312
|
+
// reaches the caller as success.
|
|
313
|
+
assertOutputValid(def.validate.output, data);
|
|
314
|
+
reply = { v: 1, id: env.id, ok: true, ...(data !== undefined ? { data } : {}) };
|
|
315
|
+
}
|
|
316
|
+
catch (err) {
|
|
317
|
+
if (env && !env.replyExpected)
|
|
318
|
+
return; // a failed cast stays silent (§13.5 at-most-once)
|
|
319
|
+
const error = err instanceof EpEnvelopeError
|
|
320
|
+
? err.toEpError()
|
|
321
|
+
: { code: "internal", message: err?.message ?? String(err) };
|
|
322
|
+
// The id echoes the request where one parsed; the reply subject is already nonce-scoped
|
|
323
|
+
// to exactly this request's caller, so attribution never rides the echo.
|
|
324
|
+
reply = { v: 1, id: env?.id ?? "invalid", ok: false, error };
|
|
325
|
+
}
|
|
326
|
+
let bytes;
|
|
327
|
+
try {
|
|
328
|
+
bytes = enc.encode(JSON.stringify(reply));
|
|
329
|
+
}
|
|
330
|
+
catch (err) {
|
|
331
|
+
// A non-serializable success payload (cycle, BigInt) must not silently drop the reply.
|
|
332
|
+
const fallback = { v: 1, id: reply.id, ok: false, error: { code: "internal", message: `the reply does not serialize: ${err.message}` } };
|
|
333
|
+
bytes = enc.encode(JSON.stringify(fallback));
|
|
334
|
+
}
|
|
335
|
+
nc.publish(deriveReplySubject(space, parsed, identity), bytes);
|
|
336
|
+
};
|
|
337
|
+
for (const def of defs) {
|
|
338
|
+
const cmd = def.command;
|
|
339
|
+
const cb = (_err, msg) => {
|
|
340
|
+
const run = handle(def, msg).catch(() => { });
|
|
341
|
+
pending.add(run);
|
|
342
|
+
void run.finally(() => pending.delete(run));
|
|
343
|
+
};
|
|
344
|
+
subs.push(nc.subscribe(`${p}.ep.one.${e}.${cmd}.>`, { queue: epClassQueueGroup(identity.endpoint), callback: cb }));
|
|
345
|
+
subs.push(nc.subscribe(`${p}.ep.all.${e}.${cmd}.>`, { callback: cb }));
|
|
346
|
+
subs.push(nc.subscribe(`${p}.ep.inst.${e}.${iId}.${cmd}.>`, { callback: cb }));
|
|
347
|
+
}
|
|
348
|
+
return {
|
|
349
|
+
async stop() {
|
|
350
|
+
await Promise.all(subs.map((s) => s.drain())); // no new deliveries
|
|
351
|
+
await Promise.allSettled([...pending]); // in-flight handlers finish before "stopped"
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
/** The declared {@link DescribeAnswer} output schema. The answer's own two fields are closed;
|
|
356
|
+
* the descriptor level is deliberately OPEN to additive evolution (§13.7: protocol.v stays 1
|
|
357
|
+
* across additive changes), with the identity, protocol pin, digest grammar, and non-empty
|
|
358
|
+
* per-cluster command lists enforced (an all-filtered cluster leaves the answer, so an empty
|
|
359
|
+
* `commands` never appears; an empty `clusters` array is the valid authorized-but-empty
|
|
360
|
+
* intersection). */
|
|
361
|
+
const DESCRIBE_ANSWER_SCHEMA = {
|
|
362
|
+
type: "object",
|
|
363
|
+
required: ["public", "descriptor"],
|
|
364
|
+
additionalProperties: false,
|
|
365
|
+
properties: {
|
|
366
|
+
public: { type: "boolean" },
|
|
367
|
+
descriptor: {
|
|
368
|
+
type: "object",
|
|
369
|
+
required: ["endpoint", "owner", "protocol", "clusters"],
|
|
370
|
+
properties: {
|
|
371
|
+
endpoint: { type: "string", minLength: 1 },
|
|
372
|
+
owner: { type: "string", minLength: 1 },
|
|
373
|
+
endpointType: { type: "string" },
|
|
374
|
+
protocol: { type: "object", required: ["v"], properties: { v: { const: 1 } } },
|
|
375
|
+
clusters: {
|
|
376
|
+
type: "array",
|
|
377
|
+
items: {
|
|
378
|
+
type: "object",
|
|
379
|
+
required: ["digest", "commands"],
|
|
380
|
+
properties: {
|
|
381
|
+
digest: { type: "string", pattern: "^sha256:[0-9a-f]{64}$" },
|
|
382
|
+
commands: { type: "array", minItems: 1, items: { type: "string", minLength: 1 } },
|
|
383
|
+
document: { type: "object" },
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
},
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
};
|
|
391
|
+
/** The reserved describe's compiled validators (§13.7): the canonical VOID input (a describe
|
|
392
|
+
* carrying any args is `bad-request` at the same pre-effect seam as every command, BEFORE the
|
|
393
|
+
* authorization-view lookup) and the declared {@link DescribeAnswer} output. Compiled once,
|
|
394
|
+
* lazily, through the same profile compiler every registered contract rides. */
|
|
395
|
+
let describeValidate;
|
|
396
|
+
function describeValidators() {
|
|
397
|
+
describeValidate ??= {
|
|
398
|
+
args: compileContract({ root: VOID_SCHEMA }).validate,
|
|
399
|
+
output: compileContract({ root: DESCRIBE_ANSWER_SCHEMA }).validate,
|
|
400
|
+
};
|
|
401
|
+
return describeValidate;
|
|
402
|
+
}
|
|
403
|
+
/** The reserved describe handler (internal: {@link serveEndpoint} is the only constructor —
|
|
404
|
+
* §13.7 makes describe mandatory and this seam non-replaceable). The authorization policy is
|
|
405
|
+
* SNAPSHOTTED at construction (the discriminant + the view function), so a later mutation of
|
|
406
|
+
* the caller-supplied `authz` object — flipping `{view}` to `{public:true}` — can never change
|
|
407
|
+
* what a running describe answers; the descriptor itself is deep-frozen at authorization. The
|
|
408
|
+
* scoped answer intersects the descriptor against a FRESH trusted view of the caller's
|
|
409
|
+
* authority; an unavailable or answerless view is `unavailable` (fail closed). An
|
|
410
|
+
* authorized-but-empty intersection is a valid (empty) answer: describe is the default-granted
|
|
411
|
+
* bootstrap (§13.9), and descriptor visibility is never inferred from its reachability alone. */
|
|
412
|
+
function describeHandler(descriptor, authz) {
|
|
413
|
+
const isPublic = authz.public === true;
|
|
414
|
+
// Snapshot the view function once; when not public the union guarantees it is present.
|
|
415
|
+
const viewFn = isPublic ? undefined : authz.view;
|
|
416
|
+
return async (ctx) => {
|
|
417
|
+
if (endpointToken(descriptor.endpoint) !== endpointToken(ctx.identity.endpoint))
|
|
418
|
+
throw new EpEnvelopeError("internal", "describe descriptor does not name the serving endpoint");
|
|
419
|
+
if (isPublic)
|
|
420
|
+
return { public: true, descriptor };
|
|
421
|
+
if (viewFn === undefined)
|
|
422
|
+
throw new EpEnvelopeError("internal", "describe has no snapshotted authorization view but is not public");
|
|
423
|
+
let view;
|
|
424
|
+
try {
|
|
425
|
+
view = await viewFn(ctx.subject.caller);
|
|
426
|
+
}
|
|
427
|
+
catch (err) {
|
|
428
|
+
throw new EpEnvelopeError("unavailable", `the trusted authorization view failed; describe fails closed, never answers from a weaker source (SPEC 13.7): ${err?.message ?? String(err)}`);
|
|
429
|
+
}
|
|
430
|
+
if (view === undefined)
|
|
431
|
+
throw new EpEnvelopeError("unavailable", "no fresh trusted authorization view for this caller; describe fails closed (SPEC 13.7)");
|
|
432
|
+
const allowed = new Set(view.commands);
|
|
433
|
+
const clusters = descriptor.clusters
|
|
434
|
+
.map((c) => {
|
|
435
|
+
const commands = c.commands.filter((cmd) => allowed.has(cmd));
|
|
436
|
+
// The inline document is an OPAQUE cluster artifact core cannot project command-wise:
|
|
437
|
+
// a partial intersection answers by digest only, so a denied command can never leak
|
|
438
|
+
// through the inline copy; the full document rides only a full-cluster authorization.
|
|
439
|
+
const full = commands.length === c.commands.length;
|
|
440
|
+
return { digest: c.digest, commands, ...(full && c.document !== undefined ? { document: c.document } : {}) };
|
|
441
|
+
})
|
|
442
|
+
.filter((c) => c.commands.length > 0);
|
|
443
|
+
return { public: false, descriptor: { ...descriptor, clusters } };
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
//# sourceMappingURL=endpoint-serve.js.map
|