@emilia-protocol/gate 0.14.0 → 0.15.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +32 -1
  2. package/README.md +32 -1
  3. package/aeb-consumption-store.js +3 -0
  4. package/dist/aeb-consumption-store.d.ts +80 -0
  5. package/dist/aeb-consumption-store.d.ts.map +1 -0
  6. package/dist/aeb-consumption-store.js +430 -0
  7. package/dist/aeb-consumption-store.js.map +1 -0
  8. package/dist/capability-receipt.d.ts +8 -0
  9. package/dist/capability-receipt.d.ts.map +1 -1
  10. package/dist/capability-receipt.js +17 -0
  11. package/dist/capability-receipt.js.map +1 -1
  12. package/dist/index.d.ts +12 -3
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +16 -4
  15. package/dist/index.js.map +1 -1
  16. package/dist/proposal-to-effect-postgres.d.ts +129 -0
  17. package/dist/proposal-to-effect-postgres.d.ts.map +1 -0
  18. package/dist/proposal-to-effect-postgres.js +1511 -0
  19. package/dist/proposal-to-effect-postgres.js.map +1 -0
  20. package/dist/proposal-to-effect-status.d.ts +55 -0
  21. package/dist/proposal-to-effect-status.d.ts.map +1 -0
  22. package/dist/proposal-to-effect-status.js +280 -0
  23. package/dist/proposal-to-effect-status.js.map +1 -0
  24. package/dist/proposal-to-effect.d.ts +151 -3
  25. package/dist/proposal-to-effect.d.ts.map +1 -1
  26. package/dist/proposal-to-effect.js +589 -46
  27. package/dist/proposal-to-effect.js.map +1 -1
  28. package/dist/remedy-case-set-postgres.d.ts +73 -0
  29. package/dist/remedy-case-set-postgres.d.ts.map +1 -0
  30. package/dist/remedy-case-set-postgres.js +846 -0
  31. package/dist/remedy-case-set-postgres.js.map +1 -0
  32. package/dist/remedy-case-set.d.ts +53 -0
  33. package/dist/remedy-case-set.d.ts.map +1 -0
  34. package/dist/remedy-case-set.js +571 -0
  35. package/dist/remedy-case-set.js.map +1 -0
  36. package/dist/remedy-program-adapters.d.ts +137 -0
  37. package/dist/remedy-program-adapters.d.ts.map +1 -0
  38. package/dist/remedy-program-adapters.js +590 -0
  39. package/dist/remedy-program-adapters.js.map +1 -0
  40. package/dist/trust-program-revocation.js.map +1 -1
  41. package/package.json +278 -63
  42. package/proposal-to-effect-postgres.js +3 -0
  43. package/proposal-to-effect-status.js +2 -0
  44. package/remedy-case-set-postgres.js +3 -0
  45. package/remedy-case-set.js +3 -0
  46. package/remedy-program-adapters.js +3 -0
  47. package/src/aeb-consumption-store.ts +521 -0
  48. package/src/capability-receipt.ts +17 -0
  49. package/src/index.ts +52 -3
  50. package/src/proposal-to-effect-postgres.ts +1740 -0
  51. package/src/proposal-to-effect-status.ts +357 -0
  52. package/src/proposal-to-effect.ts +755 -50
  53. package/src/remedy-case-set-postgres.ts +1008 -0
  54. package/src/remedy-case-set.ts +603 -0
  55. package/src/remedy-program-adapters.ts +657 -0
  56. package/src/trust-program-revocation.ts +1 -1
@@ -0,0 +1,357 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Server-pinned EP-STATUS-v1 verification for Proposal-to-Effect AEB legs.
4
+ *
5
+ * Signed non-revocation status and relying-party consumption state answer
6
+ * different questions. This helper authenticates the former with
7
+ * verifyStatusArtifact and requires an authenticated local answer for the
8
+ * latter. The later atomic AEB reserve remains the race-closing operation.
9
+ */
10
+
11
+ import type { AebDigest, AebStatusInput } from '@emilia-protocol/verify/aeb-adapter-contract';
12
+ import {
13
+ verifyStatusArtifact,
14
+ type RevokerAuthorityPin,
15
+ type StatusTarget,
16
+ } from '@emilia-protocol/verify/status';
17
+
18
+ import type {
19
+ ProposalToEffectCurrentStatusVerification,
20
+ ProposalToEffectOptions,
21
+ } from './proposal-to-effect.js';
22
+
23
+ type MaybePromise<T> = T | Promise<T>;
24
+ type ProposalToEffectStatusVerifier = ProposalToEffectOptions['aeb']['statusVerifier'];
25
+
26
+ export type ProposalToEffectStatusVerifierInput =
27
+ Parameters<ProposalToEffectStatusVerifier>[0];
28
+ export type ProposalToEffectStatusExpected =
29
+ Readonly<ProposalToEffectStatusVerifierInput['expected']>;
30
+
31
+ export interface ProposalToEffectStatusResolverContext {
32
+ expected: ProposalToEffectStatusExpected;
33
+ target: Readonly<StatusTarget>;
34
+ }
35
+
36
+ export interface ProposalToEffectPreviousHeadResolution {
37
+ /** Confirms this result came from the relying party's authenticated store. */
38
+ authenticated: boolean;
39
+ /** Null means the store authoritatively has no accepted head for the target. */
40
+ status: unknown | null;
41
+ }
42
+
43
+ export interface ProposalToEffectConsumptionState {
44
+ /** Must be true; presenter assertions and unauthenticated cache data fail. */
45
+ authenticated: boolean;
46
+ consumed: boolean;
47
+ }
48
+
49
+ export interface ProposalToEffectConsumptionResolverContext
50
+ extends ProposalToEffectStatusResolverContext {
51
+ status_digest: AebDigest;
52
+ sequence: number;
53
+ }
54
+
55
+ export interface ProposalToEffectStatusVerifierOptions {
56
+ /** Copied at factory construction; callers cannot swap the authority pin later. */
57
+ authorityPin: RevokerAuthorityPin;
58
+ /** Trusted code mapping the closed PTE expected binding to one exact status target. */
59
+ targetMapper(input: {
60
+ expected: ProposalToEffectStatusExpected;
61
+ }): MaybePromise<StatusTarget>;
62
+ /** Server-side certificate lookup. The presenter never supplies this certificate. */
63
+ certificateResolver(
64
+ input: ProposalToEffectStatusResolverContext,
65
+ ): MaybePromise<unknown>;
66
+ /** Server-side lookup of the relying party's previously accepted status head. */
67
+ previousHeadResolver(
68
+ input: ProposalToEffectStatusResolverContext,
69
+ ): MaybePromise<ProposalToEffectPreviousHeadResolution>;
70
+ /** Authenticated local consumption lookup; this is not inferred from EP-STATUS-v1. */
71
+ consumptionStateResolver(
72
+ input: ProposalToEffectConsumptionResolverContext,
73
+ ): MaybePromise<ProposalToEffectConsumptionState>;
74
+ }
75
+
76
+ const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/;
77
+ const CAID_PATTERN = /^caid:1:[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)*\.[1-9][0-9]*:[a-z0-9]+(?:-[a-z0-9]+)*:[A-Za-z0-9_-]{43}$/;
78
+ const EXPECTED_KEYS = [
79
+ 'tenant_id',
80
+ 'executor_id',
81
+ 'operation_id',
82
+ 'caid',
83
+ 'artifact_ref',
84
+ 'evidence_digest',
85
+ 'replay_unit',
86
+ ] as const;
87
+ const TARGET_KEYS = ['type', 'id', 'digest', 'usage'] as const;
88
+ const AUTHORITY_PIN_KEYS = [
89
+ 'authority_domain',
90
+ 'authority_id',
91
+ 'key_id',
92
+ 'public_key',
93
+ ] as const;
94
+
95
+ function dataRecord(value: unknown): value is Record<string, unknown> {
96
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
97
+ const prototype = Object.getPrototypeOf(value);
98
+ if (prototype !== Object.prototype && prototype !== null) return false;
99
+ return Reflect.ownKeys(value).every((key) => {
100
+ if (typeof key !== 'string') return false;
101
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
102
+ return Boolean(descriptor?.enumerable && Object.hasOwn(descriptor, 'value'));
103
+ });
104
+ }
105
+
106
+ function exactDataRecord(
107
+ value: unknown,
108
+ keys: readonly string[],
109
+ ): value is Record<string, unknown> {
110
+ if (!dataRecord(value)) return false;
111
+ const actual = Reflect.ownKeys(value);
112
+ return actual.length === keys.length && keys.every((key) => Object.hasOwn(value, key));
113
+ }
114
+
115
+ function boundedString(value: unknown, maximum = 512): value is string {
116
+ return typeof value === 'string' && value.length > 0 && value.length <= maximum;
117
+ }
118
+
119
+ function digest(value: unknown): value is AebDigest {
120
+ return typeof value === 'string' && DIGEST_PATTERN.test(value);
121
+ }
122
+
123
+ function snapshotAuthorityPin(value: unknown): Readonly<RevokerAuthorityPin> | null {
124
+ if (!exactDataRecord(value, AUTHORITY_PIN_KEYS)
125
+ || !boundedString(value.authority_domain, 253)
126
+ || !boundedString(value.authority_id)
127
+ || !boundedString(value.key_id)
128
+ || !boundedString(value.public_key, 4096)) return null;
129
+ return Object.freeze({
130
+ authority_domain: value.authority_domain,
131
+ authority_id: value.authority_id,
132
+ key_id: value.key_id,
133
+ public_key: value.public_key,
134
+ });
135
+ }
136
+
137
+ function snapshotExpected(value: unknown): ProposalToEffectStatusExpected | null {
138
+ if (!exactDataRecord(value, EXPECTED_KEYS)
139
+ || !boundedString(value.tenant_id)
140
+ || !boundedString(value.executor_id)
141
+ || !boundedString(value.operation_id)
142
+ || typeof value.caid !== 'string'
143
+ || !CAID_PATTERN.test(value.caid)
144
+ || !boundedString(value.artifact_ref)
145
+ || !digest(value.evidence_digest)
146
+ || !digest(value.replay_unit)) return null;
147
+ return Object.freeze({
148
+ tenant_id: value.tenant_id,
149
+ executor_id: value.executor_id,
150
+ operation_id: value.operation_id,
151
+ caid: value.caid,
152
+ artifact_ref: value.artifact_ref,
153
+ evidence_digest: value.evidence_digest,
154
+ replay_unit: value.replay_unit,
155
+ });
156
+ }
157
+
158
+ function snapshotTarget(value: unknown): Readonly<StatusTarget> | null {
159
+ if (!exactDataRecord(value, TARGET_KEYS)
160
+ || !['receipt', 'commit', 'delegation'].includes(value.type as string)
161
+ || !boundedString(value.id)
162
+ || !digest(value.digest)
163
+ || !['authorization', 'execution', 'delegation'].includes(value.usage as string)) {
164
+ return null;
165
+ }
166
+ return Object.freeze({
167
+ type: value.type as StatusTarget['type'],
168
+ id: value.id,
169
+ digest: value.digest,
170
+ usage: value.usage as StatusTarget['usage'],
171
+ });
172
+ }
173
+
174
+ function denseDataArray(value: unknown): value is unknown[] {
175
+ if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) return false;
176
+ const keys = Reflect.ownKeys(value);
177
+ if (keys.length !== value.length + 1 || !keys.includes('length')) return false;
178
+ for (let index = 0; index < value.length; index += 1) {
179
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
180
+ if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) return false;
181
+ }
182
+ return true;
183
+ }
184
+
185
+ function snapshotJson(value: unknown, seen = new WeakSet<object>()): unknown {
186
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
187
+ if (typeof value === 'number') {
188
+ if (Number.isFinite(value)) return value;
189
+ throw new TypeError('non-finite JSON number');
190
+ }
191
+ if (typeof value !== 'object' || seen.has(value)) {
192
+ throw new TypeError('value is outside the JSON data model');
193
+ }
194
+ seen.add(value);
195
+ if (denseDataArray(value)) {
196
+ return value.map((member) => snapshotJson(member, seen));
197
+ }
198
+ if (!dataRecord(value)) throw new TypeError('value is not a plain data object');
199
+ const snapshot: Record<string, unknown> = {};
200
+ for (const key of Object.keys(value)) {
201
+ snapshot[key] = snapshotJson(value[key], seen);
202
+ }
203
+ return snapshot;
204
+ }
205
+
206
+ function normalizedInstant(value: unknown): string | null {
207
+ if (typeof value !== 'string') return null;
208
+ const milliseconds = Date.parse(value);
209
+ if (!Number.isFinite(milliseconds)) return null;
210
+ return new Date(milliseconds).toISOString();
211
+ }
212
+
213
+ function refusal(
214
+ outcome: 'revoked' | 'indeterminate',
215
+ reason: string,
216
+ ): ProposalToEffectCurrentStatusVerification {
217
+ return {
218
+ valid: false,
219
+ outcome,
220
+ status: null,
221
+ reason,
222
+ };
223
+ }
224
+
225
+ function verifierConfiguration(
226
+ options: ProposalToEffectStatusVerifierOptions,
227
+ ): Readonly<RevokerAuthorityPin> {
228
+ const authorityPin = snapshotAuthorityPin(options?.authorityPin);
229
+ if (!authorityPin
230
+ || typeof options.targetMapper !== 'function'
231
+ || typeof options.certificateResolver !== 'function'
232
+ || typeof options.previousHeadResolver !== 'function'
233
+ || typeof options.consumptionStateResolver !== 'function') {
234
+ throw new TypeError('proposal_to_effect_status_configuration_invalid');
235
+ }
236
+ return authorityPin;
237
+ }
238
+
239
+ /**
240
+ * Build the server-side verifier expected by
241
+ * `ProposalToEffectOptions.aeb.statusVerifier`.
242
+ */
243
+ export function createProposalToEffectStatusVerifier(
244
+ options: ProposalToEffectStatusVerifierOptions,
245
+ ): ProposalToEffectStatusVerifier {
246
+ const authorityPin = verifierConfiguration(options);
247
+ const targetMapper = options.targetMapper;
248
+ const certificateResolver = options.certificateResolver;
249
+ const previousHeadResolver = options.previousHeadResolver;
250
+ const consumptionStateResolver = options.consumptionStateResolver;
251
+
252
+ return async (input): Promise<ProposalToEffectCurrentStatusVerification> => {
253
+ const expected = snapshotExpected(input?.expected);
254
+ const checkedAt = normalizedInstant(input?.now);
255
+ if (!expected || !checkedAt) {
256
+ return refusal('indeterminate', 'status_expected_binding_invalid');
257
+ }
258
+
259
+ let statusArtifact: unknown;
260
+ let target: Readonly<StatusTarget>;
261
+ try {
262
+ statusArtifact = snapshotJson(input.status_artifact);
263
+ const mapped = await targetMapper({ expected });
264
+ const snapshot = snapshotTarget(mapped);
265
+ if (!snapshot) return refusal('indeterminate', 'status_target_resolution_failed');
266
+ target = snapshot;
267
+ } catch {
268
+ return refusal('indeterminate', 'status_target_resolution_failed');
269
+ }
270
+
271
+ const context = Object.freeze({ expected, target });
272
+ let certificate: unknown;
273
+ let previousStatus: unknown;
274
+ try {
275
+ const resolvedCertificate = await certificateResolver(context);
276
+ if (resolvedCertificate === undefined || resolvedCertificate === null) {
277
+ return refusal('indeterminate', 'status_certificate_unavailable');
278
+ }
279
+ certificate = snapshotJson(resolvedCertificate);
280
+ } catch {
281
+ return refusal('indeterminate', 'status_certificate_unavailable');
282
+ }
283
+ try {
284
+ const resolution = await previousHeadResolver(context);
285
+ if (!dataRecord(resolution)
286
+ || resolution.authenticated !== true
287
+ || !Object.hasOwn(resolution, 'status')
288
+ || resolution.status === undefined) {
289
+ return refusal('indeterminate', 'status_previous_head_unavailable');
290
+ }
291
+ previousStatus = resolution.status === null
292
+ ? undefined : snapshotJson(resolution.status);
293
+ } catch {
294
+ return refusal('indeterminate', 'status_previous_head_unavailable');
295
+ }
296
+
297
+ const verification = verifyStatusArtifact(target, statusArtifact, {
298
+ authorityPin,
299
+ certificate,
300
+ previousStatus,
301
+ now: input.now,
302
+ });
303
+ if (!verification.valid || verification.outcome === 'indeterminate') {
304
+ return refusal(
305
+ 'indeterminate',
306
+ verification.reasons[0] ?? 'status_verification_failed',
307
+ );
308
+ }
309
+ if (verification.outcome === 'revoked') {
310
+ return refusal('revoked', 'status_revoked');
311
+ }
312
+ if (!digest(verification.status_digest)
313
+ || !Number.isSafeInteger(verification.sequence)
314
+ || verification.sequence === null) {
315
+ return refusal('indeterminate', 'status_verification_incomplete');
316
+ }
317
+
318
+ let consumption: ProposalToEffectConsumptionState;
319
+ try {
320
+ consumption = await consumptionStateResolver(Object.freeze({
321
+ ...context,
322
+ status_digest: verification.status_digest,
323
+ sequence: verification.sequence,
324
+ }));
325
+ } catch {
326
+ return refusal('indeterminate', 'status_consumption_state_unavailable');
327
+ }
328
+ if (!dataRecord(consumption)
329
+ || consumption.authenticated !== true
330
+ || typeof consumption.consumed !== 'boolean') {
331
+ return refusal('indeterminate', 'status_consumption_state_unknown');
332
+ }
333
+ if (consumption.consumed) {
334
+ return refusal('indeterminate', 'status_consumed');
335
+ }
336
+
337
+ const expiresAt = normalizedInstant(verification.next_update);
338
+ if (!expiresAt || Date.parse(checkedAt) >= Date.parse(expiresAt)) {
339
+ return refusal('indeterminate', 'status_expiry_invalid');
340
+ }
341
+
342
+ // This local answer is an authenticated pre-reservation observation. It
343
+ // does not claim race freedom; Proposal-to-Effect's atomic AEB reserve does.
344
+ const status: AebStatusInput = {
345
+ checked_at: checkedAt,
346
+ expires_at: expiresAt,
347
+ revocation_checked: true,
348
+ revoked: false,
349
+ consumed: false,
350
+ };
351
+ return {
352
+ valid: true,
353
+ outcome: 'current_not_revoked',
354
+ status,
355
+ };
356
+ };
357
+ }