@fabricorg/experience-gateway 1.0.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 +101 -0
- package/LICENSE +21 -0
- package/README.md +17 -0
- package/dist/index.cjs +257 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +114 -0
- package/dist/index.d.ts +114 -0
- package/dist/index.js +231 -0
- package/dist/index.js.map +1 -0
- package/package.json +57 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.0.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 337e89c: Make offline replay truthful and give three conformance suites teeth.
|
|
8
|
+
|
|
9
|
+
Offline replay carried no marker and dropped the capture moment. An adapter had to remember to set
|
|
10
|
+
`executionReason` for the gateway to apply offline eligibility rules, and capture-authority evidence
|
|
11
|
+
was stamped at receipt, so it attested a moment nobody was there for. `ExperienceReplayRequest` now
|
|
12
|
+
carries `executionReason` and `capturedAt` structurally, and the gateway uses the supplied instant,
|
|
13
|
+
bounded so it must parse and must not be in the future.
|
|
14
|
+
|
|
15
|
+
Replay resolved to `completed` or `failed`, which collapsed three outcomes the platform deliberately
|
|
16
|
+
distinguishes and kept every failure queued. A capture the platform had already applied counted as a
|
|
17
|
+
failure and retried, when replaying an idempotent action twice is the mechanism working; a capture
|
|
18
|
+
refused on authority grounds retried until its TTL rather than surfacing. `replay()` now returns
|
|
19
|
+
`reconciled` alongside the counts, holding each settled refusal and the reason given. Unrecognised
|
|
20
|
+
errors stay transient, so an unfamiliar failure is retried rather than discarded.
|
|
21
|
+
|
|
22
|
+
Gateway conformance accepted any result carrying an `actionInvocationId`, so a replacement that
|
|
23
|
+
fabricated an id and queried data directly passed; the subject now reports what the governed hosts
|
|
24
|
+
observed, and reads are certified through `ProjectionHost`. Component-pack certification accepted one
|
|
25
|
+
placeholder for every declared component and could not see a fresh function per call, because every
|
|
26
|
+
function stringifies to `undefined`. Renderers must now be distinct per component, undeclared names
|
|
27
|
+
must resolve to nothing, and determinism compares identity for anything not JSON-representable.
|
|
28
|
+
|
|
29
|
+
- a598fb9: Close the remaining review findings.
|
|
30
|
+
|
|
31
|
+
**One capability-reference grammar.** `sdui-release` accepted any name after the namespace while
|
|
32
|
+
`sdui-composer` rejected `/`, so a view named `orders/reports/daily` produced a release that validated
|
|
33
|
+
and then died at promotion with nothing wrong upstream to point at. Both now share
|
|
34
|
+
`parseCapabilityRef`, tightened to lowercase path segments matching what the platform permits for a
|
|
35
|
+
view name. Two views in one contract that reduce to the same reference are reported as
|
|
36
|
+
`ambiguous_capability_reference` at validation rather than surfacing as an ambiguous route later.
|
|
37
|
+
|
|
38
|
+
**A bounded replay window.** A client-supplied `capturedAt` had no lower bound. Deferring it to the
|
|
39
|
+
Host looked safe and was not: the Host checks staleness only when the capture-authority adapter
|
|
40
|
+
supplies an `expiresAt`, and skips the execution-time expiry check entirely for actions governed at
|
|
41
|
+
capture. A gateway that accepts replay must now declare `maxOfflineReplayAgeMs`, and the instant must
|
|
42
|
+
be RFC 3339 with an explicit offset.
|
|
43
|
+
|
|
44
|
+
**One token-set grammar.** `document.tokenSet` accepted names and versions that `supportedTokenSets`
|
|
45
|
+
rejected, while validation treats the two fields as interchangeable.
|
|
46
|
+
|
|
47
|
+
**Port requirements narrow as they are checked.** A requirement for a standard _and_ a version could
|
|
48
|
+
be satisfied by two different adapters covering one constraint each. `requirement.standard.version`
|
|
49
|
+
was declared and never read.
|
|
50
|
+
|
|
51
|
+
- 9b2e55b: Close what a fourth review found: each earlier fix closed one instance of a class and left its
|
|
52
|
+
siblings.
|
|
53
|
+
|
|
54
|
+
**Replay disposition is now exhaustive over `ActionStatus`.** The previous fix enumerated the two
|
|
55
|
+
statuses that looked like refusals, so `failed`, `validation_failed` and `waiting_for_approval` fell
|
|
56
|
+
through and were deleted from the queue and reported to the operator as applied — the same bug the
|
|
57
|
+
fix set out to kill, one layer in. Any returned outcome now means the platform holds the invocation,
|
|
58
|
+
so the capture leaves the queue either way; what differs is whether a person needs to hear about it.
|
|
59
|
+
An undeterminable adapter outcome is reported as `unknown` rather than `refused`, because the effect
|
|
60
|
+
may well have happened. `isSettledReplayFailure` learned the refusals the sibling commit introduced,
|
|
61
|
+
including a capture rejected for age, which can never come good.
|
|
62
|
+
|
|
63
|
+
**A replay without `capturedAt` is refused.** It was reachable only through an untyped cast, so
|
|
64
|
+
omitting it skipped the age check entirely and stamped receipt time into durable capture evidence.
|
|
65
|
+
|
|
66
|
+
**A capability can name a namespaced port.** `definePort` required a dot and the manifest validator
|
|
67
|
+
forbade one, so no real capability could name a defined port and the certification gate was
|
|
68
|
+
unreachable. Certification is also checked structurally rather than by the presence of a property, so
|
|
69
|
+
a hand-written claim no longer satisfies the gate that exists to demand evidence, and a port
|
|
70
|
+
requirement narrows its candidates as each constraint is applied.
|
|
71
|
+
|
|
72
|
+
**`assertPortSuiteHasTeeth` names every vacuous check** rather than returning on the first one that
|
|
73
|
+
bites, and a reference port is actually run through it. The payments control asserts its result, so
|
|
74
|
+
an adapter that never charges no longer certifies. The capability-reference grammar matches the
|
|
75
|
+
platform's own view-name rule instead of being stricter than it, and ambiguity is counted by
|
|
76
|
+
occurrence across views and intents rather than by comparing names within one contract.
|
|
77
|
+
|
|
78
|
+
### Patch Changes
|
|
79
|
+
|
|
80
|
+
- Updated dependencies [8ce5e06]
|
|
81
|
+
- Updated dependencies [337e89c]
|
|
82
|
+
- Updated dependencies [a598fb9]
|
|
83
|
+
- Updated dependencies [9a5b18a]
|
|
84
|
+
- Updated dependencies [9b2e55b]
|
|
85
|
+
- Updated dependencies [0d1f6f6]
|
|
86
|
+
- @fabricorg/ports@0.4.0
|
|
87
|
+
- @fabricorg/experience-runtime@0.2.0
|
|
88
|
+
- @fabricorg/platform-host@7.0.0
|
|
89
|
+
|
|
90
|
+
## 0.1.0
|
|
91
|
+
|
|
92
|
+
### Minor Changes
|
|
93
|
+
|
|
94
|
+
- Add trusted view and intent routing, exact action-version enforcement,
|
|
95
|
+
capture-authority adapters, public conformance, and non-forgeable initiating-release
|
|
96
|
+
evidence.
|
|
97
|
+
|
|
98
|
+
### Patch Changes
|
|
99
|
+
|
|
100
|
+
- Updated dependencies
|
|
101
|
+
- @fabricorg/experience-runtime@0.1.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Fabric
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# `@fabricorg/experience-gateway`
|
|
2
|
+
|
|
3
|
+
Trusted server adapter between an experience runtime and Fabric Hosts. It verifies
|
|
4
|
+
identity and scope through `IdentityPort`, resolves only server-held plan handles,
|
|
5
|
+
routes reads through ProjectionHost, and submits mutations through PlatformHost.
|
|
6
|
+
|
|
7
|
+
Configure PlatformHost's `composition.resolveInitiatingReleaseDigest` with
|
|
8
|
+
`createInitiatingReleaseEvidence().resolve`. Release evidence is held in an
|
|
9
|
+
instance-scoped WeakMap and is never accepted from browser request data.
|
|
10
|
+
|
|
11
|
+
Provide `actionVersion` from the registered application module set so pinned release
|
|
12
|
+
routes cannot silently invoke another action version. Actions governed at the
|
|
13
|
+
`capture` authority moment additionally require a trusted `captureAuthority` adapter;
|
|
14
|
+
the browser cannot manufacture an authorization binding.
|
|
15
|
+
|
|
16
|
+
Replacement gateways can run `experienceGatewayChecks` as a public compatibility
|
|
17
|
+
suite.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
createExperienceGateway: () => createExperienceGateway,
|
|
24
|
+
createInitiatingReleaseEvidence: () => createInitiatingReleaseEvidence,
|
|
25
|
+
experienceGatewayChecks: () => experienceGatewayChecks,
|
|
26
|
+
runExperienceGatewayChecks: () => runExperienceGatewayChecks
|
|
27
|
+
});
|
|
28
|
+
module.exports = __toCommonJS(index_exports);
|
|
29
|
+
var import_platform_host = require("@fabricorg/platform-host");
|
|
30
|
+
var import_ports = require("@fabricorg/ports");
|
|
31
|
+
function experienceGatewayChecks() {
|
|
32
|
+
return [
|
|
33
|
+
{
|
|
34
|
+
id: "fabric.experience.gateway-host-routing.v1",
|
|
35
|
+
async run(subject) {
|
|
36
|
+
const before = subject.observed.submitActionCount();
|
|
37
|
+
const result = await subject.gateway.invoke(subject.validIntent);
|
|
38
|
+
if (!result.actionInvocationId) throw new Error("Gateway did not return a governed Host invocation.");
|
|
39
|
+
const submitted = subject.observed.submitActionCount() - before;
|
|
40
|
+
if (submitted !== 1) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`Gateway returned an invocation id after ${submitted} calls to PlatformHost.submitAction; a mutation must reach the Host exactly once.`
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
return [result.actionInvocationId];
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
id: "fabric.experience.gateway-projection-routing.v1",
|
|
50
|
+
async run(subject) {
|
|
51
|
+
const before = subject.observed.projectionCount();
|
|
52
|
+
await subject.gateway.read(subject.validRead);
|
|
53
|
+
const projected = subject.observed.projectionCount() - before;
|
|
54
|
+
if (projected !== 1) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`Gateway served a read after ${projected} calls to ProjectionHost.project; a read must go through the projection host.`
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return ["read routed through ProjectionHost"];
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: "fabric.experience.gateway-scope-denial.v1",
|
|
64
|
+
async run(subject) {
|
|
65
|
+
let rejected = false;
|
|
66
|
+
try {
|
|
67
|
+
await subject.gateway.invoke(subject.deniedIntent);
|
|
68
|
+
} catch {
|
|
69
|
+
rejected = true;
|
|
70
|
+
}
|
|
71
|
+
if (!rejected) throw new Error("Gateway accepted an intent outside trusted identity scope.");
|
|
72
|
+
return ["out-of-scope intent rejected"];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
];
|
|
76
|
+
}
|
|
77
|
+
async function runExperienceGatewayChecks(subject, checks = experienceGatewayChecks()) {
|
|
78
|
+
const results = [];
|
|
79
|
+
for (const check of checks) {
|
|
80
|
+
try {
|
|
81
|
+
results.push({ id: check.id, status: "passed", evidence: await check.run(subject) });
|
|
82
|
+
} catch (error) {
|
|
83
|
+
results.push({ id: check.id, status: "failed", evidence: [], error: error instanceof Error ? error.message : String(error) });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return { passed: results.every((result) => result.status === "passed"), checks: results };
|
|
87
|
+
}
|
|
88
|
+
function createInitiatingReleaseEvidence() {
|
|
89
|
+
const evidence = /* @__PURE__ */ new WeakMap();
|
|
90
|
+
return {
|
|
91
|
+
bind(input, releaseDigest) {
|
|
92
|
+
evidence.set(input, releaseDigest);
|
|
93
|
+
},
|
|
94
|
+
resolve(input) {
|
|
95
|
+
return evidence.get(input);
|
|
96
|
+
},
|
|
97
|
+
release(input) {
|
|
98
|
+
evidence.delete(input);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function createExperienceGateway(options) {
|
|
103
|
+
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
104
|
+
return {
|
|
105
|
+
async invoke(input) {
|
|
106
|
+
const claims = await options.identity.verify(input.context.credential);
|
|
107
|
+
if (!claims) throw new Error("Experience request credential is not authenticated.");
|
|
108
|
+
(0, import_ports.assertActorClaimsCoverScope)(claims, {
|
|
109
|
+
tenantId: input.context.tenantId,
|
|
110
|
+
spaceId: input.context.spaceId
|
|
111
|
+
}, now());
|
|
112
|
+
const scope = {
|
|
113
|
+
tenantId: input.context.tenantId,
|
|
114
|
+
spaceId: input.context.spaceId,
|
|
115
|
+
actorId: claims.subject,
|
|
116
|
+
actorType: claims.actorType
|
|
117
|
+
};
|
|
118
|
+
const resolved = await options.runtime.resolveIntent({
|
|
119
|
+
planId: input.request.planId,
|
|
120
|
+
fragmentId: input.request.fragmentId,
|
|
121
|
+
eventName: input.request.eventName,
|
|
122
|
+
scope
|
|
123
|
+
});
|
|
124
|
+
if (resolved.assemblyDigest !== options.assemblyDigest) {
|
|
125
|
+
throw new Error("Experience render plan belongs to a different application assembly.");
|
|
126
|
+
}
|
|
127
|
+
if (options.actionVersion(resolved.actionId) !== resolved.actionVersion) {
|
|
128
|
+
throw new Error(`Experience intent route for "${resolved.actionId}" does not match the registered action version.`);
|
|
129
|
+
}
|
|
130
|
+
if (resolved.execution?.idempotency === "required" && !input.request.idempotencyKey) {
|
|
131
|
+
throw new Error(`Action "${resolved.actionId}" requires an idempotency key.`);
|
|
132
|
+
}
|
|
133
|
+
if (input.executionReason === "offline_replay" && resolved.execution?.connectivity !== "offline-capturable") {
|
|
134
|
+
throw new Error(`Action "${resolved.actionId}" is not eligible for offline replay.`);
|
|
135
|
+
}
|
|
136
|
+
const correlationId = options.nextCorrelationId();
|
|
137
|
+
let capturedAt;
|
|
138
|
+
if (input.executionReason === "offline_replay") {
|
|
139
|
+
const supplied = input.capturedAt ?? input.request.capturedAt;
|
|
140
|
+
if (typeof supplied !== "string") {
|
|
141
|
+
throw new Error("Offline replay requires capturedAt; receipt time cannot attest when the operator acted.");
|
|
142
|
+
}
|
|
143
|
+
capturedAt = assertCaptureMoment(supplied, now(), options.maxOfflineReplayAgeMs);
|
|
144
|
+
} else {
|
|
145
|
+
capturedAt = now().toISOString();
|
|
146
|
+
}
|
|
147
|
+
const parameterDigest = (0, import_platform_host.digestParameters)(input.request.parameters);
|
|
148
|
+
const capturedAuthority = resolved.execution?.authorityMoment === "capture" ? await options.captureAuthority?.({
|
|
149
|
+
actionId: resolved.actionId,
|
|
150
|
+
scope,
|
|
151
|
+
parameters: input.request.parameters,
|
|
152
|
+
parameterDigest,
|
|
153
|
+
capturedAt
|
|
154
|
+
}) : void 0;
|
|
155
|
+
if (resolved.execution?.authorityMoment === "capture" && !capturedAuthority) {
|
|
156
|
+
throw new Error(`Action "${resolved.actionId}" requires a trusted capture-authority adapter.`);
|
|
157
|
+
}
|
|
158
|
+
const submission = {
|
|
159
|
+
actionId: resolved.actionId,
|
|
160
|
+
parameters: input.request.parameters,
|
|
161
|
+
tenantId: scope.tenantId,
|
|
162
|
+
spaceId: scope.spaceId,
|
|
163
|
+
actorId: scope.actorId,
|
|
164
|
+
actorType: claims.actorType,
|
|
165
|
+
correlationId,
|
|
166
|
+
provenance: {
|
|
167
|
+
source: "ui",
|
|
168
|
+
correlationId,
|
|
169
|
+
...resolved.execution?.sensitivity === "restricted" ? {} : { auditAttributes: { "fabric.experience.fragment": input.request.fragmentId } }
|
|
170
|
+
},
|
|
171
|
+
...input.request.idempotencyKey === void 0 ? {} : { idempotencyKey: input.request.idempotencyKey },
|
|
172
|
+
...input.executionReason === void 0 ? {} : { executionReason: input.executionReason },
|
|
173
|
+
...capturedAuthority === void 0 ? {} : {
|
|
174
|
+
authorizationBindingId: capturedAuthority.id,
|
|
175
|
+
authorizationBinding: {
|
|
176
|
+
id: capturedAuthority.id,
|
|
177
|
+
governingMoment: "capture",
|
|
178
|
+
capturedAt,
|
|
179
|
+
...capturedAuthority.expiresAt === void 0 ? {} : { expiresAt: capturedAuthority.expiresAt },
|
|
180
|
+
tenantId: scope.tenantId,
|
|
181
|
+
actorId: scope.actorId,
|
|
182
|
+
actionId: resolved.actionId,
|
|
183
|
+
parameterDigest
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
options.evidence.bind(submission, resolved.initiatingReleaseDigest);
|
|
188
|
+
try {
|
|
189
|
+
return await options.host.submitAction(submission);
|
|
190
|
+
} finally {
|
|
191
|
+
options.evidence.release(submission);
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
async read(input) {
|
|
195
|
+
const claims = await options.identity.verify(input.context.credential);
|
|
196
|
+
if (!claims) throw new Error("Experience request credential is not authenticated.");
|
|
197
|
+
(0, import_ports.assertActorClaimsCoverScope)(claims, {
|
|
198
|
+
tenantId: input.context.tenantId,
|
|
199
|
+
spaceId: input.context.spaceId
|
|
200
|
+
}, now());
|
|
201
|
+
const scope = {
|
|
202
|
+
tenantId: input.context.tenantId,
|
|
203
|
+
spaceId: input.context.spaceId,
|
|
204
|
+
actorId: claims.subject,
|
|
205
|
+
actorType: claims.actorType
|
|
206
|
+
};
|
|
207
|
+
const resolved = await options.runtime.resolveView({
|
|
208
|
+
planId: input.request.planId,
|
|
209
|
+
fragmentId: input.request.fragmentId,
|
|
210
|
+
bindingName: input.request.bindingName,
|
|
211
|
+
scope
|
|
212
|
+
});
|
|
213
|
+
if (resolved.assemblyDigest !== options.assemblyDigest) {
|
|
214
|
+
throw new Error("Experience render plan belongs to a different application assembly.");
|
|
215
|
+
}
|
|
216
|
+
const result = await options.projectionHost.project({
|
|
217
|
+
view: { name: resolved.name, version: resolved.version },
|
|
218
|
+
scope: { tenantId: scope.tenantId, spaceId: scope.spaceId },
|
|
219
|
+
actor: { id: scope.actorId, type: claims.actorType },
|
|
220
|
+
parameters: input.request.parameters
|
|
221
|
+
});
|
|
222
|
+
return {
|
|
223
|
+
data: result.state,
|
|
224
|
+
evidence: {
|
|
225
|
+
releaseDigest: resolved.initiatingReleaseDigest,
|
|
226
|
+
assemblyDigest: resolved.assemblyDigest,
|
|
227
|
+
eventCursor: result.eventCursor,
|
|
228
|
+
source: result.source
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
var RFC3339 = /^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})$/;
|
|
235
|
+
function assertCaptureMoment(capturedAt, now, maxAgeMs) {
|
|
236
|
+
if (!RFC3339.test(capturedAt)) {
|
|
237
|
+
throw new Error("Offline replay capturedAt must be an RFC 3339 instant with an explicit offset.");
|
|
238
|
+
}
|
|
239
|
+
const parsed = Date.parse(capturedAt);
|
|
240
|
+
if (Number.isNaN(parsed)) throw new Error("Offline replay capturedAt is not a parseable instant.");
|
|
241
|
+
if (parsed > now.getTime()) throw new Error("Offline replay capturedAt is in the future.");
|
|
242
|
+
if (maxAgeMs === void 0) {
|
|
243
|
+
throw new Error("Gateway accepts offline replay but declares no maxOfflineReplayAgeMs, so a capture has no lower bound.");
|
|
244
|
+
}
|
|
245
|
+
if (now.getTime() - parsed > maxAgeMs) {
|
|
246
|
+
throw new Error(`Offline replay capturedAt is older than the ${maxAgeMs}ms this gateway accepts.`);
|
|
247
|
+
}
|
|
248
|
+
return new Date(parsed).toISOString();
|
|
249
|
+
}
|
|
250
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
251
|
+
0 && (module.exports = {
|
|
252
|
+
createExperienceGateway,
|
|
253
|
+
createInitiatingReleaseEvidence,
|
|
254
|
+
experienceGatewayChecks,
|
|
255
|
+
runExperienceGatewayChecks
|
|
256
|
+
});
|
|
257
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../index.ts"],"sourcesContent":["import type {\n\tGovernedActionHost,\n\tSubmitActionInput,\n\tSubmitActionResult,\n} from \"@fabricorg/platform-host\";\nimport { digestParameters } from \"@fabricorg/platform-host\";\nimport type { ProjectionHost } from \"@fabricorg/projection-host\";\nimport {\n\tassertActorClaimsCoverScope,\n\ttype IdentityPort,\n} from \"@fabricorg/ports\";\nimport type { ExperienceIntentRequest, ExperienceReadRequest } from \"@fabricorg/experience-runtime\";\nimport type {\n\tExperienceScope,\n\tExperienceServerRuntime,\n} from \"@fabricorg/experience-runtime/server\";\n\nexport interface TrustedExperienceRequestContext {\n\t/** Presented only to IdentityPort and never persisted or forwarded. */\n\tcredential: string;\n\ttenantId: string;\n\tspaceId: string;\n}\n\nexport interface ExperienceGateway {\n\tinvoke(input: {\n\t\tcontext: TrustedExperienceRequestContext;\n\t\trequest: ExperienceIntentRequest;\n\t\texecutionReason?: \"initial\" | \"offline_replay\";\n\t\t/**\n\t\t * When the operator acted, for a replay. Required whenever\n\t\t * `executionReason` is `offline_replay`: reading it through an untyped\n\t\t * cast made omitting it the easy mistake, and omitting it silently\n\t\t * stamped receipt time into durable capture evidence.\n\t\t */\n\t\tcapturedAt?: string;\n\t}): Promise<SubmitActionResult>;\n\tread(input: {\n\t\tcontext: TrustedExperienceRequestContext;\n\t\trequest: ExperienceReadRequest;\n\t}): Promise<{\n\t\tdata: unknown;\n\t\tevidence: {\n\t\t\treleaseDigest: string;\n\t\t\tassemblyDigest: string;\n\t\t\teventCursor: string | null;\n\t\t\tsource: \"cache\" | \"replay\";\n\t\t};\n\t}>;\n}\n\nexport interface ExperienceGatewayConformanceSubject {\n\tgateway: ExperienceGateway;\n\tvalidIntent: Parameters<ExperienceGateway[\"invoke\"]>[0];\n\tdeniedIntent: Parameters<ExperienceGateway[\"invoke\"]>[0];\n\tvalidRead: Parameters<ExperienceGateway[\"read\"]>[0];\n\t/**\n\t * What the governed hosts actually saw.\n\t *\n\t * A returned invocation id proves only that a string was produced. The\n\t * point of a gateway is that mutations reach `PlatformHost.submitAction` and\n\t * reads reach `ProjectionHost.project`; a replacement that fabricates an id\n\t * and queries the database directly satisfies every observable contract\n\t * except this one, so the subject has to report the calls.\n\t */\n\tobserved: {\n\t\tsubmitActionCount(): number;\n\t\tprojectionCount(): number;\n\t};\n}\n\nexport interface ExperienceGatewayCheck {\n\tid:\n\t\t| \"fabric.experience.gateway-host-routing.v1\"\n\t\t| \"fabric.experience.gateway-scope-denial.v1\"\n\t\t| \"fabric.experience.gateway-projection-routing.v1\";\n\trun(subject: ExperienceGatewayConformanceSubject): Promise<string[]>;\n}\n\nexport function experienceGatewayChecks(): readonly ExperienceGatewayCheck[] {\n\treturn [\n\t\t{\n\t\t\tid: \"fabric.experience.gateway-host-routing.v1\",\n\t\t\tasync run(subject) {\n\t\t\t\tconst before = subject.observed.submitActionCount();\n\t\t\t\tconst result = await subject.gateway.invoke(subject.validIntent);\n\t\t\t\tif (!result.actionInvocationId) throw new Error(\"Gateway did not return a governed Host invocation.\");\n\t\t\t\tconst submitted = subject.observed.submitActionCount() - before;\n\t\t\t\tif (submitted !== 1) {\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t`Gateway returned an invocation id after ${submitted} calls to PlatformHost.submitAction; a mutation must reach the Host exactly once.`,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\treturn [result.actionInvocationId];\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tid: \"fabric.experience.gateway-projection-routing.v1\",\n\t\t\tasync run(subject) {\n\t\t\t\tconst before = subject.observed.projectionCount();\n\t\t\t\tawait subject.gateway.read(subject.validRead);\n\t\t\t\tconst projected = subject.observed.projectionCount() - before;\n\t\t\t\tif (projected !== 1) {\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t`Gateway served a read after ${projected} calls to ProjectionHost.project; a read must go through the projection host.`,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\treturn [\"read routed through ProjectionHost\"];\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tid: \"fabric.experience.gateway-scope-denial.v1\",\n\t\t\tasync run(subject) {\n\t\t\t\tlet rejected = false;\n\t\t\t\ttry { await subject.gateway.invoke(subject.deniedIntent); } catch { rejected = true; }\n\t\t\t\tif (!rejected) throw new Error(\"Gateway accepted an intent outside trusted identity scope.\");\n\t\t\t\treturn [\"out-of-scope intent rejected\"];\n\t\t\t},\n\t\t},\n\t];\n}\n\nexport async function runExperienceGatewayChecks(\n\tsubject: ExperienceGatewayConformanceSubject,\n\tchecks: readonly ExperienceGatewayCheck[] = experienceGatewayChecks(),\n): Promise<{ passed: boolean; checks: Array<{ id: ExperienceGatewayCheck[\"id\"]; status: \"passed\" | \"failed\"; evidence: string[]; error?: string }> }> {\n\tconst results: Array<{ id: ExperienceGatewayCheck[\"id\"]; status: \"passed\" | \"failed\"; evidence: string[]; error?: string }> = [];\n\tfor (const check of checks) {\n\t\ttry { results.push({ id: check.id, status: \"passed\", evidence: await check.run(subject) }); }\n\t\tcatch (error) { results.push({ id: check.id, status: \"failed\", evidence: [], error: error instanceof Error ? error.message : String(error) }); }\n\t}\n\treturn { passed: results.every((result) => result.status === \"passed\"), checks: results };\n}\n\nexport interface InitiatingReleaseEvidence {\n\tbind(input: SubmitActionInput, releaseDigest: string): void;\n\tresolve(input: SubmitActionInput): string | undefined;\n\trelease(input: SubmitActionInput): void;\n}\n\n/**\n * Creates an instance-scoped, non-serializable evidence seam. The Host's\n * `composition.resolveInitiatingReleaseDigest` reads this WeakMap using the\n * exact submission object. A browser field cannot populate or forge it.\n */\nexport function createInitiatingReleaseEvidence(): InitiatingReleaseEvidence {\n\tconst evidence = new WeakMap<SubmitActionInput, string>();\n\treturn {\n\t\tbind(input, releaseDigest) {\n\t\t\tevidence.set(input, releaseDigest);\n\t\t},\n\t\tresolve(input) {\n\t\t\treturn evidence.get(input);\n\t\t},\n\t\trelease(input) {\n\t\t\tevidence.delete(input);\n\t\t},\n\t};\n}\n\nexport function createExperienceGateway(options: {\n\tidentity: IdentityPort;\n\truntime: ExperienceServerRuntime;\n\thost: Pick<GovernedActionHost, \"submitAction\">;\n\tprojectionHost: Pick<ProjectionHost, \"project\">;\n\tevidence: InitiatingReleaseEvidence;\n\tassemblyDigest: string;\n\tactionVersion(actionId: `${string}.${string}`): number | undefined;\n\tnextCorrelationId(): string;\n\t/**\n\t * How old an offline capture may be and still replay.\n\t *\n\t * Required to accept replay at all. The Host's own staleness check is\n\t * conditional on the capture-authority adapter supplying an `expiresAt`, and\n\t * is skipped entirely for actions governed at capture time, so without this\n\t * a client-reported instant has no lower bound.\n\t */\n\tmaxOfflineReplayAgeMs?: number;\n\tcaptureAuthority?(input: {\n\t\tactionId: `${string}.${string}`;\n\t\tscope: ExperienceScope;\n\t\tparameters: Record<string, unknown>;\n\t\tparameterDigest: string;\n\t\tcapturedAt: string;\n\t}): Promise<{ id: string; expiresAt?: string }>;\n\tnow?: () => Date;\n}): ExperienceGateway {\n\tconst now = options.now ?? (() => new Date());\n\treturn {\n\t\tasync invoke(input) {\n\t\t\tconst claims = await options.identity.verify(input.context.credential);\n\t\t\tif (!claims) throw new Error(\"Experience request credential is not authenticated.\");\n\t\t\tassertActorClaimsCoverScope(claims, {\n\t\t\t\ttenantId: input.context.tenantId,\n\t\t\t\tspaceId: input.context.spaceId,\n\t\t\t}, now());\n\t\t\tconst scope: ExperienceScope = {\n\t\t\t\ttenantId: input.context.tenantId,\n\t\t\t\tspaceId: input.context.spaceId,\n\t\t\t\tactorId: claims.subject,\n\t\t\t\tactorType: claims.actorType,\n\t\t\t};\n\t\t\tconst resolved = await options.runtime.resolveIntent({\n\t\t\t\tplanId: input.request.planId,\n\t\t\t\tfragmentId: input.request.fragmentId,\n\t\t\t\teventName: input.request.eventName,\n\t\t\t\tscope,\n\t\t\t});\n\t\t\tif (resolved.assemblyDigest !== options.assemblyDigest) {\n\t\t\t\tthrow new Error(\"Experience render plan belongs to a different application assembly.\");\n\t\t\t}\n\t\t\tif (options.actionVersion(resolved.actionId) !== resolved.actionVersion) {\n\t\t\t\tthrow new Error(`Experience intent route for \"${resolved.actionId}\" does not match the registered action version.`);\n\t\t\t}\n\t\t\tif (resolved.execution?.idempotency === \"required\" && !input.request.idempotencyKey) {\n\t\t\t\tthrow new Error(`Action \"${resolved.actionId}\" requires an idempotency key.`);\n\t\t\t}\n\t\t\tif (input.executionReason === \"offline_replay\" && resolved.execution?.connectivity !== \"offline-capturable\") {\n\t\t\t\tthrow new Error(`Action \"${resolved.actionId}\" is not eligible for offline replay.`);\n\t\t\t}\n\t\t\tconst correlationId = options.nextCorrelationId();\n\t\t\t// A replay attests when the operator acted, not when the network came\n\t\t\t// back. Stamping receipt time would make capture-authority evidence\n\t\t\t// describe a moment nobody was there for.\n\t\t\tlet capturedAt: string;\n\t\t\tif (input.executionReason === \"offline_replay\") {\n\t\t\t\t// Accept it from either position, but require it: a replay with no\n\t\t\t\t// capture moment cannot be evidenced, and defaulting to receipt\n\t\t\t\t// time would attest a moment nobody was there for.\n\t\t\t\tconst supplied = input.capturedAt\n\t\t\t\t\t?? (input.request as Partial<{ capturedAt: string }>).capturedAt;\n\t\t\t\tif (typeof supplied !== \"string\") {\n\t\t\t\t\tthrow new Error(\"Offline replay requires capturedAt; receipt time cannot attest when the operator acted.\");\n\t\t\t\t}\n\t\t\t\tcapturedAt = assertCaptureMoment(supplied, now(), options.maxOfflineReplayAgeMs);\n\t\t\t} else {\n\t\t\t\tcapturedAt = now().toISOString();\n\t\t\t}\n\t\t\tconst parameterDigest = digestParameters(input.request.parameters);\n\t\t\tconst capturedAuthority = resolved.execution?.authorityMoment === \"capture\"\n\t\t\t\t? await options.captureAuthority?.({\n\t\t\t\t\tactionId: resolved.actionId,\n\t\t\t\t\tscope,\n\t\t\t\t\tparameters: input.request.parameters,\n\t\t\t\t\tparameterDigest,\n\t\t\t\t\tcapturedAt,\n\t\t\t\t})\n\t\t\t\t: undefined;\n\t\t\tif (resolved.execution?.authorityMoment === \"capture\" && !capturedAuthority) {\n\t\t\t\tthrow new Error(`Action \"${resolved.actionId}\" requires a trusted capture-authority adapter.`);\n\t\t\t}\n\t\t\tconst submission: SubmitActionInput = {\n\t\t\t\tactionId: resolved.actionId,\n\t\t\t\tparameters: input.request.parameters,\n\t\t\t\ttenantId: scope.tenantId,\n\t\t\t\tspaceId: scope.spaceId,\n\t\t\t\tactorId: scope.actorId,\n\t\t\t\tactorType: claims.actorType,\n\t\t\t\tcorrelationId,\n\t\t\t\tprovenance: {\n\t\t\t\t\tsource: \"ui\",\n\t\t\t\t\tcorrelationId,\n\t\t\t\t\t...(resolved.execution?.sensitivity === \"restricted\"\n\t\t\t\t\t\t? {}\n\t\t\t\t\t\t: { auditAttributes: { \"fabric.experience.fragment\": input.request.fragmentId } }),\n\t\t\t\t},\n\t\t\t\t...(input.request.idempotencyKey === undefined ? {} : { idempotencyKey: input.request.idempotencyKey }),\n\t\t\t\t...(input.executionReason === undefined ? {} : { executionReason: input.executionReason }),\n\t\t\t\t...(capturedAuthority === undefined ? {} : {\n\t\t\t\t\tauthorizationBindingId: capturedAuthority.id,\n\t\t\t\t\tauthorizationBinding: {\n\t\t\t\t\t\tid: capturedAuthority.id,\n\t\t\t\t\t\tgoverningMoment: \"capture\",\n\t\t\t\t\t\tcapturedAt,\n\t\t\t\t\t\t...(capturedAuthority.expiresAt === undefined ? {} : { expiresAt: capturedAuthority.expiresAt }),\n\t\t\t\t\t\ttenantId: scope.tenantId,\n\t\t\t\t\t\tactorId: scope.actorId,\n\t\t\t\t\t\tactionId: resolved.actionId,\n\t\t\t\t\t\tparameterDigest,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t};\n\t\t\toptions.evidence.bind(submission, resolved.initiatingReleaseDigest);\n\t\t\ttry {\n\t\t\t\treturn await options.host.submitAction(submission);\n\t\t\t} finally {\n\t\t\t\toptions.evidence.release(submission);\n\t\t\t}\n\t\t},\n\t\tasync read(input) {\n\t\t\tconst claims = await options.identity.verify(input.context.credential);\n\t\t\tif (!claims) throw new Error(\"Experience request credential is not authenticated.\");\n\t\t\tassertActorClaimsCoverScope(claims, {\n\t\t\t\ttenantId: input.context.tenantId,\n\t\t\t\tspaceId: input.context.spaceId,\n\t\t\t}, now());\n\t\t\tconst scope: ExperienceScope = {\n\t\t\t\ttenantId: input.context.tenantId,\n\t\t\t\tspaceId: input.context.spaceId,\n\t\t\t\tactorId: claims.subject,\n\t\t\t\tactorType: claims.actorType,\n\t\t\t};\n\t\t\tconst resolved = await options.runtime.resolveView({\n\t\t\t\tplanId: input.request.planId,\n\t\t\t\tfragmentId: input.request.fragmentId,\n\t\t\t\tbindingName: input.request.bindingName,\n\t\t\t\tscope,\n\t\t\t});\n\t\t\tif (resolved.assemblyDigest !== options.assemblyDigest) {\n\t\t\t\tthrow new Error(\"Experience render plan belongs to a different application assembly.\");\n\t\t\t}\n\t\t\tconst result = await options.projectionHost.project({\n\t\t\t\tview: { name: resolved.name, version: resolved.version },\n\t\t\t\tscope: { tenantId: scope.tenantId, spaceId: scope.spaceId },\n\t\t\t\tactor: { id: scope.actorId, type: claims.actorType },\n\t\t\t\tparameters: input.request.parameters,\n\t\t\t});\n\t\t\treturn {\n\t\t\t\tdata: result.state,\n\t\t\t\tevidence: {\n\t\t\t\t\treleaseDigest: resolved.initiatingReleaseDigest,\n\t\t\t\t\tassemblyDigest: resolved.assemblyDigest,\n\t\t\t\t\teventCursor: result.eventCursor,\n\t\t\t\t\tsource: result.source,\n\t\t\t\t},\n\t\t\t};\n\t\t},\n\t};\n}\n\n/** RFC 3339 with a mandatory offset, so an instant means the same thing everywhere. */\nconst RFC3339 = /^\\d{4}-\\d{2}-\\d{2}[Tt]\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:[Zz]|[+-]\\d{2}:\\d{2})$/;\n\n/**\n * A supplied capture moment is client-reported, so it is bounded here rather\n * than trusted.\n *\n * Deferring the lower bound to the Host looked safe and is not: the Host checks\n * staleness only when the capture-authority adapter supplied an `expiresAt`,\n * and skips the execution-time expiry check entirely when the action's\n * authority moment is `capture`. With an adapter that omits `expiresAt` there\n * is no lower bound at all, and the value is written into durable evidence and\n * handed to the authority adapter regardless. A gateway that accepts replay\n * must therefore say how old a capture may be.\n */\nfunction assertCaptureMoment(capturedAt: string, now: Date, maxAgeMs: number | undefined): string {\n\tif (!RFC3339.test(capturedAt)) {\n\t\tthrow new Error(\"Offline replay capturedAt must be an RFC 3339 instant with an explicit offset.\");\n\t}\n\tconst parsed = Date.parse(capturedAt);\n\tif (Number.isNaN(parsed)) throw new Error(\"Offline replay capturedAt is not a parseable instant.\");\n\tif (parsed > now.getTime()) throw new Error(\"Offline replay capturedAt is in the future.\");\n\tif (maxAgeMs === undefined) {\n\t\tthrow new Error(\"Gateway accepts offline replay but declares no maxOfflineReplayAgeMs, so a capture has no lower bound.\");\n\t}\n\tif (now.getTime() - parsed > maxAgeMs) {\n\t\tthrow new Error(`Offline replay capturedAt is older than the ${maxAgeMs}ms this gateway accepts.`);\n\t}\n\treturn new Date(parsed).toISOString();\n}\n"],"mappings":"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","names":[]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { SubmitActionResult, SubmitActionInput, GovernedActionHost } from '@fabricorg/platform-host';
|
|
2
|
+
import { ProjectionHost } from '@fabricorg/projection-host';
|
|
3
|
+
import { IdentityPort } from '@fabricorg/ports';
|
|
4
|
+
import { ExperienceIntentRequest, ExperienceReadRequest } from '@fabricorg/experience-runtime';
|
|
5
|
+
import { ExperienceServerRuntime, ExperienceScope } from '@fabricorg/experience-runtime/server';
|
|
6
|
+
|
|
7
|
+
interface TrustedExperienceRequestContext {
|
|
8
|
+
/** Presented only to IdentityPort and never persisted or forwarded. */
|
|
9
|
+
credential: string;
|
|
10
|
+
tenantId: string;
|
|
11
|
+
spaceId: string;
|
|
12
|
+
}
|
|
13
|
+
interface ExperienceGateway {
|
|
14
|
+
invoke(input: {
|
|
15
|
+
context: TrustedExperienceRequestContext;
|
|
16
|
+
request: ExperienceIntentRequest;
|
|
17
|
+
executionReason?: "initial" | "offline_replay";
|
|
18
|
+
/**
|
|
19
|
+
* When the operator acted, for a replay. Required whenever
|
|
20
|
+
* `executionReason` is `offline_replay`: reading it through an untyped
|
|
21
|
+
* cast made omitting it the easy mistake, and omitting it silently
|
|
22
|
+
* stamped receipt time into durable capture evidence.
|
|
23
|
+
*/
|
|
24
|
+
capturedAt?: string;
|
|
25
|
+
}): Promise<SubmitActionResult>;
|
|
26
|
+
read(input: {
|
|
27
|
+
context: TrustedExperienceRequestContext;
|
|
28
|
+
request: ExperienceReadRequest;
|
|
29
|
+
}): Promise<{
|
|
30
|
+
data: unknown;
|
|
31
|
+
evidence: {
|
|
32
|
+
releaseDigest: string;
|
|
33
|
+
assemblyDigest: string;
|
|
34
|
+
eventCursor: string | null;
|
|
35
|
+
source: "cache" | "replay";
|
|
36
|
+
};
|
|
37
|
+
}>;
|
|
38
|
+
}
|
|
39
|
+
interface ExperienceGatewayConformanceSubject {
|
|
40
|
+
gateway: ExperienceGateway;
|
|
41
|
+
validIntent: Parameters<ExperienceGateway["invoke"]>[0];
|
|
42
|
+
deniedIntent: Parameters<ExperienceGateway["invoke"]>[0];
|
|
43
|
+
validRead: Parameters<ExperienceGateway["read"]>[0];
|
|
44
|
+
/**
|
|
45
|
+
* What the governed hosts actually saw.
|
|
46
|
+
*
|
|
47
|
+
* A returned invocation id proves only that a string was produced. The
|
|
48
|
+
* point of a gateway is that mutations reach `PlatformHost.submitAction` and
|
|
49
|
+
* reads reach `ProjectionHost.project`; a replacement that fabricates an id
|
|
50
|
+
* and queries the database directly satisfies every observable contract
|
|
51
|
+
* except this one, so the subject has to report the calls.
|
|
52
|
+
*/
|
|
53
|
+
observed: {
|
|
54
|
+
submitActionCount(): number;
|
|
55
|
+
projectionCount(): number;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
interface ExperienceGatewayCheck {
|
|
59
|
+
id: "fabric.experience.gateway-host-routing.v1" | "fabric.experience.gateway-scope-denial.v1" | "fabric.experience.gateway-projection-routing.v1";
|
|
60
|
+
run(subject: ExperienceGatewayConformanceSubject): Promise<string[]>;
|
|
61
|
+
}
|
|
62
|
+
declare function experienceGatewayChecks(): readonly ExperienceGatewayCheck[];
|
|
63
|
+
declare function runExperienceGatewayChecks(subject: ExperienceGatewayConformanceSubject, checks?: readonly ExperienceGatewayCheck[]): Promise<{
|
|
64
|
+
passed: boolean;
|
|
65
|
+
checks: Array<{
|
|
66
|
+
id: ExperienceGatewayCheck["id"];
|
|
67
|
+
status: "passed" | "failed";
|
|
68
|
+
evidence: string[];
|
|
69
|
+
error?: string;
|
|
70
|
+
}>;
|
|
71
|
+
}>;
|
|
72
|
+
interface InitiatingReleaseEvidence {
|
|
73
|
+
bind(input: SubmitActionInput, releaseDigest: string): void;
|
|
74
|
+
resolve(input: SubmitActionInput): string | undefined;
|
|
75
|
+
release(input: SubmitActionInput): void;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Creates an instance-scoped, non-serializable evidence seam. The Host's
|
|
79
|
+
* `composition.resolveInitiatingReleaseDigest` reads this WeakMap using the
|
|
80
|
+
* exact submission object. A browser field cannot populate or forge it.
|
|
81
|
+
*/
|
|
82
|
+
declare function createInitiatingReleaseEvidence(): InitiatingReleaseEvidence;
|
|
83
|
+
declare function createExperienceGateway(options: {
|
|
84
|
+
identity: IdentityPort;
|
|
85
|
+
runtime: ExperienceServerRuntime;
|
|
86
|
+
host: Pick<GovernedActionHost, "submitAction">;
|
|
87
|
+
projectionHost: Pick<ProjectionHost, "project">;
|
|
88
|
+
evidence: InitiatingReleaseEvidence;
|
|
89
|
+
assemblyDigest: string;
|
|
90
|
+
actionVersion(actionId: `${string}.${string}`): number | undefined;
|
|
91
|
+
nextCorrelationId(): string;
|
|
92
|
+
/**
|
|
93
|
+
* How old an offline capture may be and still replay.
|
|
94
|
+
*
|
|
95
|
+
* Required to accept replay at all. The Host's own staleness check is
|
|
96
|
+
* conditional on the capture-authority adapter supplying an `expiresAt`, and
|
|
97
|
+
* is skipped entirely for actions governed at capture time, so without this
|
|
98
|
+
* a client-reported instant has no lower bound.
|
|
99
|
+
*/
|
|
100
|
+
maxOfflineReplayAgeMs?: number;
|
|
101
|
+
captureAuthority?(input: {
|
|
102
|
+
actionId: `${string}.${string}`;
|
|
103
|
+
scope: ExperienceScope;
|
|
104
|
+
parameters: Record<string, unknown>;
|
|
105
|
+
parameterDigest: string;
|
|
106
|
+
capturedAt: string;
|
|
107
|
+
}): Promise<{
|
|
108
|
+
id: string;
|
|
109
|
+
expiresAt?: string;
|
|
110
|
+
}>;
|
|
111
|
+
now?: () => Date;
|
|
112
|
+
}): ExperienceGateway;
|
|
113
|
+
|
|
114
|
+
export { type ExperienceGateway, type ExperienceGatewayCheck, type ExperienceGatewayConformanceSubject, type InitiatingReleaseEvidence, type TrustedExperienceRequestContext, createExperienceGateway, createInitiatingReleaseEvidence, experienceGatewayChecks, runExperienceGatewayChecks };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { SubmitActionResult, SubmitActionInput, GovernedActionHost } from '@fabricorg/platform-host';
|
|
2
|
+
import { ProjectionHost } from '@fabricorg/projection-host';
|
|
3
|
+
import { IdentityPort } from '@fabricorg/ports';
|
|
4
|
+
import { ExperienceIntentRequest, ExperienceReadRequest } from '@fabricorg/experience-runtime';
|
|
5
|
+
import { ExperienceServerRuntime, ExperienceScope } from '@fabricorg/experience-runtime/server';
|
|
6
|
+
|
|
7
|
+
interface TrustedExperienceRequestContext {
|
|
8
|
+
/** Presented only to IdentityPort and never persisted or forwarded. */
|
|
9
|
+
credential: string;
|
|
10
|
+
tenantId: string;
|
|
11
|
+
spaceId: string;
|
|
12
|
+
}
|
|
13
|
+
interface ExperienceGateway {
|
|
14
|
+
invoke(input: {
|
|
15
|
+
context: TrustedExperienceRequestContext;
|
|
16
|
+
request: ExperienceIntentRequest;
|
|
17
|
+
executionReason?: "initial" | "offline_replay";
|
|
18
|
+
/**
|
|
19
|
+
* When the operator acted, for a replay. Required whenever
|
|
20
|
+
* `executionReason` is `offline_replay`: reading it through an untyped
|
|
21
|
+
* cast made omitting it the easy mistake, and omitting it silently
|
|
22
|
+
* stamped receipt time into durable capture evidence.
|
|
23
|
+
*/
|
|
24
|
+
capturedAt?: string;
|
|
25
|
+
}): Promise<SubmitActionResult>;
|
|
26
|
+
read(input: {
|
|
27
|
+
context: TrustedExperienceRequestContext;
|
|
28
|
+
request: ExperienceReadRequest;
|
|
29
|
+
}): Promise<{
|
|
30
|
+
data: unknown;
|
|
31
|
+
evidence: {
|
|
32
|
+
releaseDigest: string;
|
|
33
|
+
assemblyDigest: string;
|
|
34
|
+
eventCursor: string | null;
|
|
35
|
+
source: "cache" | "replay";
|
|
36
|
+
};
|
|
37
|
+
}>;
|
|
38
|
+
}
|
|
39
|
+
interface ExperienceGatewayConformanceSubject {
|
|
40
|
+
gateway: ExperienceGateway;
|
|
41
|
+
validIntent: Parameters<ExperienceGateway["invoke"]>[0];
|
|
42
|
+
deniedIntent: Parameters<ExperienceGateway["invoke"]>[0];
|
|
43
|
+
validRead: Parameters<ExperienceGateway["read"]>[0];
|
|
44
|
+
/**
|
|
45
|
+
* What the governed hosts actually saw.
|
|
46
|
+
*
|
|
47
|
+
* A returned invocation id proves only that a string was produced. The
|
|
48
|
+
* point of a gateway is that mutations reach `PlatformHost.submitAction` and
|
|
49
|
+
* reads reach `ProjectionHost.project`; a replacement that fabricates an id
|
|
50
|
+
* and queries the database directly satisfies every observable contract
|
|
51
|
+
* except this one, so the subject has to report the calls.
|
|
52
|
+
*/
|
|
53
|
+
observed: {
|
|
54
|
+
submitActionCount(): number;
|
|
55
|
+
projectionCount(): number;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
interface ExperienceGatewayCheck {
|
|
59
|
+
id: "fabric.experience.gateway-host-routing.v1" | "fabric.experience.gateway-scope-denial.v1" | "fabric.experience.gateway-projection-routing.v1";
|
|
60
|
+
run(subject: ExperienceGatewayConformanceSubject): Promise<string[]>;
|
|
61
|
+
}
|
|
62
|
+
declare function experienceGatewayChecks(): readonly ExperienceGatewayCheck[];
|
|
63
|
+
declare function runExperienceGatewayChecks(subject: ExperienceGatewayConformanceSubject, checks?: readonly ExperienceGatewayCheck[]): Promise<{
|
|
64
|
+
passed: boolean;
|
|
65
|
+
checks: Array<{
|
|
66
|
+
id: ExperienceGatewayCheck["id"];
|
|
67
|
+
status: "passed" | "failed";
|
|
68
|
+
evidence: string[];
|
|
69
|
+
error?: string;
|
|
70
|
+
}>;
|
|
71
|
+
}>;
|
|
72
|
+
interface InitiatingReleaseEvidence {
|
|
73
|
+
bind(input: SubmitActionInput, releaseDigest: string): void;
|
|
74
|
+
resolve(input: SubmitActionInput): string | undefined;
|
|
75
|
+
release(input: SubmitActionInput): void;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Creates an instance-scoped, non-serializable evidence seam. The Host's
|
|
79
|
+
* `composition.resolveInitiatingReleaseDigest` reads this WeakMap using the
|
|
80
|
+
* exact submission object. A browser field cannot populate or forge it.
|
|
81
|
+
*/
|
|
82
|
+
declare function createInitiatingReleaseEvidence(): InitiatingReleaseEvidence;
|
|
83
|
+
declare function createExperienceGateway(options: {
|
|
84
|
+
identity: IdentityPort;
|
|
85
|
+
runtime: ExperienceServerRuntime;
|
|
86
|
+
host: Pick<GovernedActionHost, "submitAction">;
|
|
87
|
+
projectionHost: Pick<ProjectionHost, "project">;
|
|
88
|
+
evidence: InitiatingReleaseEvidence;
|
|
89
|
+
assemblyDigest: string;
|
|
90
|
+
actionVersion(actionId: `${string}.${string}`): number | undefined;
|
|
91
|
+
nextCorrelationId(): string;
|
|
92
|
+
/**
|
|
93
|
+
* How old an offline capture may be and still replay.
|
|
94
|
+
*
|
|
95
|
+
* Required to accept replay at all. The Host's own staleness check is
|
|
96
|
+
* conditional on the capture-authority adapter supplying an `expiresAt`, and
|
|
97
|
+
* is skipped entirely for actions governed at capture time, so without this
|
|
98
|
+
* a client-reported instant has no lower bound.
|
|
99
|
+
*/
|
|
100
|
+
maxOfflineReplayAgeMs?: number;
|
|
101
|
+
captureAuthority?(input: {
|
|
102
|
+
actionId: `${string}.${string}`;
|
|
103
|
+
scope: ExperienceScope;
|
|
104
|
+
parameters: Record<string, unknown>;
|
|
105
|
+
parameterDigest: string;
|
|
106
|
+
capturedAt: string;
|
|
107
|
+
}): Promise<{
|
|
108
|
+
id: string;
|
|
109
|
+
expiresAt?: string;
|
|
110
|
+
}>;
|
|
111
|
+
now?: () => Date;
|
|
112
|
+
}): ExperienceGateway;
|
|
113
|
+
|
|
114
|
+
export { type ExperienceGateway, type ExperienceGatewayCheck, type ExperienceGatewayConformanceSubject, type InitiatingReleaseEvidence, type TrustedExperienceRequestContext, createExperienceGateway, createInitiatingReleaseEvidence, experienceGatewayChecks, runExperienceGatewayChecks };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// index.ts
|
|
2
|
+
import { digestParameters } from "@fabricorg/platform-host";
|
|
3
|
+
import {
|
|
4
|
+
assertActorClaimsCoverScope
|
|
5
|
+
} from "@fabricorg/ports";
|
|
6
|
+
function experienceGatewayChecks() {
|
|
7
|
+
return [
|
|
8
|
+
{
|
|
9
|
+
id: "fabric.experience.gateway-host-routing.v1",
|
|
10
|
+
async run(subject) {
|
|
11
|
+
const before = subject.observed.submitActionCount();
|
|
12
|
+
const result = await subject.gateway.invoke(subject.validIntent);
|
|
13
|
+
if (!result.actionInvocationId) throw new Error("Gateway did not return a governed Host invocation.");
|
|
14
|
+
const submitted = subject.observed.submitActionCount() - before;
|
|
15
|
+
if (submitted !== 1) {
|
|
16
|
+
throw new Error(
|
|
17
|
+
`Gateway returned an invocation id after ${submitted} calls to PlatformHost.submitAction; a mutation must reach the Host exactly once.`
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
return [result.actionInvocationId];
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: "fabric.experience.gateway-projection-routing.v1",
|
|
25
|
+
async run(subject) {
|
|
26
|
+
const before = subject.observed.projectionCount();
|
|
27
|
+
await subject.gateway.read(subject.validRead);
|
|
28
|
+
const projected = subject.observed.projectionCount() - before;
|
|
29
|
+
if (projected !== 1) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`Gateway served a read after ${projected} calls to ProjectionHost.project; a read must go through the projection host.`
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
return ["read routed through ProjectionHost"];
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: "fabric.experience.gateway-scope-denial.v1",
|
|
39
|
+
async run(subject) {
|
|
40
|
+
let rejected = false;
|
|
41
|
+
try {
|
|
42
|
+
await subject.gateway.invoke(subject.deniedIntent);
|
|
43
|
+
} catch {
|
|
44
|
+
rejected = true;
|
|
45
|
+
}
|
|
46
|
+
if (!rejected) throw new Error("Gateway accepted an intent outside trusted identity scope.");
|
|
47
|
+
return ["out-of-scope intent rejected"];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
];
|
|
51
|
+
}
|
|
52
|
+
async function runExperienceGatewayChecks(subject, checks = experienceGatewayChecks()) {
|
|
53
|
+
const results = [];
|
|
54
|
+
for (const check of checks) {
|
|
55
|
+
try {
|
|
56
|
+
results.push({ id: check.id, status: "passed", evidence: await check.run(subject) });
|
|
57
|
+
} catch (error) {
|
|
58
|
+
results.push({ id: check.id, status: "failed", evidence: [], error: error instanceof Error ? error.message : String(error) });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return { passed: results.every((result) => result.status === "passed"), checks: results };
|
|
62
|
+
}
|
|
63
|
+
function createInitiatingReleaseEvidence() {
|
|
64
|
+
const evidence = /* @__PURE__ */ new WeakMap();
|
|
65
|
+
return {
|
|
66
|
+
bind(input, releaseDigest) {
|
|
67
|
+
evidence.set(input, releaseDigest);
|
|
68
|
+
},
|
|
69
|
+
resolve(input) {
|
|
70
|
+
return evidence.get(input);
|
|
71
|
+
},
|
|
72
|
+
release(input) {
|
|
73
|
+
evidence.delete(input);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function createExperienceGateway(options) {
|
|
78
|
+
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
79
|
+
return {
|
|
80
|
+
async invoke(input) {
|
|
81
|
+
const claims = await options.identity.verify(input.context.credential);
|
|
82
|
+
if (!claims) throw new Error("Experience request credential is not authenticated.");
|
|
83
|
+
assertActorClaimsCoverScope(claims, {
|
|
84
|
+
tenantId: input.context.tenantId,
|
|
85
|
+
spaceId: input.context.spaceId
|
|
86
|
+
}, now());
|
|
87
|
+
const scope = {
|
|
88
|
+
tenantId: input.context.tenantId,
|
|
89
|
+
spaceId: input.context.spaceId,
|
|
90
|
+
actorId: claims.subject,
|
|
91
|
+
actorType: claims.actorType
|
|
92
|
+
};
|
|
93
|
+
const resolved = await options.runtime.resolveIntent({
|
|
94
|
+
planId: input.request.planId,
|
|
95
|
+
fragmentId: input.request.fragmentId,
|
|
96
|
+
eventName: input.request.eventName,
|
|
97
|
+
scope
|
|
98
|
+
});
|
|
99
|
+
if (resolved.assemblyDigest !== options.assemblyDigest) {
|
|
100
|
+
throw new Error("Experience render plan belongs to a different application assembly.");
|
|
101
|
+
}
|
|
102
|
+
if (options.actionVersion(resolved.actionId) !== resolved.actionVersion) {
|
|
103
|
+
throw new Error(`Experience intent route for "${resolved.actionId}" does not match the registered action version.`);
|
|
104
|
+
}
|
|
105
|
+
if (resolved.execution?.idempotency === "required" && !input.request.idempotencyKey) {
|
|
106
|
+
throw new Error(`Action "${resolved.actionId}" requires an idempotency key.`);
|
|
107
|
+
}
|
|
108
|
+
if (input.executionReason === "offline_replay" && resolved.execution?.connectivity !== "offline-capturable") {
|
|
109
|
+
throw new Error(`Action "${resolved.actionId}" is not eligible for offline replay.`);
|
|
110
|
+
}
|
|
111
|
+
const correlationId = options.nextCorrelationId();
|
|
112
|
+
let capturedAt;
|
|
113
|
+
if (input.executionReason === "offline_replay") {
|
|
114
|
+
const supplied = input.capturedAt ?? input.request.capturedAt;
|
|
115
|
+
if (typeof supplied !== "string") {
|
|
116
|
+
throw new Error("Offline replay requires capturedAt; receipt time cannot attest when the operator acted.");
|
|
117
|
+
}
|
|
118
|
+
capturedAt = assertCaptureMoment(supplied, now(), options.maxOfflineReplayAgeMs);
|
|
119
|
+
} else {
|
|
120
|
+
capturedAt = now().toISOString();
|
|
121
|
+
}
|
|
122
|
+
const parameterDigest = digestParameters(input.request.parameters);
|
|
123
|
+
const capturedAuthority = resolved.execution?.authorityMoment === "capture" ? await options.captureAuthority?.({
|
|
124
|
+
actionId: resolved.actionId,
|
|
125
|
+
scope,
|
|
126
|
+
parameters: input.request.parameters,
|
|
127
|
+
parameterDigest,
|
|
128
|
+
capturedAt
|
|
129
|
+
}) : void 0;
|
|
130
|
+
if (resolved.execution?.authorityMoment === "capture" && !capturedAuthority) {
|
|
131
|
+
throw new Error(`Action "${resolved.actionId}" requires a trusted capture-authority adapter.`);
|
|
132
|
+
}
|
|
133
|
+
const submission = {
|
|
134
|
+
actionId: resolved.actionId,
|
|
135
|
+
parameters: input.request.parameters,
|
|
136
|
+
tenantId: scope.tenantId,
|
|
137
|
+
spaceId: scope.spaceId,
|
|
138
|
+
actorId: scope.actorId,
|
|
139
|
+
actorType: claims.actorType,
|
|
140
|
+
correlationId,
|
|
141
|
+
provenance: {
|
|
142
|
+
source: "ui",
|
|
143
|
+
correlationId,
|
|
144
|
+
...resolved.execution?.sensitivity === "restricted" ? {} : { auditAttributes: { "fabric.experience.fragment": input.request.fragmentId } }
|
|
145
|
+
},
|
|
146
|
+
...input.request.idempotencyKey === void 0 ? {} : { idempotencyKey: input.request.idempotencyKey },
|
|
147
|
+
...input.executionReason === void 0 ? {} : { executionReason: input.executionReason },
|
|
148
|
+
...capturedAuthority === void 0 ? {} : {
|
|
149
|
+
authorizationBindingId: capturedAuthority.id,
|
|
150
|
+
authorizationBinding: {
|
|
151
|
+
id: capturedAuthority.id,
|
|
152
|
+
governingMoment: "capture",
|
|
153
|
+
capturedAt,
|
|
154
|
+
...capturedAuthority.expiresAt === void 0 ? {} : { expiresAt: capturedAuthority.expiresAt },
|
|
155
|
+
tenantId: scope.tenantId,
|
|
156
|
+
actorId: scope.actorId,
|
|
157
|
+
actionId: resolved.actionId,
|
|
158
|
+
parameterDigest
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
options.evidence.bind(submission, resolved.initiatingReleaseDigest);
|
|
163
|
+
try {
|
|
164
|
+
return await options.host.submitAction(submission);
|
|
165
|
+
} finally {
|
|
166
|
+
options.evidence.release(submission);
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
async read(input) {
|
|
170
|
+
const claims = await options.identity.verify(input.context.credential);
|
|
171
|
+
if (!claims) throw new Error("Experience request credential is not authenticated.");
|
|
172
|
+
assertActorClaimsCoverScope(claims, {
|
|
173
|
+
tenantId: input.context.tenantId,
|
|
174
|
+
spaceId: input.context.spaceId
|
|
175
|
+
}, now());
|
|
176
|
+
const scope = {
|
|
177
|
+
tenantId: input.context.tenantId,
|
|
178
|
+
spaceId: input.context.spaceId,
|
|
179
|
+
actorId: claims.subject,
|
|
180
|
+
actorType: claims.actorType
|
|
181
|
+
};
|
|
182
|
+
const resolved = await options.runtime.resolveView({
|
|
183
|
+
planId: input.request.planId,
|
|
184
|
+
fragmentId: input.request.fragmentId,
|
|
185
|
+
bindingName: input.request.bindingName,
|
|
186
|
+
scope
|
|
187
|
+
});
|
|
188
|
+
if (resolved.assemblyDigest !== options.assemblyDigest) {
|
|
189
|
+
throw new Error("Experience render plan belongs to a different application assembly.");
|
|
190
|
+
}
|
|
191
|
+
const result = await options.projectionHost.project({
|
|
192
|
+
view: { name: resolved.name, version: resolved.version },
|
|
193
|
+
scope: { tenantId: scope.tenantId, spaceId: scope.spaceId },
|
|
194
|
+
actor: { id: scope.actorId, type: claims.actorType },
|
|
195
|
+
parameters: input.request.parameters
|
|
196
|
+
});
|
|
197
|
+
return {
|
|
198
|
+
data: result.state,
|
|
199
|
+
evidence: {
|
|
200
|
+
releaseDigest: resolved.initiatingReleaseDigest,
|
|
201
|
+
assemblyDigest: resolved.assemblyDigest,
|
|
202
|
+
eventCursor: result.eventCursor,
|
|
203
|
+
source: result.source
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
var RFC3339 = /^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})$/;
|
|
210
|
+
function assertCaptureMoment(capturedAt, now, maxAgeMs) {
|
|
211
|
+
if (!RFC3339.test(capturedAt)) {
|
|
212
|
+
throw new Error("Offline replay capturedAt must be an RFC 3339 instant with an explicit offset.");
|
|
213
|
+
}
|
|
214
|
+
const parsed = Date.parse(capturedAt);
|
|
215
|
+
if (Number.isNaN(parsed)) throw new Error("Offline replay capturedAt is not a parseable instant.");
|
|
216
|
+
if (parsed > now.getTime()) throw new Error("Offline replay capturedAt is in the future.");
|
|
217
|
+
if (maxAgeMs === void 0) {
|
|
218
|
+
throw new Error("Gateway accepts offline replay but declares no maxOfflineReplayAgeMs, so a capture has no lower bound.");
|
|
219
|
+
}
|
|
220
|
+
if (now.getTime() - parsed > maxAgeMs) {
|
|
221
|
+
throw new Error(`Offline replay capturedAt is older than the ${maxAgeMs}ms this gateway accepts.`);
|
|
222
|
+
}
|
|
223
|
+
return new Date(parsed).toISOString();
|
|
224
|
+
}
|
|
225
|
+
export {
|
|
226
|
+
createExperienceGateway,
|
|
227
|
+
createInitiatingReleaseEvidence,
|
|
228
|
+
experienceGatewayChecks,
|
|
229
|
+
runExperienceGatewayChecks
|
|
230
|
+
};
|
|
231
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../index.ts"],"sourcesContent":["import type {\n\tGovernedActionHost,\n\tSubmitActionInput,\n\tSubmitActionResult,\n} from \"@fabricorg/platform-host\";\nimport { digestParameters } from \"@fabricorg/platform-host\";\nimport type { ProjectionHost } from \"@fabricorg/projection-host\";\nimport {\n\tassertActorClaimsCoverScope,\n\ttype IdentityPort,\n} from \"@fabricorg/ports\";\nimport type { ExperienceIntentRequest, ExperienceReadRequest } from \"@fabricorg/experience-runtime\";\nimport type {\n\tExperienceScope,\n\tExperienceServerRuntime,\n} from \"@fabricorg/experience-runtime/server\";\n\nexport interface TrustedExperienceRequestContext {\n\t/** Presented only to IdentityPort and never persisted or forwarded. */\n\tcredential: string;\n\ttenantId: string;\n\tspaceId: string;\n}\n\nexport interface ExperienceGateway {\n\tinvoke(input: {\n\t\tcontext: TrustedExperienceRequestContext;\n\t\trequest: ExperienceIntentRequest;\n\t\texecutionReason?: \"initial\" | \"offline_replay\";\n\t\t/**\n\t\t * When the operator acted, for a replay. Required whenever\n\t\t * `executionReason` is `offline_replay`: reading it through an untyped\n\t\t * cast made omitting it the easy mistake, and omitting it silently\n\t\t * stamped receipt time into durable capture evidence.\n\t\t */\n\t\tcapturedAt?: string;\n\t}): Promise<SubmitActionResult>;\n\tread(input: {\n\t\tcontext: TrustedExperienceRequestContext;\n\t\trequest: ExperienceReadRequest;\n\t}): Promise<{\n\t\tdata: unknown;\n\t\tevidence: {\n\t\t\treleaseDigest: string;\n\t\t\tassemblyDigest: string;\n\t\t\teventCursor: string | null;\n\t\t\tsource: \"cache\" | \"replay\";\n\t\t};\n\t}>;\n}\n\nexport interface ExperienceGatewayConformanceSubject {\n\tgateway: ExperienceGateway;\n\tvalidIntent: Parameters<ExperienceGateway[\"invoke\"]>[0];\n\tdeniedIntent: Parameters<ExperienceGateway[\"invoke\"]>[0];\n\tvalidRead: Parameters<ExperienceGateway[\"read\"]>[0];\n\t/**\n\t * What the governed hosts actually saw.\n\t *\n\t * A returned invocation id proves only that a string was produced. The\n\t * point of a gateway is that mutations reach `PlatformHost.submitAction` and\n\t * reads reach `ProjectionHost.project`; a replacement that fabricates an id\n\t * and queries the database directly satisfies every observable contract\n\t * except this one, so the subject has to report the calls.\n\t */\n\tobserved: {\n\t\tsubmitActionCount(): number;\n\t\tprojectionCount(): number;\n\t};\n}\n\nexport interface ExperienceGatewayCheck {\n\tid:\n\t\t| \"fabric.experience.gateway-host-routing.v1\"\n\t\t| \"fabric.experience.gateway-scope-denial.v1\"\n\t\t| \"fabric.experience.gateway-projection-routing.v1\";\n\trun(subject: ExperienceGatewayConformanceSubject): Promise<string[]>;\n}\n\nexport function experienceGatewayChecks(): readonly ExperienceGatewayCheck[] {\n\treturn [\n\t\t{\n\t\t\tid: \"fabric.experience.gateway-host-routing.v1\",\n\t\t\tasync run(subject) {\n\t\t\t\tconst before = subject.observed.submitActionCount();\n\t\t\t\tconst result = await subject.gateway.invoke(subject.validIntent);\n\t\t\t\tif (!result.actionInvocationId) throw new Error(\"Gateway did not return a governed Host invocation.\");\n\t\t\t\tconst submitted = subject.observed.submitActionCount() - before;\n\t\t\t\tif (submitted !== 1) {\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t`Gateway returned an invocation id after ${submitted} calls to PlatformHost.submitAction; a mutation must reach the Host exactly once.`,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\treturn [result.actionInvocationId];\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tid: \"fabric.experience.gateway-projection-routing.v1\",\n\t\t\tasync run(subject) {\n\t\t\t\tconst before = subject.observed.projectionCount();\n\t\t\t\tawait subject.gateway.read(subject.validRead);\n\t\t\t\tconst projected = subject.observed.projectionCount() - before;\n\t\t\t\tif (projected !== 1) {\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t`Gateway served a read after ${projected} calls to ProjectionHost.project; a read must go through the projection host.`,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\treturn [\"read routed through ProjectionHost\"];\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tid: \"fabric.experience.gateway-scope-denial.v1\",\n\t\t\tasync run(subject) {\n\t\t\t\tlet rejected = false;\n\t\t\t\ttry { await subject.gateway.invoke(subject.deniedIntent); } catch { rejected = true; }\n\t\t\t\tif (!rejected) throw new Error(\"Gateway accepted an intent outside trusted identity scope.\");\n\t\t\t\treturn [\"out-of-scope intent rejected\"];\n\t\t\t},\n\t\t},\n\t];\n}\n\nexport async function runExperienceGatewayChecks(\n\tsubject: ExperienceGatewayConformanceSubject,\n\tchecks: readonly ExperienceGatewayCheck[] = experienceGatewayChecks(),\n): Promise<{ passed: boolean; checks: Array<{ id: ExperienceGatewayCheck[\"id\"]; status: \"passed\" | \"failed\"; evidence: string[]; error?: string }> }> {\n\tconst results: Array<{ id: ExperienceGatewayCheck[\"id\"]; status: \"passed\" | \"failed\"; evidence: string[]; error?: string }> = [];\n\tfor (const check of checks) {\n\t\ttry { results.push({ id: check.id, status: \"passed\", evidence: await check.run(subject) }); }\n\t\tcatch (error) { results.push({ id: check.id, status: \"failed\", evidence: [], error: error instanceof Error ? error.message : String(error) }); }\n\t}\n\treturn { passed: results.every((result) => result.status === \"passed\"), checks: results };\n}\n\nexport interface InitiatingReleaseEvidence {\n\tbind(input: SubmitActionInput, releaseDigest: string): void;\n\tresolve(input: SubmitActionInput): string | undefined;\n\trelease(input: SubmitActionInput): void;\n}\n\n/**\n * Creates an instance-scoped, non-serializable evidence seam. The Host's\n * `composition.resolveInitiatingReleaseDigest` reads this WeakMap using the\n * exact submission object. A browser field cannot populate or forge it.\n */\nexport function createInitiatingReleaseEvidence(): InitiatingReleaseEvidence {\n\tconst evidence = new WeakMap<SubmitActionInput, string>();\n\treturn {\n\t\tbind(input, releaseDigest) {\n\t\t\tevidence.set(input, releaseDigest);\n\t\t},\n\t\tresolve(input) {\n\t\t\treturn evidence.get(input);\n\t\t},\n\t\trelease(input) {\n\t\t\tevidence.delete(input);\n\t\t},\n\t};\n}\n\nexport function createExperienceGateway(options: {\n\tidentity: IdentityPort;\n\truntime: ExperienceServerRuntime;\n\thost: Pick<GovernedActionHost, \"submitAction\">;\n\tprojectionHost: Pick<ProjectionHost, \"project\">;\n\tevidence: InitiatingReleaseEvidence;\n\tassemblyDigest: string;\n\tactionVersion(actionId: `${string}.${string}`): number | undefined;\n\tnextCorrelationId(): string;\n\t/**\n\t * How old an offline capture may be and still replay.\n\t *\n\t * Required to accept replay at all. The Host's own staleness check is\n\t * conditional on the capture-authority adapter supplying an `expiresAt`, and\n\t * is skipped entirely for actions governed at capture time, so without this\n\t * a client-reported instant has no lower bound.\n\t */\n\tmaxOfflineReplayAgeMs?: number;\n\tcaptureAuthority?(input: {\n\t\tactionId: `${string}.${string}`;\n\t\tscope: ExperienceScope;\n\t\tparameters: Record<string, unknown>;\n\t\tparameterDigest: string;\n\t\tcapturedAt: string;\n\t}): Promise<{ id: string; expiresAt?: string }>;\n\tnow?: () => Date;\n}): ExperienceGateway {\n\tconst now = options.now ?? (() => new Date());\n\treturn {\n\t\tasync invoke(input) {\n\t\t\tconst claims = await options.identity.verify(input.context.credential);\n\t\t\tif (!claims) throw new Error(\"Experience request credential is not authenticated.\");\n\t\t\tassertActorClaimsCoverScope(claims, {\n\t\t\t\ttenantId: input.context.tenantId,\n\t\t\t\tspaceId: input.context.spaceId,\n\t\t\t}, now());\n\t\t\tconst scope: ExperienceScope = {\n\t\t\t\ttenantId: input.context.tenantId,\n\t\t\t\tspaceId: input.context.spaceId,\n\t\t\t\tactorId: claims.subject,\n\t\t\t\tactorType: claims.actorType,\n\t\t\t};\n\t\t\tconst resolved = await options.runtime.resolveIntent({\n\t\t\t\tplanId: input.request.planId,\n\t\t\t\tfragmentId: input.request.fragmentId,\n\t\t\t\teventName: input.request.eventName,\n\t\t\t\tscope,\n\t\t\t});\n\t\t\tif (resolved.assemblyDigest !== options.assemblyDigest) {\n\t\t\t\tthrow new Error(\"Experience render plan belongs to a different application assembly.\");\n\t\t\t}\n\t\t\tif (options.actionVersion(resolved.actionId) !== resolved.actionVersion) {\n\t\t\t\tthrow new Error(`Experience intent route for \"${resolved.actionId}\" does not match the registered action version.`);\n\t\t\t}\n\t\t\tif (resolved.execution?.idempotency === \"required\" && !input.request.idempotencyKey) {\n\t\t\t\tthrow new Error(`Action \"${resolved.actionId}\" requires an idempotency key.`);\n\t\t\t}\n\t\t\tif (input.executionReason === \"offline_replay\" && resolved.execution?.connectivity !== \"offline-capturable\") {\n\t\t\t\tthrow new Error(`Action \"${resolved.actionId}\" is not eligible for offline replay.`);\n\t\t\t}\n\t\t\tconst correlationId = options.nextCorrelationId();\n\t\t\t// A replay attests when the operator acted, not when the network came\n\t\t\t// back. Stamping receipt time would make capture-authority evidence\n\t\t\t// describe a moment nobody was there for.\n\t\t\tlet capturedAt: string;\n\t\t\tif (input.executionReason === \"offline_replay\") {\n\t\t\t\t// Accept it from either position, but require it: a replay with no\n\t\t\t\t// capture moment cannot be evidenced, and defaulting to receipt\n\t\t\t\t// time would attest a moment nobody was there for.\n\t\t\t\tconst supplied = input.capturedAt\n\t\t\t\t\t?? (input.request as Partial<{ capturedAt: string }>).capturedAt;\n\t\t\t\tif (typeof supplied !== \"string\") {\n\t\t\t\t\tthrow new Error(\"Offline replay requires capturedAt; receipt time cannot attest when the operator acted.\");\n\t\t\t\t}\n\t\t\t\tcapturedAt = assertCaptureMoment(supplied, now(), options.maxOfflineReplayAgeMs);\n\t\t\t} else {\n\t\t\t\tcapturedAt = now().toISOString();\n\t\t\t}\n\t\t\tconst parameterDigest = digestParameters(input.request.parameters);\n\t\t\tconst capturedAuthority = resolved.execution?.authorityMoment === \"capture\"\n\t\t\t\t? await options.captureAuthority?.({\n\t\t\t\t\tactionId: resolved.actionId,\n\t\t\t\t\tscope,\n\t\t\t\t\tparameters: input.request.parameters,\n\t\t\t\t\tparameterDigest,\n\t\t\t\t\tcapturedAt,\n\t\t\t\t})\n\t\t\t\t: undefined;\n\t\t\tif (resolved.execution?.authorityMoment === \"capture\" && !capturedAuthority) {\n\t\t\t\tthrow new Error(`Action \"${resolved.actionId}\" requires a trusted capture-authority adapter.`);\n\t\t\t}\n\t\t\tconst submission: SubmitActionInput = {\n\t\t\t\tactionId: resolved.actionId,\n\t\t\t\tparameters: input.request.parameters,\n\t\t\t\ttenantId: scope.tenantId,\n\t\t\t\tspaceId: scope.spaceId,\n\t\t\t\tactorId: scope.actorId,\n\t\t\t\tactorType: claims.actorType,\n\t\t\t\tcorrelationId,\n\t\t\t\tprovenance: {\n\t\t\t\t\tsource: \"ui\",\n\t\t\t\t\tcorrelationId,\n\t\t\t\t\t...(resolved.execution?.sensitivity === \"restricted\"\n\t\t\t\t\t\t? {}\n\t\t\t\t\t\t: { auditAttributes: { \"fabric.experience.fragment\": input.request.fragmentId } }),\n\t\t\t\t},\n\t\t\t\t...(input.request.idempotencyKey === undefined ? {} : { idempotencyKey: input.request.idempotencyKey }),\n\t\t\t\t...(input.executionReason === undefined ? {} : { executionReason: input.executionReason }),\n\t\t\t\t...(capturedAuthority === undefined ? {} : {\n\t\t\t\t\tauthorizationBindingId: capturedAuthority.id,\n\t\t\t\t\tauthorizationBinding: {\n\t\t\t\t\t\tid: capturedAuthority.id,\n\t\t\t\t\t\tgoverningMoment: \"capture\",\n\t\t\t\t\t\tcapturedAt,\n\t\t\t\t\t\t...(capturedAuthority.expiresAt === undefined ? {} : { expiresAt: capturedAuthority.expiresAt }),\n\t\t\t\t\t\ttenantId: scope.tenantId,\n\t\t\t\t\t\tactorId: scope.actorId,\n\t\t\t\t\t\tactionId: resolved.actionId,\n\t\t\t\t\t\tparameterDigest,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t};\n\t\t\toptions.evidence.bind(submission, resolved.initiatingReleaseDigest);\n\t\t\ttry {\n\t\t\t\treturn await options.host.submitAction(submission);\n\t\t\t} finally {\n\t\t\t\toptions.evidence.release(submission);\n\t\t\t}\n\t\t},\n\t\tasync read(input) {\n\t\t\tconst claims = await options.identity.verify(input.context.credential);\n\t\t\tif (!claims) throw new Error(\"Experience request credential is not authenticated.\");\n\t\t\tassertActorClaimsCoverScope(claims, {\n\t\t\t\ttenantId: input.context.tenantId,\n\t\t\t\tspaceId: input.context.spaceId,\n\t\t\t}, now());\n\t\t\tconst scope: ExperienceScope = {\n\t\t\t\ttenantId: input.context.tenantId,\n\t\t\t\tspaceId: input.context.spaceId,\n\t\t\t\tactorId: claims.subject,\n\t\t\t\tactorType: claims.actorType,\n\t\t\t};\n\t\t\tconst resolved = await options.runtime.resolveView({\n\t\t\t\tplanId: input.request.planId,\n\t\t\t\tfragmentId: input.request.fragmentId,\n\t\t\t\tbindingName: input.request.bindingName,\n\t\t\t\tscope,\n\t\t\t});\n\t\t\tif (resolved.assemblyDigest !== options.assemblyDigest) {\n\t\t\t\tthrow new Error(\"Experience render plan belongs to a different application assembly.\");\n\t\t\t}\n\t\t\tconst result = await options.projectionHost.project({\n\t\t\t\tview: { name: resolved.name, version: resolved.version },\n\t\t\t\tscope: { tenantId: scope.tenantId, spaceId: scope.spaceId },\n\t\t\t\tactor: { id: scope.actorId, type: claims.actorType },\n\t\t\t\tparameters: input.request.parameters,\n\t\t\t});\n\t\t\treturn {\n\t\t\t\tdata: result.state,\n\t\t\t\tevidence: {\n\t\t\t\t\treleaseDigest: resolved.initiatingReleaseDigest,\n\t\t\t\t\tassemblyDigest: resolved.assemblyDigest,\n\t\t\t\t\teventCursor: result.eventCursor,\n\t\t\t\t\tsource: result.source,\n\t\t\t\t},\n\t\t\t};\n\t\t},\n\t};\n}\n\n/** RFC 3339 with a mandatory offset, so an instant means the same thing everywhere. */\nconst RFC3339 = /^\\d{4}-\\d{2}-\\d{2}[Tt]\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:[Zz]|[+-]\\d{2}:\\d{2})$/;\n\n/**\n * A supplied capture moment is client-reported, so it is bounded here rather\n * than trusted.\n *\n * Deferring the lower bound to the Host looked safe and is not: the Host checks\n * staleness only when the capture-authority adapter supplied an `expiresAt`,\n * and skips the execution-time expiry check entirely when the action's\n * authority moment is `capture`. With an adapter that omits `expiresAt` there\n * is no lower bound at all, and the value is written into durable evidence and\n * handed to the authority adapter regardless. A gateway that accepts replay\n * must therefore say how old a capture may be.\n */\nfunction assertCaptureMoment(capturedAt: string, now: Date, maxAgeMs: number | undefined): string {\n\tif (!RFC3339.test(capturedAt)) {\n\t\tthrow new Error(\"Offline replay capturedAt must be an RFC 3339 instant with an explicit offset.\");\n\t}\n\tconst parsed = Date.parse(capturedAt);\n\tif (Number.isNaN(parsed)) throw new Error(\"Offline replay capturedAt is not a parseable instant.\");\n\tif (parsed > now.getTime()) throw new Error(\"Offline replay capturedAt is in the future.\");\n\tif (maxAgeMs === undefined) {\n\t\tthrow new Error(\"Gateway accepts offline replay but declares no maxOfflineReplayAgeMs, so a capture has no lower bound.\");\n\t}\n\tif (now.getTime() - parsed > maxAgeMs) {\n\t\tthrow new Error(`Offline replay capturedAt is older than the ${maxAgeMs}ms this gateway accepts.`);\n\t}\n\treturn new Date(parsed).toISOString();\n}\n"],"mappings":"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","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fabricorg/experience-gateway",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Trusted server adapter from scoped experience plans to governed Fabric Hosts.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"main": "./dist/index.cjs",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"import": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"default": "./dist/index.js"
|
|
15
|
+
},
|
|
16
|
+
"require": {
|
|
17
|
+
"types": "./dist/index.d.cts",
|
|
18
|
+
"default": "./dist/index.cjs"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"README.md",
|
|
25
|
+
"CHANGELOG.md",
|
|
26
|
+
"LICENSE"
|
|
27
|
+
],
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@fabricorg/experience-runtime": "^0.2.0"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"@fabricorg/platform-host": "^7.0.0",
|
|
33
|
+
"@fabricorg/projection-host": "^1.1.0",
|
|
34
|
+
"@fabricorg/ports": "^0.4.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/node": "^22.10.0",
|
|
38
|
+
"tsup": "^8.5.0",
|
|
39
|
+
"typescript": "^5.7.3",
|
|
40
|
+
"vitest": "^4.1.5",
|
|
41
|
+
"@fabricorg/platform-host": "^7.0.0",
|
|
42
|
+
"@fabricorg/projection-host": "^1.1.0",
|
|
43
|
+
"@fabricorg/ports": "^0.4.0"
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=20"
|
|
47
|
+
},
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public"
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"clean": "rm -rf dist tsconfig.tsbuildinfo",
|
|
53
|
+
"build": "tsup",
|
|
54
|
+
"type-check": "tsc --noEmit",
|
|
55
|
+
"test": "vitest run"
|
|
56
|
+
}
|
|
57
|
+
}
|