@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,1519 @@
|
|
|
1
|
+
// ─── AbacPolicies resource ──────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Wraps the ABAC (Attribute-Based Access Control) policies surface
|
|
4
|
+
// (Prompt C.3 — session 21):
|
|
5
|
+
//
|
|
6
|
+
// - GET /api/v1/abac-policies list (session 21)
|
|
7
|
+
// - POST /api/v1/abac-policies create (session 21)
|
|
8
|
+
// - GET /api/v1/abac-policies/[id] retrieve (session 22)
|
|
9
|
+
// - PATCH /api/v1/abac-policies/[id] update (session 22)
|
|
10
|
+
// - DELETE /api/v1/abac-policies/[id] delete (session 22)
|
|
11
|
+
//
|
|
12
|
+
// Eighth non-decisions resource on `@attestry/sdk` (after `auditLog`,
|
|
13
|
+
// `regulatoryChanges`, `complianceCheck`, `check`, `gate`, `batch`,
|
|
14
|
+
// `shipGate`). Sibling to all 10 existing resource classes on the SDK.
|
|
15
|
+
// FIRST 5-method CRUD cluster on the SDK — prior multi-method clusters
|
|
16
|
+
// either grew an existing class (`decisions` reached 7 methods over
|
|
17
|
+
// many sessions; `auditLog` reached 2; `batch` shipped with 2) OR
|
|
18
|
+
// shipped a smaller surface. `.list()` and `.create()` shipped in
|
|
19
|
+
// session 21; `.retrieve()`, `.update()`, and `.delete()` complete
|
|
20
|
+
// the cluster in session 22.
|
|
21
|
+
//
|
|
22
|
+
// **Dual-auth admin scope** — the kernel route gates on
|
|
23
|
+
// `requireSessionOrApiKey(request, { sessionRoles: ["admin"],
|
|
24
|
+
// apiKeyPermissions: [API_KEY_PERMISSIONS.ADMIN] })`. The dual-auth
|
|
25
|
+
// helper routes by request header presence: an `x-api-key` header
|
|
26
|
+
// (even empty-string) takes the api-key path; absent header takes
|
|
27
|
+
// the session path. The SDK's transport ALWAYS sends `x-api-key`
|
|
28
|
+
// (constructed at `transport.ts:headers.set("x-api-key", apiKey)`),
|
|
29
|
+
// so the api-key path is the ONLY one reachable from SDK consumers.
|
|
30
|
+
// The session path is exercised by the dashboard, not by the SDK.
|
|
31
|
+
//
|
|
32
|
+
// **NOT the first SDK use of dual-auth** — `auditLog.export`
|
|
33
|
+
// (session 12) and `decisions.verifyChain` (session 19) already use
|
|
34
|
+
// `requireSessionOrApiKey` middleware on the kernel side. The novelty
|
|
35
|
+
// for `abacPolicies` is that it's the first SDK CRUD cluster (5
|
|
36
|
+
// methods) under dual-auth admin, not the first dual-auth use.
|
|
37
|
+
//
|
|
38
|
+
// **Status-code surface — 401 vs 403 distinguished** (asymmetric with
|
|
39
|
+
// the ADMIN-only-route convention from carry-forward invariant #42).
|
|
40
|
+
// The kernel returns:
|
|
41
|
+
// - **HTTP 401** for: no `x-api-key` header (when session is also
|
|
42
|
+
// absent — SDK path is api-key-only, so this is the "no key"
|
|
43
|
+
// case); empty `x-api-key` header (`""`); invalid key (no matching
|
|
44
|
+
// keyHash row in the `apiKeys` table); expired key (`expiresAt <
|
|
45
|
+
// now`).
|
|
46
|
+
// - **HTTP 403** for: a VALID api-key in the org whose `permissions`
|
|
47
|
+
// column does NOT include `ADMIN`. Error message:
|
|
48
|
+
// `"API key lacks required permission. Required: admin. Key has:
|
|
49
|
+
// ..."`. Source: `src/lib/middleware/permissions.ts:57-62`.
|
|
50
|
+
// Pin BOTH branches separately. **`auditLog.export` is the SAME
|
|
51
|
+
// dual-auth surface** — its kernel route uses the identical
|
|
52
|
+
// `requireSessionOrApiKey(request, { sessionRoles: ["admin"],
|
|
53
|
+
// apiKeyPermissions: [API_KEY_PERMISSIONS.ADMIN] })` gate (verified at
|
|
54
|
+
// `src/app/api/v1/audit-log/export/route.ts:66-68`), so it too
|
|
55
|
+
// returns 401 for no/invalid/expired key and 403 for a valid key
|
|
56
|
+
// lacking `ADMIN`. The `audit-log.ts` JSDoc previously claimed "HTTP
|
|
57
|
+
// 401 for both" — a mis-read of the kernel test, which MOCKS
|
|
58
|
+
// `AuthError(401)` and never exercises the real middleware;
|
|
59
|
+
// `audit-log.ts` was corrected in session-22 hostile review #2.
|
|
60
|
+
// Carry-forward invariant #42's "auditLog.export collapses both to
|
|
61
|
+
// 401" premise is corrected by the same review.
|
|
62
|
+
// Established invariant: **dual-auth admin routes surface BOTH 401 AND
|
|
63
|
+
// 403** (verified by reading `requireRole` at `auth.ts:96-110` +
|
|
64
|
+
// `requireApiKeyWithPermission` at `permissions.ts:35-66`).
|
|
65
|
+
//
|
|
66
|
+
// **No pagination on `.list()`** — the response is `{items: AbacPolicy[],
|
|
67
|
+
// count: number}` with NO cursor / nextCursor field. Server-side
|
|
68
|
+
// `listAbacPolicies` caps at `MAX_POLICIES_PER_ORG_FETCH = 200` rows
|
|
69
|
+
// (`src/lib/auth/abac-policies.ts:113`). Consumers calling `.list()`
|
|
70
|
+
// get up to 200 rows verbatim. If an org has >200 policies (rare —
|
|
71
|
+
// the policy table is intended for ~tens of rules), only the lowest
|
|
72
|
+
// 200 by `priority` ASC are returned. Documented kernel surface gap
|
|
73
|
+
// — invariant #50 (silent kernel-side truncation enumeration; the
|
|
74
|
+
// 200 cap is the ONLY truncation on this method).
|
|
75
|
+
//
|
|
76
|
+
// **Audit log side effect — NONE on `.list()`** — read-shaped routes
|
|
77
|
+
// without `writeAuditLog` calls (asymmetric with `gate.evaluate`,
|
|
78
|
+
// `batch.submit`, `shipGate.check` which all write). `.create()` /
|
|
79
|
+
// `.update()` / `.delete()` will write entries (`abac_policy.create`
|
|
80
|
+
// / `.update` / `.delete`) but `.list()` is quiet. The `.retrieve()`
|
|
81
|
+
// method (session 22) will also be quiet.
|
|
82
|
+
//
|
|
83
|
+
// **Symmetric prototype-pollution defense — RESPONSE side only on
|
|
84
|
+
// `.list()`** — module-load snapshot of `Object.hasOwn` applied to
|
|
85
|
+
// the response shape. The input side is N/A (`.list()` takes no
|
|
86
|
+
// input — only `options?: RequestOptions`).
|
|
87
|
+
//
|
|
88
|
+
// Sync JSON request/response: reuses `client._request` and the
|
|
89
|
+
// existing `{success:true, data}` envelope-unwrap (carry-forward
|
|
90
|
+
// invariant #9). NO new SDK primitive needed.
|
|
91
|
+
import { AttestryError } from "../errors.js";
|
|
92
|
+
import { readInputField } from "./safe-input-read.js";
|
|
93
|
+
// ─── Public closed-enum runtime arrays ──────────────────────────────────────
|
|
94
|
+
//
|
|
95
|
+
// Mirrored from kernel `src/lib/auth.ts` (RESOURCES + ACTIONS) and the
|
|
96
|
+
// kernel's Effect type alias. **Object.freeze**'d (P1 hardening — mirror
|
|
97
|
+
// of constants.ts pattern) so a hostile/buggy npm dep cannot mutate the
|
|
98
|
+
// validation arrays at runtime, bypassing the `.includes()` checks in
|
|
99
|
+
// `.create()` pre-validation.
|
|
100
|
+
//
|
|
101
|
+
// Drift-pinned in `src/lib/incidents/__tests__/sdk-drift.test.ts` so any
|
|
102
|
+
// kernel-side addition / rename / reorder lands at the SDK before
|
|
103
|
+
// consumer regressions.
|
|
104
|
+
//
|
|
105
|
+
// Used by `.create()` pre-validation to reject unknown resource /
|
|
106
|
+
// action / effect values synchronously (closed-enum SDK fields always
|
|
107
|
+
// pre-reject — carry-forward invariant #41).
|
|
108
|
+
/**
|
|
109
|
+
* Closed-enum of resources an ABAC policy can target. Runtime mirror
|
|
110
|
+
* of the kernel's `RESOURCES` const at `src/lib/auth.ts:29-40`.
|
|
111
|
+
* Object.freeze'd to prevent runtime mutation.
|
|
112
|
+
*/
|
|
113
|
+
export const ABAC_POLICY_RESOURCES = Object.freeze([
|
|
114
|
+
"systems",
|
|
115
|
+
"assessments",
|
|
116
|
+
"documents",
|
|
117
|
+
"attestations",
|
|
118
|
+
"evidence",
|
|
119
|
+
"users",
|
|
120
|
+
"api_keys",
|
|
121
|
+
"audit_log",
|
|
122
|
+
"organization",
|
|
123
|
+
"regulations",
|
|
124
|
+
]);
|
|
125
|
+
/**
|
|
126
|
+
* Closed-enum of actions an ABAC policy can gate. Runtime mirror of
|
|
127
|
+
* the kernel's `ACTIONS` const at `src/lib/auth.ts:42-48`.
|
|
128
|
+
*/
|
|
129
|
+
export const ABAC_POLICY_ACTIONS = Object.freeze([
|
|
130
|
+
"create",
|
|
131
|
+
"read",
|
|
132
|
+
"update",
|
|
133
|
+
"delete",
|
|
134
|
+
"manage",
|
|
135
|
+
]);
|
|
136
|
+
/**
|
|
137
|
+
* Closed-enum of effects. Runtime mirror of the kernel's `Effect` type
|
|
138
|
+
* alias at `src/lib/auth/abac-policies.ts:77` + the Zod
|
|
139
|
+
* `z.enum(["allow", "deny"]).default("allow")` at
|
|
140
|
+
* `src/lib/auth/abac-policies.ts:756`.
|
|
141
|
+
*/
|
|
142
|
+
export const ABAC_POLICY_EFFECTS = Object.freeze(["allow", "deny"]);
|
|
143
|
+
// ─── Public closed-spec bounds (mirror kernel constants) ────────────────────
|
|
144
|
+
//
|
|
145
|
+
// Drift-pinned in `sdk-drift.test.ts` so kernel-side changes surface
|
|
146
|
+
// before consumer regressions. Used by `.create()` for synchronous
|
|
147
|
+
// pre-validation per invariant #49.
|
|
148
|
+
/** Mirror of kernel `MAX_POLICY_NAME_LENGTH = 128` at `abac-policies.ts:115`. */
|
|
149
|
+
const MAX_POLICY_NAME_LENGTH = 128;
|
|
150
|
+
/** Mirror of kernel `description.max(2000)` at `abac-policies.ts:753`. */
|
|
151
|
+
const MAX_POLICY_DESCRIPTION_LENGTH = 2000;
|
|
152
|
+
/** Mirror of kernel `MIN_PRIORITY = 0` at `abac-policies.ts:117`. */
|
|
153
|
+
const MIN_POLICY_PRIORITY = 0;
|
|
154
|
+
/** Mirror of kernel `MAX_PRIORITY = 1000` at `abac-policies.ts:118`. */
|
|
155
|
+
const MAX_POLICY_PRIORITY = 1000;
|
|
156
|
+
// ─── UUID path-segment validation (mirror kernel `badId`) ───────────────────
|
|
157
|
+
//
|
|
158
|
+
// RFC 4122 hyphenated form (8-4-4-4-12 hex, case-insensitive). Mirror
|
|
159
|
+
// of `batch.get` / `gate.evaluate` / `check.run` / `shipGate.check`'s
|
|
160
|
+
// `UUID_REGEX`, AND a runtime mirror of the kernel's `UUID_RE` at
|
|
161
|
+
// `src/app/api/v1/abac-policies/[id]/route.ts:32-33`. The kernel's
|
|
162
|
+
// `badId` helper rejects a malformed id with HTTP 400 "Invalid policy
|
|
163
|
+
// id." BEFORE rate-limit + auth. The SDK pre-validates `id` for
|
|
164
|
+
// `.retrieve()` / `.update()` / `.delete()` (the three id-path
|
|
165
|
+
// methods) so that 400 is reachable only via an `as any` cast or a
|
|
166
|
+
// kernel-side id-flavor change (ULID, KSUID, etc.).
|
|
167
|
+
//
|
|
168
|
+
// **No `encodeURIComponent` on the path segment** — a string matching
|
|
169
|
+
// this regex is ASCII hex digits + hyphens only: every code point is
|
|
170
|
+
// URL-safe AND none can form a lone UTF-16 surrogate, so
|
|
171
|
+
// `encodeURIComponent` could neither throw a `URIError` nor alter the
|
|
172
|
+
// string. The validated `id` is interpolated into the request path
|
|
173
|
+
// raw. Mirror of `batch.get` (asymmetric with `decisions.retrieve`,
|
|
174
|
+
// whose free-form `id` needs `encodePathSegment` + path-traversal +
|
|
175
|
+
// URIError defenses — carry-forward invariant #32 applies only where
|
|
176
|
+
// a segment can actually reach `encodeURIComponent`; a pre-validated
|
|
177
|
+
// UUID cannot). Drift-pinned in `sdk-drift.test.ts`.
|
|
178
|
+
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}$/;
|
|
179
|
+
// Module-load snapshot of `Object.hasOwn` — defends against a
|
|
180
|
+
// late-loading hostile/buggy npm dependency that overrides the global
|
|
181
|
+
// (e.g., `Object.hasOwn = () => true`). Without the snapshot, the
|
|
182
|
+
// response-side prototype-pollution defense below would use whatever
|
|
183
|
+
// `Object.hasOwn` the dependency replaced at request time.
|
|
184
|
+
//
|
|
185
|
+
// Mirror of `audit-log.ts` / `batch.ts` / `gate.ts` / `check.ts` /
|
|
186
|
+
// `compliance-check.ts` / `ship-gate.ts` pattern. Used on the response
|
|
187
|
+
// side; the input side is N/A for `.list()` (no input fields).
|
|
188
|
+
const objectHasOwn = Object.hasOwn;
|
|
189
|
+
// ─── Resource class ─────────────────────────────────────────────────────────
|
|
190
|
+
/**
|
|
191
|
+
* `abacPolicies` resource — sibling to all 10 existing resource classes
|
|
192
|
+
* on the SDK. The class is the landing pad for the 5-method CRUD
|
|
193
|
+
* cluster; `.list()` is the first method to ship (session 21), with
|
|
194
|
+
* `.create()` / `.retrieve()` / `.update()` / `.delete()` arriving in
|
|
195
|
+
* session 22. Resource-class-per-kernel-resource convention,
|
|
196
|
+
* invariant #43.
|
|
197
|
+
*/
|
|
198
|
+
export class AbacPoliciesResource {
|
|
199
|
+
client;
|
|
200
|
+
constructor(client) {
|
|
201
|
+
this.client = client;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* List ABAC policies for the caller's org. Returns up to 200 rows
|
|
205
|
+
* ordered by `priority` ASC. No pagination; the cap is a documented
|
|
206
|
+
* kernel surface gap.
|
|
207
|
+
*
|
|
208
|
+
* **Dual-auth admin scope** — the kernel route gates on
|
|
209
|
+
* `requireSessionOrApiKey(request, { sessionRoles: ["admin"],
|
|
210
|
+
* apiKeyPermissions: [API_KEY_PERMISSIONS.ADMIN] })`. The SDK's
|
|
211
|
+
* transport always sends `x-api-key`, so the api-key path is the
|
|
212
|
+
* only one reachable from SDK consumers. An api-key without the
|
|
213
|
+
* `ADMIN` permission returns 403 (NOT 401 — see status code
|
|
214
|
+
* surface below).
|
|
215
|
+
*
|
|
216
|
+
* **Status-code surface — 401 AND 403 distinguished** (verified
|
|
217
|
+
* by reading `src/lib/middleware/auth.ts:96-110` and
|
|
218
|
+
* `src/lib/middleware/permissions.ts:35-66`):
|
|
219
|
+
* - **HTTP 401**: no `x-api-key` header, empty `x-api-key`
|
|
220
|
+
* header (`""`), invalid key (no matching row), expired key.
|
|
221
|
+
* - **HTTP 403**: valid api-key in the org whose `permissions`
|
|
222
|
+
* column does NOT include `ADMIN`. Error message: `"API key
|
|
223
|
+
* lacks required permission. Required: admin. Key has: ..."`.
|
|
224
|
+
* `auditLog.export` shares this EXACT dual-auth surface — its
|
|
225
|
+
* kernel route uses the identical `requireSessionOrApiKey(...
|
|
226
|
+
* sessionRoles:["admin"], apiKeyPermissions:[ADMIN] ...)` gate, so
|
|
227
|
+
* it too returns 401 vs 403 distinctly. (The `audit-log.ts` JSDoc's
|
|
228
|
+
* prior "HTTP 401 for both" claim was a mis-read of the kernel
|
|
229
|
+
* test's mocked `AuthError(401)` — corrected in session-22 hostile
|
|
230
|
+
* review #2.)
|
|
231
|
+
*
|
|
232
|
+
* **No pagination** — `count` is `items.length`, NOT a total org
|
|
233
|
+
* count. Server-side cap is 200 rows by priority ASC. Orgs with
|
|
234
|
+
* >200 policies are silently truncated (documented invariant #50
|
|
235
|
+
* gap; the SDK does NOT auto-paginate this method because the
|
|
236
|
+
* kernel emits no cursor — there's no next-page anchor to follow).
|
|
237
|
+
*
|
|
238
|
+
* **No `writeAuditLog` side effect** — `.list()` is quiet
|
|
239
|
+
* (asymmetric with `gate.evaluate` / `batch.submit` /
|
|
240
|
+
* `shipGate.check` which all write entries).
|
|
241
|
+
*
|
|
242
|
+
* **Symmetric prototype-pollution defense — RESPONSE side only**:
|
|
243
|
+
* the P2 validator uses the module-load `objectHasOwn` snapshot on
|
|
244
|
+
* each response field read. Input side is N/A (`.list()` has no
|
|
245
|
+
* input fields). Mirror of `audit-log.verifyChain` /
|
|
246
|
+
* `regulatoryChanges.list` pattern.
|
|
247
|
+
*
|
|
248
|
+
* Errors — **happy-path precedence ordering** is rate-limit → auth
|
|
249
|
+
* → DB lookup → successResponse. **The 500-catchall is a SEPARATE
|
|
250
|
+
* DIMENSION** — any throwable not matched by the named `instanceof`
|
|
251
|
+
* arms (only `AuthError` here) falls to 500, regardless of where
|
|
252
|
+
* in the happy-path it fired.
|
|
253
|
+
* - `AttestryAPIError` (status 429) — rate limit FIRES FIRST
|
|
254
|
+
* (auto-retried by default — invariant #18; per-IP rate-limit
|
|
255
|
+
* key `abac-policies-list:${ip}` against the
|
|
256
|
+
* `assessmentLimiter` — 30 req / 1-min sliding window).
|
|
257
|
+
* - `AttestryAPIError` (status 401) — no API key OR invalid key
|
|
258
|
+
* OR expired key. Fires AFTER rate-limit.
|
|
259
|
+
* - `AttestryAPIError` (status 403) — valid api-key in the org
|
|
260
|
+
* whose permissions do NOT include `ADMIN`. Distinct branch
|
|
261
|
+
* from 401 — pin BOTH separately per the dual-auth status-
|
|
262
|
+
* code surface.
|
|
263
|
+
* - `AttestryAPIError` (status 500) — internal kernel error
|
|
264
|
+
* (scrubbed message via `internalErrorResponse`). **The 500
|
|
265
|
+
* surface is orthogonal to the precedence list above**: ANY
|
|
266
|
+
* throwable not matched by the route's single `instanceof
|
|
267
|
+
* AuthError` arm falls to 500.
|
|
268
|
+
* - `AttestryError` ("request aborted by caller") — caller-
|
|
269
|
+
* supplied `options.signal` fired.
|
|
270
|
+
* - `AttestryError` (P2 hardening) — kernel response failed
|
|
271
|
+
* SDK-side shape validation (not an object, `items` not an
|
|
272
|
+
* array, `count` not a number).
|
|
273
|
+
* - `AttestryAPIError` (P3 hardening) — kernel response had a
|
|
274
|
+
* wrong Content-Type (transport-level guard before body
|
|
275
|
+
* parsing).
|
|
276
|
+
*
|
|
277
|
+
* **Notably ABSENT**:
|
|
278
|
+
* - **No 400** — no input → nothing to validate.
|
|
279
|
+
* - **No 404** — `.list()` returns `{items: [], count: 0}` on
|
|
280
|
+
* empty result (NOT 404). 404 is only on `.retrieve()` /
|
|
281
|
+
* `.update()` / `.delete()` (session 22).
|
|
282
|
+
*
|
|
283
|
+
* **Response-shape validation** (P2 hardening — symmetric defense
|
|
284
|
+
* on response side per the module-load `objectHasOwn` snapshot;
|
|
285
|
+
* mirror of `regulatoryChanges.list` / `incidents.list` patterns):
|
|
286
|
+
* - Rejects with `AttestryError` if the kernel response isn't
|
|
287
|
+
* a non-null, non-array object.
|
|
288
|
+
* - Rejects if `items` isn't an array.
|
|
289
|
+
* - Rejects if `count` isn't a number.
|
|
290
|
+
* - Per-row item shape NOT validated (faithful courier — P4
|
|
291
|
+
* candidate; matches incidents.list / regulatoryChanges.list).
|
|
292
|
+
*
|
|
293
|
+
* **Transport-shape validation** (P3 hardening):
|
|
294
|
+
* - Rejects with `AttestryAPIError` if the kernel responds with
|
|
295
|
+
* a non-`application/json` Content-Type.
|
|
296
|
+
*
|
|
297
|
+
* @example List all ABAC policies for the caller's org
|
|
298
|
+
* ```ts
|
|
299
|
+
* const { items, count } = await client.abacPolicies.list();
|
|
300
|
+
* console.log(`${count} policies in this org:`);
|
|
301
|
+
* for (const policy of items) {
|
|
302
|
+
* console.log(` ${policy.priority} ${policy.effect} ${policy.action} ${policy.resource}: ${policy.name}`);
|
|
303
|
+
* }
|
|
304
|
+
* ```
|
|
305
|
+
*
|
|
306
|
+
* @example Inspect a policy's condition AST
|
|
307
|
+
* ```ts
|
|
308
|
+
* const { items } = await client.abacPolicies.list();
|
|
309
|
+
* const ownerOnly = items.find((p) => p.name === "owner-only");
|
|
310
|
+
* if (ownerOnly && ownerOnly.condition.op === "attrEq") {
|
|
311
|
+
* console.log(`Compares ${ownerOnly.condition.left} === ${ownerOnly.condition.right}`);
|
|
312
|
+
* }
|
|
313
|
+
* ```
|
|
314
|
+
*/
|
|
315
|
+
list(options) {
|
|
316
|
+
return this.client
|
|
317
|
+
._request({
|
|
318
|
+
method: "GET",
|
|
319
|
+
path: "/api/v1/abac-policies",
|
|
320
|
+
options,
|
|
321
|
+
})
|
|
322
|
+
.then((result) => validateAbacPoliciesListResponse(result));
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Create a new ABAC policy in the caller's org. Returns the
|
|
326
|
+
* inserted row on success (HTTP 201).
|
|
327
|
+
*
|
|
328
|
+
* **Dual-auth admin scope** — same as `.list()`: kernel uses
|
|
329
|
+
* `requireSessionOrApiKey(request, { sessionRoles: ["admin"],
|
|
330
|
+
* apiKeyPermissions: [API_KEY_PERMISSIONS.ADMIN] })`. The SDK's
|
|
331
|
+
* transport always sends `x-api-key`, so the api-key path is the
|
|
332
|
+
* only one reachable from SDK consumers. **HTTP 401** for no/
|
|
333
|
+
* invalid/expired key, **HTTP 403** for valid-key-without-ADMIN
|
|
334
|
+
* permission. Pin BOTH branches separately.
|
|
335
|
+
*
|
|
336
|
+
* **FIRST SDK route to return HTTP 201 on success** (NOT 200).
|
|
337
|
+
* The transport unwraps the `{success:true, data}` envelope and
|
|
338
|
+
* returns the body on any 2xx status — consumers receive the
|
|
339
|
+
* created row directly. To inspect the literal HTTP status,
|
|
340
|
+
* consumers must inspect via fetch-instrumented middleware
|
|
341
|
+
* (not exposed by the SDK today; P4 candidate).
|
|
342
|
+
*
|
|
343
|
+
* **FIRST SDK route with HTTP 409 Conflict surface**. The
|
|
344
|
+
* `(org_id, name)` unique constraint trips `AbacPolicyNameConflictError`
|
|
345
|
+
* at the DB layer; the kernel maps to 409 with error message
|
|
346
|
+
* `An ABAC policy named "<name>" already exists in this organization.`.
|
|
347
|
+
* Consumers should branch on `err.status === 409` to render a
|
|
348
|
+
* specific "name taken" UX.
|
|
349
|
+
*
|
|
350
|
+
* **Three-way 422 fan-out — distinct wire shapes per error class**:
|
|
351
|
+
* The kernel's POST handler catch block has THREE 422-mapping arms:
|
|
352
|
+
*
|
|
353
|
+
* 1. **`BodyParseError`** (most common — from `parseBody(request,
|
|
354
|
+
* schema)`) → `{ success: false, error: "Validation failed.",
|
|
355
|
+
* details: Array<{ path: string, message: string }> }`. Raised
|
|
356
|
+
* when Zod's `.strict()` schema rejects (e.g., extra field,
|
|
357
|
+
* wrong type, out-of-range value).
|
|
358
|
+
* 2. **`ZodError`** (DEFENSIVE — DEAD on happy path; `parseBody`
|
|
359
|
+
* catches Zod and converts to `BodyParseError`. The catch arm
|
|
360
|
+
* exists as defense-in-depth if some other code path throws
|
|
361
|
+
* raw `ZodError`) → `{ success: false, error: "Validation
|
|
362
|
+
* failed.", details: ZodIssue[] }` (richer than BodyParseError's
|
|
363
|
+
* mapped form — includes `code`, `expected`, `received`).
|
|
364
|
+
* 3. **`AbacPolicyValidationError`** (REACHABLE — from server-side
|
|
365
|
+
* canonical AST validation in `createAbacPolicy`) → `{ success:
|
|
366
|
+
* false, error: "ABAC policy validation failed: <messages>",
|
|
367
|
+
* details: { errors: string[] } }`. Raised when the condition
|
|
368
|
+
* AST violates depth / clause / value-list / total-node budgets,
|
|
369
|
+
* or has unknown ops / malformed attr paths.
|
|
370
|
+
*
|
|
371
|
+
* SDK surfaces all three uniformly as `AttestryAPIError(422)` —
|
|
372
|
+
* consumers inspect `err.details` to discriminate:
|
|
373
|
+
* - `Array.isArray(err.details)` → BodyParseError OR ZodError
|
|
374
|
+
* (per-field validation failure; iterate for `{path, message}`).
|
|
375
|
+
* - `err.details && Array.isArray(err.details.errors)` →
|
|
376
|
+
* AbacPolicyValidationError (AST violation; iterate `details.errors`
|
|
377
|
+
* for descriptive strings).
|
|
378
|
+
*
|
|
379
|
+
* Drift-pinned in spec-diff round (both wire shapes + the DEAD
|
|
380
|
+
* ZodError catch arm).
|
|
381
|
+
*
|
|
382
|
+
* **Fifth SDK route to PRE-VALIDATE every Zod closed-spec rule
|
|
383
|
+
* synchronously** (after `check.run`, `gate.evaluate`,
|
|
384
|
+
* `batch.submit`, `shipGate.check`). **FIRST SDK route with
|
|
385
|
+
* PARTIAL pre-validation** — the SDK pre-validates all 7 closed-
|
|
386
|
+
* spec fields synchronously (name length, description length-or-
|
|
387
|
+
* null, resource/action/effect closed-enums, priority int +
|
|
388
|
+
* bounds, enabled boolean) AND defers the recursive AST validation
|
|
389
|
+
* on `condition` to the server canonical validator. The condition
|
|
390
|
+
* field is `z.record(z.string(), z.unknown())` at the schema level
|
|
391
|
+
* (OPEN-spec, not pre-validatable without shipping the full
|
|
392
|
+
* recursive validator). Invariant #49 calibration: closed-spec
|
|
393
|
+
* rules ARE pre-validatable; recursive grammar rules are NOT
|
|
394
|
+
* (too expensive to mirror).
|
|
395
|
+
*
|
|
396
|
+
* **`writeAuditLog` side effect — every successful `.create()` call
|
|
397
|
+
* writes one audit entry** with `action: "abac_policy.create"` and
|
|
398
|
+
* `resourceType: "abac_policy"` (kernel route.ts:105-120; both
|
|
399
|
+
* strings drift-pinned). Properties:
|
|
400
|
+
* - Org-scoped, hash-chained.
|
|
401
|
+
* - **Time-blocking** but error-tolerant: kernel uses
|
|
402
|
+
* `await writeAuditLog(...)`; check response latency INCLUDES
|
|
403
|
+
* the audit-log write time.
|
|
404
|
+
* - Write FAILURE does NOT fail the request (try/catch swallows).
|
|
405
|
+
* - NOT counted against `decisionsPerMonth` quota.
|
|
406
|
+
* - **Audit log is NOT written on failed create** (Zod / canonical
|
|
407
|
+
* validation / name conflict all surface BEFORE writeAuditLog).
|
|
408
|
+
*
|
|
409
|
+
* **Kernel-side 30-second timeout** (`maxDuration = 30`). Same as
|
|
410
|
+
* `.list()` and `auditLog.export`; looser than `gate.evaluate` /
|
|
411
|
+
* `shipGate.check`'s 15s. ABAC policy creation does NO heavy
|
|
412
|
+
* computation — the 30s budget is for DB I/O headroom.
|
|
413
|
+
*
|
|
414
|
+
* **Default-applied fields**: `effect` defaults to `"allow"`,
|
|
415
|
+
* `priority` defaults to `100`, `enabled` defaults to `true`. The
|
|
416
|
+
* SDK OMITS these fields from the request body when the consumer
|
|
417
|
+
* omits them (so the kernel applies its default). Per invariant #52.
|
|
418
|
+
*
|
|
419
|
+
* Errors:
|
|
420
|
+
* - `AttestryAPIError` (429) — rate limit (auto-retried; per-IP
|
|
421
|
+
* key `abac-policies-create:${ip}` against `assessmentLimiter`).
|
|
422
|
+
* - `AttestryAPIError` (401) — no/invalid/expired api-key.
|
|
423
|
+
* - `AttestryAPIError` (403) — valid api-key without ADMIN permission.
|
|
424
|
+
* - `AttestryAPIError` (422) — Zod-schema validation OR canonical-
|
|
425
|
+
* validator AST failure. Discriminate via `err.details` shape
|
|
426
|
+
* (see "Three-way 422 fan-out" above).
|
|
427
|
+
* - `AttestryAPIError` (409) — `(org_id, name)` uniqueness
|
|
428
|
+
* conflict.
|
|
429
|
+
* - `AttestryAPIError` (500) — internal kernel error (scrubbed).
|
|
430
|
+
* - `AttestryError` ("request aborted by caller") — `options.signal`
|
|
431
|
+
* fired.
|
|
432
|
+
* - `AttestryError` (P2 hardening) — kernel response failed
|
|
433
|
+
* SDK-side shape validation. Pre-validated per-row shape:
|
|
434
|
+
* non-null object + all 13 fields present and correctly typed.
|
|
435
|
+
* - `AttestryAPIError` (P3 hardening) — wrong Content-Type on
|
|
436
|
+
* response.
|
|
437
|
+
* - `TypeError` (synchronous, no fetch issued) — SDK-side input
|
|
438
|
+
* validation: missing required field, wrong type, out-of-range
|
|
439
|
+
* value, unknown closed-enum value, malformed input.
|
|
440
|
+
*
|
|
441
|
+
* @example Create a simple "owner can edit own assessments" policy
|
|
442
|
+
* ```ts
|
|
443
|
+
* const policy = await client.abacPolicies.create({
|
|
444
|
+
* name: "owner-can-edit-own",
|
|
445
|
+
* description: "Owners can edit their own assessments.",
|
|
446
|
+
* resource: "assessments",
|
|
447
|
+
* action: "update",
|
|
448
|
+
* effect: "allow",
|
|
449
|
+
* condition: {
|
|
450
|
+
* op: "attrEq",
|
|
451
|
+
* left: "principal.id",
|
|
452
|
+
* right: "resource.ownerId",
|
|
453
|
+
* },
|
|
454
|
+
* priority: 100,
|
|
455
|
+
* enabled: true,
|
|
456
|
+
* });
|
|
457
|
+
* console.log(`Created policy ${policy.id}`);
|
|
458
|
+
* ```
|
|
459
|
+
*
|
|
460
|
+
* @example Catch name-conflict (HTTP 409)
|
|
461
|
+
* ```ts
|
|
462
|
+
* try {
|
|
463
|
+
* await client.abacPolicies.create({...});
|
|
464
|
+
* } catch (err) {
|
|
465
|
+
* if (err instanceof AttestryAPIError && err.status === 409) {
|
|
466
|
+
* // Show "name taken" UX
|
|
467
|
+
* }
|
|
468
|
+
* }
|
|
469
|
+
* ```
|
|
470
|
+
*
|
|
471
|
+
* @example Discriminate 422 wire-shape (Zod vs canonical-validator)
|
|
472
|
+
* ```ts
|
|
473
|
+
* try {
|
|
474
|
+
* await client.abacPolicies.create({...});
|
|
475
|
+
* } catch (err) {
|
|
476
|
+
* if (err instanceof AttestryAPIError && err.status === 422) {
|
|
477
|
+
* const details = err.details as unknown;
|
|
478
|
+
* if (Array.isArray(details)) {
|
|
479
|
+
* // Zod field-level failures: [{path, message}, ...]
|
|
480
|
+
* } else if (details && typeof details === "object" &&
|
|
481
|
+
* Array.isArray((details as {errors?: unknown}).errors)) {
|
|
482
|
+
* // Canonical-validator AST failures: {errors: string[]}
|
|
483
|
+
* }
|
|
484
|
+
* }
|
|
485
|
+
* }
|
|
486
|
+
* ```
|
|
487
|
+
*/
|
|
488
|
+
create(input, options) {
|
|
489
|
+
// Top-level shape — input is REQUIRED.
|
|
490
|
+
if (input === null ||
|
|
491
|
+
typeof input !== "object" ||
|
|
492
|
+
Array.isArray(input)) {
|
|
493
|
+
throw new TypeError("abacPolicies.create: `input` must be a non-null object with " +
|
|
494
|
+
"`name`, `resource`, `action`, `condition` (and optional " +
|
|
495
|
+
"`description`, `effect`, `priority`, `enabled`)");
|
|
496
|
+
}
|
|
497
|
+
// Snapshot each field's value EXACTLY ONCE up front via the
|
|
498
|
+
// own-property indexer, then operate only on the locals
|
|
499
|
+
// downstream. Four motivations (mirror of ship-gate.ts):
|
|
500
|
+
// 1. Prototype-pollution defense (generalization of #48).
|
|
501
|
+
// 2. TOCTOU defense — Proxy / getter inputs read once.
|
|
502
|
+
// 3. Missing-key shape: objectHasOwn correctly returns false.
|
|
503
|
+
// 4. Throwing-getter defense — `readInputField` converts a
|
|
504
|
+
// throwing accessor's exception into the documented
|
|
505
|
+
// synchronous `TypeError` input contract (session-22
|
|
506
|
+
// hostile review #1; the SDK-wide MEDIUM-1 fix).
|
|
507
|
+
const hasName = objectHasOwn(input, "name");
|
|
508
|
+
const nameRaw = hasName
|
|
509
|
+
? readInputField(input, "name", "abacPolicies.create")
|
|
510
|
+
: undefined;
|
|
511
|
+
const hasDescription = objectHasOwn(input, "description");
|
|
512
|
+
const descriptionRaw = hasDescription
|
|
513
|
+
? readInputField(input, "description", "abacPolicies.create")
|
|
514
|
+
: undefined;
|
|
515
|
+
const hasResource = objectHasOwn(input, "resource");
|
|
516
|
+
const resourceRaw = hasResource
|
|
517
|
+
? readInputField(input, "resource", "abacPolicies.create")
|
|
518
|
+
: undefined;
|
|
519
|
+
const hasAction = objectHasOwn(input, "action");
|
|
520
|
+
const actionRaw = hasAction
|
|
521
|
+
? readInputField(input, "action", "abacPolicies.create")
|
|
522
|
+
: undefined;
|
|
523
|
+
const hasEffect = objectHasOwn(input, "effect");
|
|
524
|
+
const effectRaw = hasEffect
|
|
525
|
+
? readInputField(input, "effect", "abacPolicies.create")
|
|
526
|
+
: undefined;
|
|
527
|
+
const hasCondition = objectHasOwn(input, "condition");
|
|
528
|
+
const conditionRaw = hasCondition
|
|
529
|
+
? readInputField(input, "condition", "abacPolicies.create")
|
|
530
|
+
: undefined;
|
|
531
|
+
const hasPriority = objectHasOwn(input, "priority");
|
|
532
|
+
const priorityRaw = hasPriority
|
|
533
|
+
? readInputField(input, "priority", "abacPolicies.create")
|
|
534
|
+
: undefined;
|
|
535
|
+
const hasEnabled = objectHasOwn(input, "enabled");
|
|
536
|
+
const enabledRaw = hasEnabled
|
|
537
|
+
? readInputField(input, "enabled", "abacPolicies.create")
|
|
538
|
+
: undefined;
|
|
539
|
+
// ─── Required field validation ──────────────────────────────────────────
|
|
540
|
+
// name — REQUIRED non-empty string, length 1-128.
|
|
541
|
+
if (!hasName || nameRaw === undefined) {
|
|
542
|
+
throw new TypeError("abacPolicies.create: `name` is required");
|
|
543
|
+
}
|
|
544
|
+
if (typeof nameRaw !== "string") {
|
|
545
|
+
throw new TypeError(`abacPolicies.create: \`name\` must be a string ` +
|
|
546
|
+
`(got ${describeType(nameRaw)})`);
|
|
547
|
+
}
|
|
548
|
+
if (nameRaw.length === 0) {
|
|
549
|
+
throw new TypeError("abacPolicies.create: `name` must be a non-empty string");
|
|
550
|
+
}
|
|
551
|
+
if (nameRaw.length > MAX_POLICY_NAME_LENGTH) {
|
|
552
|
+
throw new TypeError(`abacPolicies.create: \`name\` exceeds the kernel's max length of ` +
|
|
553
|
+
`${MAX_POLICY_NAME_LENGTH} chars (got ${nameRaw.length})`);
|
|
554
|
+
}
|
|
555
|
+
// resource — REQUIRED closed-enum.
|
|
556
|
+
if (!hasResource || resourceRaw === undefined) {
|
|
557
|
+
throw new TypeError("abacPolicies.create: `resource` is required");
|
|
558
|
+
}
|
|
559
|
+
if (typeof resourceRaw !== "string") {
|
|
560
|
+
throw new TypeError(`abacPolicies.create: \`resource\` must be a string ` +
|
|
561
|
+
`(got ${describeType(resourceRaw)})`);
|
|
562
|
+
}
|
|
563
|
+
if (!ABAC_POLICY_RESOURCES.includes(resourceRaw)) {
|
|
564
|
+
throw new TypeError(`abacPolicies.create: \`resource\` must be one of ` +
|
|
565
|
+
`[${ABAC_POLICY_RESOURCES.join(", ")}] (got "${resourceRaw}")`);
|
|
566
|
+
}
|
|
567
|
+
// action — REQUIRED closed-enum.
|
|
568
|
+
if (!hasAction || actionRaw === undefined) {
|
|
569
|
+
throw new TypeError("abacPolicies.create: `action` is required");
|
|
570
|
+
}
|
|
571
|
+
if (typeof actionRaw !== "string") {
|
|
572
|
+
throw new TypeError(`abacPolicies.create: \`action\` must be a string ` +
|
|
573
|
+
`(got ${describeType(actionRaw)})`);
|
|
574
|
+
}
|
|
575
|
+
if (!ABAC_POLICY_ACTIONS.includes(actionRaw)) {
|
|
576
|
+
throw new TypeError(`abacPolicies.create: \`action\` must be one of ` +
|
|
577
|
+
`[${ABAC_POLICY_ACTIONS.join(", ")}] (got "${actionRaw}")`);
|
|
578
|
+
}
|
|
579
|
+
// condition — REQUIRED non-null object (AST validation defers to server).
|
|
580
|
+
if (!hasCondition || conditionRaw === undefined) {
|
|
581
|
+
throw new TypeError("abacPolicies.create: `condition` is required");
|
|
582
|
+
}
|
|
583
|
+
if (conditionRaw === null ||
|
|
584
|
+
typeof conditionRaw !== "object" ||
|
|
585
|
+
Array.isArray(conditionRaw)) {
|
|
586
|
+
throw new TypeError(`abacPolicies.create: \`condition\` must be a non-null object ` +
|
|
587
|
+
`(got ${describeType(conditionRaw)}). The recursive AST grammar ` +
|
|
588
|
+
`is validated server-side by the canonical validator.`);
|
|
589
|
+
}
|
|
590
|
+
// ─── Optional field validation (per invariant #52 — omit when missing) ─
|
|
591
|
+
// description — OPTIONAL string OR null. An explicit `undefined`
|
|
592
|
+
// own-property is treated as omission (consistent with the JSDoc
|
|
593
|
+
// claim "Accepts `string`, `null`, or `undefined` (omitted)" AND
|
|
594
|
+
// with the symmetric pattern used by `effect` / `priority` /
|
|
595
|
+
// `enabled` below). The kernel's Zod is `.optional().nullable()`
|
|
596
|
+
// so `undefined` AND missing-key are both accepted server-side;
|
|
597
|
+
// JSON.stringify drops undefined fields anyway. Pre-rejecting
|
|
598
|
+
// undefined here was a HIGH false-positive: a consumer doing
|
|
599
|
+
// `{...form, description: form.maybeStr}` where `maybeStr: string |
|
|
600
|
+
// undefined` would hit a TypeError despite the JSDoc claim
|
|
601
|
+
// (hostile-review HIGH-1).
|
|
602
|
+
if (hasDescription && descriptionRaw !== undefined) {
|
|
603
|
+
if (descriptionRaw !== null && typeof descriptionRaw !== "string") {
|
|
604
|
+
throw new TypeError(`abacPolicies.create: \`description\` must be a string or null ` +
|
|
605
|
+
`when present (got ${describeType(descriptionRaw)})`);
|
|
606
|
+
}
|
|
607
|
+
if (typeof descriptionRaw === "string" &&
|
|
608
|
+
descriptionRaw.length > MAX_POLICY_DESCRIPTION_LENGTH) {
|
|
609
|
+
throw new TypeError(`abacPolicies.create: \`description\` exceeds the kernel's max ` +
|
|
610
|
+
`length of ${MAX_POLICY_DESCRIPTION_LENGTH} chars ` +
|
|
611
|
+
`(got ${descriptionRaw.length})`);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
// effect — OPTIONAL closed-enum. Default "allow" applied server-side.
|
|
615
|
+
if (hasEffect && effectRaw !== undefined) {
|
|
616
|
+
if (typeof effectRaw !== "string") {
|
|
617
|
+
throw new TypeError(`abacPolicies.create: \`effect\` must be a string when present ` +
|
|
618
|
+
`(got ${describeType(effectRaw)})`);
|
|
619
|
+
}
|
|
620
|
+
if (!ABAC_POLICY_EFFECTS.includes(effectRaw)) {
|
|
621
|
+
throw new TypeError(`abacPolicies.create: \`effect\` must be one of ` +
|
|
622
|
+
`[${ABAC_POLICY_EFFECTS.join(", ")}] (got "${effectRaw}")`);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
// priority — OPTIONAL int [0, 1000]. Default 100 applied server-side.
|
|
626
|
+
if (hasPriority && priorityRaw !== undefined) {
|
|
627
|
+
if (typeof priorityRaw !== "number" || !Number.isFinite(priorityRaw)) {
|
|
628
|
+
throw new TypeError(`abacPolicies.create: \`priority\` must be a finite number when ` +
|
|
629
|
+
`present (got ${describeType(priorityRaw)})`);
|
|
630
|
+
}
|
|
631
|
+
if (!Number.isInteger(priorityRaw)) {
|
|
632
|
+
throw new TypeError(`abacPolicies.create: \`priority\` must be an integer when ` +
|
|
633
|
+
`present (got ${priorityRaw})`);
|
|
634
|
+
}
|
|
635
|
+
if (priorityRaw < MIN_POLICY_PRIORITY ||
|
|
636
|
+
priorityRaw > MAX_POLICY_PRIORITY) {
|
|
637
|
+
throw new TypeError(`abacPolicies.create: \`priority\` must be in range ` +
|
|
638
|
+
`[${MIN_POLICY_PRIORITY}, ${MAX_POLICY_PRIORITY}] ` +
|
|
639
|
+
`(got ${priorityRaw})`);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
// enabled — OPTIONAL boolean. Default true applied server-side.
|
|
643
|
+
if (hasEnabled && enabledRaw !== undefined) {
|
|
644
|
+
if (typeof enabledRaw !== "boolean") {
|
|
645
|
+
throw new TypeError(`abacPolicies.create: \`enabled\` must be a boolean when present ` +
|
|
646
|
+
`(got ${describeType(enabledRaw)})`);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
// ─── Body construction (omit defaults so kernel applies them — #52) ────
|
|
650
|
+
const body = {
|
|
651
|
+
name: nameRaw,
|
|
652
|
+
resource: resourceRaw,
|
|
653
|
+
action: actionRaw,
|
|
654
|
+
condition: conditionRaw,
|
|
655
|
+
};
|
|
656
|
+
if (hasDescription && descriptionRaw !== undefined) {
|
|
657
|
+
// Pass through both string and null verbatim (caller's intent).
|
|
658
|
+
// Explicit `undefined` is treated as omission per the JSDoc
|
|
659
|
+
// contract — JSON.stringify would drop the key anyway, but the
|
|
660
|
+
// symmetric guard with effect/priority/enabled keeps the body
|
|
661
|
+
// construction predictable (hostile-review HIGH-1).
|
|
662
|
+
body.description = descriptionRaw;
|
|
663
|
+
}
|
|
664
|
+
if (hasEffect && effectRaw !== undefined) {
|
|
665
|
+
body.effect = effectRaw;
|
|
666
|
+
}
|
|
667
|
+
if (hasPriority && priorityRaw !== undefined) {
|
|
668
|
+
body.priority = priorityRaw;
|
|
669
|
+
}
|
|
670
|
+
if (hasEnabled && enabledRaw !== undefined) {
|
|
671
|
+
body.enabled = enabledRaw;
|
|
672
|
+
}
|
|
673
|
+
return this.client
|
|
674
|
+
._request({
|
|
675
|
+
method: "POST",
|
|
676
|
+
path: "/api/v1/abac-policies",
|
|
677
|
+
body,
|
|
678
|
+
options,
|
|
679
|
+
})
|
|
680
|
+
.then((result) => validateAbacPolicy(result, "abacPolicies.create"));
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Retrieve one ABAC policy by id. Returns the policy row.
|
|
684
|
+
*
|
|
685
|
+
* **FIRST `abacPolicies` method with a UUID path segment** — `id`
|
|
686
|
+
* is interpolated into the request path
|
|
687
|
+
* (`/api/v1/abac-policies/<id>`). `.list()` / `.create()` hit the
|
|
688
|
+
* collection path with no segment; `.update()` / `.delete()` share
|
|
689
|
+
* this id-path shape.
|
|
690
|
+
*
|
|
691
|
+
* **Dual-auth admin scope** — same as `.list()` / `.create()`: the
|
|
692
|
+
* kernel route gates on `requireSessionOrApiKey(request, {
|
|
693
|
+
* sessionRoles: ["admin"], apiKeyPermissions:
|
|
694
|
+
* [API_KEY_PERMISSIONS.ADMIN] })`. The SDK transport always sends
|
|
695
|
+
* `x-api-key`, so the api-key path is the only one reachable from
|
|
696
|
+
* SDK consumers. **HTTP 401** for no/invalid/expired key, **HTTP
|
|
697
|
+
* 403** for a valid key whose permissions do NOT include `ADMIN`.
|
|
698
|
+
* Pin BOTH branches separately (asymmetric with carry-forward
|
|
699
|
+
* invariant #42's ADMIN-only 401-collapse).
|
|
700
|
+
*
|
|
701
|
+
* **UUID pre-validation** — the SDK pre-validates `id` against
|
|
702
|
+
* `UUID_REGEX` synchronously (`TypeError`, NO fetch issued) before
|
|
703
|
+
* constructing the URL. The kernel's own `badId` check would
|
|
704
|
+
* return HTTP 400 "Invalid policy id." on a malformed id, but the
|
|
705
|
+
* SDK pre-empts it — that 400 is reachable only via an `as any`
|
|
706
|
+
* cast or a kernel-side id-flavor change. Mirror of `batch.get`.
|
|
707
|
+
*
|
|
708
|
+
* **No `encodeURIComponent` / URIError defense on the path
|
|
709
|
+
* segment** — a string matching `UUID_REGEX` is ASCII hex digits +
|
|
710
|
+
* hyphens, so it is URL-safe verbatim and cannot trigger a
|
|
711
|
+
* `URIError`. The validated `id` is interpolated raw. Asymmetric
|
|
712
|
+
* with `decisions.retrieve` (free-form id → `encodePathSegment`
|
|
713
|
+
* with path-traversal + URIError defenses).
|
|
714
|
+
*
|
|
715
|
+
* **404 surface** — the kernel's `getAbacPolicyById(orgId, id)`
|
|
716
|
+
* returns `null` for a missing id OR a cross-org id (the
|
|
717
|
+
* `eq(orgId)` clause silently filters policies in other orgs), and
|
|
718
|
+
* the GET handler maps that to `errorResponse("ABAC policy not
|
|
719
|
+
* found.", 404)`. **Inline literal message** — distinct from
|
|
720
|
+
* `.update()` / `.delete()`'s 404, which is raised by
|
|
721
|
+
* `AbacPolicyNotFoundError` with the id-embedded message `"ABAC
|
|
722
|
+
* policy <id> not found in this organization."`.
|
|
723
|
+
*
|
|
724
|
+
* **No `writeAuditLog` side effect** — `.retrieve()` is a quiet
|
|
725
|
+
* read (same as `.list()`; asymmetric with `.create()` /
|
|
726
|
+
* `.update()` / `.delete()`, which each write an `abac_policy.*`
|
|
727
|
+
* audit-log entry).
|
|
728
|
+
*
|
|
729
|
+
* **Kernel-side 30-second timeout** (`maxDuration = 30`). Same as
|
|
730
|
+
* `.list()` / `.create()` / `auditLog.export`; looser than
|
|
731
|
+
* `gate.evaluate` / `shipGate.check`'s 15s.
|
|
732
|
+
*
|
|
733
|
+
* Errors — **happy-path precedence ordering**: UUID format (kernel
|
|
734
|
+
* `badId` — SDK-pre-empted) → rate-limit → auth → DB lookup →
|
|
735
|
+
* successResponse. **The 500-catchall is a SEPARATE DIMENSION** —
|
|
736
|
+
* any throwable not matched by the GET handler's single
|
|
737
|
+
* `instanceof AuthError` arm falls to 500.
|
|
738
|
+
* - `AttestryAPIError` (status 429) — rate limit FIRES FIRST
|
|
739
|
+
* among the network surfaces (auto-retried by default —
|
|
740
|
+
* invariant #18; per-IP key `abac-policies-get:${ip}` against
|
|
741
|
+
* `assessmentLimiter` — 30 req / 1-min sliding window).
|
|
742
|
+
* - `AttestryAPIError` (status 401) — no/invalid/expired api-key.
|
|
743
|
+
* - `AttestryAPIError` (status 403) — valid api-key whose
|
|
744
|
+
* permissions do NOT include `ADMIN`. Distinct branch from
|
|
745
|
+
* 401 — pin BOTH separately.
|
|
746
|
+
* - `AttestryAPIError` (status 404) — policy not found OR a
|
|
747
|
+
* cross-org id (kernel collapses both to "ABAC policy not
|
|
748
|
+
* found.").
|
|
749
|
+
* - `AttestryAPIError` (status 400) — kernel `badId` rejected
|
|
750
|
+
* the id. **SDK-pre-empted** — reachable from a consumer only
|
|
751
|
+
* via an `as any` cast or a kernel-side id-flavor change.
|
|
752
|
+
* - `AttestryAPIError` (status 500) — internal kernel error
|
|
753
|
+
* (scrubbed message via `internalErrorResponse`). **Orthogonal
|
|
754
|
+
* to the precedence list** — ANY throwable not matched by the
|
|
755
|
+
* route's single `instanceof AuthError` arm falls here.
|
|
756
|
+
* - `AttestryError` ("request aborted by caller") — caller-
|
|
757
|
+
* supplied `options.signal` fired.
|
|
758
|
+
* - `AttestryError` (P2 hardening) — kernel response failed
|
|
759
|
+
* SDK-side shape validation (non-object, or any of the 13
|
|
760
|
+
* `AbacPolicy` fields missing / wrong-typed).
|
|
761
|
+
* - `AttestryAPIError` (P3 hardening) — kernel response had a
|
|
762
|
+
* wrong Content-Type (transport-level guard before body
|
|
763
|
+
* parsing).
|
|
764
|
+
* - `TypeError` (synchronous, NO fetch issued) — `id` is missing,
|
|
765
|
+
* a non-string, an empty string, or not an RFC 4122 UUID.
|
|
766
|
+
*
|
|
767
|
+
* **SDK-side validation** (synchronous `TypeError`, no fetch
|
|
768
|
+
* issued):
|
|
769
|
+
* - `id`: required; must be a non-empty string matching
|
|
770
|
+
* `UUID_REGEX` (RFC 4122 hyphenated, case-insensitive).
|
|
771
|
+
*
|
|
772
|
+
* **Response-shape validation** (P2 hardening) — the shared
|
|
773
|
+
* `validateAbacPolicy` validator checks all 13 `AbacPolicy` fields
|
|
774
|
+
* via the module-load `objectHasOwn` snapshot (symmetric
|
|
775
|
+
* prototype-pollution defense). `condition` is validated as a
|
|
776
|
+
* non-null object only — the recursive AST is faithful-courier.
|
|
777
|
+
*
|
|
778
|
+
* **Transport-shape validation** (P3 hardening) — rejects with
|
|
779
|
+
* `AttestryAPIError` if the kernel responds with a
|
|
780
|
+
* non-`application/json` Content-Type.
|
|
781
|
+
*
|
|
782
|
+
* @example Retrieve a policy by id
|
|
783
|
+
* ```ts
|
|
784
|
+
* const policy = await client.abacPolicies.retrieve(
|
|
785
|
+
* "550e8400-e29b-41d4-a716-446655440000",
|
|
786
|
+
* );
|
|
787
|
+
* console.log(`${policy.effect} ${policy.action} ${policy.resource}`);
|
|
788
|
+
* ```
|
|
789
|
+
*
|
|
790
|
+
* @example Handle not-found (HTTP 404)
|
|
791
|
+
* ```ts
|
|
792
|
+
* try {
|
|
793
|
+
* return await client.abacPolicies.retrieve(id);
|
|
794
|
+
* } catch (err) {
|
|
795
|
+
* if (err instanceof AttestryAPIError && err.status === 404) {
|
|
796
|
+
* return null; // policy doesn't exist (or belongs to another org)
|
|
797
|
+
* }
|
|
798
|
+
* throw err;
|
|
799
|
+
* }
|
|
800
|
+
* ```
|
|
801
|
+
*/
|
|
802
|
+
retrieve(id, options) {
|
|
803
|
+
assertValidPolicyId(id, "abacPolicies.retrieve");
|
|
804
|
+
return this.client
|
|
805
|
+
._request({
|
|
806
|
+
method: "GET",
|
|
807
|
+
path: `/api/v1/abac-policies/${id}`,
|
|
808
|
+
options,
|
|
809
|
+
})
|
|
810
|
+
.then((result) => validateAbacPolicy(result, "abacPolicies.retrieve"));
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* Update one ABAC policy by id (PARTIAL update). Returns the
|
|
814
|
+
* **updated row** — the policy as it exists AFTER the patch is
|
|
815
|
+
* applied — on HTTP 200.
|
|
816
|
+
*
|
|
817
|
+
* **SECOND SDK method using the HTTP `PATCH` verb** (`incidents.update`
|
|
818
|
+
* is the first). The kernel route is `PATCH /api/v1/abac-policies/[id]`.
|
|
819
|
+
*
|
|
820
|
+
* **Partial update — every input field is optional.** A consumer
|
|
821
|
+
* patches only the fields they want to change; omitted fields keep
|
|
822
|
+
* their current value. The SDK builds the request body from the
|
|
823
|
+
* present-and-not-`undefined` fields only — an omitted field (or an
|
|
824
|
+
* explicit `field: undefined`) is left out of the body so the kernel
|
|
825
|
+
* leaves that column untouched.
|
|
826
|
+
*
|
|
827
|
+
* **Empty-patch pre-validation.** The kernel's `updateAbacPolicySchema`
|
|
828
|
+
* ends in a `.refine()` rejecting a body with NO updatable field
|
|
829
|
+
* (`"PATCH body must include at least one updatable field"`). The
|
|
830
|
+
* SDK pre-rejects an empty patch — `update(id, {})`, an
|
|
831
|
+
* all-`undefined` patch, or a patch carrying ONLY unknown keys —
|
|
832
|
+
* synchronously with a `TypeError` (NO fetch issued) so the consumer
|
|
833
|
+
* never burns a round-trip on a guaranteed 422.
|
|
834
|
+
*
|
|
835
|
+
* **Dual-auth admin scope** — same as `.list()` / `.create()` /
|
|
836
|
+
* `.retrieve()` / `.delete()`: `requireSessionOrApiKey(request, {
|
|
837
|
+
* sessionRoles: ["admin"], apiKeyPermissions:
|
|
838
|
+
* [API_KEY_PERMISSIONS.ADMIN] })`. **HTTP 401** for no/invalid/
|
|
839
|
+
* expired key, **HTTP 403** for a valid key whose permissions do NOT
|
|
840
|
+
* include `ADMIN`. Pin BOTH branches separately.
|
|
841
|
+
*
|
|
842
|
+
* **UUID pre-validation** — `id` is pre-validated against
|
|
843
|
+
* `UUID_REGEX` synchronously via the shared `assertValidPolicyId`
|
|
844
|
+
* helper (`TypeError`, NO fetch issued). The kernel `badId` 400
|
|
845
|
+
* ("Invalid policy id.") is SDK-pre-empted. **No `encodeURIComponent`
|
|
846
|
+
* / URIError defense** — a validated UUID is ASCII hex + hyphens and
|
|
847
|
+
* is interpolated into the path raw (mirror of `batch.get`).
|
|
848
|
+
*
|
|
849
|
+
* **Partial Zod pre-validation** — the closed-spec fields that ARE
|
|
850
|
+
* present are pre-validated synchronously (name length, description
|
|
851
|
+
* length-or-null, resource/action/effect closed-enums, priority
|
|
852
|
+
* int+bounds, enabled boolean); a present `condition` is checked
|
|
853
|
+
* only as a non-null object, deferring the recursive AST grammar to
|
|
854
|
+
* the kernel's canonical validator. Mirror of `.create()`'s partial
|
|
855
|
+
* pre-validation — but here EVERY field is optional.
|
|
856
|
+
*
|
|
857
|
+
* **`description: null` CLEARS the description.** Passing
|
|
858
|
+
* `description: null` is a valid non-empty patch — the kernel
|
|
859
|
+
* persists `null`. An explicit `description: undefined` is treated
|
|
860
|
+
* as omission (symmetric with `.create()`).
|
|
861
|
+
*
|
|
862
|
+
* **Three-way 422 fan-out — same distinct wire shapes as `.create()`:**
|
|
863
|
+
* 1. **`BodyParseError`** (Zod `.strict()` rejection via
|
|
864
|
+
* `parseBody`) → `details: Array<{ path, message }>`.
|
|
865
|
+
* 2. **`ZodError`** (DEFENSIVE — DEAD on the happy path;
|
|
866
|
+
* `parseBody` catches Zod and converts to `BodyParseError`) →
|
|
867
|
+
* `details: ZodIssue[]`.
|
|
868
|
+
* 3. **`AbacPolicyValidationError`** (server-side canonical AST
|
|
869
|
+
* validation) → `details: { errors: string[] }`.
|
|
870
|
+
* SDK surfaces all three uniformly as `AttestryAPIError(422)` —
|
|
871
|
+
* discriminate via `err.details` (see the `.create()` examples).
|
|
872
|
+
*
|
|
873
|
+
* **HTTP 409 Conflict** — patching `name` to a value already used
|
|
874
|
+
* by a sibling policy in the org trips the `(orgId, name)` unique
|
|
875
|
+
* constraint → `AbacPolicyNameConflictError` → 409.
|
|
876
|
+
*
|
|
877
|
+
* **HTTP 404** — the kernel's `updateAbacPolicy` throws
|
|
878
|
+
* `AbacPolicyNotFoundError` when the `(id, orgId)`-scoped lookup
|
|
879
|
+
* misses (a missing id OR a cross-org id). **The message is
|
|
880
|
+
* id-embedded** — `"ABAC policy <id> not found in this
|
|
881
|
+
* organization."` — same shape as `.delete()`'s 404 (distinct from
|
|
882
|
+
* `.retrieve()`'s INLINE `"ABAC policy not found."`).
|
|
883
|
+
*
|
|
884
|
+
* **6 named-error catch arms — the LARGEST on the SDK**, in order:
|
|
885
|
+
* `AuthError`, `BodyParseError`, `ZodError`,
|
|
886
|
+
* `AbacPolicyValidationError`, `AbacPolicyNameConflictError`,
|
|
887
|
+
* `AbacPolicyNotFoundError`. Everything else falls to the 500
|
|
888
|
+
* `internalErrorResponse` catchall.
|
|
889
|
+
*
|
|
890
|
+
* **`writeAuditLog` side effect** — every successful `.update()`
|
|
891
|
+
* writes one audit-log entry with `action: "abac_policy.update"`
|
|
892
|
+
* and `resourceType: "abac_policy"`; the entry's `details` records
|
|
893
|
+
* the changed field names plus a structured `before`/`after` diff.
|
|
894
|
+
* The write is org-scoped + hash-chained, `await`-ed (so `.update()`
|
|
895
|
+
* latency includes the audit write) but error-tolerant (a write
|
|
896
|
+
* failure does NOT fail the request) and is NOT counted against any
|
|
897
|
+
* quota. The audit log is NOT written on a failed update — 404 /
|
|
898
|
+
* 409 / 422 all surface BEFORE the `writeAuditLog` call.
|
|
899
|
+
*
|
|
900
|
+
* **Kernel-side 30-second timeout** (`maxDuration = 30`). Same as
|
|
901
|
+
* `.list()` / `.create()` / `.retrieve()` / `.delete()`.
|
|
902
|
+
*
|
|
903
|
+
* Errors — **happy-path precedence ordering**: UUID format (kernel
|
|
904
|
+
* `badId` — SDK-pre-empted) → rate-limit → auth → body parse → DB
|
|
905
|
+
* lookup/update → audit write → successResponse.
|
|
906
|
+
* - `AttestryAPIError` (429) — rate limit FIRES FIRST among the
|
|
907
|
+
* network surfaces (auto-retried; per-IP key
|
|
908
|
+
* `abac-policies-patch:${ip}` against `assessmentLimiter`).
|
|
909
|
+
* - `AttestryAPIError` (401) — no/invalid/expired api-key.
|
|
910
|
+
* - `AttestryAPIError` (403) — valid api-key without `ADMIN`.
|
|
911
|
+
* Distinct branch from 401 — pin BOTH separately.
|
|
912
|
+
* - `AttestryAPIError` (422) — Zod-schema validation OR canonical-
|
|
913
|
+
* validator AST failure. Discriminate via `err.details` shape.
|
|
914
|
+
* - `AttestryAPIError` (409) — `(orgId, name)` uniqueness conflict.
|
|
915
|
+
* - `AttestryAPIError` (404) — policy not found OR a cross-org id
|
|
916
|
+
* (`AbacPolicyNotFoundError`; id-embedded message).
|
|
917
|
+
* - `AttestryAPIError` (400) — kernel `badId` rejected the id.
|
|
918
|
+
* **SDK-pre-empted**.
|
|
919
|
+
* - `AttestryAPIError` (500) — internal kernel error (scrubbed).
|
|
920
|
+
* - `AttestryError` ("request aborted by caller") — `options.signal`
|
|
921
|
+
* fired.
|
|
922
|
+
* - `AttestryError` (P2 hardening) — the updated row failed
|
|
923
|
+
* SDK-side shape validation (non-object, or any of the 13
|
|
924
|
+
* `AbacPolicy` fields missing / wrong-typed).
|
|
925
|
+
* - `AttestryAPIError` (P3 hardening) — wrong Content-Type on the
|
|
926
|
+
* response.
|
|
927
|
+
* - `TypeError` (synchronous, NO fetch issued) — invalid `id`;
|
|
928
|
+
* `input` not a non-null object; a present field is the wrong
|
|
929
|
+
* type / out of range / an unknown closed-enum value; OR the
|
|
930
|
+
* patch is empty (no updatable field).
|
|
931
|
+
*
|
|
932
|
+
* @example Patch a single field (the rest of the policy is unchanged)
|
|
933
|
+
* ```ts
|
|
934
|
+
* const updated = await client.abacPolicies.update(
|
|
935
|
+
* "550e8400-e29b-41d4-a716-446655440000",
|
|
936
|
+
* { enabled: false },
|
|
937
|
+
* );
|
|
938
|
+
* console.log(`Policy "${updated.name}" is now ${updated.enabled ? "on" : "off"}`);
|
|
939
|
+
* ```
|
|
940
|
+
*
|
|
941
|
+
* @example Clear a description (pass null) and re-prioritize
|
|
942
|
+
* ```ts
|
|
943
|
+
* await client.abacPolicies.update(id, { description: null, priority: 10 });
|
|
944
|
+
* ```
|
|
945
|
+
*
|
|
946
|
+
* @example Catch a name-conflict (HTTP 409)
|
|
947
|
+
* ```ts
|
|
948
|
+
* try {
|
|
949
|
+
* await client.abacPolicies.update(id, { name: "taken-name" });
|
|
950
|
+
* } catch (err) {
|
|
951
|
+
* if (err instanceof AttestryAPIError && err.status === 409) {
|
|
952
|
+
* // another policy in the org already uses that name
|
|
953
|
+
* }
|
|
954
|
+
* }
|
|
955
|
+
* ```
|
|
956
|
+
*/
|
|
957
|
+
update(id, input, options) {
|
|
958
|
+
// `id` first — the path segment is validated before the body,
|
|
959
|
+
// mirror of the kernel PATCH handler (`badId` runs before
|
|
960
|
+
// `parseBody`).
|
|
961
|
+
assertValidPolicyId(id, "abacPolicies.update");
|
|
962
|
+
// Top-level shape — `input` is REQUIRED (a non-null, non-array
|
|
963
|
+
// object). `update(id)` with no second argument lands here too
|
|
964
|
+
// (`undefined` is not an object).
|
|
965
|
+
if (input === null ||
|
|
966
|
+
typeof input !== "object" ||
|
|
967
|
+
Array.isArray(input)) {
|
|
968
|
+
throw new TypeError("abacPolicies.update: `input` must be a non-null object with at " +
|
|
969
|
+
"least one updatable field (`name`, `description`, `resource`, " +
|
|
970
|
+
"`action`, `effect`, `condition`, `priority`, `enabled`)");
|
|
971
|
+
}
|
|
972
|
+
// Snapshot each field's value EXACTLY ONCE up front via the
|
|
973
|
+
// own-property indexer, then operate only on the locals downstream
|
|
974
|
+
// (mirror of `.create()`): prototype-pollution defense, TOCTOU
|
|
975
|
+
// defense (Proxy / getter inputs read once), a correct missing-key
|
|
976
|
+
// shape (objectHasOwn returns false), and the throwing-getter
|
|
977
|
+
// defense (`readInputField` converts a throwing accessor into the
|
|
978
|
+
// documented synchronous `TypeError` — session-22 hostile review
|
|
979
|
+
// #1, the SDK-wide MEDIUM-1 fix).
|
|
980
|
+
const hasName = objectHasOwn(input, "name");
|
|
981
|
+
const nameRaw = hasName
|
|
982
|
+
? readInputField(input, "name", "abacPolicies.update")
|
|
983
|
+
: undefined;
|
|
984
|
+
const hasDescription = objectHasOwn(input, "description");
|
|
985
|
+
const descriptionRaw = hasDescription
|
|
986
|
+
? readInputField(input, "description", "abacPolicies.update")
|
|
987
|
+
: undefined;
|
|
988
|
+
const hasResource = objectHasOwn(input, "resource");
|
|
989
|
+
const resourceRaw = hasResource
|
|
990
|
+
? readInputField(input, "resource", "abacPolicies.update")
|
|
991
|
+
: undefined;
|
|
992
|
+
const hasAction = objectHasOwn(input, "action");
|
|
993
|
+
const actionRaw = hasAction
|
|
994
|
+
? readInputField(input, "action", "abacPolicies.update")
|
|
995
|
+
: undefined;
|
|
996
|
+
const hasEffect = objectHasOwn(input, "effect");
|
|
997
|
+
const effectRaw = hasEffect
|
|
998
|
+
? readInputField(input, "effect", "abacPolicies.update")
|
|
999
|
+
: undefined;
|
|
1000
|
+
const hasCondition = objectHasOwn(input, "condition");
|
|
1001
|
+
const conditionRaw = hasCondition
|
|
1002
|
+
? readInputField(input, "condition", "abacPolicies.update")
|
|
1003
|
+
: undefined;
|
|
1004
|
+
const hasPriority = objectHasOwn(input, "priority");
|
|
1005
|
+
const priorityRaw = hasPriority
|
|
1006
|
+
? readInputField(input, "priority", "abacPolicies.update")
|
|
1007
|
+
: undefined;
|
|
1008
|
+
const hasEnabled = objectHasOwn(input, "enabled");
|
|
1009
|
+
const enabledRaw = hasEnabled
|
|
1010
|
+
? readInputField(input, "enabled", "abacPolicies.update")
|
|
1011
|
+
: undefined;
|
|
1012
|
+
// ─── Per-field validation (EVERY field optional — PATCH semantics) ─────
|
|
1013
|
+
// Each block fires only when the field is present AND not
|
|
1014
|
+
// `undefined` — an explicit `undefined` own-property is treated as
|
|
1015
|
+
// omission, the same convention `.create()` applies to its optional
|
|
1016
|
+
// fields.
|
|
1017
|
+
// name — when present: non-empty string, length 1-128.
|
|
1018
|
+
if (hasName && nameRaw !== undefined) {
|
|
1019
|
+
if (typeof nameRaw !== "string") {
|
|
1020
|
+
throw new TypeError(`abacPolicies.update: \`name\` must be a string when present ` +
|
|
1021
|
+
`(got ${describeType(nameRaw)})`);
|
|
1022
|
+
}
|
|
1023
|
+
if (nameRaw.length === 0) {
|
|
1024
|
+
throw new TypeError("abacPolicies.update: `name` must be a non-empty string when present");
|
|
1025
|
+
}
|
|
1026
|
+
if (nameRaw.length > MAX_POLICY_NAME_LENGTH) {
|
|
1027
|
+
throw new TypeError(`abacPolicies.update: \`name\` exceeds the kernel's max length ` +
|
|
1028
|
+
`of ${MAX_POLICY_NAME_LENGTH} chars (got ${nameRaw.length})`);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
// description — when present: string OR null. An explicit
|
|
1032
|
+
// `undefined` is treated as omission; `null` CLEARS the field.
|
|
1033
|
+
if (hasDescription && descriptionRaw !== undefined) {
|
|
1034
|
+
if (descriptionRaw !== null && typeof descriptionRaw !== "string") {
|
|
1035
|
+
throw new TypeError(`abacPolicies.update: \`description\` must be a string or null ` +
|
|
1036
|
+
`when present (got ${describeType(descriptionRaw)})`);
|
|
1037
|
+
}
|
|
1038
|
+
if (typeof descriptionRaw === "string" &&
|
|
1039
|
+
descriptionRaw.length > MAX_POLICY_DESCRIPTION_LENGTH) {
|
|
1040
|
+
throw new TypeError(`abacPolicies.update: \`description\` exceeds the kernel's max ` +
|
|
1041
|
+
`length of ${MAX_POLICY_DESCRIPTION_LENGTH} chars ` +
|
|
1042
|
+
`(got ${descriptionRaw.length})`);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
// resource — when present: closed-enum.
|
|
1046
|
+
if (hasResource && resourceRaw !== undefined) {
|
|
1047
|
+
if (typeof resourceRaw !== "string") {
|
|
1048
|
+
throw new TypeError(`abacPolicies.update: \`resource\` must be a string when present ` +
|
|
1049
|
+
`(got ${describeType(resourceRaw)})`);
|
|
1050
|
+
}
|
|
1051
|
+
if (!ABAC_POLICY_RESOURCES.includes(resourceRaw)) {
|
|
1052
|
+
throw new TypeError(`abacPolicies.update: \`resource\` must be one of ` +
|
|
1053
|
+
`[${ABAC_POLICY_RESOURCES.join(", ")}] (got "${resourceRaw}")`);
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
// action — when present: closed-enum.
|
|
1057
|
+
if (hasAction && actionRaw !== undefined) {
|
|
1058
|
+
if (typeof actionRaw !== "string") {
|
|
1059
|
+
throw new TypeError(`abacPolicies.update: \`action\` must be a string when present ` +
|
|
1060
|
+
`(got ${describeType(actionRaw)})`);
|
|
1061
|
+
}
|
|
1062
|
+
if (!ABAC_POLICY_ACTIONS.includes(actionRaw)) {
|
|
1063
|
+
throw new TypeError(`abacPolicies.update: \`action\` must be one of ` +
|
|
1064
|
+
`[${ABAC_POLICY_ACTIONS.join(", ")}] (got "${actionRaw}")`);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
// effect — when present: closed-enum.
|
|
1068
|
+
if (hasEffect && effectRaw !== undefined) {
|
|
1069
|
+
if (typeof effectRaw !== "string") {
|
|
1070
|
+
throw new TypeError(`abacPolicies.update: \`effect\` must be a string when present ` +
|
|
1071
|
+
`(got ${describeType(effectRaw)})`);
|
|
1072
|
+
}
|
|
1073
|
+
if (!ABAC_POLICY_EFFECTS.includes(effectRaw)) {
|
|
1074
|
+
throw new TypeError(`abacPolicies.update: \`effect\` must be one of ` +
|
|
1075
|
+
`[${ABAC_POLICY_EFFECTS.join(", ")}] (got "${effectRaw}")`);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
// condition — when present: non-null object (AST defers to server).
|
|
1079
|
+
if (hasCondition && conditionRaw !== undefined) {
|
|
1080
|
+
if (conditionRaw === null ||
|
|
1081
|
+
typeof conditionRaw !== "object" ||
|
|
1082
|
+
Array.isArray(conditionRaw)) {
|
|
1083
|
+
throw new TypeError(`abacPolicies.update: \`condition\` must be a non-null object ` +
|
|
1084
|
+
`when present (got ${describeType(conditionRaw)}). The ` +
|
|
1085
|
+
`recursive AST grammar is validated server-side by the ` +
|
|
1086
|
+
`canonical validator.`);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
// priority — when present: integer [0, 1000].
|
|
1090
|
+
if (hasPriority && priorityRaw !== undefined) {
|
|
1091
|
+
if (typeof priorityRaw !== "number" || !Number.isFinite(priorityRaw)) {
|
|
1092
|
+
throw new TypeError(`abacPolicies.update: \`priority\` must be a finite number when ` +
|
|
1093
|
+
`present (got ${describeType(priorityRaw)})`);
|
|
1094
|
+
}
|
|
1095
|
+
if (!Number.isInteger(priorityRaw)) {
|
|
1096
|
+
throw new TypeError(`abacPolicies.update: \`priority\` must be an integer when ` +
|
|
1097
|
+
`present (got ${priorityRaw})`);
|
|
1098
|
+
}
|
|
1099
|
+
if (priorityRaw < MIN_POLICY_PRIORITY ||
|
|
1100
|
+
priorityRaw > MAX_POLICY_PRIORITY) {
|
|
1101
|
+
throw new TypeError(`abacPolicies.update: \`priority\` must be in range ` +
|
|
1102
|
+
`[${MIN_POLICY_PRIORITY}, ${MAX_POLICY_PRIORITY}] ` +
|
|
1103
|
+
`(got ${priorityRaw})`);
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
// enabled — when present: boolean.
|
|
1107
|
+
if (hasEnabled && enabledRaw !== undefined) {
|
|
1108
|
+
if (typeof enabledRaw !== "boolean") {
|
|
1109
|
+
throw new TypeError(`abacPolicies.update: \`enabled\` must be a boolean when present ` +
|
|
1110
|
+
`(got ${describeType(enabledRaw)})`);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
// ─── Body construction (only present-and-not-undefined fields) ─────────
|
|
1114
|
+
const body = {};
|
|
1115
|
+
if (hasName && nameRaw !== undefined) {
|
|
1116
|
+
body.name = nameRaw;
|
|
1117
|
+
}
|
|
1118
|
+
if (hasDescription && descriptionRaw !== undefined) {
|
|
1119
|
+
// `null` rides through verbatim — it CLEARS the description.
|
|
1120
|
+
body.description = descriptionRaw;
|
|
1121
|
+
}
|
|
1122
|
+
if (hasResource && resourceRaw !== undefined) {
|
|
1123
|
+
body.resource = resourceRaw;
|
|
1124
|
+
}
|
|
1125
|
+
if (hasAction && actionRaw !== undefined) {
|
|
1126
|
+
body.action = actionRaw;
|
|
1127
|
+
}
|
|
1128
|
+
if (hasEffect && effectRaw !== undefined) {
|
|
1129
|
+
body.effect = effectRaw;
|
|
1130
|
+
}
|
|
1131
|
+
if (hasCondition && conditionRaw !== undefined) {
|
|
1132
|
+
body.condition = conditionRaw;
|
|
1133
|
+
}
|
|
1134
|
+
if (hasPriority && priorityRaw !== undefined) {
|
|
1135
|
+
body.priority = priorityRaw;
|
|
1136
|
+
}
|
|
1137
|
+
if (hasEnabled && enabledRaw !== undefined) {
|
|
1138
|
+
body.enabled = enabledRaw;
|
|
1139
|
+
}
|
|
1140
|
+
// ─── Empty-patch pre-validation ────────────────────────────────────────
|
|
1141
|
+
// The kernel `updateAbacPolicySchema` ends in a `.refine()` that
|
|
1142
|
+
// rejects a body carrying no updatable field. Pre-reject the empty
|
|
1143
|
+
// patch synchronously so the consumer gets a `TypeError` (NO fetch
|
|
1144
|
+
// issued) instead of burning a round-trip on a guaranteed 422. An
|
|
1145
|
+
// input of `{}`, an all-`undefined` patch, or a patch carrying only
|
|
1146
|
+
// unknown keys all produce a zero-key `body`.
|
|
1147
|
+
if (Object.keys(body).length === 0) {
|
|
1148
|
+
throw new TypeError("abacPolicies.update: `input` must include at least one updatable " +
|
|
1149
|
+
"field (`name`, `description`, `resource`, `action`, `effect`, " +
|
|
1150
|
+
"`condition`, `priority`, `enabled`) — the kernel rejects an " +
|
|
1151
|
+
"empty patch");
|
|
1152
|
+
}
|
|
1153
|
+
return this.client
|
|
1154
|
+
._request({
|
|
1155
|
+
method: "PATCH",
|
|
1156
|
+
path: `/api/v1/abac-policies/${id}`,
|
|
1157
|
+
body,
|
|
1158
|
+
options,
|
|
1159
|
+
})
|
|
1160
|
+
.then((result) => validateAbacPolicy(result, "abacPolicies.update"));
|
|
1161
|
+
}
|
|
1162
|
+
/**
|
|
1163
|
+
* Delete one ABAC policy by id. Returns the **deleted row** — the
|
|
1164
|
+
* policy as it existed immediately before deletion — on HTTP 200.
|
|
1165
|
+
*
|
|
1166
|
+
* **Returns the deleted row, NOT `void`.** The kernel's DELETE
|
|
1167
|
+
* handler emits `successResponse(row, 200)` carrying the
|
|
1168
|
+
* just-deleted `AbacPolicy`, so a caller can log / audit / render
|
|
1169
|
+
* an undo affordance with the full prior state. Consumers MUST NOT
|
|
1170
|
+
* expect `Promise<void>` or a `{ deleted: true }` envelope — the
|
|
1171
|
+
* resolved value is a complete `AbacPolicy`.
|
|
1172
|
+
*
|
|
1173
|
+
* **FIRST SDK method using the HTTP `DELETE` verb.** Every prior
|
|
1174
|
+
* SDK route is GET / POST / PATCH. The transport's
|
|
1175
|
+
* `InternalRequestArgs.method` union already includes `"DELETE"`,
|
|
1176
|
+
* so no new transport primitive is needed.
|
|
1177
|
+
*
|
|
1178
|
+
* **Dual-auth admin scope** — same as `.list()` / `.create()` /
|
|
1179
|
+
* `.retrieve()`: `requireSessionOrApiKey(request, { sessionRoles:
|
|
1180
|
+
* ["admin"], apiKeyPermissions: [API_KEY_PERMISSIONS.ADMIN] })`.
|
|
1181
|
+
* **HTTP 401** for no/invalid/expired key, **HTTP 403** for a valid
|
|
1182
|
+
* key whose permissions do NOT include `ADMIN`. Pin BOTH branches.
|
|
1183
|
+
*
|
|
1184
|
+
* **UUID pre-validation** — `id` is pre-validated against
|
|
1185
|
+
* `UUID_REGEX` synchronously (`TypeError`, NO fetch issued) via the
|
|
1186
|
+
* shared `assertValidPolicyId` helper. The kernel `badId` 400
|
|
1187
|
+
* ("Invalid policy id.") is SDK-pre-empted. **No `encodeURIComponent`
|
|
1188
|
+
* / URIError defense** — a validated UUID is ASCII hex + hyphens and
|
|
1189
|
+
* is interpolated into the path raw (see `UUID_REGEX`; mirror of
|
|
1190
|
+
* `batch.get`).
|
|
1191
|
+
*
|
|
1192
|
+
* **404 surface** — the kernel's `deleteAbacPolicy(orgId, id)`
|
|
1193
|
+
* throws `AbacPolicyNotFoundError` when the `(id, orgId)`-scoped
|
|
1194
|
+
* delete matches zero rows (a missing id OR a cross-org id — the
|
|
1195
|
+
* `eq(orgId)` clause scopes the delete). The DELETE handler maps it
|
|
1196
|
+
* to `errorResponse(error.message, 404)`. **The message is
|
|
1197
|
+
* id-embedded** — `"ABAC policy <id> not found in this
|
|
1198
|
+
* organization."` — distinct from `.retrieve()`'s INLINE
|
|
1199
|
+
* `"ABAC policy not found."`. (`.retrieve()` uses an inline string;
|
|
1200
|
+
* `.update()` / `.delete()` raise `AbacPolicyNotFoundError`.)
|
|
1201
|
+
*
|
|
1202
|
+
* **`writeAuditLog` side effect — every successful `.delete()` call
|
|
1203
|
+
* writes one audit-log entry** with `action: "abac_policy.delete"`
|
|
1204
|
+
* and `resourceType: "abac_policy"`. The entry's `details` records
|
|
1205
|
+
* the deleted policy's `name` / `resource` / `action` / `effect`
|
|
1206
|
+
* for forensics. The write is org-scoped + hash-chained; it is
|
|
1207
|
+
* `await`-ed (so `.delete()` latency includes the audit write) but
|
|
1208
|
+
* error-tolerant (a write failure does NOT fail the request); it is
|
|
1209
|
+
* NOT counted against any quota. The audit log is NOT written on a
|
|
1210
|
+
* failed delete — a 404 surfaces BEFORE the `writeAuditLog` call.
|
|
1211
|
+
*
|
|
1212
|
+
* **Kernel-side 30-second timeout** (`maxDuration = 30`). Same as
|
|
1213
|
+
* `.list()` / `.create()` / `.retrieve()`.
|
|
1214
|
+
*
|
|
1215
|
+
* Errors — **happy-path precedence ordering**: UUID format (kernel
|
|
1216
|
+
* `badId` — SDK-pre-empted) → rate-limit → auth → DB delete → audit
|
|
1217
|
+
* write → successResponse. **The 500-catchall is a SEPARATE
|
|
1218
|
+
* DIMENSION** — any throwable not matched by the DELETE handler's 2
|
|
1219
|
+
* `instanceof` arms (`AuthError`, `AbacPolicyNotFoundError`) falls
|
|
1220
|
+
* to 500.
|
|
1221
|
+
* - `AttestryAPIError` (status 429) — rate limit FIRES FIRST
|
|
1222
|
+
* among the network surfaces (auto-retried by default —
|
|
1223
|
+
* invariant #18; per-IP key `abac-policies-delete:${ip}`
|
|
1224
|
+
* against `assessmentLimiter` — 30 req / 1-min sliding window).
|
|
1225
|
+
* - `AttestryAPIError` (status 401) — no/invalid/expired api-key.
|
|
1226
|
+
* - `AttestryAPIError` (status 403) — valid api-key without
|
|
1227
|
+
* `ADMIN`. Distinct branch from 401 — pin BOTH separately.
|
|
1228
|
+
* - `AttestryAPIError` (status 404) — policy not found OR a
|
|
1229
|
+
* cross-org id (`AbacPolicyNotFoundError`; id-embedded message).
|
|
1230
|
+
* - `AttestryAPIError` (status 400) — kernel `badId` rejected the
|
|
1231
|
+
* id. **SDK-pre-empted** — reachable from a consumer only via
|
|
1232
|
+
* an `as any` cast or a kernel-side id-flavor change.
|
|
1233
|
+
* - `AttestryAPIError` (status 500) — internal kernel error
|
|
1234
|
+
* (scrubbed message via `internalErrorResponse`). **Orthogonal
|
|
1235
|
+
* to the precedence list** — ANY throwable not matched by the
|
|
1236
|
+
* route's 2 `instanceof` arms falls here.
|
|
1237
|
+
* - `AttestryError` ("request aborted by caller") — caller-
|
|
1238
|
+
* supplied `options.signal` fired.
|
|
1239
|
+
* - `AttestryError` (P2 hardening) — the deleted row failed
|
|
1240
|
+
* SDK-side shape validation (non-object, or any of the 13
|
|
1241
|
+
* `AbacPolicy` fields missing / wrong-typed).
|
|
1242
|
+
* - `AttestryAPIError` (P3 hardening) — kernel response had a
|
|
1243
|
+
* wrong Content-Type (transport-level guard before body
|
|
1244
|
+
* parsing).
|
|
1245
|
+
* - `TypeError` (synchronous, NO fetch issued) — `id` is missing,
|
|
1246
|
+
* a non-string, an empty string, or not an RFC 4122 UUID.
|
|
1247
|
+
*
|
|
1248
|
+
* **SDK-side validation** (synchronous `TypeError`, no fetch
|
|
1249
|
+
* issued):
|
|
1250
|
+
* - `id`: required; must be a non-empty string matching
|
|
1251
|
+
* `UUID_REGEX` (RFC 4122 hyphenated, case-insensitive).
|
|
1252
|
+
*
|
|
1253
|
+
* **Response-shape validation** (P2 hardening) — the shared
|
|
1254
|
+
* `validateAbacPolicy` validator checks all 13 `AbacPolicy` fields
|
|
1255
|
+
* of the deleted row via the module-load `objectHasOwn` snapshot.
|
|
1256
|
+
* `condition` is validated as a non-null object only — the
|
|
1257
|
+
* recursive AST is faithful-courier.
|
|
1258
|
+
*
|
|
1259
|
+
* **Transport-shape validation** (P3 hardening) — rejects with
|
|
1260
|
+
* `AttestryAPIError` if the kernel responds with a
|
|
1261
|
+
* non-`application/json` Content-Type.
|
|
1262
|
+
*
|
|
1263
|
+
* @example Delete a policy and log the prior state
|
|
1264
|
+
* ```ts
|
|
1265
|
+
* const deleted = await client.abacPolicies.delete(
|
|
1266
|
+
* "550e8400-e29b-41d4-a716-446655440000",
|
|
1267
|
+
* );
|
|
1268
|
+
* console.log(`Deleted "${deleted.name}" (${deleted.effect} ${deleted.action})`);
|
|
1269
|
+
* ```
|
|
1270
|
+
*
|
|
1271
|
+
* @example Treat a not-found delete as idempotent success
|
|
1272
|
+
* ```ts
|
|
1273
|
+
* try {
|
|
1274
|
+
* await client.abacPolicies.delete(id);
|
|
1275
|
+
* } catch (err) {
|
|
1276
|
+
* if (err instanceof AttestryAPIError && err.status === 404) {
|
|
1277
|
+
* // already gone — fine for an idempotent caller
|
|
1278
|
+
* } else {
|
|
1279
|
+
* throw err;
|
|
1280
|
+
* }
|
|
1281
|
+
* }
|
|
1282
|
+
* ```
|
|
1283
|
+
*/
|
|
1284
|
+
delete(id, options) {
|
|
1285
|
+
assertValidPolicyId(id, "abacPolicies.delete");
|
|
1286
|
+
return this.client
|
|
1287
|
+
._request({
|
|
1288
|
+
method: "DELETE",
|
|
1289
|
+
path: `/api/v1/abac-policies/${id}`,
|
|
1290
|
+
options,
|
|
1291
|
+
})
|
|
1292
|
+
.then((result) => validateAbacPolicy(result, "abacPolicies.delete"));
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
// ─── UUID path-segment pre-validation ───────────────────────────────────────
|
|
1296
|
+
/**
|
|
1297
|
+
* Pre-validate an ABAC policy `id` path segment. Shared by
|
|
1298
|
+
* `.retrieve()` / `.update()` / `.delete()` — the three methods that
|
|
1299
|
+
* interpolate `id` into the request path `/api/v1/abac-policies/<id>`.
|
|
1300
|
+
*
|
|
1301
|
+
* The kernel validates `id` with a strict RFC 4122 UUID regex
|
|
1302
|
+
* (`badId` at `src/app/api/v1/abac-policies/[id]/route.ts:35-37`) and
|
|
1303
|
+
* returns HTTP 400 "Invalid policy id." on a mismatch — BEFORE
|
|
1304
|
+
* rate-limit + auth. The SDK pre-validates synchronously (mirror of
|
|
1305
|
+
* `batch.get`) so that 400 is reachable from a consumer only via an
|
|
1306
|
+
* `as any` cast or a kernel-side id-flavor change (ULID, KSUID, ...).
|
|
1307
|
+
*
|
|
1308
|
+
* **No `encodeURIComponent` / URIError defense** — see `UUID_REGEX`
|
|
1309
|
+
* above: a validated UUID is ASCII hex + hyphens, so the caller
|
|
1310
|
+
* interpolates it into the path raw. Carry-forward invariant #32
|
|
1311
|
+
* (URIError → TypeError conversion) applies only where a path segment
|
|
1312
|
+
* can actually reach `encodeURIComponent` with potentially-malformed
|
|
1313
|
+
* input (`decisions.retrieve`'s free-form id); a pre-validated UUID
|
|
1314
|
+
* cannot, so there is no `URIError` surface to convert.
|
|
1315
|
+
*
|
|
1316
|
+
* Throws `TypeError` synchronously (NO fetch issued) when `id` is a
|
|
1317
|
+
* non-string, an empty string, or not an RFC 4122 hyphenated UUID.
|
|
1318
|
+
*/
|
|
1319
|
+
function assertValidPolicyId(id, methodName) {
|
|
1320
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
1321
|
+
throw new TypeError(`${methodName}: \`id\` must be a non-empty string`);
|
|
1322
|
+
}
|
|
1323
|
+
if (!UUID_REGEX.test(id)) {
|
|
1324
|
+
throw new TypeError(`${methodName}: \`id\` must be an RFC 4122 hyphenated UUID ` +
|
|
1325
|
+
`(matched regex: /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-` +
|
|
1326
|
+
`[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/, got ${JSON.stringify(id)})`);
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
// ─── Response-shape validation (P2 hardening) ───────────────────────────────
|
|
1330
|
+
/**
|
|
1331
|
+
* P2 hardening: validate the `.list()` response shape. Mirror of
|
|
1332
|
+
* `assertIncidentsListResponse` in `incidents.ts` and the
|
|
1333
|
+
* `regulatoryChanges.list` validator pattern.
|
|
1334
|
+
*
|
|
1335
|
+
* The kernel emits
|
|
1336
|
+
* `{success: true, data: {items: AbacPolicy[], count: number}}`. After
|
|
1337
|
+
* the transport's envelope-unwrap, this validator sees `{items,
|
|
1338
|
+
* count}`. Asserts:
|
|
1339
|
+
* - non-null, non-array object
|
|
1340
|
+
* - `items` is an array
|
|
1341
|
+
* - `count` is a number
|
|
1342
|
+
*
|
|
1343
|
+
* **Per-row item shape NOT validated** (faithful courier — P4
|
|
1344
|
+
* candidate; consistent with `incidents.list` /
|
|
1345
|
+
* `regulatoryChanges.list`).
|
|
1346
|
+
*
|
|
1347
|
+
* **`objectHasOwn` is the module-load snapshot** (set at module-load
|
|
1348
|
+
* via `const objectHasOwn = Object.hasOwn`). A hostile dep that
|
|
1349
|
+
* monkey-patches `Object.hasOwn` AFTER SDK import time does NOT
|
|
1350
|
+
* affect this validator. Symmetric prototype-pollution defense on
|
|
1351
|
+
* the response side; input side is N/A for `.list()`.
|
|
1352
|
+
*/
|
|
1353
|
+
function validateAbacPoliciesListResponse(result) {
|
|
1354
|
+
if (result === null ||
|
|
1355
|
+
typeof result !== "object" ||
|
|
1356
|
+
Array.isArray(result)) {
|
|
1357
|
+
throw new AttestryError(`abacPolicies.list: expected an object response from the kernel ` +
|
|
1358
|
+
`(got ${describeType(result)})`);
|
|
1359
|
+
}
|
|
1360
|
+
const obj = result;
|
|
1361
|
+
// items — ALWAYS-PRESENT array (empty when org has no policies).
|
|
1362
|
+
// UNCONDITIONAL own-property check (the validator's items branch
|
|
1363
|
+
// is unconditional).
|
|
1364
|
+
const items = objectHasOwn(obj, "items") ? obj.items : undefined;
|
|
1365
|
+
if (!Array.isArray(items)) {
|
|
1366
|
+
throw new AttestryError(`abacPolicies.list: expected response.items to be an array ` +
|
|
1367
|
+
`(got ${describeType(items)})`);
|
|
1368
|
+
}
|
|
1369
|
+
// count — ALWAYS-PRESENT number.
|
|
1370
|
+
const count = objectHasOwn(obj, "count") ? obj.count : undefined;
|
|
1371
|
+
if (typeof count !== "number") {
|
|
1372
|
+
throw new AttestryError(`abacPolicies.list: expected response.count to be a number ` +
|
|
1373
|
+
`(got ${describeType(count)})`);
|
|
1374
|
+
}
|
|
1375
|
+
return result;
|
|
1376
|
+
}
|
|
1377
|
+
/**
|
|
1378
|
+
* P2 hardening: validate a single `AbacPolicy` row response. **Shared
|
|
1379
|
+
* by `.create()` / `.retrieve()` / `.update()` / `.delete()`** — all
|
|
1380
|
+
* four kernel handlers emit `{success: true, data: <row>}` (HTTP 201
|
|
1381
|
+
* for `.create()`, 200 for the other three) and the transport
|
|
1382
|
+
* unwraps `data`, so this validator sees the row directly. The
|
|
1383
|
+
* `methodName` argument (`"abacPolicies.create"` / `".retrieve"` /
|
|
1384
|
+
* `".update"` / `".delete"`) prefixes every thrown `AttestryError`
|
|
1385
|
+
* message so a malformed response names the method the consumer
|
|
1386
|
+
* actually called — mirror of the shared `encodePathSegment` /
|
|
1387
|
+
* `assertNonNullObjectResponse` `methodName`-parameter convention.
|
|
1388
|
+
*
|
|
1389
|
+
* Symmetric prototype-pollution defense — read EACH field via the
|
|
1390
|
+
* module-load `objectHasOwn` snapshot so a hostile npm dep polluting
|
|
1391
|
+
* `Object.prototype.<field>` cannot mask a kernel regression that
|
|
1392
|
+
* drops a field. Mirror of `ship-gate.ts` / `gate.ts` patterns.
|
|
1393
|
+
*
|
|
1394
|
+
* All 13 fields are ALWAYS-PRESENT on the wire (`description` and
|
|
1395
|
+
* `createdByUserId` are `string | null`, NOT optional; kernel uses
|
|
1396
|
+
* `?? null` coalesce at `rowToPolicy`). Validator's branches for
|
|
1397
|
+
* each field are UNCONDITIONAL.
|
|
1398
|
+
*
|
|
1399
|
+
* **`condition` is validated as `non-null object`** only — the
|
|
1400
|
+
* recursive AST grammar is the kernel's source of truth. SDK
|
|
1401
|
+
* passes the AST through verbatim once it's confirmed to be an
|
|
1402
|
+
* object (faithful courier on the recursive structure).
|
|
1403
|
+
*
|
|
1404
|
+
* **Single-field rejection semantics** (mirror of ship-gate.ts /
|
|
1405
|
+
* gate.ts / batch.ts / audit-log.ts) — validator checks fields in
|
|
1406
|
+
* declaration order and throws on the FIRST failing field. Project
|
|
1407
|
+
* convention.
|
|
1408
|
+
*/
|
|
1409
|
+
function validateAbacPolicy(result, methodName) {
|
|
1410
|
+
if (result === null ||
|
|
1411
|
+
typeof result !== "object" ||
|
|
1412
|
+
Array.isArray(result)) {
|
|
1413
|
+
throw new AttestryError(`${methodName}: expected an object response from the kernel ` +
|
|
1414
|
+
`(got ${describeType(result)})`);
|
|
1415
|
+
}
|
|
1416
|
+
const obj = result;
|
|
1417
|
+
// id — string (UUID).
|
|
1418
|
+
const id = objectHasOwn(obj, "id") ? obj.id : undefined;
|
|
1419
|
+
if (typeof id !== "string") {
|
|
1420
|
+
throw new AttestryError(`${methodName}: expected response.id to be a string ` +
|
|
1421
|
+
`(got ${describeType(id)})`);
|
|
1422
|
+
}
|
|
1423
|
+
// orgId — string (UUID).
|
|
1424
|
+
const orgId = objectHasOwn(obj, "orgId") ? obj.orgId : undefined;
|
|
1425
|
+
if (typeof orgId !== "string") {
|
|
1426
|
+
throw new AttestryError(`${methodName}: expected response.orgId to be a string ` +
|
|
1427
|
+
`(got ${describeType(orgId)})`);
|
|
1428
|
+
}
|
|
1429
|
+
// name — string.
|
|
1430
|
+
const name = objectHasOwn(obj, "name") ? obj.name : undefined;
|
|
1431
|
+
if (typeof name !== "string") {
|
|
1432
|
+
throw new AttestryError(`${methodName}: expected response.name to be a string ` +
|
|
1433
|
+
`(got ${describeType(name)})`);
|
|
1434
|
+
}
|
|
1435
|
+
// description — string OR null (ALWAYS-PRESENT — kernel uses ?? null).
|
|
1436
|
+
const description = objectHasOwn(obj, "description")
|
|
1437
|
+
? obj.description
|
|
1438
|
+
: undefined;
|
|
1439
|
+
if (description !== null && typeof description !== "string") {
|
|
1440
|
+
throw new AttestryError(`${methodName}: expected response.description to be a string ` +
|
|
1441
|
+
`or null (got ${describeType(description)})`);
|
|
1442
|
+
}
|
|
1443
|
+
// resource — string (closed-enum, but P2 is faithful courier).
|
|
1444
|
+
const resource = objectHasOwn(obj, "resource") ? obj.resource : undefined;
|
|
1445
|
+
if (typeof resource !== "string") {
|
|
1446
|
+
throw new AttestryError(`${methodName}: expected response.resource to be a string ` +
|
|
1447
|
+
`(got ${describeType(resource)})`);
|
|
1448
|
+
}
|
|
1449
|
+
// action — string (closed-enum, faithful courier).
|
|
1450
|
+
const action = objectHasOwn(obj, "action") ? obj.action : undefined;
|
|
1451
|
+
if (typeof action !== "string") {
|
|
1452
|
+
throw new AttestryError(`${methodName}: expected response.action to be a string ` +
|
|
1453
|
+
`(got ${describeType(action)})`);
|
|
1454
|
+
}
|
|
1455
|
+
// effect — string (closed-enum, faithful courier).
|
|
1456
|
+
const effect = objectHasOwn(obj, "effect") ? obj.effect : undefined;
|
|
1457
|
+
if (typeof effect !== "string") {
|
|
1458
|
+
throw new AttestryError(`${methodName}: expected response.effect to be a string ` +
|
|
1459
|
+
`(got ${describeType(effect)})`);
|
|
1460
|
+
}
|
|
1461
|
+
// condition — non-null object (AST recursive shape NOT validated;
|
|
1462
|
+
// faithful courier on the recursive grammar).
|
|
1463
|
+
const condition = objectHasOwn(obj, "condition") ? obj.condition : undefined;
|
|
1464
|
+
if (condition === null ||
|
|
1465
|
+
typeof condition !== "object" ||
|
|
1466
|
+
Array.isArray(condition)) {
|
|
1467
|
+
throw new AttestryError(`${methodName}: expected response.condition to be a non-null ` +
|
|
1468
|
+
`object (got ${describeType(condition)})`);
|
|
1469
|
+
}
|
|
1470
|
+
// priority — number.
|
|
1471
|
+
const priority = objectHasOwn(obj, "priority") ? obj.priority : undefined;
|
|
1472
|
+
if (typeof priority !== "number") {
|
|
1473
|
+
throw new AttestryError(`${methodName}: expected response.priority to be a number ` +
|
|
1474
|
+
`(got ${describeType(priority)})`);
|
|
1475
|
+
}
|
|
1476
|
+
// enabled — boolean.
|
|
1477
|
+
const enabled = objectHasOwn(obj, "enabled") ? obj.enabled : undefined;
|
|
1478
|
+
if (typeof enabled !== "boolean") {
|
|
1479
|
+
throw new AttestryError(`${methodName}: expected response.enabled to be a boolean ` +
|
|
1480
|
+
`(got ${describeType(enabled)})`);
|
|
1481
|
+
}
|
|
1482
|
+
// createdByUserId — string OR null.
|
|
1483
|
+
const createdByUserId = objectHasOwn(obj, "createdByUserId")
|
|
1484
|
+
? obj.createdByUserId
|
|
1485
|
+
: undefined;
|
|
1486
|
+
if (createdByUserId !== null && typeof createdByUserId !== "string") {
|
|
1487
|
+
throw new AttestryError(`${methodName}: expected response.createdByUserId to be a ` +
|
|
1488
|
+
`string or null (got ${describeType(createdByUserId)})`);
|
|
1489
|
+
}
|
|
1490
|
+
// createdAt — string (ISO-8601; NOT Date — wire shape).
|
|
1491
|
+
const createdAt = objectHasOwn(obj, "createdAt") ? obj.createdAt : undefined;
|
|
1492
|
+
if (typeof createdAt !== "string") {
|
|
1493
|
+
throw new AttestryError(`${methodName}: expected response.createdAt to be a string ` +
|
|
1494
|
+
`(got ${describeType(createdAt)})`);
|
|
1495
|
+
}
|
|
1496
|
+
// updatedAt — string (ISO-8601).
|
|
1497
|
+
const updatedAt = objectHasOwn(obj, "updatedAt") ? obj.updatedAt : undefined;
|
|
1498
|
+
if (typeof updatedAt !== "string") {
|
|
1499
|
+
throw new AttestryError(`${methodName}: expected response.updatedAt to be a string ` +
|
|
1500
|
+
`(got ${describeType(updatedAt)})`);
|
|
1501
|
+
}
|
|
1502
|
+
return result;
|
|
1503
|
+
}
|
|
1504
|
+
/**
|
|
1505
|
+
* Human-readable type description for error messages. Distinguishes
|
|
1506
|
+
* `null` and `array` from generic `object`. Duplicated in
|
|
1507
|
+
* `decisions.ts` / `incidents.ts` / `regulatory-changes.ts` /
|
|
1508
|
+
* `compliance-check.ts` / `check.ts` / `gate.ts` / `batch.ts` /
|
|
1509
|
+
* `audit-log.ts` / `ship-gate.ts` per project pattern (small helper,
|
|
1510
|
+
* leaf-resource modules, no shared module yet).
|
|
1511
|
+
*/
|
|
1512
|
+
function describeType(value) {
|
|
1513
|
+
if (value === null)
|
|
1514
|
+
return "null";
|
|
1515
|
+
if (Array.isArray(value))
|
|
1516
|
+
return "array";
|
|
1517
|
+
return typeof value;
|
|
1518
|
+
}
|
|
1519
|
+
//# sourceMappingURL=abac-policies.js.map
|