@attestry/sdk 0.6.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/LICENSE +190 -0
- package/README.md +1269 -0
- package/dist/client.d.ts +58 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +74 -0
- package/dist/client.js.map +1 -0
- package/dist/constants.d.ts +7 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +43 -0
- package/dist/constants.js.map +1 -0
- package/dist/errors.d.ts +16 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +41 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/lines-parser.d.ts +50 -0
- package/dist/lines-parser.d.ts.map +1 -0
- package/dist/lines-parser.js +211 -0
- package/dist/lines-parser.js.map +1 -0
- package/dist/ndjson-parser.d.ts +57 -0
- package/dist/ndjson-parser.d.ts.map +1 -0
- package/dist/ndjson-parser.js +245 -0
- package/dist/ndjson-parser.js.map +1 -0
- package/dist/resources/abac-policies.d.ts +1034 -0
- package/dist/resources/abac-policies.d.ts.map +1 -0
- package/dist/resources/abac-policies.js +1519 -0
- package/dist/resources/abac-policies.js.map +1 -0
- package/dist/resources/audit-log.d.ts +588 -0
- package/dist/resources/audit-log.d.ts.map +1 -0
- package/dist/resources/audit-log.js +629 -0
- package/dist/resources/audit-log.js.map +1 -0
- package/dist/resources/batch.d.ts +845 -0
- package/dist/resources/batch.d.ts.map +1 -0
- package/dist/resources/batch.js +1074 -0
- package/dist/resources/batch.js.map +1 -0
- package/dist/resources/chat.d.ts +151 -0
- package/dist/resources/chat.d.ts.map +1 -0
- package/dist/resources/chat.js +124 -0
- package/dist/resources/chat.js.map +1 -0
- package/dist/resources/check.d.ts +348 -0
- package/dist/resources/check.d.ts.map +1 -0
- package/dist/resources/check.js +543 -0
- package/dist/resources/check.js.map +1 -0
- package/dist/resources/compliance-check.d.ts +330 -0
- package/dist/resources/compliance-check.d.ts.map +1 -0
- package/dist/resources/compliance-check.js +402 -0
- package/dist/resources/compliance-check.js.map +1 -0
- package/dist/resources/decisions.d.ts +1208 -0
- package/dist/resources/decisions.d.ts.map +1 -0
- package/dist/resources/decisions.js +1362 -0
- package/dist/resources/decisions.js.map +1 -0
- package/dist/resources/evidence-pack.d.ts +1080 -0
- package/dist/resources/evidence-pack.d.ts.map +1 -0
- package/dist/resources/evidence-pack.js +1789 -0
- package/dist/resources/evidence-pack.js.map +1 -0
- package/dist/resources/gate.d.ts +613 -0
- package/dist/resources/gate.d.ts.map +1 -0
- package/dist/resources/gate.js +737 -0
- package/dist/resources/gate.js.map +1 -0
- package/dist/resources/incidents.d.ts +136 -0
- package/dist/resources/incidents.d.ts.map +1 -0
- package/dist/resources/incidents.js +229 -0
- package/dist/resources/incidents.js.map +1 -0
- package/dist/resources/regulatory-changes.d.ts +307 -0
- package/dist/resources/regulatory-changes.d.ts.map +1 -0
- package/dist/resources/regulatory-changes.js +365 -0
- package/dist/resources/regulatory-changes.js.map +1 -0
- package/dist/resources/safe-input-read.d.ts +21 -0
- package/dist/resources/safe-input-read.d.ts.map +1 -0
- package/dist/resources/safe-input-read.js +57 -0
- package/dist/resources/safe-input-read.js.map +1 -0
- package/dist/resources/ship-gate.d.ts +475 -0
- package/dist/resources/ship-gate.d.ts.map +1 -0
- package/dist/resources/ship-gate.js +727 -0
- package/dist/resources/ship-gate.js.map +1 -0
- package/dist/resources/vision.d.ts +540 -0
- package/dist/resources/vision.d.ts.map +1 -0
- package/dist/resources/vision.js +1036 -0
- package/dist/resources/vision.js.map +1 -0
- package/dist/retry.d.ts +103 -0
- package/dist/retry.d.ts.map +1 -0
- package/dist/retry.js +224 -0
- package/dist/retry.js.map +1 -0
- package/dist/sse-parser.d.ts +64 -0
- package/dist/sse-parser.d.ts.map +1 -0
- package/dist/sse-parser.js +271 -0
- package/dist/sse-parser.js.map +1 -0
- package/dist/transport.d.ts +142 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +455 -0
- package/dist/transport.js.map +1 -0
- package/dist/types.d.ts +61 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/package.json +44 -0
|
@@ -0,0 +1,1074 @@
|
|
|
1
|
+
// ─── Batch resource ─────────────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Wraps the bulk classification + assessment surface (session 18):
|
|
4
|
+
//
|
|
5
|
+
// - POST /api/v1/batch Body: {jobType, systemIds, config?}
|
|
6
|
+
// - GET /api/v1/batch/<UUID> Retrieve batch-job status + results
|
|
7
|
+
//
|
|
8
|
+
// Sixth non-decisions resource on `@attestry/sdk`. Sibling to
|
|
9
|
+
// `IncidentsResource`, `DecisionsResource`, `ChatResource`,
|
|
10
|
+
// `AuditLogResource`, `RegulatoryChangesResource`,
|
|
11
|
+
// `ComplianceCheckResource`, `CheckResource`, `GateResource`.
|
|
12
|
+
//
|
|
13
|
+
// **First SDK resource with asymmetric auth between methods on the
|
|
14
|
+
// same resource** (carry-forward invariant candidate #54). The two
|
|
15
|
+
// methods use DIFFERENT permission requirements:
|
|
16
|
+
// - `submit()` (POST): kernel uses
|
|
17
|
+
// `requireApiKeyWithPermission(req, CLASSIFY, WRITE_ASSESSMENTS)`
|
|
18
|
+
// — UNION/OR semantics (`Array.some()` at permissions.ts:53-55).
|
|
19
|
+
// **First SDK route to use a WRITE-side union pair**; every prior
|
|
20
|
+
// SDK route's union has been READ-side
|
|
21
|
+
// (`READ_ASSESSMENTS or READ_SYSTEMS`).
|
|
22
|
+
// - `get()` (GET): kernel uses
|
|
23
|
+
// `requireApiKeyWithPermission(req, READ_ASSESSMENTS)` — single-
|
|
24
|
+
// permission auth, NOT a union. Status reads only need READ.
|
|
25
|
+
// Pin BOTH 401 (no/invalid key) AND 403 (key has NONE of the required
|
|
26
|
+
// permissions) branches separately per invariant #45 / #54.
|
|
27
|
+
//
|
|
28
|
+
// **NEW plan-guard 403 surface on `submit()`** (carry-forward
|
|
29
|
+
// invariant candidate #55). The kernel calls
|
|
30
|
+
// `requirePlan(org, "hasBatchProcessing")` at route.ts:67 BEFORE Zod
|
|
31
|
+
// body parsing. A free-tier org submitting a batch hits the plan
|
|
32
|
+
// gate FIRST (independently of permissions). The kernel emits
|
|
33
|
+
// `PlanLimitError` → **403 with a DIFFERENT recovery path than the
|
|
34
|
+
// permission-403**. Wording difference (verify against
|
|
35
|
+
// `src/lib/middleware/plan-guard.ts:requirePlan` — the throw is at
|
|
36
|
+
// lines 106-111 of that file at the time of writing; the surrounding
|
|
37
|
+
// function spans lines 96-112):
|
|
38
|
+
// - Permission 403:
|
|
39
|
+
// `"API key lacks required permission. Required: classify or
|
|
40
|
+
// write:assessments. Key has: <perms>."`
|
|
41
|
+
// - Plan 403:
|
|
42
|
+
// `"The \"hasBatchProcessing\" feature is not available on your
|
|
43
|
+
// current plan (<plan>). Please upgrade to access this feature."`
|
|
44
|
+
// The SDK surfaces both uniformly as `AttestryAPIError(403)`;
|
|
45
|
+
// consumers regex-match `apiErr.message` if they need to distinguish
|
|
46
|
+
// (the wording difference is documented; no SDK-side discriminator
|
|
47
|
+
// helper today — invariant candidate #55 option A). A future kernel
|
|
48
|
+
// version adding structured error metadata would unlock a clean
|
|
49
|
+
// discriminator field on `apiErr.details`.
|
|
50
|
+
//
|
|
51
|
+
// **THIRD SDK route to PRE-VALIDATE every Zod closed-spec rule
|
|
52
|
+
// synchronously** (after `check.run` and `gate.evaluate`). The
|
|
53
|
+
// kernel uses `parseBody(request, batchSubmitSchema)` where
|
|
54
|
+
// `batchSubmitSchema = z.object({jobType: z.enum([...]), systemIds:
|
|
55
|
+
// z.array(z.string().uuid()).min(1).max(50), config:
|
|
56
|
+
// z.object({frameworks: z.array(z.string().min(1).max(100))
|
|
57
|
+
// .max(20).optional()}).optional()})`. The SDK pre-validates EVERY
|
|
58
|
+
// closed-spec rule synchronously:
|
|
59
|
+
// - `jobType` membership in the 3-string enum (#41 carry-forward).
|
|
60
|
+
// - `systemIds` array length [1, 50] inclusive + per-element UUID
|
|
61
|
+
// format (#49 carry-forward; **the `.min(1)` is new** —
|
|
62
|
+
// gate/check's frameworks was `.max(20)` with empty allowed).
|
|
63
|
+
// - `config.frameworks` carry-forward from gate/check exactly:
|
|
64
|
+
// array length ≤20 + per-element string length [1, 100].
|
|
65
|
+
// The SDK's runtime checks always run regardless of TypeScript types
|
|
66
|
+
// — `as any` casts do NOT bypass them. So 422 from this route
|
|
67
|
+
// reaches consumers ONLY via kernel-side rule changes the SDK
|
|
68
|
+
// hasn't synced to.
|
|
69
|
+
//
|
|
70
|
+
// **Asymmetric cross-org / not-found error code on `submit()`** —
|
|
71
|
+
// the kernel verifies every requested system belongs to the caller's
|
|
72
|
+
// org (route.ts:71-85) and collapses any cross-org OR missing system
|
|
73
|
+
// to **404 with offending IDs EMBEDDED in the message string**:
|
|
74
|
+
// `"Systems not found or not in your organization: <id1>, <id2>, ..."`.
|
|
75
|
+
// **NEW shape vs gate's literal 404** — the message embeds variable
|
|
76
|
+
// data (the comma-joined invalid UUIDs). The SDK documents but does
|
|
77
|
+
// NOT parse the embedded IDs (faithful courier — consumers can regex-
|
|
78
|
+
// match if they want the IDs).
|
|
79
|
+
//
|
|
80
|
+
// **400 surface on `get()`** — the kernel's `isValidUuid(id)` check
|
|
81
|
+
// at route.ts:36 returns false → `errorResponse("Invalid batch job
|
|
82
|
+
// ID format", 400)`. **First 400 on a non-XOR SDK route** —
|
|
83
|
+
// `complianceCheck.check`'s 400 fires when consumer provides BOTH
|
|
84
|
+
// `systemId` AND `orgName`; batch's 400 is a path-param format
|
|
85
|
+
// failure. The SDK pre-validates the UUID format synchronously (D7
|
|
86
|
+
// — same regex as `systemId` in `submit()`), so the kernel 400 is
|
|
87
|
+
// reachable only via `as any` casts or a kernel-side switch to a
|
|
88
|
+
// different UUID flavor.
|
|
89
|
+
//
|
|
90
|
+
// **TWO silent kernel-side truncations** (faithful courier;
|
|
91
|
+
// invariant candidate #50):
|
|
92
|
+
// 1. `orgSystems` row-population on `submit()` — `.limit(500)` at
|
|
93
|
+
// route.ts:76. The kernel reads up to 500 systems from the
|
|
94
|
+
// caller's org to verify each `systemIds[i]` belongs to the org.
|
|
95
|
+
// If the org has >500 systems, the 501st+ are absent from
|
|
96
|
+
// `systemMap`; a `systemIds[i]` referencing one of those would
|
|
97
|
+
// surface as a 404 with the ID in the message (`"Systems not
|
|
98
|
+
// found..."`) EVEN THOUGH the system exists and the consumer
|
|
99
|
+
// owns it. **Documented as a kernel surface gap** — orgs with
|
|
100
|
+
// >500 systems may see spurious 404s on batch submissions; the
|
|
101
|
+
// SDK does NOT mask. Spec-diff drift pin anchors to
|
|
102
|
+
// `.from(schema.aiSystems)[\s\S]*?.limit(500)`.
|
|
103
|
+
// 2. `batchJobs` row-population on `get()` — `.limit(1)` at
|
|
104
|
+
// route.ts:49. Defensive only — the `where` clause already
|
|
105
|
+
// narrows to one row by primary-key UUID. A kernel-side
|
|
106
|
+
// schema change (e.g., a composite primary key or a soft-
|
|
107
|
+
// deleted-rows union) is the only way `.limit(1)` becomes load-
|
|
108
|
+
// bearing. Pin separately as belt-and-suspenders.
|
|
109
|
+
//
|
|
110
|
+
// **CLOSED ENUM on input `jobType`** — `BATCH_JOB_TYPES` is exported
|
|
111
|
+
// frozen so consumers can iterate (`for (const t of
|
|
112
|
+
// BATCH_JOB_TYPES)`) and the SDK pre-rejects unknown values
|
|
113
|
+
// (invariant #41). The 3 valid values are:
|
|
114
|
+
// - `"classify"` — run the rule-based classifier on each system.
|
|
115
|
+
// - `"assess"` — return each system's CURRENT risk classification
|
|
116
|
+
// state (read-only; no classifier re-run).
|
|
117
|
+
// - `"classify_and_assess"` — classify each system AND return the
|
|
118
|
+
// fresh classification (the `"classify"` write-path + a richer
|
|
119
|
+
// response). **Note**: despite the name, no separate "assess" run
|
|
120
|
+
// happens; the kernel only branches between (a)
|
|
121
|
+
// classify-then-emit and (b) emit-current-state. Verify against
|
|
122
|
+
// `route.ts:108-149` if rebuilding the semantics from the wire.
|
|
123
|
+
// Drift-pin the enum string array against the kernel's `z.enum` in
|
|
124
|
+
// the spec-diff round.
|
|
125
|
+
//
|
|
126
|
+
// **TWO DISTINCT STATUS ENUMS on the response** (invariant candidate
|
|
127
|
+
// #56 — partial-success in inline-async jobs). The wire field
|
|
128
|
+
// `status` lives in TWO places with DIFFERENT closed enums:
|
|
129
|
+
// - **Batch-job status** (top-level `response.status`): on POST,
|
|
130
|
+
// `"completed" | "failed"` only (kernel-computed at handler end —
|
|
131
|
+
// `failed === total ? "failed" : "completed"` at route.ts:170).
|
|
132
|
+
// On GET, the WIDER `"pending" | "processing" | "completed" |
|
|
133
|
+
// "failed"` enum (DB column straight pass-through). The closed
|
|
134
|
+
// enum is STRICTLY WIDER on GET than POST — a GET on a job
|
|
135
|
+
// submitted through THIS SDK never observes `"pending"`
|
|
136
|
+
// (already-processed inline), but a GET on a job submitted via a
|
|
137
|
+
// future async path (or by a non-SDK caller mid-flight) could.
|
|
138
|
+
// Type contract is closed at each call site; runtime is open
|
|
139
|
+
// (faithful courier — the P2 validator checks `typeof status ===
|
|
140
|
+
// "string"` only, mirroring gate's `gate: "pass" | "fail"`
|
|
141
|
+
// pattern).
|
|
142
|
+
// - **Per-row result status** (`response.results[i].status`):
|
|
143
|
+
// `"success" | "error"` only, in BOTH POST and GET responses.
|
|
144
|
+
// Discriminator for `classifications` vs `errorMessage` — use
|
|
145
|
+
// `row.status === "success"` (closed-enum string match), NOT
|
|
146
|
+
// `row.errorMessage === undefined` (prototype-pollution-unsafe
|
|
147
|
+
// under `Object.prototype.errorMessage` pollution; the equality
|
|
148
|
+
// check walks the prototype and would return false even when the
|
|
149
|
+
// own-property is genuinely absent).
|
|
150
|
+
// **Document the distinction prominently** — consumers reading
|
|
151
|
+
// `if (response.status === "completed")` are checking a different
|
|
152
|
+
// thing than `if (response.results[0].status === "success")`. Both
|
|
153
|
+
// drift-pinned in the spec-diff round.
|
|
154
|
+
//
|
|
155
|
+
// **`writeAuditLog` side effect on `submit()`** — every successful
|
|
156
|
+
// POST writes one `batch.submitted` audit log entry (route.ts:182-195;
|
|
157
|
+
// SAME pattern as `gate.evaluate` per invariant candidate #53). Use
|
|
158
|
+
// the session-17-corrected wording on the side effect's timing:
|
|
159
|
+
// **TIME-BLOCKING but error-tolerant** — the kernel uses `await
|
|
160
|
+
// writeAuditLog(...)` which awaits two DB ops inside the function
|
|
161
|
+
// (SELECT previous-hash + INSERT new entry, at
|
|
162
|
+
// `src/lib/api.ts:130-159`). The submit-call response latency
|
|
163
|
+
// INCLUDES the audit-log write time. Error semantics ARE
|
|
164
|
+
// non-blocking: `writeAuditLog` wraps its body in a try/catch that
|
|
165
|
+
// swallows errors and logs them, so a write FAILURE does NOT fail
|
|
166
|
+
// the batch submission. Audit log writes are NOT counted against
|
|
167
|
+
// `decisionsPerMonth` quota. **`get()` does NOT write an audit log**
|
|
168
|
+
// — status reads are quiet.
|
|
169
|
+
//
|
|
170
|
+
// **Symmetric prototype-pollution defense** (carry-forward of
|
|
171
|
+
// session-16 second-hostile-review MEDIUM #3 + session-17 build-
|
|
172
|
+
// round baked-in pattern) — module-load snapshot of `Object.hasOwn`
|
|
173
|
+
// applied to BOTH input AND response sides on BOTH methods. Without
|
|
174
|
+
// the response-side defense, a kernel regression that drops a
|
|
175
|
+
// response field combined with a hostile npm dep polluting
|
|
176
|
+
// `Object.prototype.<field>` would let the polluted value pass
|
|
177
|
+
// typeof-check via prototype walk. With the defense, missing own-
|
|
178
|
+
// property → describeType(undefined) → AttestryError. See build-
|
|
179
|
+
// round audit doc D7.
|
|
180
|
+
//
|
|
181
|
+
// **NO URIError defense on body / path** — `submit()` body uses
|
|
182
|
+
// `JSON.stringify`, which handles lone UTF-16 surrogates by emitting
|
|
183
|
+
// them as literal `\uDxxx` escapes (per JSON spec); the URIError
|
|
184
|
+
// defect class (invariant #32) applies only to query-string paths
|
|
185
|
+
// (`encodeURIComponent`). `get()`'s path segment is a UUID — the
|
|
186
|
+
// SDK pre-validates the format before constructing the URL, so a
|
|
187
|
+
// lone-surrogate or non-hex `id` is rejected synchronously
|
|
188
|
+
// (TypeError) before reaching `encodeURIComponent`. The kernel-side
|
|
189
|
+
// `isValidUuid` would also reject in the 400 fallback path if the
|
|
190
|
+
// SDK's pre-validation were ever bypassed (`as any`).
|
|
191
|
+
//
|
|
192
|
+
// Sync JSON request/response on BOTH methods: reuses
|
|
193
|
+
// `client._request` and the existing `{success:true, data}`
|
|
194
|
+
// envelope-unwrap (carry-forward invariant #9). NO new SDK
|
|
195
|
+
// primitive needed. Returns `Promise<BatchSubmitResponse>` /
|
|
196
|
+
// `Promise<BatchJobStatus>`.
|
|
197
|
+
import { AttestryError } from "../errors.js";
|
|
198
|
+
import { readInputField } from "./safe-input-read.js";
|
|
199
|
+
// Module-load snapshot of `Object.hasOwn` — defends against a
|
|
200
|
+
// late-loading hostile/buggy npm dependency that overrides the global
|
|
201
|
+
// (e.g., `Object.hasOwn = () => true`). Without the snapshot, the
|
|
202
|
+
// prototype-pollution defenses below would use whatever Object.hasOwn
|
|
203
|
+
// the dependency replaced it with at request time. Snapshotting at
|
|
204
|
+
// module load captures the original implementation BEFORE most
|
|
205
|
+
// consumer code has a chance to monkey-patch.
|
|
206
|
+
//
|
|
207
|
+
// Caveat: this is partial. If the hostile dependency is imported
|
|
208
|
+
// BEFORE @attestry/sdk in the consumer's load graph, the snapshot
|
|
209
|
+
// captures the bad version. Consumers ordering imports
|
|
210
|
+
// SDK-then-untrusted-deps benefit; the reverse ordering does not.
|
|
211
|
+
// Combined with `Object.hasOwn` itself being immune to
|
|
212
|
+
// `obj.hasOwnProperty = ...` overrides (per MDN), this gives a
|
|
213
|
+
// layered defense.
|
|
214
|
+
//
|
|
215
|
+
// Mirror of `gate.evaluate` / `check.run` / `complianceCheck.check`'s
|
|
216
|
+
// pattern. Used symmetrically on input AND response sides (session-16
|
|
217
|
+
// second-hostile-review MEDIUM #3 carry-forward — defense on both
|
|
218
|
+
// boundaries).
|
|
219
|
+
const objectHasOwn = Object.hasOwn;
|
|
220
|
+
// UUID format regex — RFC 4122 hyphenated form (8-4-4-4-12 hex,
|
|
221
|
+
// case-insensitive). Matches Zod's `z.string().uuid()` regex
|
|
222
|
+
// effectively. Mirror of `gate.evaluate` / `check.run`'s UUID_REGEX.
|
|
223
|
+
// Drift-pinned in `sdk-drift.test.ts` spec-diff round so a kernel-
|
|
224
|
+
// side switch to a different UUID flavor (ULID, KSUID, etc.) fires
|
|
225
|
+
// before consumer regressions.
|
|
226
|
+
const UUID_REGEX = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
227
|
+
/**
|
|
228
|
+
* Closed enum for `BatchSubmitInput.jobType`. Mirrors the kernel's
|
|
229
|
+
* `batchSubmitSchema.jobType` at `src/app/api/v1/batch/route.ts:30`:
|
|
230
|
+
* `z.enum(["classify", "assess", "classify_and_assess"])`.
|
|
231
|
+
*
|
|
232
|
+
* Frozen at module load to prevent runtime mutation by hostile /
|
|
233
|
+
* buggy dependencies (P1 hardening — mirrors the freeze on
|
|
234
|
+
* `AUDIT_LOG_EXPORT_FORMATS` / `CHAT_MESSAGE_ROLES` etc.). Iterating
|
|
235
|
+
* `for (const t of BATCH_JOB_TYPES)` lists the 3 valid values; the
|
|
236
|
+
* SDK's `submit()` pre-rejects any unknown string with `TypeError`
|
|
237
|
+
* (invariant #41 — closed-enum SDK pre-rejection-eligible surface).
|
|
238
|
+
*
|
|
239
|
+
* Drift-pinned in `sdk-drift.test.ts` spec-diff round against the
|
|
240
|
+
* kernel's Zod enum to catch a kernel-side widening (e.g., adding
|
|
241
|
+
* `"generate_docs"` — the stale db/schema.ts:1030 comment mentions
|
|
242
|
+
* this value but the route's Zod schema does NOT include it; the
|
|
243
|
+
* comment is stale, the Zod is authoritative).
|
|
244
|
+
*
|
|
245
|
+
* **Semantics of each value**:
|
|
246
|
+
* - `"classify"` — run the rule-based classifier on each system
|
|
247
|
+
* and PERSIST the new `riskClassifications` to the system row
|
|
248
|
+
* (write-side effect). Per-row `classifications` contains the
|
|
249
|
+
* fresh classification.
|
|
250
|
+
* - `"assess"` — emit each system's CURRENT `riskClassifications`
|
|
251
|
+
* state from the DB (read-only; no write side effect, despite
|
|
252
|
+
* `WRITE_ASSESSMENTS` being a valid auth permission). Per-row
|
|
253
|
+
* `classifications` contains whatever was already on the row
|
|
254
|
+
* (may be `null` if no prior classification).
|
|
255
|
+
* - `"classify_and_assess"` — same as `"classify"` (the kernel
|
|
256
|
+
* branches `classify || classify_and_assess` together at
|
|
257
|
+
* route.ts:112). The two-name distinction is purely semantic
|
|
258
|
+
* for the consumer — both write the new classification and
|
|
259
|
+
* emit it.
|
|
260
|
+
*/
|
|
261
|
+
export const BATCH_JOB_TYPES = Object.freeze([
|
|
262
|
+
"classify",
|
|
263
|
+
"assess",
|
|
264
|
+
"classify_and_assess",
|
|
265
|
+
]);
|
|
266
|
+
/**
|
|
267
|
+
* Closed enum for `BatchJobStatus.status` (the GET response's wider
|
|
268
|
+
* batch-job-level status). Mirrors the kernel's
|
|
269
|
+
* `schema.batchJobs.status` DB column comment at
|
|
270
|
+
* `src/lib/db/schema.ts:1031`: `'pending' | 'processing' | 'completed' |
|
|
271
|
+
* 'failed'`.
|
|
272
|
+
*
|
|
273
|
+
* **Wider than the POST response's `status`** — `submit()` returns
|
|
274
|
+
* `status: "completed" | "failed"` only (computed at handler end from
|
|
275
|
+
* `failed === total`). A `get()` call against a job submitted by the
|
|
276
|
+
* SDK observes only `"completed" | "failed"` in practice (the job
|
|
277
|
+
* processed inline before the row was committed), but a `get()`
|
|
278
|
+
* against a job submitted via a future async path OR an out-of-band
|
|
279
|
+
* caller mid-flight could observe `"pending"` / `"processing"`.
|
|
280
|
+
*
|
|
281
|
+
* Frozen at module load (P1 hardening — same rationale as
|
|
282
|
+
* `BATCH_JOB_TYPES`). Iterating `for (const s of BATCH_JOB_STATUSES)`
|
|
283
|
+
* lists all 4 values. **The DB column has no kernel-side enum
|
|
284
|
+
* constraint** — the SDK exposes the closed union at the type level
|
|
285
|
+
* but the runtime validator checks `typeof status === "string"` only
|
|
286
|
+
* (faithful courier — same asymmetry as gate's `gate: "pass" |
|
|
287
|
+
* "fail"` pattern).
|
|
288
|
+
*
|
|
289
|
+
* Drift-pinned in the spec-diff round (sdk-drift.test.ts —
|
|
290
|
+
* "BATCH_JOB_STATUSES in SDK matches the kernel db/schema.ts
|
|
291
|
+
* comment + .default() literal") via TWO assertions inside the
|
|
292
|
+
* same `it()`:
|
|
293
|
+
* - **Assertion 1**: the schema's `.default("pending")` literal
|
|
294
|
+
* at `src/lib/db/schema.ts:1031` (machine-checkable). Fires
|
|
295
|
+
* on a default-change (e.g., from `"pending"` to `"queued"`).
|
|
296
|
+
* - **Assertion 2**: the schema column COMMENT listing all 4
|
|
297
|
+
* values (documentation source-of-truth). Fires on a
|
|
298
|
+
* widening (e.g., a new `"cancelled"` status added to the
|
|
299
|
+
* comment) OR a comment removal.
|
|
300
|
+
* The pin's failure message distinguishes which assertion fires.
|
|
301
|
+
*/
|
|
302
|
+
export const BATCH_JOB_STATUSES = Object.freeze([
|
|
303
|
+
"pending",
|
|
304
|
+
"processing",
|
|
305
|
+
"completed",
|
|
306
|
+
"failed",
|
|
307
|
+
]);
|
|
308
|
+
/**
|
|
309
|
+
* `batch` resource — sibling to `IncidentsResource`,
|
|
310
|
+
* `DecisionsResource`, `ChatResource`, `AuditLogResource`,
|
|
311
|
+
* `RegulatoryChangesResource`, `ComplianceCheckResource`,
|
|
312
|
+
* `CheckResource`, `GateResource`.
|
|
313
|
+
*
|
|
314
|
+
* Multi-method resource (mirror of `ChatResource`'s `send` +
|
|
315
|
+
* `stream`). Wraps TWO kernel routes:
|
|
316
|
+
* - `POST /api/v1/batch` via `submit(input, options?)`
|
|
317
|
+
* - `GET /api/v1/batch/<UUID>` via `get(id, options?)`
|
|
318
|
+
*
|
|
319
|
+
* **First SDK resource with asymmetric auth between methods on the
|
|
320
|
+
* same resource** (invariant candidate #54). `submit()` requires a
|
|
321
|
+
* key with `CLASSIFY` or `WRITE_ASSESSMENTS` AND enterprise plan;
|
|
322
|
+
* `get()` requires only `READ_ASSESSMENTS`. See per-method JSDoc.
|
|
323
|
+
*/
|
|
324
|
+
export class BatchResource {
|
|
325
|
+
client;
|
|
326
|
+
constructor(client) {
|
|
327
|
+
this.client = client;
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Submit a batch job — classify and/or read the current
|
|
331
|
+
* classification state for 1-50 systems in one call. Returns a
|
|
332
|
+
* `BatchSubmitResponse` with a per-row `results` envelope
|
|
333
|
+
* describing each system's outcome.
|
|
334
|
+
*
|
|
335
|
+
* **Partial-success contract**: the call resolves successfully
|
|
336
|
+
* (no throw) even when every row failed. Inspect
|
|
337
|
+
* `response.failedSystems` (or iterate `response.results` filtering
|
|
338
|
+
* `row.status === "error"`) to detect per-row errors. Top-level
|
|
339
|
+
* failures (auth, plan, rate limit, Zod, cross-org systemId,
|
|
340
|
+
* internal) DO throw `AttestryAPIError`. Mirror of
|
|
341
|
+
* `decisions.bulk`'s contract.
|
|
342
|
+
*
|
|
343
|
+
* **Multi-permission UNION auth scope (WRITE-side)**: kernel uses
|
|
344
|
+
* `requireApiKeyWithPermission(req, CLASSIFY, WRITE_ASSESSMENTS)`
|
|
345
|
+
* — OR semantics (`Array.some()` at `permissions.ts:53-55`).
|
|
346
|
+
* **First SDK route to use a WRITE-side union pair** (every prior
|
|
347
|
+
* SDK union has been READ-side). A key with EITHER permission
|
|
348
|
+
* (or `ADMIN`, or null/empty permissions for backwards-compat)
|
|
349
|
+
* succeeds. **HTTP 401** for no/invalid API key; **HTTP 403** for
|
|
350
|
+
* an authenticated key that has NEITHER required permission. Pin
|
|
351
|
+
* BOTH branches separately. Invariant #45 / #54.
|
|
352
|
+
*
|
|
353
|
+
* **NEW plan-guard 403 surface** (invariant candidate #55).
|
|
354
|
+
* **BEFORE Zod body parsing**, the kernel calls
|
|
355
|
+
* `requirePlan(org, "hasBatchProcessing")` at route.ts:67. A
|
|
356
|
+
* free-tier (or trial-expired non-enterprise) org hits the plan
|
|
357
|
+
* gate FIRST, regardless of body validity or systemIds. The
|
|
358
|
+
* kernel emits `PlanLimitError` which the route catches at line
|
|
359
|
+
* 216 and surfaces as **403** with a literal message of the form
|
|
360
|
+
* `'The "hasBatchProcessing" feature is not available on your
|
|
361
|
+
* current plan (<plan>). Please upgrade to access this feature.'`
|
|
362
|
+
* Distinct from the permission-403 message
|
|
363
|
+
* `'API key lacks required permission. Required: ... Key has: ...'`
|
|
364
|
+
* — consumers regex-match the message contents if they need to
|
|
365
|
+
* distinguish "upgrade your plan" from "grant more permissions to
|
|
366
|
+
* your key". Both surface uniformly as `AttestryAPIError(403)`
|
|
367
|
+
* (no SDK-side discriminator helper today).
|
|
368
|
+
*
|
|
369
|
+
* **Asymmetric cross-org / not-found error code (404 with
|
|
370
|
+
* EMBEDDED IDs)**: the kernel verifies every requested system
|
|
371
|
+
* belongs to the caller's org and collapses cross-org OR missing
|
|
372
|
+
* to 404. **NEW shape vs gate's literal 404** — the message
|
|
373
|
+
* embeds the comma-joined invalid UUIDs:
|
|
374
|
+
* `'Systems not found or not in your organization: <id1>, <id2>, ...'`.
|
|
375
|
+
* The SDK does NOT parse the embedded IDs — faithful courier;
|
|
376
|
+
* consumers can regex-match if they want to surface specific IDs
|
|
377
|
+
* to users.
|
|
378
|
+
*
|
|
379
|
+
* **TWO silent kernel-side truncations** (invariant candidate
|
|
380
|
+
* #50):
|
|
381
|
+
* 1. `orgSystems` row-population — `.limit(500)` at route.ts:76.
|
|
382
|
+
* The kernel reads up to 500 systems from the caller's org
|
|
383
|
+
* to verify membership. Orgs with >500 systems may see
|
|
384
|
+
* spurious 404s on batch submissions referencing systems
|
|
385
|
+
* outside the first 500 rows. **Documented kernel surface
|
|
386
|
+
* gap**; the SDK does NOT mask. Pin anchored to
|
|
387
|
+
* `.from(schema.aiSystems)[\s\S]*?.limit(500)` in the spec-
|
|
388
|
+
* diff round.
|
|
389
|
+
* 2. (GET-side `.limit(1)` is documented under `get()` below.)
|
|
390
|
+
*
|
|
391
|
+
* **`writeAuditLog` side effect** — every successful `submit()`
|
|
392
|
+
* call writes one `batch.submitted` entry to the org's audit log
|
|
393
|
+
* (route.ts:182-195). Properties of the write:
|
|
394
|
+
* - Org-scoped, hash-chained (per `writeAuditLog` at
|
|
395
|
+
* `src/lib/api.ts:125-`).
|
|
396
|
+
* - **Time-blocking** but error-tolerant: the kernel uses
|
|
397
|
+
* `await writeAuditLog(...)`, which awaits two DB ops (SELECT
|
|
398
|
+
* previous-hash + INSERT new entry). The submit-call response
|
|
399
|
+
* latency INCLUDES the audit-log write time. Error semantics
|
|
400
|
+
* ARE non-blocking: `writeAuditLog` wraps its body in a
|
|
401
|
+
* try/catch that swallows errors and logs them, so a write
|
|
402
|
+
* FAILURE does NOT fail the submit request.
|
|
403
|
+
* - NOT counted against `decisionsPerMonth` quota.
|
|
404
|
+
*
|
|
405
|
+
* **Closed-enum input `jobType`** — pre-rejected SDK-side if not
|
|
406
|
+
* one of `BATCH_JOB_TYPES`. Use `BATCH_JOB_TYPES` to iterate or
|
|
407
|
+
* narrow at call sites.
|
|
408
|
+
*
|
|
409
|
+
* **Per-row discriminator** — use `row.status === "success"` (NOT
|
|
410
|
+
* `row.errorMessage === undefined`, which is prototype-pollution
|
|
411
|
+
* unsafe). See `BatchSystemResult` JSDoc for the full rationale.
|
|
412
|
+
*
|
|
413
|
+
* Errors — ordered by kernel firing precedence (rate-limit →
|
|
414
|
+
* auth → org-load (404 if missing) → plan-guard → Zod body
|
|
415
|
+
* validation → DB membership check → inline processing →
|
|
416
|
+
* internal). A request with multiple problems surfaces ONLY the
|
|
417
|
+
* highest-precedence one. For example: a request with bad auth
|
|
418
|
+
* AND a free-tier org surfaces 401, not 403 (plan-guard fires
|
|
419
|
+
* AFTER auth). A request whose org row was deleted between key
|
|
420
|
+
* issuance and request time surfaces a 404 BEFORE the plan-
|
|
421
|
+
* guard 403 fires (rare in practice). A request with valid auth
|
|
422
|
+
* + valid org + free-tier + a malformed body surfaces 403
|
|
423
|
+
* (plan-guard fires BEFORE Zod body validation).
|
|
424
|
+
*
|
|
425
|
+
* - `AttestryAPIError` (status 429) — rate limit FIRES FIRST
|
|
426
|
+
* (auto-retried by default — invariant #18; per-IP rate-limit
|
|
427
|
+
* key `v1-batch:${ip}`; tighter limiter than apiLimiter —
|
|
428
|
+
* `assessmentLimiter` at 30 req/min vs apiLimiter's 60).
|
|
429
|
+
* - `AttestryAPIError` (status 401) — no API key OR invalid key.
|
|
430
|
+
* - `AttestryAPIError` (status 403, permission branch) — key
|
|
431
|
+
* has NEITHER `CLASSIFY` nor `WRITE_ASSESSMENTS`.
|
|
432
|
+
* - `AttestryAPIError` (status 404, org-not-found branch) — the
|
|
433
|
+
* caller's org row was deleted (route.ts:66: `if (!org)
|
|
434
|
+
* return errorResponse("Organization not found", 404)`).
|
|
435
|
+
* **Distinct from the systems-not-found 404 below** — same
|
|
436
|
+
* status code, different message. Rare in practice (orgs
|
|
437
|
+
* aren't deleted while keys are active).
|
|
438
|
+
* - `AttestryAPIError` (status 403, plan-gate branch) — the
|
|
439
|
+
* org's effective plan doesn't have `hasBatchProcessing`.
|
|
440
|
+
* Distinct wording from the permission-403 (above) — consumers
|
|
441
|
+
* regex-match to distinguish.
|
|
442
|
+
* - `AttestryAPIError` (status 422) — Zod schema rejection
|
|
443
|
+
* (kernel's `BodyParseError` surface — `parseBody(request,
|
|
444
|
+
* batchSubmitSchema)` failed). `apiErr.details` carries the
|
|
445
|
+
* full kernel error body verbatim (the transport does NOT
|
|
446
|
+
* strip the `{success:false, ...}` envelope on error
|
|
447
|
+
* responses — only the `{success:true, data}` envelope on
|
|
448
|
+
* success). The wire shape is: `{success: false, error:
|
|
449
|
+
* "Validation failed.", details: Array<{path: string;
|
|
450
|
+
* message: string}>}` — `error` is the literal string
|
|
451
|
+
* "Validation failed." (with trailing period), `details` is
|
|
452
|
+
* an array (NOT a keyed map) of `{path, message}` pairs
|
|
453
|
+
* derived from Zod's `result.error.errors`. Consumers reading
|
|
454
|
+
* field-by-field errors should iterate `apiErr.details.details`
|
|
455
|
+
* (the kernel's `details` array nested under the SDK's parsed-
|
|
456
|
+
* body wrapper). **The SDK pre-validates all closed-spec
|
|
457
|
+
* rules** (jobType enum membership, systemIds array length
|
|
458
|
+
* [1, 50] + per-element UUID format, frameworks array length
|
|
459
|
+
* ≤20 + per-element string length [1, 100]) AND the runtime
|
|
460
|
+
* checks always run regardless of TypeScript types — `as any`
|
|
461
|
+
* casts do NOT bypass them. So 422 reaches consumers ONLY
|
|
462
|
+
* via kernel rule changes the SDK hasn't synced to. Invariant
|
|
463
|
+
* #51.
|
|
464
|
+
* - `AttestryAPIError` (status 404, systems-not-found branch) —
|
|
465
|
+
* one or more `systemIds[i]` are not in the caller's org (or
|
|
466
|
+
* don't exist). The kernel collapses cross-org and genuine-
|
|
467
|
+
* missing to 404 with the literal message
|
|
468
|
+
* `"Systems not found or not in your organization: <id1>, <id2>, ..."`.
|
|
469
|
+
* **Embedded IDs** — the SDK does NOT parse the offending UUIDs
|
|
470
|
+
* out of the message; consumers can regex-match if needed.
|
|
471
|
+
* - `AttestryAPIError` (status 500) — internal kernel error
|
|
472
|
+
* (scrubbed message via `internalErrorResponse`).
|
|
473
|
+
* - `AttestryError` ("request aborted by caller") — caller-
|
|
474
|
+
* supplied `options.signal` fired (pre-aborted or mid-flight).
|
|
475
|
+
* - `AttestryError` (P2 hardening) — kernel response failed
|
|
476
|
+
* SDK-side shape validation (not an object, wrong type on
|
|
477
|
+
* any of the 10 response fields).
|
|
478
|
+
* - `AttestryAPIError` (P3 hardening) — kernel response had a
|
|
479
|
+
* wrong Content-Type (transport-level guard before body
|
|
480
|
+
* parsing).
|
|
481
|
+
* - `TypeError` — input failed SDK-side validation (null /
|
|
482
|
+
* array / non-object input, missing jobType, unknown
|
|
483
|
+
* jobType, missing systemIds, non-array systemIds, empty
|
|
484
|
+
* systemIds, oversize systemIds, non-string systemIds
|
|
485
|
+
* element, non-UUID systemIds element, non-object config,
|
|
486
|
+
* non-array config.frameworks, oversize config.frameworks,
|
|
487
|
+
* non-string config.frameworks element, oversize/empty
|
|
488
|
+
* config.frameworks element). **THROWN SYNCHRONOUSLY** (no
|
|
489
|
+
* fetch issued; the function does NOT return a promise in
|
|
490
|
+
* this case). Distinct from `AttestryAPIError` /
|
|
491
|
+
* `AttestryError` above, which reject through the returned
|
|
492
|
+
* promise. Consumers using `await client.batch.submit(...)`
|
|
493
|
+
* see both surfaces uniformly; consumers wrapping the call
|
|
494
|
+
* in a non-awaiting context (e.g., `client.batch.submit(...)
|
|
495
|
+
* .then(...)`) must catch the synchronous throw with a
|
|
496
|
+
* surrounding try/catch — the `.then()` chain alone does
|
|
497
|
+
* NOT catch synchronous TypeErrors.
|
|
498
|
+
*
|
|
499
|
+
* **Notably ABSENT**:
|
|
500
|
+
* - **No 400** on POST — all input validation is Zod (422).
|
|
501
|
+
* The GET method DOES have a 400 (malformed `id` path
|
|
502
|
+
* parameter).
|
|
503
|
+
* - **No 413** — body size limit not explicit.
|
|
504
|
+
* - **No 402** — read-shaped from a quota perspective (despite
|
|
505
|
+
* writing per-system classifications, doesn't count against
|
|
506
|
+
* `decisionsPerMonth`).
|
|
507
|
+
*
|
|
508
|
+
* **SDK-side validation** (synchronous `TypeError`, no fetch
|
|
509
|
+
* issued):
|
|
510
|
+
* - `input` itself: required; must be a non-null, non-array
|
|
511
|
+
* object.
|
|
512
|
+
* - `input.jobType`: required own-property; must be a string;
|
|
513
|
+
* must be one of `BATCH_JOB_TYPES`.
|
|
514
|
+
* - `input.systemIds`: required own-property; must be an Array;
|
|
515
|
+
* length [1, 50] inclusive; each element a non-empty string
|
|
516
|
+
* matching `UUID_REGEX`. Snapshot via `Array.from` for TOCTOU
|
|
517
|
+
* defense.
|
|
518
|
+
* - `input.config` (when own-property present, value not
|
|
519
|
+
* undefined): must be a non-null non-array object.
|
|
520
|
+
* - `input.config.frameworks` (when own-property present, value
|
|
521
|
+
* not undefined): must be an array of ≤20 strings, each of
|
|
522
|
+
* length 1-100. Snapshot via `Array.from` for TOCTOU defense.
|
|
523
|
+
*
|
|
524
|
+
* **Response-shape validation** (P2 hardening — symmetric defense
|
|
525
|
+
* on response side, mirror of session-16 second-hostile-review
|
|
526
|
+
* MEDIUM #3 carry-forward):
|
|
527
|
+
* - Rejects with `AttestryError` if the kernel response isn't a
|
|
528
|
+
* non-null, non-array object.
|
|
529
|
+
* - Rejects if `id` / `jobType` / `status` / `createdAt` /
|
|
530
|
+
* `completedAt` aren't strings.
|
|
531
|
+
* - Rejects if `startedAt` isn't a string or null.
|
|
532
|
+
* - Rejects if `totalSystems` / `processedSystems` /
|
|
533
|
+
* `failedSystems` aren't numbers.
|
|
534
|
+
* - Rejects if `results` isn't an array.
|
|
535
|
+
* - Per-row shape (open-spec — `BatchSystemResult`) is faithful-
|
|
536
|
+
* courier — NOT validated.
|
|
537
|
+
* - Each response field read goes through the module-load
|
|
538
|
+
* `objectHasOwn` snapshot (symmetric to input-side defense).
|
|
539
|
+
*
|
|
540
|
+
* **Transport-shape validation** (P3 hardening):
|
|
541
|
+
* - Rejects with `AttestryAPIError` if the kernel responds with
|
|
542
|
+
* a non-`application/json` Content-Type.
|
|
543
|
+
*
|
|
544
|
+
* @example Submit a classify job for 3 systems
|
|
545
|
+
* ```ts
|
|
546
|
+
* const result = await client.batch.submit({
|
|
547
|
+
* jobType: "classify",
|
|
548
|
+
* systemIds: [
|
|
549
|
+
* "11111111-1111-1111-1111-111111111111",
|
|
550
|
+
* "22222222-2222-2222-2222-222222222222",
|
|
551
|
+
* "33333333-3333-3333-3333-333333333333",
|
|
552
|
+
* ],
|
|
553
|
+
* });
|
|
554
|
+
* console.log(`Processed ${result.processedSystems}/${result.totalSystems}`);
|
|
555
|
+
* for (const row of result.results) {
|
|
556
|
+
* if (row.status === "success") {
|
|
557
|
+
* console.log(`OK ${row.systemId}:`, row.classifications);
|
|
558
|
+
* } else {
|
|
559
|
+
* // CRITICAL: branch on `row.status === "error"` — NOT
|
|
560
|
+
* // `row.errorMessage === undefined` (prototype-pollution
|
|
561
|
+
* // unsafe).
|
|
562
|
+
* console.error(`FAIL ${row.systemId}: ${row.errorMessage}`);
|
|
563
|
+
* }
|
|
564
|
+
* }
|
|
565
|
+
* ```
|
|
566
|
+
*
|
|
567
|
+
* @example Submit with framework filter (round-trip only today)
|
|
568
|
+
* ```ts
|
|
569
|
+
* const job = await client.batch.submit({
|
|
570
|
+
* jobType: "classify_and_assess",
|
|
571
|
+
* systemIds: ["11111111-1111-1111-1111-111111111111"],
|
|
572
|
+
* config: { frameworks: ["EU_AI_ACT", "ISO_42001"] },
|
|
573
|
+
* });
|
|
574
|
+
* // config.frameworks is persisted on the row but has no effect on
|
|
575
|
+
* // the current inline classification path.
|
|
576
|
+
* ```
|
|
577
|
+
*/
|
|
578
|
+
submit(input, options) {
|
|
579
|
+
// Top-level shape — input is REQUIRED. typeof null === "object"
|
|
580
|
+
// and typeof [] === "object", so guard both explicitly.
|
|
581
|
+
if (input === null ||
|
|
582
|
+
typeof input !== "object" ||
|
|
583
|
+
Array.isArray(input)) {
|
|
584
|
+
throw new TypeError("batch.submit: `input` must be a non-null object with `jobType` + `systemIds`");
|
|
585
|
+
}
|
|
586
|
+
// Snapshot each field's value EXACTLY ONCE up front via the
|
|
587
|
+
// own-property indexer. Three motivations (same as gate.evaluate):
|
|
588
|
+
// 1. Prototype-pollution defense (generalization of #48).
|
|
589
|
+
// Pollution of `Object.prototype.<field>` does NOT trick
|
|
590
|
+
// the SDK into silently sending the polluted value when
|
|
591
|
+
// the user passes an object without that own property.
|
|
592
|
+
// Uses the module-load `objectHasOwn` snapshot so a late-
|
|
593
|
+
// loading dep overriding `Object.hasOwn` doesn't defeat
|
|
594
|
+
// the defense.
|
|
595
|
+
// 2. TOCTOU defense: a Proxy or getter-defining input could
|
|
596
|
+
// yield DIFFERENT values across multiple reads. Snapshot-
|
|
597
|
+
// then-validate collapses validate-then-send to a single
|
|
598
|
+
// read per field; the validated value is provably the
|
|
599
|
+
// value sent.
|
|
600
|
+
// 3. Explicit empty / missing fields are treated as omission
|
|
601
|
+
// — `objectHasOwn` correctly returns false on missing keys.
|
|
602
|
+
const hasJobType = objectHasOwn(input, "jobType");
|
|
603
|
+
const jobTypeRaw = hasJobType
|
|
604
|
+
? readInputField(input, "jobType", "batch.submit")
|
|
605
|
+
: undefined;
|
|
606
|
+
const hasSystemIds = objectHasOwn(input, "systemIds");
|
|
607
|
+
const systemIdsRaw = hasSystemIds
|
|
608
|
+
? readInputField(input, "systemIds", "batch.submit")
|
|
609
|
+
: undefined;
|
|
610
|
+
const hasConfig = objectHasOwn(input, "config");
|
|
611
|
+
const configRaw = hasConfig
|
|
612
|
+
? readInputField(input, "config", "batch.submit")
|
|
613
|
+
: undefined;
|
|
614
|
+
// jobType — REQUIRED, closed-enum string membership.
|
|
615
|
+
if (!hasJobType || jobTypeRaw === undefined) {
|
|
616
|
+
throw new TypeError("batch.submit: `jobType` is required");
|
|
617
|
+
}
|
|
618
|
+
if (typeof jobTypeRaw !== "string") {
|
|
619
|
+
throw new TypeError(`batch.submit: \`jobType\` must be a string ` +
|
|
620
|
+
`(got ${describeType(jobTypeRaw)})`);
|
|
621
|
+
}
|
|
622
|
+
// Closed-enum SDK pre-rejection (invariant #41). The kernel's
|
|
623
|
+
// Zod `z.enum([...])` enforces the same; the SDK pre-rejects
|
|
624
|
+
// synchronously to fail fast and surface a clear error.
|
|
625
|
+
if (!BATCH_JOB_TYPES.includes(jobTypeRaw)) {
|
|
626
|
+
throw new TypeError(`batch.submit: \`jobType\` must be one of ` +
|
|
627
|
+
`${JSON.stringify(BATCH_JOB_TYPES)} (got ${JSON.stringify(jobTypeRaw)})`);
|
|
628
|
+
}
|
|
629
|
+
const validatedJobType = jobTypeRaw;
|
|
630
|
+
// systemIds — REQUIRED array of 1-50 UUIDs. Snapshot via
|
|
631
|
+
// Array.from up front so a Proxy whose `.length` or `[i]`
|
|
632
|
+
// changes between reads can't slip past validation. Per-element
|
|
633
|
+
// pre-validation matches Zod's `.array(z.string().uuid())
|
|
634
|
+
// .min(1).max(50)` exactly.
|
|
635
|
+
if (!hasSystemIds || systemIdsRaw === undefined) {
|
|
636
|
+
throw new TypeError("batch.submit: `systemIds` is required");
|
|
637
|
+
}
|
|
638
|
+
if (!Array.isArray(systemIdsRaw)) {
|
|
639
|
+
throw new TypeError(`batch.submit: \`systemIds\` must be an array ` +
|
|
640
|
+
`(got ${describeType(systemIdsRaw)})`);
|
|
641
|
+
}
|
|
642
|
+
const systemIdsSnapshot = Array.from(systemIdsRaw);
|
|
643
|
+
// .min(1) — distinct from gate/check's frameworks (which allowed
|
|
644
|
+
// empty). Empty batches are rejected at the Zod level; SDK pre-
|
|
645
|
+
// rejects for symmetry.
|
|
646
|
+
if (systemIdsSnapshot.length < 1) {
|
|
647
|
+
throw new TypeError("batch.submit: `systemIds` must contain at least 1 entry " +
|
|
648
|
+
"(empty arrays rejected — Zod `.min(1, \"At least one system ID is required\")`)");
|
|
649
|
+
}
|
|
650
|
+
if (systemIdsSnapshot.length > 50) {
|
|
651
|
+
throw new TypeError(`batch.submit: \`systemIds\` array exceeds the kernel's max ` +
|
|
652
|
+
`length of 50 (got ${systemIdsSnapshot.length})`);
|
|
653
|
+
}
|
|
654
|
+
for (let i = 0; i < systemIdsSnapshot.length; i++) {
|
|
655
|
+
const elem = systemIdsSnapshot[i];
|
|
656
|
+
if (typeof elem !== "string") {
|
|
657
|
+
throw new TypeError(`batch.submit: \`systemIds[${i}]\` must be a string ` +
|
|
658
|
+
`(got ${describeType(elem)})`);
|
|
659
|
+
}
|
|
660
|
+
if (elem.length === 0) {
|
|
661
|
+
throw new TypeError(`batch.submit: \`systemIds[${i}]\` must be a non-empty string`);
|
|
662
|
+
}
|
|
663
|
+
if (!UUID_REGEX.test(elem)) {
|
|
664
|
+
throw new TypeError(`batch.submit: \`systemIds[${i}]\` must be an RFC 4122 hyphenated UUID ` +
|
|
665
|
+
`(matched regex: /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-` +
|
|
666
|
+
`[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/, got ${JSON.stringify(elem)})`);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
const validatedSystemIds = systemIdsSnapshot;
|
|
670
|
+
// config — OPTIONAL. When provided, must be a non-null non-
|
|
671
|
+
// array object with optional `frameworks` matching gate/check's
|
|
672
|
+
// exact shape.
|
|
673
|
+
let validatedConfig;
|
|
674
|
+
if (hasConfig && configRaw !== undefined) {
|
|
675
|
+
if (configRaw === null ||
|
|
676
|
+
typeof configRaw !== "object" ||
|
|
677
|
+
Array.isArray(configRaw)) {
|
|
678
|
+
throw new TypeError(`batch.submit: \`config\` must be a non-null object when provided ` +
|
|
679
|
+
`(got ${describeType(configRaw)})`);
|
|
680
|
+
}
|
|
681
|
+
const hasFrameworks = objectHasOwn(configRaw, "frameworks");
|
|
682
|
+
const frameworksRaw = hasFrameworks
|
|
683
|
+
? configRaw.frameworks
|
|
684
|
+
: undefined;
|
|
685
|
+
let validatedFrameworks;
|
|
686
|
+
if (hasFrameworks && frameworksRaw !== undefined) {
|
|
687
|
+
if (!Array.isArray(frameworksRaw)) {
|
|
688
|
+
throw new TypeError(`batch.submit: \`config.frameworks\` must be an array when provided ` +
|
|
689
|
+
`(got ${describeType(frameworksRaw)})`);
|
|
690
|
+
}
|
|
691
|
+
const fwSnapshot = Array.from(frameworksRaw);
|
|
692
|
+
if (fwSnapshot.length > 20) {
|
|
693
|
+
throw new TypeError(`batch.submit: \`config.frameworks\` array exceeds the kernel's max ` +
|
|
694
|
+
`length of 20 (got ${fwSnapshot.length})`);
|
|
695
|
+
}
|
|
696
|
+
for (let i = 0; i < fwSnapshot.length; i++) {
|
|
697
|
+
const elem = fwSnapshot[i];
|
|
698
|
+
if (typeof elem !== "string") {
|
|
699
|
+
throw new TypeError(`batch.submit: \`config.frameworks[${i}]\` must be a string ` +
|
|
700
|
+
`(got ${describeType(elem)})`);
|
|
701
|
+
}
|
|
702
|
+
if (elem.length === 0) {
|
|
703
|
+
throw new TypeError(`batch.submit: \`config.frameworks[${i}]\` must be a non-empty string`);
|
|
704
|
+
}
|
|
705
|
+
if (elem.length > 100) {
|
|
706
|
+
throw new TypeError(`batch.submit: \`config.frameworks[${i}]\` exceeds the kernel's max ` +
|
|
707
|
+
`length of 100 chars (got ${elem.length})`);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
validatedFrameworks = fwSnapshot;
|
|
711
|
+
}
|
|
712
|
+
validatedConfig = {};
|
|
713
|
+
if (validatedFrameworks !== undefined) {
|
|
714
|
+
validatedConfig.frameworks = validatedFrameworks;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
// Construct the body. Omit `config` if the consumer didn't
|
|
718
|
+
// provide it (kernel applies its own default — none today;
|
|
719
|
+
// `config` is `.optional()` with no `.default()`). Omitting an
|
|
720
|
+
// optional field is preferred over emitting `null` so the
|
|
721
|
+
// kernel sees the field as absent.
|
|
722
|
+
const body = {
|
|
723
|
+
jobType: validatedJobType,
|
|
724
|
+
systemIds: validatedSystemIds,
|
|
725
|
+
};
|
|
726
|
+
if (validatedConfig !== undefined) {
|
|
727
|
+
body.config = validatedConfig;
|
|
728
|
+
}
|
|
729
|
+
return this.client
|
|
730
|
+
._request({
|
|
731
|
+
method: "POST",
|
|
732
|
+
path: "/api/v1/batch",
|
|
733
|
+
body,
|
|
734
|
+
options,
|
|
735
|
+
})
|
|
736
|
+
.then((result) => validateBatchSubmitResponse(result));
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Retrieve a batch job's status and results by UUID. Returns a
|
|
740
|
+
* `BatchJobStatus` with the wider 4-value `status` enum (NOT the
|
|
741
|
+
* narrower `"completed" | "failed"` of POST) plus the original
|
|
742
|
+
* `config` (round-tripped from submission).
|
|
743
|
+
*
|
|
744
|
+
* **Single-permission auth scope (DIFFERENT from `submit()`)** —
|
|
745
|
+
* kernel uses `requireApiKeyWithPermission(req, READ_ASSESSMENTS)`
|
|
746
|
+
* with ONLY ONE required permission, NOT a union. Status reads
|
|
747
|
+
* don't need `CLASSIFY` or `WRITE_ASSESSMENTS`. **First SDK
|
|
748
|
+
* resource with asymmetric auth between methods on the same
|
|
749
|
+
* resource** (invariant candidate #54). Pin BOTH 401 (no/invalid
|
|
750
|
+
* key) AND 403 (key lacks `READ_ASSESSMENTS`) branches.
|
|
751
|
+
*
|
|
752
|
+
* **NO plan-guard surface on `get()`** — `requirePlan` is invoked
|
|
753
|
+
* only in `submit()`. A free-tier org can `get()` a job that was
|
|
754
|
+
* previously submitted (e.g., on a higher plan that has since
|
|
755
|
+
* downgraded). The submission would have been gated; the read
|
|
756
|
+
* isn't.
|
|
757
|
+
*
|
|
758
|
+
* **400 surface on malformed UUID path parameter** — the kernel's
|
|
759
|
+
* `isValidUuid(id)` check at route.ts:36 returns false →
|
|
760
|
+
* `errorResponse("Invalid batch job ID format", 400)`. The SDK
|
|
761
|
+
* pre-validates the UUID format synchronously (`TypeError`) — so
|
|
762
|
+
* the 400 reaches consumers only via `as any` casts or a kernel-
|
|
763
|
+
* side switch to a different UUID flavor (ULID, KSUID, etc.).
|
|
764
|
+
*
|
|
765
|
+
* **404 surface (literal string)** — the kernel's `where(id +
|
|
766
|
+
* orgId)` query returns zero rows → `errorResponse("Batch job not
|
|
767
|
+
* found", 404)`. **NEW shape** vs `submit()`'s 404 with embedded
|
|
768
|
+
* IDs — the `get()` 404 is a LITERAL string with no variable
|
|
769
|
+
* data. Cross-org `id` collapses to the same 404 (the `eq(orgId,
|
|
770
|
+
* apiKeyUser.orgId)` clause silently filters out matching IDs in
|
|
771
|
+
* other orgs).
|
|
772
|
+
*
|
|
773
|
+
* **No `writeAuditLog` side effect on `get()`** — status reads
|
|
774
|
+
* are quiet. Asymmetric with `submit()`'s `batch.submitted`
|
|
775
|
+
* write.
|
|
776
|
+
*
|
|
777
|
+
* **Defensive `.limit(1)` on the batchJobs query** (route.ts:49)
|
|
778
|
+
* — the `where` clause already narrows to one row by primary-key
|
|
779
|
+
* UUID, so this cap is belt-and-suspenders against a hypothetical
|
|
780
|
+
* future schema change (composite primary key, soft-deleted-rows
|
|
781
|
+
* union). Pin separately as a kernel surface gap. Invariant
|
|
782
|
+
* candidate #50.
|
|
783
|
+
*
|
|
784
|
+
* Errors — ordered by kernel firing precedence (rate-limit →
|
|
785
|
+
* auth → UUID format → DB lookup → internal):
|
|
786
|
+
* - `AttestryAPIError` (status 429) — rate limit (auto-retried;
|
|
787
|
+
* per-IP key `v1-batch-status:${ip}`; uses the standard
|
|
788
|
+
* `apiLimiter` at 60 req/min — looser than `submit()`'s 30/
|
|
789
|
+
* min `assessmentLimiter`).
|
|
790
|
+
* - `AttestryAPIError` (status 401) — no API key OR invalid key.
|
|
791
|
+
* - `AttestryAPIError` (status 403) — authenticated key lacks
|
|
792
|
+
* `READ_ASSESSMENTS` permission (single-permission check,
|
|
793
|
+
* NOT a union). Pin separately from `submit()`'s 403.
|
|
794
|
+
* - `AttestryAPIError` (status 400) — kernel's `isValidUuid(id)`
|
|
795
|
+
* returned false. **The SDK pre-validates UUID format**, so
|
|
796
|
+
* this 400 reaches consumers ONLY via `as any` casts or
|
|
797
|
+
* kernel-side UUID flavor changes.
|
|
798
|
+
* - `AttestryAPIError` (status 404) — batch job not found OR
|
|
799
|
+
* cross-org `id` (kernel collapses to "Batch job not found";
|
|
800
|
+
* literal string with NO embedded variable data — distinct
|
|
801
|
+
* from `submit()`'s 404 shape).
|
|
802
|
+
* - `AttestryAPIError` (status 500) — internal kernel error
|
|
803
|
+
* (scrubbed message).
|
|
804
|
+
* - `AttestryError` ("request aborted by caller") — caller-
|
|
805
|
+
* supplied `options.signal` fired.
|
|
806
|
+
* - `AttestryError` (P2 hardening) — kernel response failed
|
|
807
|
+
* SDK-side shape validation (11 fields).
|
|
808
|
+
* - `AttestryAPIError` (P3 hardening) — wrong Content-Type.
|
|
809
|
+
* - `TypeError` — input failed SDK-side validation (missing
|
|
810
|
+
* `id`, non-string `id`, empty `id`, malformed UUID `id`).
|
|
811
|
+
* **THROWN SYNCHRONOUSLY** (no fetch issued; not via the
|
|
812
|
+
* returned promise). Consumers using `.then(...)` without
|
|
813
|
+
* a surrounding try/catch see this surface as an uncaught
|
|
814
|
+
* synchronous throw — same caveat as `submit()`.
|
|
815
|
+
*
|
|
816
|
+
* **SDK-side validation** (synchronous `TypeError`, no fetch
|
|
817
|
+
* issued):
|
|
818
|
+
* - `id`: required; must be a non-empty string matching
|
|
819
|
+
* `UUID_REGEX` (RFC 4122 hyphenated, case-insensitive).
|
|
820
|
+
*
|
|
821
|
+
* **Response-shape validation** (P2 hardening — 11 fields, all
|
|
822
|
+
* always-present; symmetric defense on response side via the
|
|
823
|
+
* module-load `objectHasOwn` snapshot):
|
|
824
|
+
* - Rejects with `AttestryError` if the response isn't a non-
|
|
825
|
+
* null, non-array object.
|
|
826
|
+
* - Rejects if `id` / `jobType` / `status` / `createdAt` aren't
|
|
827
|
+
* strings.
|
|
828
|
+
* - Rejects if `totalSystems` / `processedSystems` /
|
|
829
|
+
* `failedSystems` aren't numbers.
|
|
830
|
+
* - Rejects if `results` isn't `null` OR an array.
|
|
831
|
+
* - Rejects if `config` isn't `null` OR a non-array object.
|
|
832
|
+
* - Rejects if `startedAt` / `completedAt` aren't `null` OR
|
|
833
|
+
* a string.
|
|
834
|
+
* - Per-row shape (open-spec `BatchSystemResult`) is faithful-
|
|
835
|
+
* courier — NOT validated.
|
|
836
|
+
* - `config.frameworks` shape is faithful-courier — NOT
|
|
837
|
+
* validated.
|
|
838
|
+
*
|
|
839
|
+
* **NO URIError defense on the `id` path segment** — the SDK
|
|
840
|
+
* pre-validates the UUID format (synchronous `TypeError`) BEFORE
|
|
841
|
+
* constructing the URL. A lone-surrogate or non-hex `id` is
|
|
842
|
+
* rejected before any `encodeURIComponent`-style call could fire.
|
|
843
|
+
* Hex-only UUIDs are guaranteed-safe for path concatenation.
|
|
844
|
+
*
|
|
845
|
+
* @example Poll a job's status
|
|
846
|
+
* ```ts
|
|
847
|
+
* const job = await client.batch.get("11111111-1111-1111-1111-111111111111");
|
|
848
|
+
* if (job.status === "completed") {
|
|
849
|
+
* console.log(`Processed ${job.processedSystems}/${job.totalSystems}`);
|
|
850
|
+
* } else if (job.status === "failed") {
|
|
851
|
+
* console.error("Batch failed entirely");
|
|
852
|
+
* } else {
|
|
853
|
+
* // "pending" / "processing" — still in flight
|
|
854
|
+
* console.log(`Job is ${job.status}`);
|
|
855
|
+
* }
|
|
856
|
+
* ```
|
|
857
|
+
*/
|
|
858
|
+
get(id, options) {
|
|
859
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
860
|
+
throw new TypeError("batch.get: `id` must be a non-empty string");
|
|
861
|
+
}
|
|
862
|
+
// UUID format pre-validation (D7 — SDK matches kernel's
|
|
863
|
+
// `isValidUuid` check at route.ts:36). Mirror of gate /
|
|
864
|
+
// check's UUID_REGEX. The kernel's 400 ("Invalid batch job ID
|
|
865
|
+
// format") is reachable only via `as any` cast or future
|
|
866
|
+
// kernel-side UUID flavor changes.
|
|
867
|
+
if (!UUID_REGEX.test(id)) {
|
|
868
|
+
throw new TypeError("batch.get: `id` must be an RFC 4122 hyphenated UUID " +
|
|
869
|
+
"(matched regex: /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-" +
|
|
870
|
+
"[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/, got " +
|
|
871
|
+
JSON.stringify(id) +
|
|
872
|
+
")");
|
|
873
|
+
}
|
|
874
|
+
return this.client
|
|
875
|
+
._request({
|
|
876
|
+
method: "GET",
|
|
877
|
+
path: `/api/v1/batch/${id}`,
|
|
878
|
+
options,
|
|
879
|
+
})
|
|
880
|
+
.then((result) => validateBatchJobStatusResponse(result));
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* P2 hardening: validate the POST response's 10 always-present
|
|
885
|
+
* fields. Symmetric prototype-pollution defense — read EACH field
|
|
886
|
+
* via the module-load `objectHasOwn` snapshot so a hostile npm dep
|
|
887
|
+
* polluting `Object.prototype.<field>` cannot mask a kernel
|
|
888
|
+
* regression that drops the field (per session-16 second-hostile-
|
|
889
|
+
* review MEDIUM #3 carry-forward — defense applied on both input
|
|
890
|
+
* AND response boundaries).
|
|
891
|
+
*
|
|
892
|
+
* Returns the validated `result` (typed `BatchSubmitResponse`) on
|
|
893
|
+
* success; throws `AttestryError` on any shape violation. Extracted
|
|
894
|
+
* as a free function so the resource method body stays focused on
|
|
895
|
+
* input validation + request construction.
|
|
896
|
+
*/
|
|
897
|
+
function validateBatchSubmitResponse(result) {
|
|
898
|
+
if (result === null ||
|
|
899
|
+
typeof result !== "object" ||
|
|
900
|
+
Array.isArray(result)) {
|
|
901
|
+
throw new AttestryError(`batch.submit: expected an object response from the kernel ` +
|
|
902
|
+
`(got ${describeType(result)})`);
|
|
903
|
+
}
|
|
904
|
+
const obj = result;
|
|
905
|
+
const id = objectHasOwn(obj, "id") ? obj.id : undefined;
|
|
906
|
+
if (typeof id !== "string") {
|
|
907
|
+
throw new AttestryError(`batch.submit: expected response.id to be a string ` +
|
|
908
|
+
`(got ${describeType(id)})`);
|
|
909
|
+
}
|
|
910
|
+
const jobType = objectHasOwn(obj, "jobType") ? obj.jobType : undefined;
|
|
911
|
+
if (typeof jobType !== "string") {
|
|
912
|
+
throw new AttestryError(`batch.submit: expected response.jobType to be a string ` +
|
|
913
|
+
`(got ${describeType(jobType)})`);
|
|
914
|
+
}
|
|
915
|
+
const status = objectHasOwn(obj, "status") ? obj.status : undefined;
|
|
916
|
+
if (typeof status !== "string") {
|
|
917
|
+
throw new AttestryError(`batch.submit: expected response.status to be a string ` +
|
|
918
|
+
`(got ${describeType(status)})`);
|
|
919
|
+
}
|
|
920
|
+
const totalSystems = objectHasOwn(obj, "totalSystems")
|
|
921
|
+
? obj.totalSystems
|
|
922
|
+
: undefined;
|
|
923
|
+
if (typeof totalSystems !== "number") {
|
|
924
|
+
throw new AttestryError(`batch.submit: expected response.totalSystems to be a number ` +
|
|
925
|
+
`(got ${describeType(totalSystems)})`);
|
|
926
|
+
}
|
|
927
|
+
const processedSystems = objectHasOwn(obj, "processedSystems")
|
|
928
|
+
? obj.processedSystems
|
|
929
|
+
: undefined;
|
|
930
|
+
if (typeof processedSystems !== "number") {
|
|
931
|
+
throw new AttestryError(`batch.submit: expected response.processedSystems to be a number ` +
|
|
932
|
+
`(got ${describeType(processedSystems)})`);
|
|
933
|
+
}
|
|
934
|
+
const failedSystems = objectHasOwn(obj, "failedSystems")
|
|
935
|
+
? obj.failedSystems
|
|
936
|
+
: undefined;
|
|
937
|
+
if (typeof failedSystems !== "number") {
|
|
938
|
+
throw new AttestryError(`batch.submit: expected response.failedSystems to be a number ` +
|
|
939
|
+
`(got ${describeType(failedSystems)})`);
|
|
940
|
+
}
|
|
941
|
+
const results = objectHasOwn(obj, "results") ? obj.results : undefined;
|
|
942
|
+
if (!Array.isArray(results)) {
|
|
943
|
+
throw new AttestryError(`batch.submit: expected response.results to be an array ` +
|
|
944
|
+
`(got ${describeType(results)})`);
|
|
945
|
+
}
|
|
946
|
+
const createdAt = objectHasOwn(obj, "createdAt")
|
|
947
|
+
? obj.createdAt
|
|
948
|
+
: undefined;
|
|
949
|
+
if (typeof createdAt !== "string") {
|
|
950
|
+
throw new AttestryError(`batch.submit: expected response.createdAt to be a string ` +
|
|
951
|
+
`(got ${describeType(createdAt)})`);
|
|
952
|
+
}
|
|
953
|
+
const startedAt = objectHasOwn(obj, "startedAt")
|
|
954
|
+
? obj.startedAt
|
|
955
|
+
: undefined;
|
|
956
|
+
if (startedAt !== null && typeof startedAt !== "string") {
|
|
957
|
+
throw new AttestryError(`batch.submit: expected response.startedAt to be a string or null ` +
|
|
958
|
+
`(got ${describeType(startedAt)})`);
|
|
959
|
+
}
|
|
960
|
+
const completedAt = objectHasOwn(obj, "completedAt")
|
|
961
|
+
? obj.completedAt
|
|
962
|
+
: undefined;
|
|
963
|
+
if (typeof completedAt !== "string") {
|
|
964
|
+
throw new AttestryError(`batch.submit: expected response.completedAt to be a string ` +
|
|
965
|
+
`(got ${describeType(completedAt)})`);
|
|
966
|
+
}
|
|
967
|
+
return result;
|
|
968
|
+
}
|
|
969
|
+
/**
|
|
970
|
+
* P2 hardening: validate the GET response's 11 always-present
|
|
971
|
+
* fields. Same symmetric defense as `validateBatchSubmitResponse`.
|
|
972
|
+
*
|
|
973
|
+
* `results` and `config` are NULLABLE (DB jsonb columns); the
|
|
974
|
+
* validator accepts `null` OR the typed shape. `startedAt` and
|
|
975
|
+
* `completedAt` are BOTH nullable on GET (in contrast to POST where
|
|
976
|
+
* `completedAt` is always a string).
|
|
977
|
+
*/
|
|
978
|
+
function validateBatchJobStatusResponse(result) {
|
|
979
|
+
if (result === null ||
|
|
980
|
+
typeof result !== "object" ||
|
|
981
|
+
Array.isArray(result)) {
|
|
982
|
+
throw new AttestryError(`batch.get: expected an object response from the kernel ` +
|
|
983
|
+
`(got ${describeType(result)})`);
|
|
984
|
+
}
|
|
985
|
+
const obj = result;
|
|
986
|
+
const id = objectHasOwn(obj, "id") ? obj.id : undefined;
|
|
987
|
+
if (typeof id !== "string") {
|
|
988
|
+
throw new AttestryError(`batch.get: expected response.id to be a string ` +
|
|
989
|
+
`(got ${describeType(id)})`);
|
|
990
|
+
}
|
|
991
|
+
const jobType = objectHasOwn(obj, "jobType") ? obj.jobType : undefined;
|
|
992
|
+
if (typeof jobType !== "string") {
|
|
993
|
+
throw new AttestryError(`batch.get: expected response.jobType to be a string ` +
|
|
994
|
+
`(got ${describeType(jobType)})`);
|
|
995
|
+
}
|
|
996
|
+
const status = objectHasOwn(obj, "status") ? obj.status : undefined;
|
|
997
|
+
if (typeof status !== "string") {
|
|
998
|
+
throw new AttestryError(`batch.get: expected response.status to be a string ` +
|
|
999
|
+
`(got ${describeType(status)})`);
|
|
1000
|
+
}
|
|
1001
|
+
const totalSystems = objectHasOwn(obj, "totalSystems")
|
|
1002
|
+
? obj.totalSystems
|
|
1003
|
+
: undefined;
|
|
1004
|
+
if (typeof totalSystems !== "number") {
|
|
1005
|
+
throw new AttestryError(`batch.get: expected response.totalSystems to be a number ` +
|
|
1006
|
+
`(got ${describeType(totalSystems)})`);
|
|
1007
|
+
}
|
|
1008
|
+
const processedSystems = objectHasOwn(obj, "processedSystems")
|
|
1009
|
+
? obj.processedSystems
|
|
1010
|
+
: undefined;
|
|
1011
|
+
if (typeof processedSystems !== "number") {
|
|
1012
|
+
throw new AttestryError(`batch.get: expected response.processedSystems to be a number ` +
|
|
1013
|
+
`(got ${describeType(processedSystems)})`);
|
|
1014
|
+
}
|
|
1015
|
+
const failedSystems = objectHasOwn(obj, "failedSystems")
|
|
1016
|
+
? obj.failedSystems
|
|
1017
|
+
: undefined;
|
|
1018
|
+
if (typeof failedSystems !== "number") {
|
|
1019
|
+
throw new AttestryError(`batch.get: expected response.failedSystems to be a number ` +
|
|
1020
|
+
`(got ${describeType(failedSystems)})`);
|
|
1021
|
+
}
|
|
1022
|
+
const results = objectHasOwn(obj, "results") ? obj.results : undefined;
|
|
1023
|
+
if (results !== null && !Array.isArray(results)) {
|
|
1024
|
+
throw new AttestryError(`batch.get: expected response.results to be an array or null ` +
|
|
1025
|
+
`(got ${describeType(results)})`);
|
|
1026
|
+
}
|
|
1027
|
+
const config = objectHasOwn(obj, "config") ? obj.config : undefined;
|
|
1028
|
+
if (config !== null &&
|
|
1029
|
+
(typeof config !== "object" || Array.isArray(config))) {
|
|
1030
|
+
throw new AttestryError(`batch.get: expected response.config to be an object or null ` +
|
|
1031
|
+
`(got ${describeType(config)})`);
|
|
1032
|
+
}
|
|
1033
|
+
const createdAt = objectHasOwn(obj, "createdAt")
|
|
1034
|
+
? obj.createdAt
|
|
1035
|
+
: undefined;
|
|
1036
|
+
if (typeof createdAt !== "string") {
|
|
1037
|
+
throw new AttestryError(`batch.get: expected response.createdAt to be a string ` +
|
|
1038
|
+
`(got ${describeType(createdAt)})`);
|
|
1039
|
+
}
|
|
1040
|
+
const startedAt = objectHasOwn(obj, "startedAt")
|
|
1041
|
+
? obj.startedAt
|
|
1042
|
+
: undefined;
|
|
1043
|
+
if (startedAt !== null && typeof startedAt !== "string") {
|
|
1044
|
+
throw new AttestryError(`batch.get: expected response.startedAt to be a string or null ` +
|
|
1045
|
+
`(got ${describeType(startedAt)})`);
|
|
1046
|
+
}
|
|
1047
|
+
const completedAt = objectHasOwn(obj, "completedAt")
|
|
1048
|
+
? obj.completedAt
|
|
1049
|
+
: undefined;
|
|
1050
|
+
if (completedAt !== null && typeof completedAt !== "string") {
|
|
1051
|
+
throw new AttestryError(`batch.get: expected response.completedAt to be a string or null ` +
|
|
1052
|
+
`(got ${describeType(completedAt)})`);
|
|
1053
|
+
}
|
|
1054
|
+
return result;
|
|
1055
|
+
}
|
|
1056
|
+
/**
|
|
1057
|
+
* Human-readable type description for error messages. Distinguishes
|
|
1058
|
+
* `null` and `array` from generic `object`. Duplicated in
|
|
1059
|
+
* `decisions.ts`, `incidents.ts`, `regulatory-changes.ts`,
|
|
1060
|
+
* `compliance-check.ts`, `check.ts`, `gate.ts` per project pattern
|
|
1061
|
+
* (small helper, leaf-resource modules, no shared module yet).
|
|
1062
|
+
*
|
|
1063
|
+
* Every branch is reachable in this file through the multiple call
|
|
1064
|
+
* sites (top-level shape, each field type guard, systemIds element
|
|
1065
|
+
* non-string, frameworks element non-string, config non-object).
|
|
1066
|
+
*/
|
|
1067
|
+
function describeType(value) {
|
|
1068
|
+
if (value === null)
|
|
1069
|
+
return "null";
|
|
1070
|
+
if (Array.isArray(value))
|
|
1071
|
+
return "array";
|
|
1072
|
+
return typeof value;
|
|
1073
|
+
}
|
|
1074
|
+
//# sourceMappingURL=batch.js.map
|