@atlasent/sdk 1.5.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/hono.js CHANGED
@@ -1,4 +1,25 @@
1
1
  // src/errors.ts
2
+ var StreamTimeoutError = class extends Error {
3
+ name = "StreamTimeoutError";
4
+ /** Timeout that was exceeded, in milliseconds. */
5
+ timeoutMs;
6
+ constructor(timeoutMs) {
7
+ super(`AtlaSent stream timed out after ${timeoutMs}ms with no event`);
8
+ this.timeoutMs = timeoutMs;
9
+ }
10
+ };
11
+ var StreamParseError = class extends Error {
12
+ name = "StreamParseError";
13
+ /** The raw data string that failed to parse. */
14
+ rawData;
15
+ constructor(rawData, cause) {
16
+ super(`AtlaSent stream received malformed JSON: ${rawData.slice(0, 200)}`);
17
+ this.rawData = rawData;
18
+ if (cause !== void 0) {
19
+ this.cause = cause;
20
+ }
21
+ }
22
+ };
2
23
  var AtlaSentError = class extends Error {
3
24
  // Subclasses override to their own literal (e.g. "AtlaSentDeniedError");
4
25
  // keep this assignable rather than pinned to a single literal.
@@ -22,6 +43,18 @@ var AtlaSentError = class extends Error {
22
43
  this.retryAfterMs = init.retryAfterMs;
23
44
  }
24
45
  };
46
+ var KNOWN_PERMIT_OUTCOMES = /* @__PURE__ */ new Set([
47
+ "permit_consumed",
48
+ "permit_expired",
49
+ "permit_revoked",
50
+ "permit_not_found"
51
+ ]);
52
+ function normalizePermitOutcome(raw) {
53
+ if (raw !== void 0 && KNOWN_PERMIT_OUTCOMES.has(raw)) {
54
+ return raw;
55
+ }
56
+ return void 0;
57
+ }
25
58
  var AtlaSentDeniedError = class extends AtlaSentError {
26
59
  name = "AtlaSentDeniedError";
27
60
  /** Policy decision — `"deny"` today; `"hold"` / `"escalate"` reserved. */
@@ -32,6 +65,12 @@ var AtlaSentDeniedError = class extends AtlaSentError {
32
65
  reason;
33
66
  /** Hash-chained audit-trail entry associated with the decision. */
34
67
  auditHash;
68
+ /**
69
+ * Discriminator for permit-side denial reasons. Populated only
70
+ * when the server reported `verified=false` from `/v1-verify-permit`;
71
+ * `undefined` for evaluate-time denials. See {@link PermitOutcome}.
72
+ */
73
+ outcome;
35
74
  constructor(init) {
36
75
  const msg = init.reason ? `AtlaSent ${init.decision}: ${init.reason}` : `AtlaSent ${init.decision}`;
37
76
  const errInit = { status: 200 };
@@ -41,92 +80,620 @@ var AtlaSentDeniedError = class extends AtlaSentError {
41
80
  this.evaluationId = init.evaluationId;
42
81
  this.reason = init.reason;
43
82
  this.auditHash = init.auditHash;
83
+ this.outcome = init.outcome;
84
+ }
85
+ // ── Outcome discriminators ───────────────────────────────────────
86
+ // Convenience predicates that mirror the operator runbook's matrix.
87
+ // Callers can compare `outcome` directly; these are sugar so the
88
+ // common cases are explicit at the call site.
89
+ /** `true` when the permit was explicitly revoked (D3 endpoint). */
90
+ get isRevoked() {
91
+ return this.outcome === "permit_revoked";
92
+ }
93
+ /** `true` when the permit's TTL passed before verification. */
94
+ get isExpired() {
95
+ return this.outcome === "permit_expired";
96
+ }
97
+ /**
98
+ * `true` when the permit was already consumed by a prior verify
99
+ * (v1 single-use replay protection).
100
+ */
101
+ get isConsumed() {
102
+ return this.outcome === "permit_consumed";
103
+ }
104
+ /**
105
+ * `true` when the permit id wasn't recognized server-side
106
+ * (typo, cross-tenant lookup, or pre-issuance race).
107
+ */
108
+ get isNotFound() {
109
+ return this.outcome === "permit_not_found";
44
110
  }
45
111
  };
46
112
 
113
+ // src/types.ts
114
+ var PRODUCTION_DEPLOY_ACTION = "production.deploy";
115
+ var DEPLOY_GATE_CODES = Object.freeze({
116
+ ALLOW: "ALLOW",
117
+ DENY_POLICY: "DENY_POLICY",
118
+ DENY_AUTHORITY: "DENY_AUTHORITY",
119
+ DENY_ENVIRONMENT: "DENY_ENVIRONMENT",
120
+ PERMIT_EXPIRED: "PERMIT_EXPIRED",
121
+ VERIFY_FAILED: "VERIFY_FAILED",
122
+ ESCALATE_REQUIRED: "ESCALATE_REQUIRED",
123
+ OVERRIDE_APPROVED: "OVERRIDE_APPROVED"
124
+ });
125
+
126
+ // src/compat.ts
127
+ function normalizeEvaluateRequest(input) {
128
+ if ("action" in input && !("action_type" in input)) {
129
+ console.warn(
130
+ "[atlasent] Deprecation: action/agent request shape is deprecated. Use action_type/actor_id instead. This compatibility shim will be removed in v3.0.0."
131
+ );
132
+ const legacy = input;
133
+ const normalized = {
134
+ action_type: legacy.action,
135
+ actor_id: legacy.agent
136
+ };
137
+ if (legacy.context !== void 0) {
138
+ normalized.context = legacy.context;
139
+ }
140
+ return normalized;
141
+ }
142
+ return input;
143
+ }
144
+
145
+ // src/retry.ts
146
+ var DEFAULT_RETRY_POLICY = {
147
+ maxAttempts: 4,
148
+ baseDelayMs: 2e3,
149
+ maxDelayMs: 16e3
150
+ };
151
+ var RETRYABLE_CODES = /* @__PURE__ */ new Set([
152
+ "network",
153
+ "timeout",
154
+ "rate_limited",
155
+ "server_error",
156
+ "bad_response"
157
+ ]);
158
+ function isRetryable(err) {
159
+ if (!(err instanceof AtlaSentError)) return false;
160
+ if (err.code === void 0) return false;
161
+ return RETRYABLE_CODES.has(err.code);
162
+ }
163
+ function computeBackoffMs(attempt, policy = {}, err, random = Math.random) {
164
+ const merged = mergePolicy(policy);
165
+ const safeAttempt = Math.max(0, Math.floor(attempt));
166
+ const exp = Math.min(safeAttempt, 30);
167
+ const ceiling = Math.min(merged.maxDelayMs, merged.baseDelayMs * 2 ** exp);
168
+ const jittered = Math.floor(ceiling * clampUnit(random()));
169
+ const retryAfterMs = err instanceof AtlaSentError && typeof err.retryAfterMs === "number" ? Math.max(0, err.retryAfterMs) : 0;
170
+ return Math.max(retryAfterMs, jittered);
171
+ }
172
+ function hasAttemptsLeft(attempt, policy = {}) {
173
+ const merged = mergePolicy(policy);
174
+ return attempt + 1 < merged.maxAttempts;
175
+ }
176
+ function mergePolicy(policy) {
177
+ const maxAttempts = Math.max(
178
+ 1,
179
+ Math.floor(policy.maxAttempts ?? DEFAULT_RETRY_POLICY.maxAttempts)
180
+ );
181
+ const baseDelayMs = Math.max(
182
+ 0,
183
+ policy.baseDelayMs ?? DEFAULT_RETRY_POLICY.baseDelayMs
184
+ );
185
+ const maxDelayMs = Math.max(
186
+ baseDelayMs,
187
+ policy.maxDelayMs ?? DEFAULT_RETRY_POLICY.maxDelayMs
188
+ );
189
+ return { maxAttempts, baseDelayMs, maxDelayMs };
190
+ }
191
+ function clampUnit(n) {
192
+ if (!Number.isFinite(n)) return 0;
193
+ if (n < 0) return 0;
194
+ if (n >= 1) return 0.999999999;
195
+ return n;
196
+ }
197
+
47
198
  // src/client.ts
48
199
  var DEFAULT_BASE_URL = "https://api.atlasent.io";
49
200
  var DEFAULT_TIMEOUT_MS = 1e4;
50
- var SDK_VERSION = "0.1.0";
201
+ var SDK_VERSION = "2.2.0";
202
+ function _buildUserAgent() {
203
+ const isNode2 = typeof process !== "undefined" && typeof process?.versions?.node === "string";
204
+ return isNode2 ? `@atlasent/sdk/${SDK_VERSION} node/${process.version}` : `@atlasent/sdk/${SDK_VERSION} browser`;
205
+ }
206
+ var CONTEXT_PROPERTIES_SOFT_CAP = 64;
207
+ function _warnOversizeContext(context) {
208
+ if (context && Object.keys(context).length > CONTEXT_PROPERTIES_SOFT_CAP) {
209
+ console.warn(
210
+ `[atlasent] context has ${Object.keys(context).length} top-level keys (soft cap ${CONTEXT_PROPERTIES_SOFT_CAP}); the server may reject this. Pack richer payloads under a single top-level key.`
211
+ );
212
+ }
213
+ }
214
+ function _enforceTls(baseUrl) {
215
+ const nodeEnvValue = typeof process !== "undefined" && process.env ? process.env.ATLASENT_ALLOW_INSECURE_HTTP : void 0;
216
+ const allow = nodeEnvValue === "1" || globalThis.ATLASENT_ALLOW_INSECURE_HTTP === "1";
217
+ if (allow) return baseUrl;
218
+ let parsed;
219
+ try {
220
+ parsed = new URL(baseUrl);
221
+ } catch {
222
+ throw new AtlaSentError(`Invalid baseUrl: ${baseUrl}`, {
223
+ code: "bad_request"
224
+ });
225
+ }
226
+ if (parsed.protocol !== "https:") {
227
+ throw new AtlaSentError(
228
+ `AtlaSent baseUrl must use https:// (got ${parsed.protocol}). For local development, set ATLASENT_ALLOW_INSECURE_HTTP=1.`,
229
+ { code: "bad_request" }
230
+ );
231
+ }
232
+ return baseUrl;
233
+ }
234
+ var API_KEY_PATTERN = /^ask_(?:live|test)_[A-Za-z0-9_-]+$/;
235
+ function _validateApiKey(apiKey) {
236
+ if (typeof apiKey !== "string" || apiKey.length === 0) {
237
+ throw new AtlaSentError("apiKey is required", { code: "invalid_api_key" });
238
+ }
239
+ if (!API_KEY_PATTERN.test(apiKey)) {
240
+ const head = apiKey.slice(0, 8);
241
+ throw new AtlaSentError(
242
+ `AtlaSent apiKey does not match expected shape \`ask_(live|test)_<entropy>\` (got prefix=${JSON.stringify(head)}). Check for whitespace, quotes, or trailing characters.`,
243
+ { code: "invalid_api_key" }
244
+ );
245
+ }
246
+ return apiKey;
247
+ }
248
+ var isNode = typeof process !== "undefined" && typeof process.versions?.node === "string";
249
+ var NODE_VERSION = isNode ? process.version : null;
250
+ function deployGateEvidence(input) {
251
+ const evidence = {};
252
+ if (input.permitId) evidence.permitId = input.permitId;
253
+ if (input.permitHash) evidence.permitHash = input.permitHash;
254
+ if (input.auditHash) evidence.auditHash = input.auditHash;
255
+ if (input.verifiedAt) evidence.verifiedAt = input.verifiedAt;
256
+ return evidence;
257
+ }
51
258
  var AtlaSentClient = class {
52
259
  apiKey;
53
260
  baseUrl;
54
261
  timeoutMs;
55
262
  fetchImpl;
263
+ userAgent;
264
+ retryPolicy;
56
265
  constructor(options) {
57
266
  if (!options.apiKey || typeof options.apiKey !== "string") {
58
267
  throw new AtlaSentError("apiKey is required", {
59
268
  code: "invalid_api_key"
60
269
  });
61
270
  }
62
- this.apiKey = options.apiKey;
63
- this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
271
+ if (typeof AbortSignal.timeout !== "function") {
272
+ throw new AtlaSentError(
273
+ "@atlasent/sdk requires AbortSignal.timeout, which is not available in this runtime. Minimum supported browsers: Chrome 103+, Firefox 100+, Safari 16+. Upgrade your browser or add an AbortSignal.timeout polyfill.",
274
+ { code: "network" }
275
+ );
276
+ }
277
+ this.apiKey = _validateApiKey(options.apiKey);
278
+ this.baseUrl = _enforceTls(options.baseUrl ?? DEFAULT_BASE_URL).replace(
279
+ /\/+$/,
280
+ ""
281
+ );
64
282
  this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
65
283
  this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
284
+ this.userAgent = _buildUserAgent();
285
+ this.retryPolicy = mergePolicy(options.retryPolicy ?? {});
66
286
  }
67
287
  /**
68
288
  * Ask the policy engine whether an agent action is permitted.
69
289
  *
70
- * A "DENY" is **not** thrown it is returned in
290
+ * Accepts either the current v2.0 shape (`action_type` / `actor_id`)
291
+ * or the legacy v1.x shape (`action` / `agent`). Legacy callers
292
+ * receive a deprecation warning via `console.warn`; the shim is
293
+ * handled by {@link normalizeEvaluateRequest} and will be removed
294
+ * in v3.0.0.
295
+ *
296
+ * A "deny" is **not** thrown — it is returned in
71
297
  * `response.decision`. Network errors, invalid API key, rate
72
298
  * limits, timeouts, and malformed responses throw
73
299
  * {@link AtlaSentError}.
74
300
  */
75
301
  async evaluate(input) {
302
+ _warnOversizeContext(input.context);
303
+ const normalized = normalizeEvaluateRequest(
304
+ input
305
+ );
76
306
  const body = {
77
- action: input.action,
78
- agent: input.agent,
79
- context: input.context ?? {},
80
- api_key: this.apiKey
307
+ action_type: normalized.action_type,
308
+ actor_id: normalized.actor_id,
309
+ context: normalized.context ?? {}
81
310
  };
82
- const { body: wire, rateLimit } = await this.post("/v1-evaluate", body);
83
- if (typeof wire.permitted !== "boolean" || typeof wire.decision_id !== "string") {
311
+ const { body: wire, rateLimit } = await this.post(
312
+ "/v1-evaluate",
313
+ body
314
+ );
315
+ let decision = typeof wire.decision === "string" ? wire.decision.toLowerCase() : wire.decision;
316
+ if (decision === void 0 && typeof wire.permitted === "boolean") {
317
+ decision = wire.permitted ? "allow" : "deny";
318
+ }
319
+ const permitToken = wire.permit_token ?? wire.decision_id;
320
+ if (decision !== "allow" && decision !== "deny" && decision !== "hold" && decision !== "escalate") {
321
+ throw new AtlaSentError(
322
+ "Malformed response from /v1-evaluate: missing `decision` (or legacy `permitted`)",
323
+ { code: "bad_response" }
324
+ );
325
+ }
326
+ if (decision === "allow" && (typeof permitToken !== "string" || permitToken.length === 0)) {
84
327
  throw new AtlaSentError(
85
- "Malformed response from /v1-evaluate: missing `permitted` or `decision_id`",
328
+ "Malformed response from /v1-evaluate: decision='allow' but no `permit_token` (or legacy `decision_id`)",
86
329
  { code: "bad_response" }
87
330
  );
88
331
  }
332
+ const reason = wire.denial?.reason ?? wire.reason ?? "";
333
+ const permitId = permitToken ?? "";
89
334
  return {
90
- decision: wire.permitted ? "ALLOW" : "DENY",
91
- permitId: wire.decision_id,
92
- reason: wire.reason ?? "",
335
+ decision,
336
+ decision_canonical: decision,
337
+ evaluationId: permitId,
338
+ permitId,
339
+ // /v1-evaluate does not return a control-plane-shaped Permit body;
340
+ // callers needing the full record fetch GET /v1/permits/:id.
341
+ permit: null,
342
+ permitToken: decision === "allow" ? permitToken ?? null : null,
343
+ reasons: reason ? [reason] : [],
344
+ reason,
93
345
  auditHash: wire.audit_hash ?? "",
94
346
  timestamp: wire.timestamp ?? "",
95
347
  rateLimit
96
348
  };
97
349
  }
350
+ /**
351
+ * Pre-flight evaluation that always returns the constraint trace.
352
+ *
353
+ * Wraps `POST /v1-evaluate?include=constraint_trace`. Use this from
354
+ * a workflow's submission step to surface trivial defects (missing
355
+ * fields, wrong roles, mis-set context) BEFORE pushing the request
356
+ * onto an approval queue — only requests that would actually pass
357
+ * make it through to a human reviewer.
358
+ *
359
+ * Returns an {@link EvaluatePreflightResponse} carrying the regular
360
+ * {@link EvaluateResponse} plus the {@link ConstraintTrace}. Unlike
361
+ * {@link evaluate}, this method does NOT mark a non-allow as a
362
+ * thrown condition — the whole point is to inspect both the outcome
363
+ * AND the per-policy trace, so the caller branches on
364
+ * `result.evaluation.decision` and reads `result.constraintTrace`
365
+ * to render the failing stages.
366
+ *
367
+ * The constraint-trace shape mirrors `ConstraintTraceResponse` in
368
+ * atlasent-api (`packages/types/src/index.ts`). On older
369
+ * atlasent-api deployments that omit the trace, `constraintTrace`
370
+ * is `null` rather than throwing — forward-compatible degradation.
371
+ *
372
+ * Performance: one extra round-trip on submission. Latency is
373
+ * comparable to {@link evaluate}; the response body is fuller
374
+ * (includes the per-stage trace) so the wire payload is larger.
375
+ * If the caller does not need the trace, prefer {@link evaluate}.
376
+ */
377
+ async evaluatePreflight(input) {
378
+ _warnOversizeContext(input.context);
379
+ const body = {
380
+ action_type: input.action,
381
+ actor_id: input.agent,
382
+ context: input.context ?? {}
383
+ };
384
+ const query = new URLSearchParams({ include: "constraint_trace" });
385
+ const { body: wire, rateLimit } = await this.post(
386
+ "/v1-evaluate",
387
+ body,
388
+ query
389
+ );
390
+ let decision = typeof wire.decision === "string" ? wire.decision.toLowerCase() : wire.decision;
391
+ if (decision === void 0 && typeof wire.permitted === "boolean") {
392
+ decision = wire.permitted ? "allow" : "deny";
393
+ }
394
+ if (decision !== "allow" && decision !== "deny" && decision !== "hold" && decision !== "escalate") {
395
+ throw new AtlaSentError(
396
+ "Malformed response from /v1-evaluate: missing `decision` (or legacy `permitted`)",
397
+ { code: "bad_response" }
398
+ );
399
+ }
400
+ const permitToken = wire.permit_token ?? wire.decision_id;
401
+ const reason = wire.denial?.reason ?? wire.reason ?? "";
402
+ const permitId = permitToken ?? "";
403
+ const evaluation = {
404
+ decision,
405
+ decision_canonical: decision,
406
+ evaluationId: permitId,
407
+ permitId,
408
+ // /v1-evaluate does not return a control-plane-shaped Permit body;
409
+ // callers needing the full record fetch GET /v1/permits/:id.
410
+ permit: null,
411
+ permitToken: decision === "allow" ? permitToken ?? null : null,
412
+ reasons: reason ? [reason] : [],
413
+ reason,
414
+ auditHash: wire.audit_hash ?? "",
415
+ timestamp: wire.timestamp ?? "",
416
+ rateLimit
417
+ };
418
+ let constraintTrace = null;
419
+ if (wire.constraint_trace !== void 0 && wire.constraint_trace !== null && typeof wire.constraint_trace === "object") {
420
+ constraintTrace = wire.constraint_trace;
421
+ }
422
+ return { evaluation, constraintTrace };
423
+ }
98
424
  /**
99
425
  * Verify that a previously issued permit is still valid.
100
426
  *
427
+ * @deprecated Use {@link verifyPermitById} — the canonical REST
428
+ * surface (`POST /v1/permits/{id}/verify`) returns the unified
429
+ * verification envelope plus the full {@link PermitRecord}, instead
430
+ * of the legacy `{verified, outcome, permitHash}` shape this method
431
+ * emits. Will be removed in `@atlasent/sdk@3`.
432
+ *
101
433
  * A `verified: false` response is **not** thrown — inspect the
102
434
  * returned object. Only transport / server errors throw.
103
435
  */
104
436
  async verifyPermit(input) {
437
+ _warnOversizeContext(input.context);
105
438
  const body = {
106
- decision_id: input.permitId,
107
- action: input.action ?? "",
108
- agent: input.agent ?? "",
109
- context: input.context ?? {},
110
- api_key: this.apiKey
439
+ permit_token: input.permitId,
440
+ action_type: input.action ?? "",
441
+ actor_id: input.agent ?? ""
111
442
  };
443
+ if (input.environment !== void 0) {
444
+ body.environment = input.environment;
445
+ }
446
+ if (input.execution_hash !== void 0) {
447
+ body.execution_hash = input.execution_hash;
448
+ }
112
449
  const { body: wire, rateLimit } = await this.post(
113
450
  "/v1-verify-permit",
114
451
  body
115
452
  );
116
- if (typeof wire.verified !== "boolean") {
453
+ const valid = typeof wire.valid === "boolean" ? wire.valid : wire.verified;
454
+ if (typeof valid !== "boolean") {
117
455
  throw new AtlaSentError(
118
- "Malformed response from /v1-verify-permit: missing `verified`",
456
+ "Malformed response from /v1-verify-permit: missing `valid` (or legacy `verified`)",
119
457
  { code: "bad_response" }
120
458
  );
121
459
  }
122
460
  return {
123
- verified: wire.verified,
461
+ verified: valid,
124
462
  outcome: wire.outcome ?? "",
125
463
  permitHash: wire.permit_hash ?? "",
126
464
  timestamp: wire.timestamp ?? "",
127
465
  rateLimit
128
466
  };
129
467
  }
468
+ /**
469
+ * Run the canonical Deploy Gate V1 flow:
470
+ * evaluate `production.deploy`, verify the issued permit server-side,
471
+ * and return allow/block plus audit/evidence metadata.
472
+ *
473
+ * This helper never treats a signed/offline permit artifact as sufficient
474
+ * authorization. Execution is allowed only when `POST /v1-evaluate` returns
475
+ * `decision: "allow"` with a permit AND `POST /v1-verify-permit` returns
476
+ * `verified: true` / `valid: true`.
477
+ */
478
+ async deployGate(input = {}) {
479
+ const agent = input.agent ?? "ci-deploy-bot";
480
+ const action = input.action ?? PRODUCTION_DEPLOY_ACTION;
481
+ const context = input.context ?? {};
482
+ const evaluation = await this.evaluate({ agent, action, context });
483
+ if (evaluation.decision !== "allow") {
484
+ return {
485
+ allowed: false,
486
+ evaluation,
487
+ reason: evaluation.reason || `Deploy Gate blocked by decision=${evaluation.decision}`,
488
+ evidence: deployGateEvidence({
489
+ permitId: evaluation.permitId,
490
+ auditHash: evaluation.auditHash
491
+ })
492
+ };
493
+ }
494
+ const verification = await this.verifyPermit({
495
+ permitId: evaluation.permitId,
496
+ agent,
497
+ action,
498
+ context
499
+ });
500
+ if (!verification.verified) {
501
+ return {
502
+ allowed: false,
503
+ evaluation,
504
+ verification,
505
+ reason: verification.outcome ? `Deploy Gate blocked by permit verification outcome=${verification.outcome}` : "Deploy Gate blocked because permit verification failed",
506
+ evidence: deployGateEvidence({
507
+ permitId: evaluation.permitId,
508
+ permitHash: verification.permitHash,
509
+ auditHash: evaluation.auditHash,
510
+ verifiedAt: verification.timestamp
511
+ })
512
+ };
513
+ }
514
+ return {
515
+ allowed: true,
516
+ evaluation,
517
+ verification,
518
+ reason: evaluation.reason || "Deploy Gate permit verified",
519
+ evidence: deployGateEvidence({
520
+ permitId: evaluation.permitId,
521
+ permitHash: verification.permitHash,
522
+ auditHash: evaluation.auditHash,
523
+ verifiedAt: verification.timestamp
524
+ })
525
+ };
526
+ }
527
+ /**
528
+ * Revoke a previously-issued permit so it can no longer pass
529
+ * {@link verifyPermit}.
530
+ *
531
+ * @deprecated Use {@link revokePermitById} — the canonical REST
532
+ * surface (`POST /v1/permits/{id}/revoke`) returns the full updated
533
+ * {@link PermitRecord} with `revoked_at`/`revoked_by`/`revoke_reason`
534
+ * populated, instead of the legacy `{revoked, permitId}` envelope
535
+ * this method emits. Will be removed in `@atlasent/sdk@3`.
536
+ *
537
+ * Use this when an agent's action is cancelled, superseded, or
538
+ * determined to be unauthorized after the fact. The revocation is
539
+ * recorded in the audit log with the optional `reason`.
540
+ *
541
+ * Throws {@link AtlaSentError} on transport / auth failures.
542
+ */
543
+ async revokePermit(input) {
544
+ const body = {
545
+ decision_id: input.permitId,
546
+ reason: input.reason ?? "",
547
+ api_key: this.apiKey
548
+ };
549
+ const { body: wire, rateLimit } = await this.post("/v1-revoke-permit", body);
550
+ if (typeof wire.revoked !== "boolean" || typeof wire.decision_id !== "string") {
551
+ throw new AtlaSentError(
552
+ "Malformed response from /v1-revoke-permit: missing `revoked` or `decision_id`",
553
+ { code: "bad_response" }
554
+ );
555
+ }
556
+ return {
557
+ revoked: wire.revoked,
558
+ permitId: wire.decision_id,
559
+ revokedAt: wire.revoked_at,
560
+ auditHash: wire.audit_hash,
561
+ rateLimit
562
+ };
563
+ }
564
+ /**
565
+ * Revoke a permit through the canonical REST surface
566
+ * (`POST /v1/permits/{permitId}/revoke`).
567
+ *
568
+ * Returns the full updated {@link PermitRecord} with `status === 'revoked'`
569
+ * and `revoked_at` / `revoked_by` / `revoke_reason` populated. After
570
+ * revocation, subsequent verify calls return `410 PERMIT_REVOKED`.
571
+ *
572
+ * Idempotent on `409 permit_revoked` for already-revoked permits;
573
+ * server returns the existing revoked row in that case.
574
+ *
575
+ * Throws {@link AtlaSentError} on `404` (permit not in calling org),
576
+ * `409` (already in a terminal state), `410` (expired before revoke),
577
+ * or `429` (rate limited).
578
+ */
579
+ async revokePermitById(permitId, input = {}) {
580
+ if (!permitId) {
581
+ throw new AtlaSentError("permitId is required", { code: "bad_request" });
582
+ }
583
+ const body = {};
584
+ if (input.reason !== void 0) body.reason = input.reason;
585
+ const { body: wire, rateLimit } = await this.post(
586
+ `/v1/permits/${encodeURIComponent(permitId)}/revoke`,
587
+ body
588
+ );
589
+ return { permit: wire, rateLimit };
590
+ }
591
+ /**
592
+ * Verify a permit through the canonical REST surface
593
+ * (`POST /v1/permits/{permitId}/verify`).
594
+ *
595
+ * Returns the unified verification envelope (`valid`,
596
+ * `verification_type: 'permit'`, `reason`, `verified_at`, `evidence`)
597
+ * plus the full {@link PermitRecord} fields preserved at the top
598
+ * level. The `valid` field is the contract — pin to it.
599
+ *
600
+ * A `valid: false` is **not** thrown when the server returns 200 with
601
+ * a denial reason (matches the verify-shape unification on the wire);
602
+ * it is thrown on 4xx (`404` not found, `410` expired/consumed).
603
+ */
604
+ async verifyPermitById(permitId) {
605
+ if (!permitId) {
606
+ throw new AtlaSentError("permitId is required", { code: "bad_request" });
607
+ }
608
+ const { body: wire, rateLimit } = await this.post(`/v1/permits/${encodeURIComponent(permitId)}/verify`, {});
609
+ const { valid, verification_type, reason, verified_at, evidence, ...row } = wire;
610
+ return {
611
+ valid,
612
+ verification_type,
613
+ reason,
614
+ verified_at,
615
+ evidence,
616
+ permit: row,
617
+ rateLimit
618
+ };
619
+ }
620
+ /**
621
+ * Get a single permit's full lifecycle state.
622
+ *
623
+ * Calls `GET /v1/permits/{permitId}` (the canonical REST surface).
624
+ * Returns `status`, all timestamps, `revoked_at` / `revoked_by` /
625
+ * `revoke_reason` (when applicable), and the bound `payload_hash`
626
+ * / `decision_id`.
627
+ *
628
+ * Operator-facing introspection — answers "what state is this permit
629
+ * in, and why?" without reading audit logs.
630
+ *
631
+ * Throws {@link AtlaSentError} on `404` (permit not in calling org)
632
+ * or `410` (expired before retrieval).
633
+ */
634
+ async getPermit(permitId) {
635
+ if (!permitId) {
636
+ throw new AtlaSentError("permitId is required", { code: "bad_request" });
637
+ }
638
+ const { body: wire, rateLimit } = await this.get(
639
+ `/v1/permits/${encodeURIComponent(permitId)}`
640
+ );
641
+ return { permit: wire, rateLimit };
642
+ }
643
+ /**
644
+ * Poll whether a permit is currently valid.
645
+ *
646
+ * Calls `GET /v1/permits/{permitId}/valid` — a lightweight read
647
+ * returning only the status snapshot optimised for guard heartbeat
648
+ * polling. Guards with `permitRevalidationIntervalMs` set race this
649
+ * against `tool.execute()` and throw {@link PermitRevoked} when
650
+ * `status === "revoked"` arrives.
651
+ *
652
+ * Throws {@link AtlaSentError} on transport / auth failures.
653
+ */
654
+ async checkPermitValid(permitId) {
655
+ if (!permitId) {
656
+ throw new AtlaSentError("permitId is required", { code: "bad_request" });
657
+ }
658
+ const { body } = await this.get(
659
+ `/v1/permits/${encodeURIComponent(permitId)}/valid`
660
+ );
661
+ return body;
662
+ }
663
+ /**
664
+ * List permits issued to the calling org, most-recently-issued first.
665
+ *
666
+ * Calls `GET /v1/permits` (the canonical REST surface). Cursor-paged.
667
+ * Filters narrow on server side; pagination uses the `created_at`
668
+ * timestamp opaquely (`nextCursor`).
669
+ *
670
+ * Designed for incident review, debugging, and compliance
671
+ * reconstruction.
672
+ */
673
+ async listPermits(input = {}) {
674
+ const params = new URLSearchParams();
675
+ if (input.status) params.set("status", input.status);
676
+ if (input.actorId) params.set("actor_id", input.actorId);
677
+ if (input.actionType) params.set("action_type", input.actionType);
678
+ if (input.from) params.set("from", input.from);
679
+ if (input.to) params.set("to", input.to);
680
+ if (input.limit !== void 0) params.set("limit", String(input.limit));
681
+ if (input.cursor) params.set("cursor", input.cursor);
682
+ const { body: wire, rateLimit } = await this.get("/v1/permits", params);
683
+ if (!Array.isArray(wire.permits)) {
684
+ throw new AtlaSentError(
685
+ "Malformed response from /v1/permits: missing `permits` array",
686
+ { code: "bad_response" }
687
+ );
688
+ }
689
+ const result = {
690
+ permits: wire.permits,
691
+ total: typeof wire.total === "number" ? wire.total : wire.permits.length,
692
+ rateLimit
693
+ };
694
+ if (wire.next_cursor !== void 0) result.nextCursor = wire.next_cursor;
695
+ return result;
696
+ }
130
697
  /**
131
698
  * Self-introspection: ask the server to describe the API key this
132
699
  * client was constructed with. Returns the key's ID, organization,
@@ -141,9 +708,7 @@ var AtlaSentClient = class {
141
708
  * taxonomy as {@link AtlaSentClient.evaluate}.
142
709
  */
143
710
  async keySelf() {
144
- const { body: wire, rateLimit } = await this.get(
145
- "/v1-api-key-self"
146
- );
711
+ const { body: wire, rateLimit } = await this.get("/v1-api-key-self");
147
712
  if (typeof wire.key_id !== "string" || typeof wire.organization_id !== "string") {
148
713
  throw new AtlaSentError(
149
714
  "Malformed response from /v1-api-key-self: missing `key_id` or `organization_id`",
@@ -218,8 +783,129 @@ var AtlaSentClient = class {
218
783
  }
219
784
  return { ...wire, rateLimit };
220
785
  }
221
- async post(path, body) {
222
- return this.request(path, "POST", body, void 0);
786
+ /**
787
+ * Open a streaming evaluation session against `POST /v1-evaluate-stream`.
788
+ *
789
+ * Yields {@link StreamDecisionEvent} and {@link StreamProgressEvent} objects
790
+ * as the server emits them. The iterator ends cleanly when the server sends
791
+ * `event: done`; it throws {@link AtlaSentError} on transport errors or when
792
+ * the server sends `event: error`.
793
+ *
794
+ * The final {@link StreamDecisionEvent} (isFinal: true) carries a `permitId`
795
+ * suitable for passing to {@link verifyPermit} after the stream closes.
796
+ *
797
+ * Hardening:
798
+ * - Throws {@link StreamTimeoutError} when no event arrives within
799
+ * `opts.timeoutMs` (default 30 s). Pass `0` to disable.
800
+ * - Retries up to `opts.maxRetries` times (default 3) with 1 s / 2 s / 4 s
801
+ * delays on network drop (before a terminal event). Sends `Last-Event-ID`
802
+ * on reconnect when the server has emitted event IDs.
803
+ * - Throws {@link StreamParseError} on partial / malformed JSON rather than
804
+ * crashing with a raw `SyntaxError`.
805
+ * - Closes cleanly on `event: done` or a decision event with `done: true`.
806
+ *
807
+ * ```ts
808
+ * for await (const event of client.protectStream({ agent, action })) {
809
+ * if (event.type === "decision" && event.isFinal) {
810
+ * await client.verifyPermit({ permitId: event.permitId });
811
+ * }
812
+ * }
813
+ * ```
814
+ */
815
+ async *protectStream(input, opts = {}) {
816
+ const streamTimeoutMs = opts.timeoutMs ?? 3e4;
817
+ const maxRetries = opts.maxRetries ?? 3;
818
+ const body = {
819
+ action: input.action,
820
+ agent: input.agent,
821
+ context: input.context ?? {},
822
+ api_key: this.apiKey
823
+ };
824
+ const requestId = globalThis.crypto.randomUUID();
825
+ const url = `${this.baseUrl}/v1-evaluate-stream`;
826
+ let lastEventId;
827
+ let retryCount = 0;
828
+ while (true) {
829
+ const headers = {
830
+ Accept: "text/event-stream",
831
+ "Content-Type": "application/json",
832
+ Authorization: `Bearer ${this.apiKey}`,
833
+ "User-Agent": this.userAgent,
834
+ "X-Request-ID": requestId
835
+ };
836
+ if (lastEventId !== void 0) {
837
+ headers["Last-Event-ID"] = lastEventId;
838
+ }
839
+ const connectionTimeoutSignal = AbortSignal.timeout(this.timeoutMs);
840
+ const signal = opts.signal ? AbortSignal.any([connectionTimeoutSignal, opts.signal]) : connectionTimeoutSignal;
841
+ let response;
842
+ try {
843
+ response = await this.fetchImpl(url, {
844
+ method: "POST",
845
+ headers,
846
+ body: JSON.stringify(body),
847
+ signal
848
+ });
849
+ } catch (err) {
850
+ const mapped = mapFetchError(err, requestId);
851
+ if (mapped.code === "network" && retryCount < maxRetries) {
852
+ retryCount++;
853
+ await sleep(1e3 * Math.pow(2, retryCount - 1));
854
+ continue;
855
+ }
856
+ throw mapped;
857
+ }
858
+ if (!response.ok) {
859
+ throw await buildHttpError(response, requestId);
860
+ }
861
+ if (!response.body) {
862
+ throw new AtlaSentError("Expected streaming body from AtlaSent API", {
863
+ code: "bad_response",
864
+ status: response.status,
865
+ requestId
866
+ });
867
+ }
868
+ let streamDone = false;
869
+ let networkDrop = false;
870
+ try {
871
+ for await (const event of parseSseStream(
872
+ response.body,
873
+ requestId,
874
+ streamTimeoutMs,
875
+ (id) => {
876
+ lastEventId = id;
877
+ }
878
+ )) {
879
+ yield event;
880
+ if (event.type === "decision" && event.isFinal) {
881
+ streamDone = true;
882
+ }
883
+ }
884
+ streamDone = true;
885
+ } catch (err) {
886
+ if (err instanceof AtlaSentError && err.code === "network") {
887
+ networkDrop = true;
888
+ } else {
889
+ throw err;
890
+ }
891
+ }
892
+ if (streamDone) break;
893
+ if (networkDrop && retryCount < maxRetries) {
894
+ retryCount++;
895
+ await sleep(1e3 * Math.pow(2, retryCount - 1));
896
+ continue;
897
+ }
898
+ if (networkDrop) {
899
+ throw new AtlaSentError(
900
+ `AtlaSent stream dropped after ${retryCount} reconnection attempts`,
901
+ { code: "network", requestId }
902
+ );
903
+ }
904
+ break;
905
+ }
906
+ }
907
+ async post(path, body, query) {
908
+ return this.request(path, "POST", body, query);
223
909
  }
224
910
  async get(path, query) {
225
911
  return this.request(path, "GET", void 0, query);
@@ -231,47 +917,622 @@ var AtlaSentClient = class {
231
917
  const headers = {
232
918
  Accept: "application/json",
233
919
  Authorization: `Bearer ${this.apiKey}`,
234
- "User-Agent": `@atlasent/sdk/${SDK_VERSION} node/${process.version}`,
920
+ "User-Agent": this.userAgent,
235
921
  "X-Request-ID": requestId
236
922
  };
237
923
  if (method === "POST") headers["Content-Type"] = "application/json";
238
- const init = {
239
- method,
240
- headers,
241
- signal: AbortSignal.timeout(this.timeoutMs)
242
- };
243
- if (method === "POST") init.body = JSON.stringify(body);
244
- let response;
245
- try {
246
- response = await this.fetchImpl(url, init);
247
- } catch (err) {
248
- throw mapFetchError(err, requestId);
924
+ const bodyStr = method === "POST" ? JSON.stringify(body) : void 0;
925
+ for (let attempt = 0; ; attempt++) {
926
+ const init = {
927
+ method,
928
+ headers,
929
+ signal: AbortSignal.timeout(this.timeoutMs)
930
+ };
931
+ if (bodyStr !== void 0) init.body = bodyStr;
932
+ let response;
933
+ try {
934
+ response = await this.fetchImpl(url, init);
935
+ } catch (err) {
936
+ const mapped = mapFetchError(err, requestId);
937
+ if (isRetryable(mapped) && hasAttemptsLeft(attempt, this.retryPolicy)) {
938
+ await sleep(computeBackoffMs(attempt, this.retryPolicy, mapped));
939
+ continue;
940
+ }
941
+ throw mapped;
942
+ }
943
+ if (!response.ok) {
944
+ const httpErr = await buildHttpError(response, requestId);
945
+ if (isRetryable(httpErr) && hasAttemptsLeft(attempt, this.retryPolicy)) {
946
+ await sleep(computeBackoffMs(attempt, this.retryPolicy, httpErr));
947
+ continue;
948
+ }
949
+ throw httpErr;
950
+ }
951
+ let parsed;
952
+ try {
953
+ parsed = await response.json();
954
+ } catch (err) {
955
+ const jsonErr = new AtlaSentError(
956
+ "Invalid JSON response from AtlaSent API",
957
+ {
958
+ code: "bad_response",
959
+ status: response.status,
960
+ requestId,
961
+ cause: err
962
+ }
963
+ );
964
+ if (isRetryable(jsonErr) && hasAttemptsLeft(attempt, this.retryPolicy)) {
965
+ await sleep(computeBackoffMs(attempt, this.retryPolicy, jsonErr));
966
+ continue;
967
+ }
968
+ throw jsonErr;
969
+ }
970
+ if (parsed === null || typeof parsed !== "object") {
971
+ const shapeErr = new AtlaSentError(
972
+ "Expected a JSON object from AtlaSent API",
973
+ {
974
+ code: "bad_response",
975
+ status: response.status,
976
+ requestId
977
+ }
978
+ );
979
+ if (isRetryable(shapeErr) && hasAttemptsLeft(attempt, this.retryPolicy)) {
980
+ await sleep(computeBackoffMs(attempt, this.retryPolicy, shapeErr));
981
+ continue;
982
+ }
983
+ throw shapeErr;
984
+ }
985
+ return {
986
+ body: parsed,
987
+ rateLimit: parseRateLimitHeaders(response.headers)
988
+ };
249
989
  }
250
- if (!response.ok) {
251
- throw await buildHttpError(response, requestId);
990
+ }
991
+ /**
992
+ * Open a new HITL escalation. Bridges a `hold` outcome from
993
+ * `protect()` to the approval queue: an agent that receives a
994
+ * `hold` decision calls this to enroll the proposed action for
995
+ * human review. The returned escalation can then be polled with
996
+ * `getHitlEscalation()` or driven to terminal by
997
+ * `approveHitlEscalation()` / `rejectHitlEscalation()`.
998
+ *
999
+ * Quorum, pool size, fallback decision and routing inherit from
1000
+ * the server-side policy when omitted from `input`.
1001
+ *
1002
+ * Calls `POST /v1/hitl`.
1003
+ */
1004
+ async createHitlEscalation(input) {
1005
+ const { body, rateLimit } = await this.post(
1006
+ "/v1/hitl",
1007
+ input
1008
+ );
1009
+ return { escalation: body, rateLimit };
1010
+ }
1011
+ /**
1012
+ * List HITL escalations for the calling org. Defaults to
1013
+ * `status=pending`; pass `status` to query other queues
1014
+ * (`escalated`, `approved`, `rejected`, `auto_approved`,
1015
+ * `timed_out`).
1016
+ *
1017
+ * Calls `GET /v1/hitl`.
1018
+ */
1019
+ async listHitlEscalations(input = {}) {
1020
+ const params = new URLSearchParams();
1021
+ if (input.status) params.set("status", input.status);
1022
+ if (input.agentId) params.set("agent_id", input.agentId);
1023
+ if (input.assignedToUserId)
1024
+ params.set("assigned_to_user_id", input.assignedToUserId);
1025
+ if (input.limit !== void 0) params.set("limit", String(input.limit));
1026
+ if (input.cursor) params.set("cursor", input.cursor);
1027
+ const { body, rateLimit } = await this.get(
1028
+ "/v1/hitl",
1029
+ params
1030
+ );
1031
+ return { data: body, rateLimit };
1032
+ }
1033
+ /**
1034
+ * Get a HITL escalation. The server payload includes a live
1035
+ * `quorum_progress` snapshot when the escalation is still open.
1036
+ *
1037
+ * Calls `GET /v1/hitl/:id`.
1038
+ */
1039
+ async getHitlEscalation(escalationId) {
1040
+ if (!escalationId) {
1041
+ throw new AtlaSentError("escalationId is required", {
1042
+ code: "bad_request"
1043
+ });
252
1044
  }
253
- let parsed;
254
- try {
255
- parsed = await response.json();
256
- } catch (err) {
257
- throw new AtlaSentError("Invalid JSON response from AtlaSent API", {
258
- code: "bad_response",
259
- status: response.status,
260
- requestId,
261
- cause: err
1045
+ const { body, rateLimit } = await this.get(
1046
+ `/v1/hitl/${encodeURIComponent(escalationId)}`
1047
+ );
1048
+ return { escalation: body, rateLimit };
1049
+ }
1050
+ /**
1051
+ * List per-approver vote rows for an escalation.
1052
+ * Calls `GET /v1/hitl/:id/approvals`.
1053
+ */
1054
+ async listHitlApprovals(escalationId) {
1055
+ const { body, rateLimit } = await this.get(`/v1/hitl/${encodeURIComponent(escalationId)}/approvals`);
1056
+ return { approvals: body.approvals ?? [], rateLimit };
1057
+ }
1058
+ /**
1059
+ * List the escalation chain hops for an escalation. Each `/escalate`
1060
+ * call appends one row.
1061
+ * Calls `GET /v1/hitl/:id/chain`.
1062
+ */
1063
+ async getHitlChain(escalationId) {
1064
+ const { body, rateLimit } = await this.get(
1065
+ `/v1/hitl/${encodeURIComponent(escalationId)}/chain`
1066
+ );
1067
+ return { chain: body.chain ?? [], rateLimit };
1068
+ }
1069
+ /**
1070
+ * Record an approve vote. Resolves the escalation only once the
1071
+ * server-side quorum count is satisfied; before that the response
1072
+ * carries a refreshed escalation row with the latest
1073
+ * `quorum_progress`.
1074
+ *
1075
+ * Calls `POST /v1/hitl/:id/approve`. The server returns 409
1076
+ * `duplicate_vote` if the same principal has already voted, and
1077
+ * 409 `already_rejected` if a concurrent reject crossed the line.
1078
+ */
1079
+ async approveHitlEscalation(escalationId, input = {}) {
1080
+ const { body, rateLimit } = await this.post(
1081
+ `/v1/hitl/${encodeURIComponent(escalationId)}/approve`,
1082
+ input
1083
+ );
1084
+ return { escalation: body, rateLimit };
1085
+ }
1086
+ /**
1087
+ * Record a reject vote. Reject is short-circuit terminal — a single
1088
+ * reject closes the escalation regardless of how many approves have
1089
+ * accumulated.
1090
+ *
1091
+ * Calls `POST /v1/hitl/:id/reject`.
1092
+ */
1093
+ async rejectHitlEscalation(escalationId, input = {}) {
1094
+ const { body, rateLimit } = await this.post(
1095
+ `/v1/hitl/${encodeURIComponent(escalationId)}/reject`,
1096
+ input
1097
+ );
1098
+ return { escalation: body, rateLimit };
1099
+ }
1100
+ /**
1101
+ * Re-route an open escalation to a higher tier. Bounded by the
1102
+ * escalation's `max_escalation_depth` — the server returns 409
1103
+ * `chain_exhausted` and applies the configured fallback decision
1104
+ * once the ceiling is hit.
1105
+ *
1106
+ * Calls `POST /v1/hitl/:id/escalate`.
1107
+ */
1108
+ async escalateHitlEscalation(escalationId, input) {
1109
+ const { body, rateLimit } = await this.post(
1110
+ `/v1/hitl/${encodeURIComponent(escalationId)}/escalate`,
1111
+ input
1112
+ );
1113
+ return { escalation: body, rateLimit };
1114
+ }
1115
+ /**
1116
+ * Manually apply the escalation's `fallback_decision`. Useful for
1117
+ * admin recovery of a hung escalation when the cron sweeper hasn't
1118
+ * run yet, or to short-circuit a stuck flow during incident
1119
+ * response.
1120
+ *
1121
+ * Calls `POST /v1/hitl/:id/timeout`.
1122
+ */
1123
+ async timeoutHitlEscalation(escalationId) {
1124
+ const { body, rateLimit } = await this.post(
1125
+ `/v1/hitl/${encodeURIComponent(escalationId)}/timeout`,
1126
+ {}
1127
+ );
1128
+ return { escalation: body, rateLimit };
1129
+ }
1130
+ /**
1131
+ * Run a named governance graph traversal query.
1132
+ *
1133
+ * Dispatches to `GET /v1/governance/graph/query?type=<queryType>`.
1134
+ * Each query type returns a different row shape — the return type
1135
+ * narrows automatically based on the literal `queryType` argument.
1136
+ *
1137
+ * `"user_approvals"` requires `params.actor_id` — the server returns
1138
+ * a 400 if it is absent.
1139
+ */
1140
+ async queryGovernanceGraph(queryType, params = {}) {
1141
+ const qs = new URLSearchParams({ type: queryType });
1142
+ if (params.actor_id) qs.set("actor_id", params.actor_id);
1143
+ const { body, rateLimit } = await this.get("/v1/governance/graph/query", qs);
1144
+ return { ...body, rateLimit };
1145
+ }
1146
+ /**
1147
+ * Reconstruct the multi-system execution timeline for a specific incident.
1148
+ *
1149
+ * Calls `GET /v1/governance/timeline/incident/{incidentId}`. Backed
1150
+ * server-side by `reconstruct_incident_chains_v2()`, which fixes the
1151
+ * `executor_id → actor_id` bug that silently produced empty timelines
1152
+ * in the original function.
1153
+ *
1154
+ * Returns full execution rows including the §13.1 columns
1155
+ * (`delegation_chain_id`, `replay_of_execution_id`, `incident_id`,
1156
+ * `policy_version_id`, `bundle_version_id`) alongside the actor
1157
+ * timeline and evidence rows.
1158
+ */
1159
+ async getIncidentTimeline(incidentId) {
1160
+ if (!incidentId) {
1161
+ throw new AtlaSentError("incidentId is required", {
1162
+ code: "bad_request"
262
1163
  });
263
1164
  }
264
- if (parsed === null || typeof parsed !== "object") {
265
- throw new AtlaSentError("Expected a JSON object from AtlaSent API", {
266
- code: "bad_response",
267
- status: response.status,
268
- requestId
1165
+ const { body, rateLimit } = await this.get(`/v1/governance/timeline/incident/${encodeURIComponent(incidentId)}`);
1166
+ return { ...body, rateLimit };
1167
+ }
1168
+ // ── Connector Management ─────────────────────────────────────────────────
1169
+ /**
1170
+ * List connectors registered for the calling org.
1171
+ * Calls `GET /v1/governance/connectors`.
1172
+ */
1173
+ async listConnectors(options = {}) {
1174
+ const params = new URLSearchParams();
1175
+ if (options.cursor) params.set("cursor", options.cursor);
1176
+ if (options.limit !== void 0) params.set("limit", String(options.limit));
1177
+ const { body, rateLimit } = await this.get("/v1/governance/connectors", params);
1178
+ const result = {
1179
+ connectors: body.connectors ?? [],
1180
+ total: body.total,
1181
+ rateLimit
1182
+ };
1183
+ if (body.next_cursor) result.nextCursor = body.next_cursor;
1184
+ return result;
1185
+ }
1186
+ /**
1187
+ * Register and install a new connector for the calling org.
1188
+ * Calls `POST /v1/governance/connectors`.
1189
+ */
1190
+ async installConnector(input) {
1191
+ const { body, rateLimit } = await this.post("/v1/governance/connectors", input);
1192
+ return { connector: body, rateLimit };
1193
+ }
1194
+ /**
1195
+ * Store encrypted credentials for a connector.
1196
+ * Calls `POST /v1/governance/connectors/{id}/authenticate`.
1197
+ */
1198
+ async authenticateConnector(connectorId, input) {
1199
+ if (!connectorId) {
1200
+ throw new AtlaSentError("connectorId is required", {
1201
+ code: "bad_request"
269
1202
  });
270
1203
  }
1204
+ const { body, rateLimit } = await this.post(
1205
+ `/v1/governance/connectors/${encodeURIComponent(connectorId)}/authenticate`,
1206
+ input
1207
+ );
271
1208
  return {
272
- body: parsed,
273
- rateLimit: parseRateLimitHeaders(response.headers)
1209
+ credential_id: body.credential_id,
1210
+ version: body.version,
1211
+ rateLimit
1212
+ };
1213
+ }
1214
+ /**
1215
+ * Trigger an incremental sync for a connector.
1216
+ * Calls `POST /v1/governance/connectors/{id}/sync`.
1217
+ */
1218
+ async syncConnector(connectorId) {
1219
+ if (!connectorId) {
1220
+ throw new AtlaSentError("connectorId is required", {
1221
+ code: "bad_request"
1222
+ });
1223
+ }
1224
+ const { body, rateLimit } = await this.post(`/v1/governance/connectors/${encodeURIComponent(connectorId)}/sync`, {});
1225
+ return { ...body, rateLimit };
1226
+ }
1227
+ /**
1228
+ * Revoke a connector and all its associated credentials.
1229
+ * Calls `POST /v1/governance/connectors/{id}/revoke`.
1230
+ */
1231
+ async revokeConnector(connectorId, reason) {
1232
+ if (!connectorId) {
1233
+ throw new AtlaSentError("connectorId is required", {
1234
+ code: "bad_request"
1235
+ });
1236
+ }
1237
+ const body = {};
1238
+ if (reason !== void 0) body.reason = reason;
1239
+ const { body: wire, rateLimit } = await this.post(
1240
+ `/v1/governance/connectors/${encodeURIComponent(connectorId)}/revoke`,
1241
+ body
1242
+ );
1243
+ return { ...wire, rateLimit };
1244
+ }
1245
+ /**
1246
+ * Rotate the credentials for a connector.
1247
+ * Calls `POST /v1/governance/connectors/{id}/rotate-credentials`.
1248
+ */
1249
+ async rotateConnectorCredentials(connectorId) {
1250
+ if (!connectorId) {
1251
+ throw new AtlaSentError("connectorId is required", {
1252
+ code: "bad_request"
1253
+ });
1254
+ }
1255
+ const { body, rateLimit } = await this.post(
1256
+ `/v1/governance/connectors/${encodeURIComponent(connectorId)}/rotate-credentials`,
1257
+ {}
1258
+ );
1259
+ return { ...body, rateLimit };
1260
+ }
1261
+ /**
1262
+ * List enforcement policies for the calling org, optionally filtered by connector type.
1263
+ * Calls `GET /v1/governance/enforcement-policies`.
1264
+ */
1265
+ async listEnforcementPolicies(connectorType) {
1266
+ const params = new URLSearchParams();
1267
+ if (connectorType) params.set("connector_type", connectorType);
1268
+ const { body, rateLimit } = await this.get("/v1/governance/enforcement-policies", params);
1269
+ return { policies: body.policies ?? [], total: body.total, rateLimit };
1270
+ }
1271
+ /**
1272
+ * Create or update a connector enforcement policy.
1273
+ * Calls `POST /v1/governance/enforcement-policies`.
1274
+ */
1275
+ async upsertEnforcementPolicy(input) {
1276
+ const { body, rateLimit } = await this.post("/v1/governance/enforcement-policies", input);
1277
+ return { policy: body, rateLimit };
1278
+ }
1279
+ // ── Organizational Risk Graph ─────────────────────────────────────────────
1280
+ /**
1281
+ * Trigger a fresh org-level risk score computation.
1282
+ * Calls `POST /v1/governance/risk/compute`.
1283
+ */
1284
+ async computeOrgRisk(options = {}) {
1285
+ const { body, rateLimit } = await this.post("/v1/governance/risk/compute", options);
1286
+ return { score: body, rateLimit };
1287
+ }
1288
+ /**
1289
+ * Retrieve the most recently computed risk score for the calling org.
1290
+ * Calls `GET /v1/governance/risk/latest`.
1291
+ */
1292
+ async getLatestOrgRisk() {
1293
+ const { body, rateLimit } = await this.get("/v1/governance/risk/latest");
1294
+ return { score: body.score ?? null, rateLimit };
1295
+ }
1296
+ /**
1297
+ * Page through historical org risk scores, most-recent first.
1298
+ * Calls `GET /v1/governance/risk/history`.
1299
+ */
1300
+ async listOrgRiskHistory(options = {}) {
1301
+ const params = new URLSearchParams();
1302
+ if (options.cursor) params.set("cursor", options.cursor);
1303
+ if (options.limit !== void 0) params.set("limit", String(options.limit));
1304
+ const { body, rateLimit } = await this.get("/v1/governance/risk/history", params);
1305
+ const result = {
1306
+ scores: body.scores ?? [],
1307
+ total: body.total,
1308
+ rateLimit
274
1309
  };
1310
+ if (body.next_cursor) result.nextCursor = body.next_cursor;
1311
+ return result;
1312
+ }
1313
+ // ── Cross-Org Permission Negotiation ──────────────────────────────────────
1314
+ async checkCrossOrgPermission(req) {
1315
+ const { body } = await this.post(
1316
+ "/v1/cross-org/permissions/check",
1317
+ req
1318
+ );
1319
+ return body;
1320
+ }
1321
+ async listCrossOrgPermissionChecks(params) {
1322
+ const qs = new URLSearchParams();
1323
+ if (params?.source_org_id) qs.set("source_org_id", params.source_org_id);
1324
+ if (params?.target_org_id) qs.set("target_org_id", params.target_org_id);
1325
+ if (params?.allowed !== void 0)
1326
+ qs.set("allowed", String(params.allowed));
1327
+ if (params?.limit !== void 0) qs.set("limit", String(params.limit));
1328
+ const { body } = await this.get("/v1/cross-org/permissions/checks", qs);
1329
+ return body.checks ?? [];
1330
+ }
1331
+ // ── Anomaly Response Automation ───────────────────────────────────────────
1332
+ async listAnomalyResponseRules() {
1333
+ const { body } = await this.get(
1334
+ "/v1/anomaly-response/rules"
1335
+ );
1336
+ return body.rules ?? [];
1337
+ }
1338
+ async createAnomalyResponseRule(req) {
1339
+ const { body } = await this.post(
1340
+ "/v1/anomaly-response/rules",
1341
+ req
1342
+ );
1343
+ return body;
1344
+ }
1345
+ async updateAnomalyResponseRule(id, updates) {
1346
+ const { body } = await this.post(
1347
+ `/v1/anomaly-response/rules/${encodeURIComponent(id)}/update`,
1348
+ updates
1349
+ );
1350
+ return body;
1351
+ }
1352
+ async deleteAnomalyResponseRule(id) {
1353
+ await this.post(
1354
+ `/v1/anomaly-response/rules/${encodeURIComponent(id)}/delete`,
1355
+ {}
1356
+ );
1357
+ }
1358
+ async triggerAnomalyResponse(req) {
1359
+ const { body } = await this.post(
1360
+ "/v1/anomaly-response/trigger",
1361
+ req
1362
+ );
1363
+ return body.events ?? [];
1364
+ }
1365
+ async listAnomalyResponseEvents(params) {
1366
+ const qs = new URLSearchParams();
1367
+ if (params?.execution_id) qs.set("execution_id", params.execution_id);
1368
+ if (params?.limit !== void 0) qs.set("limit", String(params.limit));
1369
+ const { body } = await this.get(
1370
+ "/v1/anomaly-response/events",
1371
+ qs
1372
+ );
1373
+ return body.events ?? [];
1374
+ }
1375
+ // ── Budget Exception Workflows ────────────────────────────────────────────
1376
+ async listBudgetExceptions(params) {
1377
+ const qs = new URLSearchParams();
1378
+ if (params?.status) qs.set("status", params.status);
1379
+ if (params?.budget_policy_id)
1380
+ qs.set("budget_policy_id", params.budget_policy_id);
1381
+ if (params?.limit !== void 0) qs.set("limit", String(params.limit));
1382
+ if (params?.offset !== void 0) qs.set("offset", String(params.offset));
1383
+ const { body } = await this.get(
1384
+ "/v1/budget-exceptions",
1385
+ qs
1386
+ );
1387
+ return body.exceptions ?? [];
1388
+ }
1389
+ async getBudgetException(id) {
1390
+ const { body } = await this.get(
1391
+ `/v1/budget-exceptions/${encodeURIComponent(id)}`
1392
+ );
1393
+ return body;
1394
+ }
1395
+ async createBudgetException(req) {
1396
+ const { body } = await this.post(
1397
+ "/v1/budget-exceptions",
1398
+ req
1399
+ );
1400
+ return body;
1401
+ }
1402
+ async approveBudgetException(id, req) {
1403
+ const { body } = await this.post(
1404
+ `/v1/budget-exceptions/${encodeURIComponent(id)}/approve`,
1405
+ req
1406
+ );
1407
+ return body;
1408
+ }
1409
+ async rejectBudgetException(id, review_notes) {
1410
+ const { body } = await this.post(
1411
+ `/v1/budget-exceptions/${encodeURIComponent(id)}/reject`,
1412
+ { review_notes }
1413
+ );
1414
+ return body;
1415
+ }
1416
+ async cancelBudgetException(id) {
1417
+ const { body } = await this.post(
1418
+ `/v1/budget-exceptions/${encodeURIComponent(id)}/cancel`,
1419
+ {}
1420
+ );
1421
+ return body;
1422
+ }
1423
+ // ── Regulatory Escalation Chain ───────────────────────────────────────────
1424
+ async listRegulatoryAuthorityLevels() {
1425
+ const { body } = await this.get(
1426
+ "/v1/regulatory/authority-levels"
1427
+ );
1428
+ return body.levels ?? [];
1429
+ }
1430
+ async createRegulatoryAuthorityLevel(req) {
1431
+ const { body } = await this.post(
1432
+ "/v1/regulatory/authority-levels",
1433
+ req
1434
+ );
1435
+ return body;
1436
+ }
1437
+ async listRegulatoryEscalations(params) {
1438
+ const qs = new URLSearchParams();
1439
+ if (params?.status) qs.set("status", params.status);
1440
+ if (params?.subject_type) qs.set("subject_type", params.subject_type);
1441
+ if (params?.subject_id) qs.set("subject_id", params.subject_id);
1442
+ const { body } = await this.get(
1443
+ "/v1/regulatory/escalations",
1444
+ qs
1445
+ );
1446
+ return body.escalations ?? [];
1447
+ }
1448
+ async createRegulatoryEscalation(req) {
1449
+ const { body } = await this.post(
1450
+ "/v1/regulatory/escalations",
1451
+ req
1452
+ );
1453
+ return body;
1454
+ }
1455
+ async acknowledgeRegulatoryEscalation(id) {
1456
+ const { body } = await this.post(
1457
+ `/v1/regulatory/escalations/${encodeURIComponent(id)}/acknowledge`,
1458
+ {}
1459
+ );
1460
+ return body;
1461
+ }
1462
+ async resolveRegulatoryEscalation(id, resolution, resolution_details) {
1463
+ const { body } = await this.post(
1464
+ `/v1/regulatory/escalations/${encodeURIComponent(id)}/resolve`,
1465
+ { resolution, resolution_details }
1466
+ );
1467
+ return body;
1468
+ }
1469
+ async overrideRegulatoryEscalation(id, reason) {
1470
+ const { body } = await this.post(
1471
+ `/v1/regulatory/escalations/${encodeURIComponent(id)}/override`,
1472
+ { reason }
1473
+ );
1474
+ return body;
1475
+ }
1476
+ // ── Incentive Signal Feedback Loop ────────────────────────────────────────
1477
+ async listSignalActions(signal_id) {
1478
+ const { body } = await this.get(
1479
+ `/v1/governance/signals/${encodeURIComponent(signal_id)}/actions`
1480
+ );
1481
+ return body.actions ?? [];
1482
+ }
1483
+ async recordSignalAction(signal_id, req) {
1484
+ const { body } = await this.post(
1485
+ `/v1/governance/signals/${encodeURIComponent(signal_id)}/actions`,
1486
+ req
1487
+ );
1488
+ return body;
1489
+ }
1490
+ async recordSignalOutcome(signal_id, action_id, req) {
1491
+ const { body } = await this.post(
1492
+ `/v1/governance/signals/${encodeURIComponent(signal_id)}/actions/${encodeURIComponent(action_id)}/outcome`,
1493
+ req
1494
+ );
1495
+ return body;
1496
+ }
1497
+ async getSignalActionSummary() {
1498
+ const { body } = await this.get(
1499
+ "/v1/governance/signals/actions/summary"
1500
+ );
1501
+ return body;
1502
+ }
1503
+ // ── Cross-Org Impersonation ───────────────────────────────────────────────
1504
+ async listImpersonationGrants() {
1505
+ const { body } = await this.get(
1506
+ "/v1/cross-org/impersonation/grants"
1507
+ );
1508
+ return body.grants ?? [];
1509
+ }
1510
+ async createImpersonationGrant(req) {
1511
+ const { body } = await this.post(
1512
+ "/v1/cross-org/impersonation/grants",
1513
+ req
1514
+ );
1515
+ return body;
1516
+ }
1517
+ async revokeImpersonationGrant(id) {
1518
+ await this.post(
1519
+ `/v1/cross-org/impersonation/grants/${encodeURIComponent(id)}/revoke`,
1520
+ {}
1521
+ );
1522
+ }
1523
+ async issueImpersonationToken(grant_id, requested_duration_seconds) {
1524
+ const { body } = await this.post(
1525
+ `/v1/cross-org/impersonation/grants/${encodeURIComponent(grant_id)}/token`,
1526
+ { requested_duration_seconds }
1527
+ );
1528
+ return body;
1529
+ }
1530
+ async validateImpersonationToken(token) {
1531
+ const { body } = await this.post(
1532
+ "/v1/cross-org/impersonation/validate",
1533
+ { token }
1534
+ );
1535
+ return body;
275
1536
  }
276
1537
  };
277
1538
  function parseRateLimitHeaders(headers) {
@@ -417,6 +1678,9 @@ function buildAuditEventsQuery(query) {
417
1678
  }
418
1679
  return params;
419
1680
  }
1681
+ function sleep(ms) {
1682
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
1683
+ }
420
1684
  function parseRetryAfter(raw) {
421
1685
  if (!raw) return void 0;
422
1686
  const seconds = Number(raw);
@@ -428,13 +1692,133 @@ function parseRetryAfter(raw) {
428
1692
  }
429
1693
  return void 0;
430
1694
  }
1695
+ async function* parseSseStream(body, requestId, timeoutMs, onEventId) {
1696
+ const reader = body.getReader();
1697
+ const decoder = new TextDecoder("utf-8");
1698
+ let buf = "";
1699
+ async function readChunk() {
1700
+ if (timeoutMs <= 0) {
1701
+ return reader.read();
1702
+ }
1703
+ return new Promise((resolve2, reject) => {
1704
+ const timer = setTimeout(() => {
1705
+ reject(new StreamTimeoutError(timeoutMs));
1706
+ }, timeoutMs);
1707
+ reader.read().then(
1708
+ (result) => {
1709
+ clearTimeout(timer);
1710
+ resolve2(result);
1711
+ },
1712
+ (err) => {
1713
+ clearTimeout(timer);
1714
+ reject(err);
1715
+ }
1716
+ );
1717
+ });
1718
+ }
1719
+ try {
1720
+ for (; ; ) {
1721
+ let done;
1722
+ let value;
1723
+ try {
1724
+ const result = await readChunk();
1725
+ done = result.done;
1726
+ value = result.value;
1727
+ } catch (err) {
1728
+ if (err instanceof StreamTimeoutError) throw err;
1729
+ throw new AtlaSentError(
1730
+ `AtlaSent stream read failed: ${err instanceof Error ? err.message : String(err)}`,
1731
+ { code: "network", requestId, cause: err }
1732
+ );
1733
+ }
1734
+ if (done) break;
1735
+ buf += decoder.decode(value, { stream: true });
1736
+ let boundary;
1737
+ while ((boundary = buf.indexOf("\n\n")) !== -1) {
1738
+ const block = buf.slice(0, boundary);
1739
+ buf = buf.slice(boundary + 2);
1740
+ let eventType = "message";
1741
+ let data = "";
1742
+ let eventId;
1743
+ for (const line of block.split("\n")) {
1744
+ if (line.startsWith("event: ")) eventType = line.slice(7).trim();
1745
+ else if (line.startsWith("data: ")) data = line.slice(6);
1746
+ else if (line.startsWith("id: ")) eventId = line.slice(4).trim();
1747
+ else if (line.startsWith("id:")) eventId = line.slice(3).trim();
1748
+ }
1749
+ if (eventId !== void 0) onEventId(eventId);
1750
+ if (!data) continue;
1751
+ if (eventType === "done") return;
1752
+ let parsed;
1753
+ try {
1754
+ parsed = JSON.parse(data);
1755
+ } catch (err) {
1756
+ throw new StreamParseError(data, err);
1757
+ }
1758
+ if (eventType === "error") {
1759
+ const e = parsed;
1760
+ throw new AtlaSentError(
1761
+ e.message ?? "Stream error from AtlaSent API",
1762
+ {
1763
+ code: e.code ?? "server_error",
1764
+ requestId: e.request_id ?? requestId
1765
+ }
1766
+ );
1767
+ }
1768
+ if (eventType === "decision") {
1769
+ const d = parsed;
1770
+ if (typeof d.permitted !== "boolean" || typeof d.decision_id !== "string") {
1771
+ throw new AtlaSentError(
1772
+ "Malformed decision event from AtlaSent API",
1773
+ {
1774
+ code: "bad_response",
1775
+ requestId
1776
+ }
1777
+ );
1778
+ }
1779
+ const streamDecision = d.permitted ? "allow" : "deny";
1780
+ const isFinal = d.is_final ?? false;
1781
+ yield {
1782
+ type: "decision",
1783
+ decision: streamDecision,
1784
+ decision_canonical: streamDecision,
1785
+ permitId: d.decision_id,
1786
+ reason: d.reason ?? "",
1787
+ auditHash: d.audit_hash ?? "",
1788
+ timestamp: d.timestamp ?? "",
1789
+ isFinal
1790
+ };
1791
+ if (isFinal || d.done === true) return;
1792
+ } else if (eventType === "progress") {
1793
+ const p = parsed;
1794
+ yield {
1795
+ type: "progress",
1796
+ stage: String(p["stage"] ?? ""),
1797
+ ...p
1798
+ };
1799
+ if (p.done === true) return;
1800
+ } else {
1801
+ if (parsed !== null && typeof parsed === "object" && parsed.done === true) {
1802
+ return;
1803
+ }
1804
+ }
1805
+ }
1806
+ }
1807
+ if (buf.trim().length > 0) {
1808
+ throw new StreamParseError(buf);
1809
+ }
1810
+ } finally {
1811
+ reader.releaseLock();
1812
+ }
1813
+ }
431
1814
 
432
1815
  // src/protect.ts
433
1816
  var sharedClient = null;
434
1817
  var overrides = {};
435
1818
  function getClient() {
436
1819
  if (sharedClient) return sharedClient;
437
- const apiKey = overrides.apiKey ?? process.env.ATLASENT_API_KEY;
1820
+ const envApiKey = typeof process !== "undefined" && process.env ? process.env.ATLASENT_API_KEY : void 0;
1821
+ const apiKey = overrides.apiKey ?? envApiKey;
438
1822
  if (!apiKey) {
439
1823
  throw new AtlaSentError(
440
1824
  "AtlaSent is not configured. Set ATLASENT_API_KEY in the environment, or call atlasent.configure({ apiKey }).",
@@ -443,8 +1827,11 @@ function getClient() {
443
1827
  }
444
1828
  const options = { apiKey };
445
1829
  if (overrides.baseUrl !== void 0) options.baseUrl = overrides.baseUrl;
446
- if (overrides.timeoutMs !== void 0) options.timeoutMs = overrides.timeoutMs;
1830
+ if (overrides.timeoutMs !== void 0)
1831
+ options.timeoutMs = overrides.timeoutMs;
447
1832
  if (overrides.fetch !== void 0) options.fetch = overrides.fetch;
1833
+ if (overrides.retryPolicy !== void 0)
1834
+ options.retryPolicy = overrides.retryPolicy;
448
1835
  sharedClient = new AtlaSentClient(options);
449
1836
  return sharedClient;
450
1837
  }
@@ -453,10 +1840,42 @@ function wireDecisionToDenied(serverDecision) {
453
1840
  if (lower === "hold" || lower === "escalate") return lower;
454
1841
  return "deny";
455
1842
  }
1843
+ function sortKeysDeep(val) {
1844
+ if (Array.isArray(val)) return val.map(sortKeysDeep);
1845
+ if (val !== null && typeof val === "object") {
1846
+ return Object.keys(val).sort().reduce((acc, k) => {
1847
+ acc[k] = sortKeysDeep(val[k]);
1848
+ return acc;
1849
+ }, {});
1850
+ }
1851
+ return val;
1852
+ }
1853
+ async function computeExecutionHash(payload) {
1854
+ const sorted = sortKeysDeep(payload);
1855
+ const canonical = JSON.stringify(sorted);
1856
+ if (typeof globalThis !== "undefined" && globalThis.crypto?.subtle?.digest) {
1857
+ const bytes = new TextEncoder().encode(canonical);
1858
+ const buf = await globalThis.crypto.subtle.digest("SHA-256", bytes);
1859
+ return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, "0")).join("");
1860
+ }
1861
+ try {
1862
+ const { createHash } = await import(
1863
+ /* @vite-ignore */
1864
+ /* webpackIgnore: true */
1865
+ "crypto"
1866
+ );
1867
+ return createHash("sha256").update(canonical, "utf8").digest("hex");
1868
+ } catch {
1869
+ console.warn(
1870
+ "[atlasent] Could not compute execution_hash: neither crypto.subtle nor node:crypto is available in this runtime."
1871
+ );
1872
+ return "";
1873
+ }
1874
+ }
456
1875
  async function protect(request) {
457
1876
  const client = getClient();
458
1877
  const evaluation = await client.evaluate(request);
459
- if (evaluation.decision !== "ALLOW") {
1878
+ if (evaluation.decision !== "allow") {
460
1879
  throw new AtlaSentDeniedError({
461
1880
  decision: wireDecisionToDenied(evaluation.decision),
462
1881
  evaluationId: evaluation.permitId,
@@ -464,19 +1883,35 @@ async function protect(request) {
464
1883
  auditHash: evaluation.auditHash
465
1884
  });
466
1885
  }
1886
+ const environment = request.context?.environment ?? (() => {
1887
+ console.warn(
1888
+ "[atlasent] environment not set on evaluate request \u2014 defaulting to 'production'. Set context.environment explicitly to suppress."
1889
+ );
1890
+ return "production";
1891
+ })();
1892
+ const evaluatePayload = {
1893
+ action_type: request.action,
1894
+ actor_id: request.agent,
1895
+ context: request.context ?? {}
1896
+ };
1897
+ const execution_hash = await computeExecutionHash(evaluatePayload);
467
1898
  const verifyRequest = {
468
1899
  permitId: evaluation.permitId,
469
1900
  agent: request.agent,
470
- action: request.action
1901
+ action: request.action,
1902
+ environment,
1903
+ ...execution_hash ? { execution_hash } : {}
471
1904
  };
472
1905
  if (request.context !== void 0) verifyRequest.context = request.context;
473
1906
  const verification = await client.verifyPermit(verifyRequest);
474
1907
  if (!verification.verified) {
1908
+ const outcome = normalizePermitOutcome(verification.outcome);
475
1909
  throw new AtlaSentDeniedError({
476
1910
  decision: "deny",
477
1911
  evaluationId: evaluation.permitId,
478
1912
  reason: `Permit failed verification (${verification.outcome})`,
479
- auditHash: evaluation.auditHash
1913
+ auditHash: evaluation.auditHash,
1914
+ ...outcome !== void 0 && { outcome }
480
1915
  });
481
1916
  }
482
1917
  return {