@canopy-io/node 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs ADDED
@@ -0,0 +1,625 @@
1
+ 'use strict';
2
+
3
+ // src/errors.ts
4
+ var CanopyError = class extends Error {
5
+ name = "CanopyError";
6
+ statusCode;
7
+ code;
8
+ details;
9
+ data;
10
+ /** The path and method that failed, for logging. */
11
+ request;
12
+ constructor(body, request) {
13
+ super(body.message);
14
+ this.statusCode = body.statusCode;
15
+ this.code = body.code;
16
+ this.details = body.details;
17
+ this.data = body.data;
18
+ this.request = request;
19
+ }
20
+ /** A 429. `retryAfterMs` is populated when the server said how long to wait. */
21
+ get isRateLimited() {
22
+ return this.statusCode === 429;
23
+ }
24
+ /** 401 or 403 — the credential is wrong or not permitted here. */
25
+ get isAuthFailure() {
26
+ return this.statusCode === 401 || this.statusCode === 403;
27
+ }
28
+ };
29
+ var CanopyConnectionError = class extends Error {
30
+ name = "CanopyConnectionError";
31
+ request;
32
+ constructor(message, request, options) {
33
+ super(message, options);
34
+ this.request = request;
35
+ }
36
+ };
37
+ function isCanopyError(error) {
38
+ return error instanceof Error && error.name === "CanopyError";
39
+ }
40
+ function isCanopyConnectionError(error) {
41
+ return error instanceof Error && error.name === "CanopyConnectionError";
42
+ }
43
+
44
+ // src/client.ts
45
+ function isCursorPagination(pagination) {
46
+ return "next_cursor" in pagination;
47
+ }
48
+ var DEFAULT_BASE_URL = "https://auth.canopy-io.com";
49
+ var DEFAULT_TIMEOUT_MS = 3e4;
50
+ var DEFAULT_MAX_RETRIES = 2;
51
+ var IDEMPOTENT_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "PUT", "DELETE"]);
52
+ var CanopyClient = class {
53
+ baseUrl;
54
+ timeoutMs;
55
+ maxRetries;
56
+ authHeaders;
57
+ extraHeaders;
58
+ fetchImpl;
59
+ constructor(options = {}) {
60
+ if (!options.apiKey && !options.accessToken) {
61
+ throw new TypeError(
62
+ "CanopyClient requires either `apiKey` (server-to-server) or `accessToken` (a user or identity JWT)."
63
+ );
64
+ }
65
+ this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
66
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
67
+ this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
68
+ this.extraHeaders = options.headers ?? {};
69
+ this.fetchImpl = options.fetch ?? globalThis.fetch;
70
+ if (typeof this.fetchImpl !== "function") {
71
+ throw new TypeError(
72
+ "No `fetch` available. Node 18+ provides one; on older runtimes pass `fetch` explicitly."
73
+ );
74
+ }
75
+ this.authHeaders = options.apiKey ? { "X-API-Key": options.apiKey } : { Authorization: `Bearer ${options.accessToken ?? ""}` };
76
+ }
77
+ /**
78
+ * Issue a request and return the payload with the envelope removed.
79
+ *
80
+ * `{ data }` → the resource
81
+ * `{ items, pagination }` → the whole object, so pagination survives
82
+ * `{ summary, results }` → the whole object
83
+ * 204 → undefined
84
+ *
85
+ * Non-2xx throws `CanopyError`; a request that never completed throws
86
+ * `CanopyConnectionError`.
87
+ */
88
+ async request(method, path, options = {}) {
89
+ const url = this.buildUrl(path, options.query);
90
+ const upper = method.toUpperCase();
91
+ const retryable = options.idempotent ?? IDEMPOTENT_METHODS.has(upper);
92
+ let lastError;
93
+ for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
94
+ if (attempt > 0) {
95
+ await delay(backoffMs(attempt, lastError));
96
+ }
97
+ try {
98
+ const response = await this.send(upper, url, options);
99
+ if (this.shouldRetry(response.status, retryable, attempt)) {
100
+ lastError = await this.toError(response, upper, path);
101
+ continue;
102
+ }
103
+ if (!response.ok) {
104
+ throw await this.toError(response, upper, path);
105
+ }
106
+ return await this.unwrap(response);
107
+ } catch (error) {
108
+ if (error instanceof CanopyError) {
109
+ throw error;
110
+ }
111
+ lastError = error;
112
+ if (!retryable || attempt === this.maxRetries) {
113
+ throw new CanopyConnectionError(
114
+ `${upper} ${path} failed: ${describe(error)}`,
115
+ { method: upper, path },
116
+ { cause: error }
117
+ );
118
+ }
119
+ }
120
+ }
121
+ throw new CanopyConnectionError(
122
+ `${upper} ${path} exhausted ${this.maxRetries + 1} attempts`,
123
+ { method: upper, path },
124
+ { cause: lastError }
125
+ );
126
+ }
127
+ shouldRetry(status, retryable, attempt) {
128
+ if (attempt >= this.maxRetries) {
129
+ return false;
130
+ }
131
+ if (status === 429) {
132
+ return true;
133
+ }
134
+ return status >= 500 && retryable;
135
+ }
136
+ async send(method, url, options) {
137
+ const controller = new AbortController();
138
+ const timer = this.timeoutMs > 0 ? setTimeout(() => controller.abort(), this.timeoutMs) : void 0;
139
+ const onAbort = () => controller.abort();
140
+ options.signal?.addEventListener("abort", onAbort, { once: true });
141
+ const headers = {
142
+ Accept: "application/json",
143
+ ...this.extraHeaders,
144
+ ...this.authHeaders
145
+ };
146
+ if (options.body !== void 0) {
147
+ headers["Content-Type"] = "application/json";
148
+ }
149
+ const init = { method, headers, signal: controller.signal };
150
+ if (options.body !== void 0) {
151
+ init.body = JSON.stringify(options.body);
152
+ }
153
+ try {
154
+ return await this.fetchImpl(url, init);
155
+ } finally {
156
+ if (timer !== void 0) {
157
+ clearTimeout(timer);
158
+ }
159
+ options.signal?.removeEventListener("abort", onAbort);
160
+ }
161
+ }
162
+ buildUrl(path, query) {
163
+ const url = new URL(
164
+ path.startsWith("/") ? path : `/${path}`,
165
+ `${this.baseUrl}/`
166
+ );
167
+ for (const [key, value] of Object.entries(query ?? {})) {
168
+ if (value === void 0 || value === null) {
169
+ continue;
170
+ }
171
+ url.searchParams.set(key, String(value));
172
+ }
173
+ return url.toString();
174
+ }
175
+ async unwrap(response) {
176
+ if (response.status === 204) {
177
+ return void 0;
178
+ }
179
+ const text = await response.text();
180
+ if (text === "") {
181
+ return void 0;
182
+ }
183
+ const parsed = JSON.parse(text);
184
+ if (parsed && typeof parsed === "object" && "data" in parsed) {
185
+ return parsed["data"];
186
+ }
187
+ return parsed;
188
+ }
189
+ async toError(response, method, path) {
190
+ const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
191
+ let body = {
192
+ statusCode: response.status,
193
+ code: null,
194
+ message: `${response.status} ${response.statusText}`.trim()
195
+ };
196
+ try {
197
+ const parsed = JSON.parse(await response.text());
198
+ if (parsed.error) {
199
+ body = {
200
+ ...parsed.error,
201
+ statusCode: parsed.error.statusCode ?? response.status
202
+ };
203
+ }
204
+ } catch {
205
+ }
206
+ const error = new CanopyError(body, { method, path });
207
+ if (retryAfter !== null) {
208
+ Object.defineProperty(error, "retryAfterMs", {
209
+ value: retryAfter,
210
+ enumerable: true
211
+ });
212
+ }
213
+ return error;
214
+ }
215
+ };
216
+ function parseRetryAfter(header) {
217
+ if (!header) {
218
+ return null;
219
+ }
220
+ const seconds = Number(header);
221
+ if (Number.isFinite(seconds)) {
222
+ return Math.max(0, seconds * 1e3);
223
+ }
224
+ const date = Date.parse(header);
225
+ if (Number.isNaN(date)) {
226
+ return null;
227
+ }
228
+ return Math.max(0, date - Date.now());
229
+ }
230
+ function backoffMs(attempt, lastError) {
231
+ const advised = lastError && typeof lastError === "object" && "retryAfterMs" in lastError ? Number(lastError.retryAfterMs) : NaN;
232
+ if (Number.isFinite(advised)) {
233
+ return advised;
234
+ }
235
+ const base = 250 * 2 ** (attempt - 1);
236
+ return base + Math.random() * base;
237
+ }
238
+ function delay(ms) {
239
+ return new Promise((resolve) => setTimeout(resolve, ms));
240
+ }
241
+ function describe(error) {
242
+ if (error instanceof Error) {
243
+ return error.name === "AbortError" ? "timed out or aborted" : error.message;
244
+ }
245
+ return String(error);
246
+ }
247
+
248
+ // src/pagination.ts
249
+ var DEFAULT_MAX_PAGES = 1e3;
250
+ var Paginator = class {
251
+ fetchPage;
252
+ initial;
253
+ maxPages;
254
+ constructor(fetchPage, initial = {}, options = {}) {
255
+ this.fetchPage = fetchPage;
256
+ this.initial = initial;
257
+ this.maxPages = options.maxPages ?? DEFAULT_MAX_PAGES;
258
+ }
259
+ /** Every item across every page. */
260
+ async *[Symbol.asyncIterator]() {
261
+ for await (const page of this.pages()) {
262
+ for (const item of page.items) {
263
+ yield item;
264
+ }
265
+ }
266
+ }
267
+ /**
268
+ * Page by page, for callers that need the pagination metadata or want to
269
+ * process in batches rather than item by item.
270
+ */
271
+ async *pages() {
272
+ let params = { ...this.initial };
273
+ let seenCursor = null;
274
+ let pageCount = 0;
275
+ for (; ; ) {
276
+ const page = await this.fetchPage(params);
277
+ pageCount++;
278
+ yield page;
279
+ const pagination = page.pagination;
280
+ if (!pagination) {
281
+ return;
282
+ }
283
+ if (page.items.length === 0) {
284
+ return;
285
+ }
286
+ if (pageCount >= this.maxPages) {
287
+ return;
288
+ }
289
+ if (isCursorPagination(pagination)) {
290
+ const next = pagination.next_cursor;
291
+ if (next === null || next === seenCursor) {
292
+ return;
293
+ }
294
+ seenCursor = next;
295
+ params = { ...params, cursor: next };
296
+ continue;
297
+ }
298
+ if (!pagination.has_next_page) {
299
+ return;
300
+ }
301
+ params = { ...params, page: pagination.page + 1 };
302
+ }
303
+ }
304
+ /**
305
+ * Collect everything into an array.
306
+ *
307
+ * Bounded on purpose: an unbounded drain of an audit log will exhaust memory
308
+ * on a busy account, so the cap is a parameter rather than a footnote. Use
309
+ * the iterator for large feeds.
310
+ */
311
+ async all(max = 1e4) {
312
+ const collected = [];
313
+ for await (const item of this) {
314
+ collected.push(item);
315
+ if (collected.length >= max) {
316
+ break;
317
+ }
318
+ }
319
+ return collected;
320
+ }
321
+ /** The first item, or undefined. Stops after one page. */
322
+ async first() {
323
+ for await (const item of this) {
324
+ return item;
325
+ }
326
+ return void 0;
327
+ }
328
+ };
329
+ function paginate(fetchPage, initial = {}, options = {}) {
330
+ return new Paginator(fetchPage, initial, options);
331
+ }
332
+
333
+ // src/resources/assignments.ts
334
+ var Assignments = class {
335
+ constructor(client) {
336
+ this.client = client;
337
+ }
338
+ client;
339
+ /** Every assignment in the Application, across all nodes. */
340
+ list(query = {}) {
341
+ return paginate(
342
+ (params) => this.client.request("GET", "/api/v1/assignments/app-wide", {
343
+ query: params
344
+ }),
345
+ { ...query }
346
+ );
347
+ }
348
+ /**
349
+ * Grant a role at a node.
350
+ *
351
+ * Not retried automatically on a 5xx: a repeat could create a second grant,
352
+ * and the server cannot tell the two apart. If the call fails without an
353
+ * answer, read the current assignments before retrying.
354
+ */
355
+ create(input) {
356
+ return this.client.request("POST", "/api/v1/assignments", { body: input });
357
+ }
358
+ update(id, input) {
359
+ return this.client.request(
360
+ "PATCH",
361
+ `/api/v1/assignments/${encodeURIComponent(id)}`,
362
+ { body: input }
363
+ );
364
+ }
365
+ delete(id) {
366
+ return this.client.request(
367
+ "DELETE",
368
+ `/api/v1/assignments/${encodeURIComponent(id)}`
369
+ );
370
+ }
371
+ /**
372
+ * Grant many at once. Answers 207 with a per-item result rather than failing
373
+ * the batch, so inspect `results` — a 2xx here does not mean every grant
374
+ * succeeded.
375
+ */
376
+ bulkCreate(input) {
377
+ return this.client.request("POST", "/api/v1/assignments/bulk-create", {
378
+ body: input
379
+ });
380
+ }
381
+ /** Same partial-success contract as `bulkCreate`. */
382
+ bulkRemove(input) {
383
+ return this.client.request("POST", "/api/v1/assignments/bulk-remove", {
384
+ body: input
385
+ });
386
+ }
387
+ /** Same partial-success contract as `bulkCreate`. */
388
+ bulkChangeRole(input) {
389
+ return this.client.request("POST", "/api/v1/assignments/bulk-change-role", {
390
+ body: input
391
+ });
392
+ }
393
+ };
394
+
395
+ // src/resources/identities.ts
396
+ var Identities = class {
397
+ constructor(client) {
398
+ this.client = client;
399
+ }
400
+ client;
401
+ list(query = {}) {
402
+ return paginate(
403
+ (params) => this.client.request("GET", "/api/v1/identities", { query: params }),
404
+ { ...query }
405
+ );
406
+ }
407
+ get(id) {
408
+ return this.client.request(
409
+ "GET",
410
+ `/api/v1/identities/${encodeURIComponent(id)}`
411
+ );
412
+ }
413
+ create(input) {
414
+ return this.client.request("POST", "/api/v1/identities", { body: input });
415
+ }
416
+ update(id, input) {
417
+ return this.client.request(
418
+ "PATCH",
419
+ `/api/v1/identities/${encodeURIComponent(id)}`,
420
+ { body: input }
421
+ );
422
+ }
423
+ delete(id) {
424
+ return this.client.request(
425
+ "DELETE",
426
+ `/api/v1/identities/${encodeURIComponent(id)}`
427
+ );
428
+ }
429
+ /**
430
+ * Deactivation is the reversible counterpart to `delete` — sessions are
431
+ * revoked and sign-in refused, but the identity and its assignments survive.
432
+ * Prefer it for offboarding you may need to undo.
433
+ */
434
+ deactivate(id) {
435
+ return this.client.request(
436
+ "POST",
437
+ `/api/v1/identities/${encodeURIComponent(id)}/deactivate`
438
+ );
439
+ }
440
+ activate(id) {
441
+ return this.client.request(
442
+ "POST",
443
+ `/api/v1/identities/${encodeURIComponent(id)}/activate`
444
+ );
445
+ }
446
+ /** Every role this identity holds, and where. */
447
+ assignments(id) {
448
+ return this.client.request(
449
+ "GET",
450
+ `/api/v1/identities/${encodeURIComponent(id)}/assignments`
451
+ );
452
+ }
453
+ /**
454
+ * The permissions this identity effectively holds, inheritance resolved.
455
+ *
456
+ * For enforcing a single check, use `permissions.evaluate` — it answers one
457
+ * question at the node that matters. This is for showing a person what
458
+ * someone can do.
459
+ */
460
+ permissions(id) {
461
+ return this.client.request(
462
+ "GET",
463
+ `/api/v1/identities/${encodeURIComponent(id)}/permissions`
464
+ );
465
+ }
466
+ };
467
+
468
+ // src/resources/permissions.ts
469
+ var Permissions = class {
470
+ constructor(client) {
471
+ this.client = client;
472
+ }
473
+ client;
474
+ /**
475
+ * Ask whether an identity holds a permission.
476
+ *
477
+ * With `scope: "node"` the engine walks the node's lineage, so a permission
478
+ * granted on an ancestor is inherited at `node_id`. With `scope: "app_wide"`
479
+ * it answers the coarse "anywhere in this Application" question and returns
480
+ * `effective_node_id: null` — that answer must never be used to guard a
481
+ * resource that belongs to a specific node, which is why the scope is a
482
+ * required field rather than a default.
483
+ */
484
+ evaluate(input) {
485
+ return this.client.request("POST", "/api/v1/permissions/evaluate", {
486
+ body: input
487
+ });
488
+ }
489
+ /**
490
+ * Evaluate many decisions in one round trip.
491
+ *
492
+ * Prefer this to a loop over `evaluate` when rendering a screen: the checks
493
+ * are answered together instead of paying request latency for each.
494
+ */
495
+ evaluateBulk(input) {
496
+ return this.client.request("POST", "/api/v1/permissions/evaluate/bulk", {
497
+ body: input
498
+ });
499
+ }
500
+ /**
501
+ * The same decision with its reasoning — which role granted it, which node
502
+ * it was inherited from. For debugging an unexpected allow or deny, not for
503
+ * the enforcement path.
504
+ */
505
+ explain(input) {
506
+ return this.client.request("POST", "/api/v1/permissions/evaluate/explain", {
507
+ body: input
508
+ });
509
+ }
510
+ /** Every permission in the Environment, page by page. */
511
+ list(query = {}) {
512
+ return paginate(
513
+ (params) => this.client.request("GET", "/api/v1/permissions", { query: params }),
514
+ { ...query }
515
+ );
516
+ }
517
+ get(id) {
518
+ return this.client.request(
519
+ "GET",
520
+ `/api/v1/permissions/${encodeURIComponent(id)}`
521
+ );
522
+ }
523
+ /** Define permissions. The request takes a batch, not a single key. */
524
+ create(input) {
525
+ return this.client.request("POST", "/api/v1/permissions", { body: input });
526
+ }
527
+ update(id, input) {
528
+ return this.client.request(
529
+ "PATCH",
530
+ `/api/v1/permissions/${encodeURIComponent(id)}`,
531
+ { body: input }
532
+ );
533
+ }
534
+ delete(id) {
535
+ return this.client.request(
536
+ "DELETE",
537
+ `/api/v1/permissions/${encodeURIComponent(id)}`
538
+ );
539
+ }
540
+ };
541
+
542
+ // src/resources/roles.ts
543
+ var Roles = class {
544
+ constructor(client) {
545
+ this.client = client;
546
+ }
547
+ client;
548
+ list(query = {}) {
549
+ return paginate(
550
+ (params) => this.client.request("GET", "/api/v1/roles", { query: params }),
551
+ { ...query }
552
+ );
553
+ }
554
+ get(id) {
555
+ return this.client.request(
556
+ "GET",
557
+ `/api/v1/roles/${encodeURIComponent(id)}`
558
+ );
559
+ }
560
+ create(input) {
561
+ return this.client.request("POST", "/api/v1/roles", { body: input });
562
+ }
563
+ update(id, input) {
564
+ return this.client.request(
565
+ "PATCH",
566
+ `/api/v1/roles/${encodeURIComponent(id)}`,
567
+ { body: input }
568
+ );
569
+ }
570
+ delete(id) {
571
+ return this.client.request(
572
+ "DELETE",
573
+ `/api/v1/roles/${encodeURIComponent(id)}`
574
+ );
575
+ }
576
+ permissions(id) {
577
+ return this.client.request(
578
+ "GET",
579
+ `/api/v1/roles/${encodeURIComponent(id)}/permissions`
580
+ );
581
+ }
582
+ /**
583
+ * Replaces the role's permissions wholesale — this is a PUT, so anything
584
+ * absent from `input` is removed. Read `permissions` first if you mean to add.
585
+ */
586
+ setPermissions(id, input) {
587
+ return this.client.request(
588
+ "PUT",
589
+ `/api/v1/roles/${encodeURIComponent(id)}/permissions`,
590
+ { body: input }
591
+ );
592
+ }
593
+ };
594
+
595
+ // src/canopy.ts
596
+ var Canopy = class {
597
+ client;
598
+ permissions;
599
+ identities;
600
+ roles;
601
+ assignments;
602
+ constructor(options) {
603
+ this.client = new CanopyClient(options);
604
+ this.permissions = new Permissions(this.client);
605
+ this.identities = new Identities(this.client);
606
+ this.roles = new Roles(this.client);
607
+ this.assignments = new Assignments(this.client);
608
+ }
609
+ };
610
+
611
+ exports.Assignments = Assignments;
612
+ exports.Canopy = Canopy;
613
+ exports.CanopyClient = CanopyClient;
614
+ exports.CanopyConnectionError = CanopyConnectionError;
615
+ exports.CanopyError = CanopyError;
616
+ exports.Identities = Identities;
617
+ exports.Paginator = Paginator;
618
+ exports.Permissions = Permissions;
619
+ exports.Roles = Roles;
620
+ exports.isCanopyConnectionError = isCanopyConnectionError;
621
+ exports.isCanopyError = isCanopyError;
622
+ exports.isCursorPagination = isCursorPagination;
623
+ exports.paginate = paginate;
624
+ //# sourceMappingURL=index.cjs.map
625
+ //# sourceMappingURL=index.cjs.map