@decionis/agent-safe-pipeline 0.1.3 → 0.2.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 (104) hide show
  1. package/README.md +477 -16
  2. package/dist/Index.d.ts +9 -0
  3. package/dist/Index.d.ts.map +1 -1
  4. package/dist/Index.js +8 -0
  5. package/dist/Index.js.map +1 -1
  6. package/dist/approval/PresenceApprovalCoordinator.d.ts +25 -0
  7. package/dist/approval/PresenceApprovalCoordinator.d.ts.map +1 -1
  8. package/dist/approval/PresenceApprovalCoordinator.js +54 -6
  9. package/dist/approval/PresenceApprovalCoordinator.js.map +1 -1
  10. package/dist/audit/AuditRecorder.d.ts +122 -0
  11. package/dist/audit/AuditRecorder.d.ts.map +1 -0
  12. package/dist/audit/AuditRecorder.js +312 -0
  13. package/dist/audit/AuditRecorder.js.map +1 -0
  14. package/dist/decision/CreateGate.d.ts +123 -0
  15. package/dist/decision/CreateGate.d.ts.map +1 -0
  16. package/dist/decision/CreateGate.js +211 -0
  17. package/dist/decision/CreateGate.js.map +1 -0
  18. package/dist/decision/DecionisGate.d.ts +109 -3
  19. package/dist/decision/DecionisGate.d.ts.map +1 -1
  20. package/dist/decision/DecionisGate.js +823 -46
  21. package/dist/decision/DecionisGate.js.map +1 -1
  22. package/dist/decision/DecisionAuthority.d.ts +76 -1
  23. package/dist/decision/DecisionAuthority.d.ts.map +1 -1
  24. package/dist/decision/DecisionAuthority.js.map +1 -1
  25. package/dist/decision/FixtureDecisionAuthority.d.ts +27 -1
  26. package/dist/decision/FixtureDecisionAuthority.d.ts.map +1 -1
  27. package/dist/decision/FixtureDecisionAuthority.js +47 -0
  28. package/dist/decision/FixtureDecisionAuthority.js.map +1 -1
  29. package/dist/decision/ImmutableGateDecision.d.ts.map +1 -1
  30. package/dist/decision/ImmutableGateDecision.js +26 -0
  31. package/dist/decision/ImmutableGateDecision.js.map +1 -1
  32. package/dist/decision/Provision.d.ts +33 -0
  33. package/dist/decision/Provision.d.ts.map +1 -0
  34. package/dist/decision/Provision.js +116 -0
  35. package/dist/decision/Provision.js.map +1 -0
  36. package/dist/decision/ShadowGate.d.ts +31 -0
  37. package/dist/decision/ShadowGate.d.ts.map +1 -0
  38. package/dist/decision/ShadowGate.js +79 -0
  39. package/dist/decision/ShadowGate.js.map +1 -0
  40. package/dist/execution/ActionRegistry.d.ts +66 -0
  41. package/dist/execution/ActionRegistry.d.ts.map +1 -1
  42. package/dist/execution/ActionRegistry.js +110 -1
  43. package/dist/execution/ActionRegistry.js.map +1 -1
  44. package/dist/execution/AuthorizationVerifier.d.ts +149 -1
  45. package/dist/execution/AuthorizationVerifier.d.ts.map +1 -1
  46. package/dist/execution/AuthorizationVerifier.js +302 -27
  47. package/dist/execution/AuthorizationVerifier.js.map +1 -1
  48. package/dist/execution/SafeExecutor.d.ts +102 -7
  49. package/dist/execution/SafeExecutor.d.ts.map +1 -1
  50. package/dist/execution/SafeExecutor.js +288 -17
  51. package/dist/execution/SafeExecutor.js.map +1 -1
  52. package/dist/http/ClientIdentification.d.ts +22 -0
  53. package/dist/http/ClientIdentification.d.ts.map +1 -0
  54. package/dist/http/ClientIdentification.js +44 -0
  55. package/dist/http/ClientIdentification.js.map +1 -0
  56. package/dist/http/Credential.d.ts +17 -0
  57. package/dist/http/Credential.d.ts.map +1 -0
  58. package/dist/http/Credential.js +5 -0
  59. package/dist/http/Credential.js.map +1 -0
  60. package/dist/http/StoredCredentials.d.ts +34 -0
  61. package/dist/http/StoredCredentials.d.ts.map +1 -0
  62. package/dist/http/StoredCredentials.js +85 -0
  63. package/dist/http/StoredCredentials.js.map +1 -0
  64. package/dist/intent/CanonicalIntentHasher.d.ts +1 -1
  65. package/dist/intent/CanonicalIntentHasher.d.ts.map +1 -1
  66. package/dist/intent/CanonicalIntentHasher.js +9 -2
  67. package/dist/intent/CanonicalIntentHasher.js.map +1 -1
  68. package/dist/intent/ExecutionIntent.d.ts +26 -2
  69. package/dist/intent/ExecutionIntent.d.ts.map +1 -1
  70. package/dist/intent/ExecutionIntent.js +11 -0
  71. package/dist/intent/ExecutionIntent.js.map +1 -1
  72. package/dist/intent/IntentCapture.d.ts +5 -0
  73. package/dist/intent/IntentCapture.d.ts.map +1 -1
  74. package/dist/intent/IntentCapture.js +15 -1
  75. package/dist/intent/IntentCapture.js.map +1 -1
  76. package/dist/report/DecisionReport.d.ts +22 -0
  77. package/dist/report/DecisionReport.d.ts.map +1 -0
  78. package/dist/report/DecisionReport.js +37 -0
  79. package/dist/report/DecisionReport.js.map +1 -0
  80. package/dist/report/DossierReport.d.ts +43 -0
  81. package/dist/report/DossierReport.d.ts.map +1 -0
  82. package/dist/report/DossierReport.js +130 -0
  83. package/dist/report/DossierReport.js.map +1 -0
  84. package/dist/report/HostedOutcome.d.ts +18 -0
  85. package/dist/report/HostedOutcome.d.ts.map +1 -0
  86. package/dist/report/HostedOutcome.js +24 -0
  87. package/dist/report/HostedOutcome.js.map +1 -0
  88. package/dist/shadow/ShadowPipeline.d.ts +75 -5
  89. package/dist/shadow/ShadowPipeline.d.ts.map +1 -1
  90. package/dist/shadow/ShadowPipeline.js +157 -7
  91. package/dist/shadow/ShadowPipeline.js.map +1 -1
  92. package/dist/testing/Index.d.ts +12 -0
  93. package/dist/testing/Index.d.ts.map +1 -0
  94. package/dist/testing/Index.js +12 -0
  95. package/dist/testing/Index.js.map +1 -0
  96. package/dist/testing/LocalAuthority.d.ts +292 -0
  97. package/dist/testing/LocalAuthority.d.ts.map +1 -0
  98. package/dist/testing/LocalAuthority.js +1124 -0
  99. package/dist/testing/LocalAuthority.js.map +1 -0
  100. package/dist/testing/LocalPresence.d.ts +210 -0
  101. package/dist/testing/LocalPresence.d.ts.map +1 -0
  102. package/dist/testing/LocalPresence.js +473 -0
  103. package/dist/testing/LocalPresence.js.map +1 -0
  104. package/package.json +40 -4
@@ -1,108 +1,885 @@
1
1
  import { z } from "zod";
2
2
  import { AuthorityBaseUrl } from "../http/AuthorityBaseUrl.js";
3
+ import { credentialReader } from "../http/Credential.js";
3
4
  import { BoundedResponseBody } from "../http/BoundedResponseBody.js";
5
+ import { userAgent } from "../http/ClientIdentification.js";
4
6
  import { CanonicalIntentHasher } from "../intent/CanonicalIntentHasher.js";
5
7
  import { FailClosedDecision, } from "./DecisionAuthority.js";
6
8
  import { immutableGateDecision } from "./ImmutableGateDecision.js";
7
- const reasonCode = z
9
+ const boundedIdentifier = z
8
10
  .string()
9
11
  .min(1)
10
- .max(120)
11
- .regex(/^[A-Z][A-Z0-9_:-]*$/);
12
+ .max(200)
13
+ .refine(hasNoControlCharacters, { message: "IDENTIFIER_INVALID" });
14
+ const intentHash = z.string().regex(/^sha256:[0-9a-f]{64}$/);
15
+ const reasonCodes = z.array(boundedIdentifier).max(50);
16
+ const roleIdentifier = z.string().regex(/^[A-Z][A-Z0-9_]{1,63}$/);
17
+ const ManagedEscalationRequestSchema = z
18
+ .object({
19
+ mode: z.literal("MANAGED"),
20
+ approver: z
21
+ .object({
22
+ principal_id: boundedIdentifier.optional(),
23
+ role_id: roleIdentifier.optional(),
24
+ })
25
+ .strict()
26
+ .refine((approver) => approver.principal_id !== undefined || approver.role_id !== undefined, {
27
+ message: "MANAGED_ESCALATION_APPROVER_EMPTY",
28
+ })
29
+ .optional(),
30
+ verification_requirements: z
31
+ .object({
32
+ methods: z
33
+ .array(z.enum(["WEBAUTHN", "ACTIVE_LIVENESS"]))
34
+ .min(1)
35
+ .max(3)
36
+ .refine((methods) => new Set(methods).size === methods.length, {
37
+ message: "MANAGED_ESCALATION_METHOD_DUPLICATE",
38
+ }),
39
+ level: z.enum(["STANDARD", "HIGH_CONFIDENCE"]).optional(),
40
+ })
41
+ .strict()
42
+ .optional(),
43
+ })
44
+ .strict();
45
+ const managedEscalationStatuses = [
46
+ "PENDING_PRESENCE",
47
+ "PRESENCE_REQUESTED",
48
+ "AWAITING_APPROVER",
49
+ "PRESENCE_VERIFIED",
50
+ "REAUTHORIZING",
51
+ "GRANT_READY",
52
+ "EXPIRED",
53
+ "REJECTED",
54
+ "BLOCKED",
55
+ "CANCELLED",
56
+ "FAILED",
57
+ ];
58
+ const managedEscalationPendingStatuses = new Set([
59
+ "PENDING_PRESENCE",
60
+ "PRESENCE_REQUESTED",
61
+ "AWAITING_APPROVER",
62
+ "PRESENCE_VERIFIED",
63
+ "REAUTHORIZING",
64
+ ]);
65
+ const ManagedEscalationWireSchema = z
66
+ .object({
67
+ outcome: z.enum(["ESCALATE_PENDING", "ALLOW", "BLOCK", "ERROR"]),
68
+ escalation_id: boundedIdentifier,
69
+ intent_id: boundedIdentifier,
70
+ status: z.enum(managedEscalationStatuses),
71
+ expires_at: z.string().datetime(),
72
+ reason_codes: reasonCodes,
73
+ })
74
+ .strict()
75
+ .superRefine((value, context) => {
76
+ const expectedOutcome = value.status === "GRANT_READY"
77
+ ? "ALLOW"
78
+ : value.status === "FAILED"
79
+ ? "ERROR"
80
+ : managedEscalationPendingStatuses.has(value.status)
81
+ ? "ESCALATE_PENDING"
82
+ : "BLOCK";
83
+ if (value.outcome !== expectedOutcome) {
84
+ context.addIssue({
85
+ code: z.ZodIssueCode.custom,
86
+ message: "MANAGED_ESCALATION_STATE_OUTCOME_MISMATCH",
87
+ });
88
+ }
89
+ });
90
+ /**
91
+ * The dossier's verification envelope, as `evaluate-decision` attaches it and
92
+ * `enforce-and-bind` may: the contract declares the block open, so only the
93
+ * page a person can open without an account is read from it.
94
+ */
95
+ const VerificationEnvelopeSchema = z
96
+ .object({
97
+ verification_page_url: z.string().url().max(2_000),
98
+ verification_url: z.string().url().max(2_000).optional(),
99
+ link_expires_at: z.string().datetime().nullable().optional(),
100
+ })
101
+ .passthrough();
102
+ /**
103
+ * Mirrors `ExecutionAuthorityDecision` in the Decionis OpenAPI contract, which
104
+ * declares `additionalProperties: false`; an undocumented field therefore fails
105
+ * closed instead of being interpreted as execution semantics.
106
+ */
12
107
  const AuthorityResponseSchema = z
13
108
  .object({
14
- decision_id: z.string().min(1).max(200),
109
+ decision_id: boundedIdentifier,
110
+ chain_id: boundedIdentifier.nullable(),
15
111
  status: z.enum(["ALLOW", "BLOCK", "ESCALATE", "REVIEW_REQUIRED", "ERROR"]),
16
112
  should_execute: z.boolean(),
17
- reason_codes: z.array(reasonCode).max(50),
18
- action_hash: z.string().regex(/^sha256:[0-9a-f]{64}$/),
19
- execution_token: z
20
- .string()
21
- .min(1)
22
- .max(100 * 1024)
23
- .nullable(),
113
+ reason_codes: reasonCodes,
114
+ action_hash: intentHash,
115
+ policy_version: boundedIdentifier.nullable().optional(),
116
+ mode: z.enum(["SHADOW", "PARALLEL", "ENFORCEMENT"]).nullable().optional(),
117
+ execution_token: z.string().min(1).max(20_000).nullable(),
24
118
  execution_token_expires_at: z.string().datetime().nullable(),
25
- dossier_id: z.string().min(1).max(200).nullable(),
119
+ dossier_id: boundedIdentifier.nullable(),
120
+ dossier_sha256: boundedIdentifier.nullable().optional(),
121
+ dossier_url: z.string().min(1).max(2_000).nullable(),
122
+ approval_request_id: boundedIdentifier.nullable().optional(),
123
+ ledger_entry_id: boundedIdentifier.nullable().optional(),
124
+ authority_classification: z.enum(["AUTHORITATIVE", "OBSERVATIONAL"]).optional(),
125
+ execution_eligible: z.boolean().optional(),
126
+ execution_binding_digest: intentHash.nullable().optional(),
127
+ execution_token_jti: boundedIdentifier.nullable().optional(),
128
+ execution_token_key_id: boundedIdentifier.nullable().optional(),
129
+ managed_escalation: ManagedEscalationWireSchema.nullable().optional(),
130
+ verification: VerificationEnvelopeSchema.nullable().optional(),
131
+ })
132
+ .strict();
133
+ const ManagedEscalationStatusResponseSchema = z
134
+ .object({
135
+ escalation_id: boundedIdentifier,
136
+ intent_id: boundedIdentifier,
137
+ action_hash: intentHash,
138
+ status: z.enum(managedEscalationStatuses),
139
+ outcome: z.enum(["ESCALATE_PENDING", "ALLOW", "BLOCK", "ERROR"]),
140
+ expires_at: z.string().datetime(),
141
+ reason_codes: reasonCodes,
142
+ decision: AuthorityResponseSchema.nullable(),
26
143
  })
27
144
  .strict();
145
+ const WAITING_STATUS_RANK = Object.freeze({
146
+ PENDING_PRESENCE: 0,
147
+ PRESENCE_REQUESTED: 1,
148
+ AWAITING_APPROVER: 2,
149
+ PRESENCE_VERIFIED: 3,
150
+ REAUTHORIZING: 4,
151
+ });
28
152
  const MAX_RESPONSE_BYTES = 100 * 1024;
153
+ const DEFAULT_MANAGED_INITIAL_DELAY_MS = 500;
154
+ const DEFAULT_MANAGED_MAX_DELAY_MS = 5_000;
155
+ const MAX_MANAGED_DELAY_MS = 5_000;
156
+ const MAX_MANAGED_ATTEMPTS = 1_000;
29
157
  export class DecionisGate {
158
+ evaluationMode;
30
159
  baseUrl;
31
160
  apiKey;
32
161
  timeoutMs;
33
162
  fetchImpl;
163
+ userAgent;
164
+ pendingAuthorizationWaits = new Map();
34
165
  constructor(options) {
35
166
  this.baseUrl = AuthorityBaseUrl.normalize(options.baseUrl, options.allowInsecureLoopback === true);
36
- this.apiKey = options.apiKey;
167
+ this.apiKey = credentialReader(options.apiKey);
37
168
  this.timeoutMs = Math.min(Math.max(options.timeoutMs ?? 4_000, 1), 15_000);
38
169
  this.fetchImpl = options.fetch ?? fetch;
170
+ this.userAgent = userAgent(options.source);
171
+ const mode = options.mode ?? "ENFORCEMENT";
172
+ if (mode !== "ENFORCEMENT" && mode !== "SHADOW") {
173
+ throw new Error("DECIONIS_GATE_MODE_INVALID");
174
+ }
175
+ this.evaluationMode = mode;
39
176
  }
40
- async evaluate(captured, evidence) {
177
+ async evaluate(captured, evidence, options = {}) {
178
+ // An intent past its expiry is refused before the authority is asked: no
179
+ // grant could outlive it, and a decision about it would only record a
180
+ // dead intent.
181
+ if (!(Date.parse(captured.intent.expiresAt) > Date.now())) {
182
+ return FailClosedDecision.create(captured.intentHash, "INTENT_EXPIRED");
183
+ }
184
+ const escalation = this.validEscalationRequest(options.escalation);
185
+ if (escalation === null) {
186
+ return FailClosedDecision.create(captured.intentHash, "MANAGED_ESCALATION_REQUEST_INVALID");
187
+ }
188
+ if (escalation !== undefined && this.evaluationMode !== "ENFORCEMENT") {
189
+ return FailClosedDecision.create(captured.intentHash, "MANAGED_ESCALATION_SHADOW_FORBIDDEN");
190
+ }
191
+ if (escalation !== undefined && evidence !== undefined) {
192
+ return FailClosedDecision.create(captured.intentHash, "MANAGED_ESCALATION_EVIDENCE_FORBIDDEN");
193
+ }
41
194
  const controller = new AbortController();
42
195
  const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
43
196
  try {
44
197
  const response = await this.fetchImpl(`${this.baseUrl}/v1/authority/enforce-and-bind`, {
45
198
  method: "POST",
46
199
  headers: {
47
- authorization: `Bearer ${this.apiKey}`,
200
+ authorization: `Bearer ${this.apiKey()}`,
48
201
  "content-type": "application/json",
49
- "idempotency-key": captured.intent.idempotencyKey,
202
+ // The contract requires the header to equal the signed intent_id;
203
+ // intent_id is the authority's grant-issuance boundary.
204
+ "idempotency-key": captured.intent.intentId,
205
+ "user-agent": this.userAgent,
50
206
  },
51
207
  body: JSON.stringify({
52
208
  ...CanonicalIntentHasher.bindingOf(captured.intent),
53
209
  intent_hash: captured.intentHash,
54
- mode: "ENFORCEMENT",
210
+ mode: this.evaluationMode,
55
211
  ...(evidence === undefined ? {} : { evidence }),
212
+ ...(escalation === undefined ? {} : { escalation }),
56
213
  }),
57
214
  signal: controller.signal,
58
215
  });
59
- if (!response.ok) {
60
- return FailClosedDecision.create(captured.intentHash, "AUTHORITY_REQUEST_FAILED");
61
- }
62
216
  const text = await BoundedResponseBody.read(response, MAX_RESPONSE_BYTES);
63
217
  if (text === null) {
64
218
  return FailClosedDecision.create(captured.intentHash, "AUTHORITY_RESPONSE_TOO_LARGE");
65
219
  }
220
+ if (!response.ok) {
221
+ return DecionisGate.failedResponse(captured, text);
222
+ }
66
223
  const parsed = AuthorityResponseSchema.parse(JSON.parse(text));
67
- if (parsed.action_hash !== captured.intentHash) {
68
- return FailClosedDecision.create(captured.intentHash, "AUTHORITY_BINDING_MISMATCH");
69
- }
70
- const verdict = parsed.status === "ALLOW"
71
- ? "ALLOW"
72
- : parsed.status === "ESCALATE" || parsed.status === "REVIEW_REQUIRED"
73
- ? "ESCALATE"
74
- : "BLOCK";
75
- const canExecute = verdict === "ALLOW" &&
76
- parsed.should_execute &&
77
- parsed.dossier_id !== null &&
78
- parsed.execution_token !== null &&
79
- parsed.execution_token_expires_at !== null &&
80
- Date.parse(parsed.execution_token_expires_at) > Date.now() &&
81
- Date.parse(parsed.execution_token_expires_at) <= Date.parse(captured.intent.expiresAt);
82
- if (verdict === "ALLOW" && !canExecute) {
83
- return FailClosedDecision.create(captured.intentHash, "AUTHORITY_GRANT_MISSING");
224
+ const managed = this.initialManagedState(captured, parsed, escalation);
225
+ if (managed.reasonCode !== undefined) {
226
+ return FailClosedDecision.create(captured.intentHash, managed.reasonCode);
227
+ }
228
+ return this.decisionFromResponse(captured, parsed, evidence, managed.state);
229
+ }
230
+ catch {
231
+ return FailClosedDecision.create(captured.intentHash, "AUTHORITY_UNAVAILABLE");
232
+ }
233
+ finally {
234
+ clearTimeout(timeout);
235
+ }
236
+ }
237
+ /** Performs one authenticated, read-only status lookup against Decionis. */
238
+ async getManagedEscalationStatus(captured, escalation, options = {}) {
239
+ const expectedFailure = this.expectedStateFailure(captured, escalation);
240
+ if (expectedFailure !== null) {
241
+ return this.failedManagedStatus(captured, escalation, expectedFailure);
242
+ }
243
+ const now = DecionisGate.clockNow(options.clock ?? Date.now);
244
+ if (now === null) {
245
+ return this.failedManagedStatus(captured, escalation, "MANAGED_ESCALATION_CLOCK_INVALID");
246
+ }
247
+ const deadline = this.managedDeadline(captured, escalation);
248
+ if (deadline === null) {
249
+ return this.failedManagedStatus(captured, escalation, "MANAGED_ESCALATION_EXPIRY_INVALID");
250
+ }
251
+ if (now >= deadline)
252
+ return this.expiredManagedStatus(captured, escalation);
253
+ if (options.signal?.aborted === true) {
254
+ return this.failedManagedStatus(captured, escalation, "MANAGED_ESCALATION_ABORTED");
255
+ }
256
+ const controller = new AbortController();
257
+ let callerAborted = false;
258
+ const onAbort = () => {
259
+ callerAborted = true;
260
+ controller.abort();
261
+ };
262
+ options.signal?.addEventListener("abort", onAbort, { once: true });
263
+ if (DecionisGate.signalAborted(options.signal))
264
+ onAbort();
265
+ const timeout = setTimeout(() => controller.abort(), Math.max(1, Math.min(this.timeoutMs, deadline - now)));
266
+ try {
267
+ const response = await this.fetchImpl(`${this.baseUrl}/v1/authority/escalations/${encodeURIComponent(escalation.escalationId)}`, {
268
+ method: "GET",
269
+ headers: { authorization: `Bearer ${this.apiKey()}`, "user-agent": this.userAgent },
270
+ signal: controller.signal,
271
+ });
272
+ const text = await BoundedResponseBody.read(response, MAX_RESPONSE_BYTES);
273
+ if (text === null) {
274
+ return this.failedManagedStatus(captured, escalation, "MANAGED_ESCALATION_RESPONSE_TOO_LARGE");
275
+ }
276
+ if (!response.ok) {
277
+ return this.failedManagedStatus(captured, escalation, "MANAGED_ESCALATION_STATUS_FAILED");
278
+ }
279
+ let body;
280
+ try {
281
+ body = JSON.parse(text);
282
+ }
283
+ catch {
284
+ return this.failedManagedStatus(captured, escalation, "MANAGED_ESCALATION_RESPONSE_INVALID");
285
+ }
286
+ const result = ManagedEscalationStatusResponseSchema.safeParse(body);
287
+ if (!result.success) {
288
+ return this.failedManagedStatus(captured, escalation, "MANAGED_ESCALATION_RESPONSE_INVALID");
289
+ }
290
+ const parsed = result.data;
291
+ const mismatch = this.statusBindingFailure(captured, escalation, parsed);
292
+ if (mismatch !== null)
293
+ return this.failedManagedStatus(captured, escalation, mismatch);
294
+ const after = DecionisGate.clockNow(options.clock ?? Date.now);
295
+ if (after === null) {
296
+ return this.failedManagedStatus(captured, escalation, "MANAGED_ESCALATION_CLOCK_INVALID");
297
+ }
298
+ if (after >= deadline)
299
+ return this.expiredManagedStatus(captured, escalation);
300
+ return this.statusResult(captured, parsed);
301
+ }
302
+ catch {
303
+ if (callerAborted) {
304
+ return this.failedManagedStatus(captured, escalation, "MANAGED_ESCALATION_ABORTED");
305
+ }
306
+ const after = DecionisGate.clockNow(options.clock ?? Date.now);
307
+ if (after !== null && after >= deadline) {
308
+ return this.expiredManagedStatus(captured, escalation);
309
+ }
310
+ return this.failedManagedStatus(captured, escalation, "MANAGED_ESCALATION_STATUS_FAILED");
311
+ }
312
+ finally {
313
+ clearTimeout(timeout);
314
+ options.signal?.removeEventListener("abort", onAbort);
315
+ }
316
+ }
317
+ /**
318
+ * Waits only on Decionis. Presence credentials, invitation locators, and
319
+ * receipt evidence never cross this client boundary in managed mode.
320
+ */
321
+ async waitForAuthorization(captured, pendingDecision, options = {}) {
322
+ const escalation = pendingDecision.managedEscalation;
323
+ if (escalation === undefined ||
324
+ pendingDecision.verdict !== "ESCALATE" ||
325
+ pendingDecision.authorization !== null ||
326
+ pendingDecision.intentHash !== captured.intentHash ||
327
+ !DecionisGate.validManagedStateSemantics(escalation) ||
328
+ this.expectedStateFailure(captured, escalation) !== null) {
329
+ return FailClosedDecision.create(captured.intentHash, "MANAGED_ESCALATION_REFERENCE_INVALID");
330
+ }
331
+ const configuration = DecionisGate.waitConfiguration(options);
332
+ if (configuration === null) {
333
+ return this.failedWaitDecision(captured, pendingDecision, escalation, "MANAGED_ESCALATION_POLLING_OPTIONS_INVALID");
334
+ }
335
+ // Default waits for the same exact binding share one read-only poll loop.
336
+ // Custom scheduling or cancellation stays caller-local.
337
+ if (Object.keys(options).length === 0) {
338
+ const key = `${captured.intentHash}\u0000${escalation.escalationId}`;
339
+ const existing = this.pendingAuthorizationWaits.get(key);
340
+ if (existing !== undefined)
341
+ return await existing;
342
+ const wait = this.waitForAuthorizationInternal(captured, pendingDecision, escalation, configuration, options.signal).finally(() => this.pendingAuthorizationWaits.delete(key));
343
+ this.pendingAuthorizationWaits.set(key, wait);
344
+ return await wait;
345
+ }
346
+ return await this.waitForAuthorizationInternal(captured, pendingDecision, escalation, configuration, options.signal);
347
+ }
348
+ async waitForAuthorizationInternal(captured, pendingDecision, escalation, configuration, signal) {
349
+ const deadline = this.managedDeadline(captured, escalation);
350
+ if (deadline === null) {
351
+ return this.failedWaitDecision(captured, pendingDecision, escalation, "MANAGED_ESCALATION_EXPIRY_INVALID");
352
+ }
353
+ let lastStatus = DecionisGate.isPendingStatus(escalation.status)
354
+ ? escalation.status
355
+ : undefined;
356
+ for (let attempt = 0; configuration.maxAttempts === null || attempt < configuration.maxAttempts; attempt += 1) {
357
+ const boundary = this.waitBoundaryFailure(configuration.clock, deadline, signal);
358
+ if (boundary !== null) {
359
+ return boundary === "INTENT_EXPIRED"
360
+ ? this.expiredWaitDecision(captured, pendingDecision, escalation)
361
+ : this.failedWaitDecision(captured, pendingDecision, escalation, boundary);
362
+ }
363
+ const status = await this.getManagedEscalationStatus(captured, escalation, {
364
+ ...(signal === undefined ? {} : { signal }),
365
+ clock: configuration.clock,
366
+ });
367
+ const afterLookup = this.waitBoundaryFailure(configuration.clock, deadline, signal);
368
+ if (afterLookup !== null) {
369
+ return afterLookup === "INTENT_EXPIRED"
370
+ ? this.expiredWaitDecision(captured, pendingDecision, escalation)
371
+ : this.failedWaitDecision(captured, pendingDecision, escalation, afterLookup);
84
372
  }
373
+ if (!DecionisGate.isPendingStatus(escalation.status) && status.status !== escalation.status) {
374
+ return this.failedWaitDecision(captured, pendingDecision, escalation, "MANAGED_ESCALATION_STATE_REGRESSION");
375
+ }
376
+ if (status.status === "GRANT_READY")
377
+ return status.decision;
378
+ if (status.outcome !== "ESCALATE_PENDING") {
379
+ return this.terminalWaitDecision(captured, pendingDecision, status);
380
+ }
381
+ if (lastStatus === undefined ||
382
+ WAITING_STATUS_RANK[status.status] < WAITING_STATUS_RANK[lastStatus]) {
383
+ return this.failedWaitDecision(captured, pendingDecision, escalation, "MANAGED_ESCALATION_STATE_REGRESSION");
384
+ }
385
+ lastStatus = status.status;
386
+ if (configuration.maxAttempts !== null && attempt + 1 >= configuration.maxAttempts) {
387
+ return this.failedWaitDecision(captured, pendingDecision, escalation, "MANAGED_ESCALATION_TIMEOUT");
388
+ }
389
+ const delay = DecionisGate.backoffDelay(configuration, attempt);
390
+ if (delay === null) {
391
+ return this.failedWaitDecision(captured, pendingDecision, escalation, "MANAGED_ESCALATION_POLLING_RANDOM_INVALID");
392
+ }
393
+ const now = DecionisGate.clockNow(configuration.clock);
394
+ if (now === null) {
395
+ return this.failedWaitDecision(captured, pendingDecision, escalation, "MANAGED_ESCALATION_CLOCK_INVALID");
396
+ }
397
+ const remaining = deadline - now;
398
+ if (remaining <= 0)
399
+ return this.expiredWaitDecision(captured, pendingDecision, escalation);
400
+ const slept = await DecionisGate.boundedSleep(configuration.sleep, Math.min(delay, remaining), remaining, signal);
401
+ if (slept !== null) {
402
+ return slept === "INTENT_EXPIRED"
403
+ ? this.expiredWaitDecision(captured, pendingDecision, escalation)
404
+ : this.failedWaitDecision(captured, pendingDecision, escalation, slept);
405
+ }
406
+ }
407
+ return this.failedWaitDecision(captured, pendingDecision, escalation, "MANAGED_ESCALATION_TIMEOUT");
408
+ }
409
+ decisionFromResponse(captured, parsed, evidence, managedEscalation) {
410
+ if (parsed.action_hash !== captured.intentHash) {
411
+ return FailClosedDecision.create(captured.intentHash, "AUTHORITY_BINDING_MISMATCH");
412
+ }
413
+ const mode = parsed.mode ?? null;
414
+ if (mode !== null && mode !== this.evaluationMode) {
415
+ return FailClosedDecision.create(captured.intentHash, "AUTHORITY_MODE_MISMATCH");
416
+ }
417
+ const verdict = parsed.status === "ALLOW"
418
+ ? "ALLOW"
419
+ : parsed.status === "ESCALATE" || parsed.status === "REVIEW_REQUIRED"
420
+ ? "ESCALATE"
421
+ : "BLOCK";
422
+ const verificationUrl = parsed.verification?.verification_page_url;
423
+ const verification = verificationUrl === undefined ? {} : { verificationUrl };
424
+ if (this.evaluationMode === "SHADOW") {
85
425
  return immutableGateDecision({
86
426
  verdict,
87
427
  decisionId: parsed.decision_id,
88
428
  dossierId: parsed.dossier_id,
89
429
  intentHash: parsed.action_hash,
90
430
  reasonCodes: parsed.reason_codes,
91
- authorization: canExecute
92
- ? {
93
- token: parsed.execution_token,
94
- expiresAt: parsed.execution_token_expires_at,
95
- }
96
- : null,
431
+ authorization: null,
97
432
  failClosed: parsed.status === "ERROR",
433
+ ...verification,
434
+ ...(evidence === undefined ? {} : { evidence }),
435
+ });
436
+ }
437
+ const canExecute = verdict === "ALLOW" &&
438
+ parsed.should_execute &&
439
+ mode === "ENFORCEMENT" &&
440
+ (parsed.authority_classification === undefined ||
441
+ parsed.authority_classification === "AUTHORITATIVE") &&
442
+ (parsed.execution_eligible === undefined || parsed.execution_eligible === true) &&
443
+ (parsed.execution_binding_digest === undefined || parsed.execution_binding_digest !== null) &&
444
+ (parsed.execution_token_jti === undefined || parsed.execution_token_jti !== null) &&
445
+ (parsed.execution_token_key_id === undefined || parsed.execution_token_key_id !== null) &&
446
+ parsed.dossier_id !== null &&
447
+ parsed.execution_token !== null &&
448
+ parsed.execution_token_expires_at !== null &&
449
+ Date.parse(parsed.execution_token_expires_at) > Date.now() &&
450
+ Date.parse(parsed.execution_token_expires_at) <= Date.parse(captured.intent.expiresAt);
451
+ if (verdict === "ALLOW" && !canExecute) {
452
+ return FailClosedDecision.create(captured.intentHash, "AUTHORITY_GRANT_MISSING");
453
+ }
454
+ return immutableGateDecision({
455
+ verdict,
456
+ decisionId: parsed.decision_id,
457
+ dossierId: parsed.dossier_id,
458
+ intentHash: parsed.action_hash,
459
+ reasonCodes: parsed.reason_codes,
460
+ authorization: canExecute
461
+ ? {
462
+ token: parsed.execution_token,
463
+ expiresAt: parsed.execution_token_expires_at,
464
+ }
465
+ : null,
466
+ failClosed: parsed.status === "ERROR",
467
+ ...verification,
468
+ ...(evidence === undefined ? {} : { evidence }),
469
+ ...(managedEscalation === undefined ? {} : { managedEscalation }),
470
+ });
471
+ }
472
+ initialManagedState(captured, response, requested) {
473
+ const managed = response.managed_escalation ?? undefined;
474
+ if (managed === undefined) {
475
+ return requested !== undefined &&
476
+ (response.status === "ESCALATE" || response.status === "REVIEW_REQUIRED")
477
+ ? { reasonCode: "MANAGED_ESCALATION_MISSING" }
478
+ : {};
479
+ }
480
+ if (requested === undefined)
481
+ return { reasonCode: "MANAGED_ESCALATION_UNEXPECTED" };
482
+ if (response.status !== "ESCALATE" ||
483
+ response.should_execute ||
484
+ response.execution_token !== null ||
485
+ response.execution_token_expires_at !== null ||
486
+ response.execution_eligible === true ||
487
+ response.execution_binding_digest != null ||
488
+ response.execution_token_jti != null ||
489
+ response.execution_token_key_id != null) {
490
+ return { reasonCode: "MANAGED_ESCALATION_PENDING_GRANT_FORBIDDEN" };
491
+ }
492
+ const state = DecionisGate.managedStateFromWire(managed);
493
+ const failure = this.expectedStateFailure(captured, state);
494
+ return failure === null ? { state } : { reasonCode: failure };
495
+ }
496
+ statusResult(captured, response) {
497
+ const base = {
498
+ escalationId: response.escalation_id,
499
+ intentId: response.intent_id,
500
+ actionHash: response.action_hash,
501
+ expiresAt: response.expires_at,
502
+ reasonCodes: Object.freeze([...response.reason_codes]),
503
+ };
504
+ if (DecionisGate.isPendingStatus(response.status)) {
505
+ if (response.outcome !== "ESCALATE_PENDING" || response.decision !== null) {
506
+ return this.failedStatusFromBase(base, "MANAGED_ESCALATION_RESPONSE_INVALID");
507
+ }
508
+ return Object.freeze({
509
+ ...base,
510
+ status: response.status,
511
+ outcome: "ESCALATE_PENDING",
512
+ decision: null,
513
+ });
514
+ }
515
+ if (response.status === "GRANT_READY") {
516
+ if (response.outcome !== "ALLOW" ||
517
+ response.decision === null ||
518
+ response.decision.managed_escalation != null) {
519
+ return this.failedStatusFromBase(base, "MANAGED_ESCALATION_GRANT_INVALID");
520
+ }
521
+ const state = DecionisGate.managedStateFromStatus(response);
522
+ const decision = this.decisionFromResponse(captured, response.decision, undefined, state);
523
+ if (decision.verdict !== "ALLOW" || decision.authorization === null || decision.failClosed) {
524
+ return this.failedStatusFromBase(base, decision.reasonCodes[0] ?? "MANAGED_ESCALATION_GRANT_INVALID");
525
+ }
526
+ return Object.freeze({
527
+ ...base,
528
+ status: "GRANT_READY",
529
+ outcome: "ALLOW",
530
+ decision,
98
531
  });
99
532
  }
533
+ if (response.status === "FAILED") {
534
+ if (response.outcome !== "ERROR" || response.decision !== null) {
535
+ return this.failedStatusFromBase(base, "MANAGED_ESCALATION_RESPONSE_INVALID");
536
+ }
537
+ return Object.freeze({ ...base, status: "FAILED", outcome: "ERROR", decision: null });
538
+ }
539
+ if (response.outcome !== "BLOCK" || response.decision !== null) {
540
+ return this.failedStatusFromBase(base, "MANAGED_ESCALATION_RESPONSE_INVALID");
541
+ }
542
+ return Object.freeze({
543
+ ...base,
544
+ status: response.status,
545
+ outcome: "BLOCK",
546
+ decision: null,
547
+ });
548
+ }
549
+ expectedStateFailure(captured, escalation) {
550
+ if (!DecionisGate.isBoundedIdentifier(escalation.escalationId)) {
551
+ return "MANAGED_ESCALATION_ID_INVALID";
552
+ }
553
+ if (escalation.intentId !== captured.intent.intentId) {
554
+ return "MANAGED_ESCALATION_INTENT_MISMATCH";
555
+ }
556
+ const expiresAt = Date.parse(escalation.expiresAt);
557
+ const intentExpiresAt = Date.parse(captured.intent.expiresAt);
558
+ if (!Number.isFinite(expiresAt) ||
559
+ !Number.isFinite(intentExpiresAt) ||
560
+ expiresAt > intentExpiresAt ||
561
+ expiresAt <= Date.parse(captured.intent.capturedAt)) {
562
+ return "MANAGED_ESCALATION_EXPIRY_INVALID";
563
+ }
564
+ return null;
565
+ }
566
+ statusBindingFailure(captured, expected, actual) {
567
+ if (actual.escalation_id !== expected.escalationId) {
568
+ return "MANAGED_ESCALATION_ID_MISMATCH";
569
+ }
570
+ if (actual.intent_id !== expected.intentId || actual.intent_id !== captured.intent.intentId) {
571
+ return "MANAGED_ESCALATION_INTENT_MISMATCH";
572
+ }
573
+ if (actual.action_hash !== captured.intentHash)
574
+ return "AUTHORITY_BINDING_MISMATCH";
575
+ if (actual.expires_at !== expected.expiresAt)
576
+ return "MANAGED_ESCALATION_EXPIRY_MISMATCH";
577
+ return null;
578
+ }
579
+ managedDeadline(captured, escalation) {
580
+ const intentExpiresAt = Date.parse(captured.intent.expiresAt);
581
+ const escalationExpiresAt = Date.parse(escalation.expiresAt);
582
+ return Number.isFinite(intentExpiresAt) && Number.isFinite(escalationExpiresAt)
583
+ ? Math.min(intentExpiresAt, escalationExpiresAt)
584
+ : null;
585
+ }
586
+ waitBoundaryFailure(clock, deadline, signal) {
587
+ if (signal?.aborted === true)
588
+ return "MANAGED_ESCALATION_ABORTED";
589
+ const now = DecionisGate.clockNow(clock);
590
+ if (now === null)
591
+ return "MANAGED_ESCALATION_CLOCK_INVALID";
592
+ return now >= deadline ? "INTENT_EXPIRED" : null;
593
+ }
594
+ terminalWaitDecision(captured, pending, status) {
595
+ const fallback = status.status === "EXPIRED"
596
+ ? ["INTENT_EXPIRED", "RECAPTURE_REQUIRED"]
597
+ : status.status === "REJECTED"
598
+ ? ["PRESENCE_REJECTED"]
599
+ : status.status === "BLOCKED"
600
+ ? ["REAUTHORIZATION_BLOCKED"]
601
+ : status.status === "CANCELLED"
602
+ ? ["CANCELLED"]
603
+ : ["MANAGED_ESCALATION_FAILED"];
604
+ const codes = status.reasonCodes.length === 0 ? fallback : status.reasonCodes;
605
+ return immutableGateDecision({
606
+ verdict: "BLOCK",
607
+ decisionId: pending.decisionId,
608
+ dossierId: pending.dossierId,
609
+ intentHash: captured.intentHash,
610
+ reasonCodes: codes,
611
+ authorization: null,
612
+ failClosed: status.outcome === "ERROR",
613
+ managedEscalation: DecionisGate.managedStateFromStatusResult(status),
614
+ });
615
+ }
616
+ expiredWaitDecision(captured, pending, escalation) {
617
+ return immutableGateDecision({
618
+ verdict: "BLOCK",
619
+ decisionId: pending.decisionId,
620
+ dossierId: pending.dossierId,
621
+ intentHash: captured.intentHash,
622
+ reasonCodes: ["INTENT_EXPIRED", "RECAPTURE_REQUIRED"],
623
+ authorization: null,
624
+ failClosed: false,
625
+ managedEscalation: {
626
+ ...escalation,
627
+ status: "EXPIRED",
628
+ outcome: "BLOCK",
629
+ reasonCodes: ["INTENT_EXPIRED", "RECAPTURE_REQUIRED"],
630
+ },
631
+ });
632
+ }
633
+ failedWaitDecision(captured, pending, escalation, reasonCode) {
634
+ return immutableGateDecision({
635
+ verdict: "BLOCK",
636
+ decisionId: pending.decisionId,
637
+ dossierId: pending.dossierId,
638
+ intentHash: captured.intentHash,
639
+ reasonCodes: [reasonCode],
640
+ authorization: null,
641
+ failClosed: true,
642
+ managedEscalation: {
643
+ ...escalation,
644
+ status: "FAILED",
645
+ outcome: "ERROR",
646
+ reasonCodes: [reasonCode],
647
+ },
648
+ });
649
+ }
650
+ failedManagedStatus(captured, escalation, reasonCode) {
651
+ return this.failedStatusFromBase({
652
+ escalationId: escalation.escalationId,
653
+ intentId: escalation.intentId,
654
+ actionHash: captured.intentHash,
655
+ expiresAt: escalation.expiresAt,
656
+ reasonCodes: Object.freeze([reasonCode]),
657
+ }, reasonCode);
658
+ }
659
+ expiredManagedStatus(captured, escalation) {
660
+ return Object.freeze({
661
+ escalationId: escalation.escalationId,
662
+ intentId: escalation.intentId,
663
+ actionHash: captured.intentHash,
664
+ expiresAt: escalation.expiresAt,
665
+ reasonCodes: Object.freeze(["INTENT_EXPIRED", "RECAPTURE_REQUIRED"]),
666
+ status: "EXPIRED",
667
+ outcome: "BLOCK",
668
+ decision: null,
669
+ });
670
+ }
671
+ failedStatusFromBase(base, reasonCode) {
672
+ return Object.freeze({
673
+ ...base,
674
+ reasonCodes: Object.freeze([reasonCode]),
675
+ status: "FAILED",
676
+ outcome: "ERROR",
677
+ decision: null,
678
+ });
679
+ }
680
+ validEscalationRequest(value) {
681
+ if (value === undefined)
682
+ return undefined;
683
+ const parsed = ManagedEscalationRequestSchema.safeParse(value);
684
+ if (!parsed.success)
685
+ return null;
686
+ return {
687
+ mode: parsed.data.mode,
688
+ ...(parsed.data.approver === undefined
689
+ ? {}
690
+ : {
691
+ approver: {
692
+ ...(parsed.data.approver.principal_id === undefined
693
+ ? {}
694
+ : { principal_id: parsed.data.approver.principal_id }),
695
+ ...(parsed.data.approver.role_id === undefined
696
+ ? {}
697
+ : { role_id: parsed.data.approver.role_id }),
698
+ },
699
+ }),
700
+ ...(parsed.data.verification_requirements === undefined
701
+ ? {}
702
+ : {
703
+ verification_requirements: {
704
+ methods: parsed.data.verification_requirements.methods,
705
+ ...(parsed.data.verification_requirements.level === undefined
706
+ ? {}
707
+ : { level: parsed.data.verification_requirements.level }),
708
+ },
709
+ }),
710
+ };
711
+ }
712
+ static managedStateFromWire(value) {
713
+ return {
714
+ escalationId: value.escalation_id,
715
+ intentId: value.intent_id,
716
+ status: value.status,
717
+ outcome: value.outcome,
718
+ expiresAt: value.expires_at,
719
+ reasonCodes: value.reason_codes,
720
+ };
721
+ }
722
+ static managedStateFromStatus(value) {
723
+ return {
724
+ escalationId: value.escalation_id,
725
+ intentId: value.intent_id,
726
+ status: value.status,
727
+ outcome: value.outcome,
728
+ expiresAt: value.expires_at,
729
+ reasonCodes: value.reason_codes,
730
+ };
731
+ }
732
+ static managedStateFromStatusResult(value) {
733
+ return {
734
+ escalationId: value.escalationId,
735
+ intentId: value.intentId,
736
+ status: value.status,
737
+ outcome: value.outcome,
738
+ expiresAt: value.expiresAt,
739
+ reasonCodes: value.reasonCodes,
740
+ };
741
+ }
742
+ static isPendingStatus(status) {
743
+ return Object.hasOwn(WAITING_STATUS_RANK, status);
744
+ }
745
+ static validManagedStateSemantics(state) {
746
+ if (DecionisGate.isPendingStatus(state.status)) {
747
+ return state.outcome === "ESCALATE_PENDING";
748
+ }
749
+ if (state.status === "GRANT_READY")
750
+ return state.outcome === "ALLOW";
751
+ if (state.status === "FAILED")
752
+ return state.outcome === "ERROR";
753
+ return state.outcome === "BLOCK";
754
+ }
755
+ static waitConfiguration(options) {
756
+ const maxAttempts = options.maxAttempts ?? null;
757
+ const initialDelayMs = options.initialDelayMs ?? DEFAULT_MANAGED_INITIAL_DELAY_MS;
758
+ const maxDelayMs = options.maxDelayMs ?? DEFAULT_MANAGED_MAX_DELAY_MS;
759
+ if ((maxAttempts !== null &&
760
+ (!Number.isInteger(maxAttempts) ||
761
+ maxAttempts < 1 ||
762
+ maxAttempts > MAX_MANAGED_ATTEMPTS)) ||
763
+ !Number.isInteger(initialDelayMs) ||
764
+ initialDelayMs < 1 ||
765
+ initialDelayMs > MAX_MANAGED_DELAY_MS ||
766
+ !Number.isInteger(maxDelayMs) ||
767
+ maxDelayMs < initialDelayMs ||
768
+ maxDelayMs > MAX_MANAGED_DELAY_MS ||
769
+ (options.clock !== undefined && typeof options.clock !== "function") ||
770
+ (options.sleep !== undefined && typeof options.sleep !== "function") ||
771
+ (options.random !== undefined && typeof options.random !== "function")) {
772
+ return null;
773
+ }
774
+ return {
775
+ maxAttempts,
776
+ initialDelayMs,
777
+ maxDelayMs,
778
+ clock: options.clock ?? Date.now,
779
+ sleep: options.sleep ?? DecionisGate.sleep,
780
+ random: options.random ?? Math.random,
781
+ };
782
+ }
783
+ static backoffDelay(configuration, attempt) {
784
+ let random;
785
+ try {
786
+ random = configuration.random();
787
+ }
100
788
  catch {
101
- return FailClosedDecision.create(captured.intentHash, "AUTHORITY_UNAVAILABLE");
789
+ return null;
102
790
  }
103
- finally {
104
- clearTimeout(timeout);
791
+ if (!Number.isFinite(random) || random < 0 || random > 1)
792
+ return null;
793
+ const exponential = configuration.initialDelayMs * 2 ** Math.min(attempt, 30);
794
+ const capped = Math.min(exponential, configuration.maxDelayMs);
795
+ return Math.max(1, Math.floor(capped * (0.5 + random * 0.5)));
796
+ }
797
+ static async boundedSleep(sleep, delayMs, remainingMs, signal) {
798
+ if (signal?.aborted === true)
799
+ return "MANAGED_ESCALATION_ABORTED";
800
+ return await new Promise((resolve) => {
801
+ let settled = false;
802
+ const settle = (reason) => {
803
+ if (settled)
804
+ return;
805
+ settled = true;
806
+ clearTimeout(timer);
807
+ signal?.removeEventListener("abort", onAbort);
808
+ resolve(reason);
809
+ };
810
+ const onAbort = () => settle("MANAGED_ESCALATION_ABORTED");
811
+ const timer = setTimeout(() => settle("INTENT_EXPIRED"), remainingMs);
812
+ signal?.addEventListener("abort", onAbort, { once: true });
813
+ void Promise.resolve()
814
+ .then(async () => await sleep(delayMs, signal))
815
+ .then(() => settle(null), () => settle(signal?.aborted === true
816
+ ? "MANAGED_ESCALATION_ABORTED"
817
+ : "MANAGED_ESCALATION_UNAVAILABLE"));
818
+ });
819
+ }
820
+ static async sleep(delayMs, signal) {
821
+ await new Promise((resolve, reject) => {
822
+ if (signal?.aborted === true) {
823
+ reject(new Error("MANAGED_ESCALATION_ABORTED"));
824
+ return;
825
+ }
826
+ const onAbort = () => {
827
+ clearTimeout(timer);
828
+ reject(new Error("MANAGED_ESCALATION_ABORTED"));
829
+ };
830
+ const timer = setTimeout(() => {
831
+ signal?.removeEventListener("abort", onAbort);
832
+ resolve();
833
+ }, delayMs);
834
+ signal?.addEventListener("abort", onAbort, { once: true });
835
+ });
836
+ }
837
+ static clockNow(clock) {
838
+ try {
839
+ const now = clock();
840
+ return Number.isFinite(now) ? now : null;
105
841
  }
842
+ catch {
843
+ return null;
844
+ }
845
+ }
846
+ static signalAborted(signal) {
847
+ return signal?.aborted === true;
848
+ }
849
+ static isBoundedIdentifier(value) {
850
+ return value.length > 0 && value.length <= 200 && hasNoControlCharacters(value);
851
+ }
852
+ /**
853
+ * The contract returns an `ERROR` decision body on 409 and 503 so the
854
+ * refusal is evidence-bearing. Anything else fails closed generically.
855
+ */
856
+ static failedResponse(captured, text) {
857
+ try {
858
+ const parsed = AuthorityResponseSchema.parse(JSON.parse(text));
859
+ if (parsed.status !== "ERROR" || parsed.action_hash !== captured.intentHash) {
860
+ return FailClosedDecision.create(captured.intentHash, "AUTHORITY_REQUEST_FAILED");
861
+ }
862
+ return immutableGateDecision({
863
+ verdict: "BLOCK",
864
+ decisionId: parsed.decision_id,
865
+ dossierId: parsed.dossier_id,
866
+ intentHash: captured.intentHash,
867
+ reasonCodes: parsed.reason_codes.length === 0 ? ["AUTHORITY_REQUEST_FAILED"] : parsed.reason_codes,
868
+ authorization: null,
869
+ failClosed: true,
870
+ });
871
+ }
872
+ catch {
873
+ return FailClosedDecision.create(captured.intentHash, "AUTHORITY_REQUEST_FAILED");
874
+ }
875
+ }
876
+ }
877
+ function hasNoControlCharacters(value) {
878
+ for (let index = 0; index < value.length; index += 1) {
879
+ const codeUnit = value.charCodeAt(index);
880
+ if (codeUnit <= 0x1f || codeUnit === 0x7f)
881
+ return false;
106
882
  }
883
+ return true;
107
884
  }
108
885
  //# sourceMappingURL=DecionisGate.js.map