@canopy-io/node 0.1.0 → 0.1.1

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 CHANGED
@@ -9,13 +9,20 @@ var CanopyError = class extends Error {
9
9
  data;
10
10
  /** The path and method that failed, for logging. */
11
11
  request;
12
- constructor(body, request) {
12
+ /**
13
+ * How long to wait before retrying, in milliseconds, when the server said so
14
+ * via `Retry-After` — populated on a 429, and on any other response that
15
+ * carries the header. Undefined when the server gave no guidance.
16
+ */
17
+ retryAfterMs;
18
+ constructor(body, request, retryAfterMs) {
13
19
  super(body.message);
14
20
  this.statusCode = body.statusCode;
15
21
  this.code = body.code;
16
22
  this.details = body.details;
17
23
  this.data = body.data;
18
24
  this.request = request;
25
+ this.retryAfterMs = retryAfterMs;
19
26
  }
20
27
  /** A 429. `retryAfterMs` is populated when the server said how long to wait. */
21
28
  get isRateLimited() {
@@ -34,12 +41,48 @@ var CanopyConnectionError = class extends Error {
34
41
  this.request = request;
35
42
  }
36
43
  };
44
+ var CanopyTokenError = class extends Error {
45
+ name = "CanopyTokenError";
46
+ /**
47
+ * One of:
48
+ *
49
+ * - `token.malformed` — not a JWS, or a segment would not decode
50
+ * - `token.unsupported_algorithm` — not RS256; Canopy issues only RS256
51
+ * - `token.key_not_found` — no published key matches the token's `kid`
52
+ * - `token.jwks_unavailable` — the key set could not be fetched or parsed
53
+ * - `token.signature_invalid` — signature does not match the signing key
54
+ * - `token.expired` / `token.not_yet_valid` — outside its validity window
55
+ * - `token.issuer_mismatch` — `iss` is not the configured issuer
56
+ * - `token.audience_mismatch` — `aud` does not include the configured audience
57
+ * - `token.audience_unverified` — token has an `aud` but none was configured
58
+ * - `token.preauth_not_allowed` — a pre-auth token, which grants no access
59
+ */
60
+ code;
61
+ constructor(code, message, options) {
62
+ super(message, options);
63
+ this.code = code;
64
+ }
65
+ };
66
+ var CanopyAuthorizerError = class extends Error {
67
+ name = "CanopyAuthorizerError";
68
+ code;
69
+ constructor(code, message, options) {
70
+ super(message, options);
71
+ this.code = code;
72
+ }
73
+ };
74
+ function isCanopyAuthorizerError(error) {
75
+ return error instanceof Error && error.name === "CanopyAuthorizerError";
76
+ }
37
77
  function isCanopyError(error) {
38
78
  return error instanceof Error && error.name === "CanopyError";
39
79
  }
40
80
  function isCanopyConnectionError(error) {
41
81
  return error instanceof Error && error.name === "CanopyConnectionError";
42
82
  }
83
+ function isCanopyTokenError(error) {
84
+ return error instanceof Error && error.name === "CanopyTokenError";
85
+ }
43
86
 
44
87
  // src/client.ts
45
88
  function isCursorPagination(pagination) {
@@ -48,11 +91,14 @@ function isCursorPagination(pagination) {
48
91
  var DEFAULT_BASE_URL = "https://auth.canopy-io.com";
49
92
  var DEFAULT_TIMEOUT_MS = 3e4;
50
93
  var DEFAULT_MAX_RETRIES = 2;
94
+ var DEFAULT_MAX_BACKOFF_MS = 3e4;
51
95
  var IDEMPOTENT_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "PUT", "DELETE"]);
96
+ var NOT_MODIFIED = 304;
52
97
  var CanopyClient = class {
53
98
  baseUrl;
54
99
  timeoutMs;
55
100
  maxRetries;
101
+ maxBackoffMs;
56
102
  authHeaders;
57
103
  extraHeaders;
58
104
  fetchImpl;
@@ -65,6 +111,7 @@ var CanopyClient = class {
65
111
  this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
66
112
  this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
67
113
  this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
114
+ this.maxBackoffMs = options.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
68
115
  this.extraHeaders = options.headers ?? {};
69
116
  this.fetchImpl = options.fetch ?? globalThis.fetch;
70
117
  if (typeof this.fetchImpl !== "function") {
@@ -86,30 +133,80 @@ var CanopyClient = class {
86
133
  * `CanopyConnectionError`.
87
134
  */
88
135
  async request(method, path, options = {}) {
136
+ const response = await this.perform(method, path, options);
137
+ return this.unwrap(response, method.toUpperCase(), path);
138
+ }
139
+ /**
140
+ * A conditional read: send the validator you already hold, and find out
141
+ * whether anything changed.
142
+ *
143
+ * The sibling of `If-Match`, which this client already sends for optimistic
144
+ * concurrency. `304 Not Modified` is a *success* — it means the copy you
145
+ * have is current — but it is not a 2xx, so `request` would raise it as an
146
+ * error. Hence a separate entry point with a return type that says which
147
+ * happened rather than one that has to be inspected.
148
+ *
149
+ * Use it to hold something expensive and revalidate cheaply — the hierarchy
150
+ * behind local authorization is the case this exists for.
151
+ */
152
+ async requestConditional(method, path, etag, options = {}) {
153
+ const conditional = etag ? { ...options, headers: { ...options.headers, "If-None-Match": etag } } : options;
154
+ const response = await this.perform(
155
+ method,
156
+ path,
157
+ conditional,
158
+ (status) => status === NOT_MODIFIED || status >= 200 && status < 300
159
+ );
160
+ if (response.status === NOT_MODIFIED) {
161
+ return { modified: false };
162
+ }
163
+ return {
164
+ modified: true,
165
+ data: await this.unwrap(response, method.toUpperCase(), path),
166
+ etag: response.headers.get("etag")
167
+ };
168
+ }
169
+ /**
170
+ * Everything up to the response: retries, backoff, cancellation and the
171
+ * status check, without deciding what the body means.
172
+ *
173
+ * Split out so a conditional read can accept `304` where an ordinary one
174
+ * must not, rather than either duplicating the retry policy or teaching
175
+ * `unwrap` about statuses that carry no body.
176
+ */
177
+ async perform(method, path, options = {}, accept = (status) => status >= 200 && status < 300) {
89
178
  const url = this.buildUrl(path, options.query);
90
179
  const upper = method.toUpperCase();
91
180
  const retryable = options.idempotent ?? IDEMPOTENT_METHODS.has(upper);
181
+ const maxRetries = options.maxRetries ?? this.maxRetries;
182
+ const maxBackoffMs = options.maxBackoffMs ?? this.maxBackoffMs;
183
+ throwIfAborted(options.signal);
92
184
  let lastError;
93
- for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
185
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
94
186
  if (attempt > 0) {
95
- await delay(backoffMs(attempt, lastError));
187
+ await delay(
188
+ backoffMs(attempt, lastError, maxBackoffMs),
189
+ options.signal
190
+ );
191
+ throwIfAborted(options.signal);
96
192
  }
97
193
  try {
98
194
  const response = await this.send(upper, url, options);
99
- if (this.shouldRetry(response.status, retryable, attempt)) {
195
+ if (this.shouldRetry(response.status, retryable, attempt, maxRetries)) {
100
196
  lastError = await this.toError(response, upper, path);
101
197
  continue;
102
198
  }
103
- if (!response.ok) {
199
+ if (!accept(response.status)) {
104
200
  throw await this.toError(response, upper, path);
105
201
  }
106
- return await this.unwrap(response);
202
+ return response;
107
203
  } catch (error) {
108
204
  if (error instanceof CanopyError) {
109
205
  throw error;
110
206
  }
111
207
  lastError = error;
112
- if (!retryable || attempt === this.maxRetries) {
208
+ throwIfAborted(options.signal);
209
+ if (!retryable || attempt === maxRetries) {
113
210
  throw new CanopyConnectionError(
114
211
  `${upper} ${path} failed: ${describe(error)}`,
115
212
  { method: upper, path },
@@ -119,13 +216,13 @@ var CanopyClient = class {
119
216
  }
120
217
  }
121
218
  throw new CanopyConnectionError(
122
- `${upper} ${path} exhausted ${this.maxRetries + 1} attempts`,
219
+ `${upper} ${path} exhausted ${maxRetries + 1} attempts`,
123
220
  { method: upper, path },
124
221
  { cause: lastError }
125
222
  );
126
223
  }
127
- shouldRetry(status, retryable, attempt) {
128
- if (attempt >= this.maxRetries) {
224
+ shouldRetry(status, retryable, attempt, maxRetries) {
225
+ if (attempt >= maxRetries) {
129
226
  return false;
130
227
  }
131
228
  if (status === 429) {
@@ -135,17 +232,21 @@ var CanopyClient = class {
135
232
  }
136
233
  async send(method, url, options) {
137
234
  const controller = new AbortController();
138
- const timer = this.timeoutMs > 0 ? setTimeout(() => controller.abort(), this.timeoutMs) : void 0;
235
+ const timeoutMs = options.timeoutMs ?? this.timeoutMs;
236
+ const timer = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : void 0;
139
237
  const onAbort = () => controller.abort();
238
+ if (options.signal?.aborted) {
239
+ controller.abort();
240
+ }
140
241
  options.signal?.addEventListener("abort", onAbort, { once: true });
141
242
  const headers = {
142
243
  Accept: "application/json",
143
- ...this.extraHeaders,
144
- ...this.authHeaders
244
+ ...this.extraHeaders
145
245
  };
146
246
  if (options.body !== void 0) {
147
247
  headers["Content-Type"] = "application/json";
148
248
  }
249
+ Object.assign(headers, options.headers, this.authHeaders);
149
250
  const init = { method, headers, signal: controller.signal };
150
251
  if (options.body !== void 0) {
151
252
  init.body = JSON.stringify(options.body);
@@ -172,7 +273,7 @@ var CanopyClient = class {
172
273
  }
173
274
  return url.toString();
174
275
  }
175
- async unwrap(response) {
276
+ async unwrap(response, method, path) {
176
277
  if (response.status === 204) {
177
278
  return void 0;
178
279
  }
@@ -180,7 +281,19 @@ var CanopyClient = class {
180
281
  if (text === "") {
181
282
  return void 0;
182
283
  }
183
- const parsed = JSON.parse(text);
284
+ let parsed;
285
+ try {
286
+ parsed = JSON.parse(text);
287
+ } catch {
288
+ throw new CanopyError(
289
+ {
290
+ statusCode: response.status,
291
+ code: null,
292
+ message: `${method} ${path} returned ${response.status} with a body that is not JSON.`
293
+ },
294
+ { method, path }
295
+ );
296
+ }
184
297
  if (parsed && typeof parsed === "object" && "data" in parsed) {
185
298
  return parsed["data"];
186
299
  }
@@ -203,14 +316,7 @@ var CanopyClient = class {
203
316
  }
204
317
  } catch {
205
318
  }
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;
319
+ return new CanopyError(body, { method, path }, retryAfter ?? void 0);
214
320
  }
215
321
  };
216
322
  function parseRetryAfter(header) {
@@ -227,20 +333,46 @@ function parseRetryAfter(header) {
227
333
  }
228
334
  return Math.max(0, date - Date.now());
229
335
  }
230
- function backoffMs(attempt, lastError) {
336
+ function backoffMs(attempt, lastError, maxBackoffMs) {
231
337
  const advised = lastError && typeof lastError === "object" && "retryAfterMs" in lastError ? Number(lastError.retryAfterMs) : NaN;
232
338
  if (Number.isFinite(advised)) {
233
- return advised;
339
+ return Math.min(advised, maxBackoffMs);
234
340
  }
235
341
  const base = 250 * 2 ** (attempt - 1);
236
- return base + Math.random() * base;
342
+ return Math.min(base + Math.random() * base, maxBackoffMs);
343
+ }
344
+ function throwIfAborted(signal) {
345
+ if (!signal?.aborted) {
346
+ return;
347
+ }
348
+ throw signal.reason ?? new DOMException("This operation was aborted", "AbortError");
237
349
  }
238
- function delay(ms) {
239
- return new Promise((resolve) => setTimeout(resolve, ms));
350
+ function delay(ms, signal) {
351
+ if (signal?.aborted) {
352
+ return Promise.resolve();
353
+ }
354
+ return new Promise((resolve) => {
355
+ const state = { settled: false };
356
+ const finish = () => {
357
+ if (state.settled) {
358
+ return;
359
+ }
360
+ state.settled = true;
361
+ if (state.timer !== void 0) {
362
+ clearTimeout(state.timer);
363
+ }
364
+ signal?.removeEventListener("abort", finish);
365
+ resolve();
366
+ };
367
+ state.timer = setTimeout(finish, ms);
368
+ if (!state.settled) {
369
+ signal?.addEventListener("abort", finish, { once: true });
370
+ }
371
+ });
240
372
  }
241
373
  function describe(error) {
242
374
  if (error instanceof Error) {
243
- return error.name === "AbortError" ? "timed out or aborted" : error.message;
375
+ return error.name === "AbortError" ? "timed out" : error.message;
244
376
  }
245
377
  return String(error);
246
378
  }
@@ -443,11 +575,25 @@ var Identities = class {
443
575
  `/api/v1/identities/${encodeURIComponent(id)}/activate`
444
576
  );
445
577
  }
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`
578
+ /**
579
+ * Every role this identity holds, and where — across all pages.
580
+ *
581
+ * Paginated (20 per page by default), so this returns a `Paginator` rather
582
+ * than one response. Reading a single page here would under-report what an
583
+ * identity can do, which is the dangerous direction to be wrong in.
584
+ *
585
+ * ```ts
586
+ * for await (const assignment of canopy.identities.assignments(id)) { … }
587
+ * ```
588
+ */
589
+ assignments(id, query = {}) {
590
+ return paginate(
591
+ (params) => this.client.request(
592
+ "GET",
593
+ `/api/v1/identities/${encodeURIComponent(id)}/assignments`,
594
+ { query: params }
595
+ ),
596
+ { ...query }
451
597
  );
452
598
  }
453
599
  /**
@@ -465,6 +611,14 @@ var Identities = class {
465
611
  }
466
612
  };
467
613
 
614
+ // src/schema.ts
615
+ function withConcurrency(options = {}) {
616
+ if (options.ifMatch === void 0) {
617
+ return {};
618
+ }
619
+ return { headers: { "If-Match": options.ifMatch } };
620
+ }
621
+
468
622
  // src/resources/permissions.ts
469
623
  var Permissions = class {
470
624
  constructor(client) {
@@ -480,10 +634,19 @@ var Permissions = class {
480
634
  * `effective_node_id: null` — that answer must never be used to guard a
481
635
  * resource that belongs to a specific node, which is why the scope is a
482
636
  * required field rather than a default.
637
+ *
638
+ * This runs on the request path, so it is the call most worth passing
639
+ * `signal` and a tight `timeoutMs` to: without them a slow answer here holds
640
+ * an inbound request open for the client-wide deadline on every attempt.
483
641
  */
484
- evaluate(input) {
642
+ evaluate(input, options = {}) {
485
643
  return this.client.request("POST", "/api/v1/permissions/evaluate", {
486
- body: input
644
+ body: input,
645
+ // A POST only because the question travels in a body; it computes a
646
+ // decision and writes nothing, so repeating it is safe — and this is the
647
+ // call least able to afford giving up on a transient 5xx.
648
+ idempotent: true,
649
+ ...options
487
650
  });
488
651
  }
489
652
  /**
@@ -492,9 +655,12 @@ var Permissions = class {
492
655
  * Prefer this to a loop over `evaluate` when rendering a screen: the checks
493
656
  * are answered together instead of paying request latency for each.
494
657
  */
495
- evaluateBulk(input) {
658
+ evaluateBulk(input, options = {}) {
496
659
  return this.client.request("POST", "/api/v1/permissions/evaluate/bulk", {
497
- body: input
660
+ body: input,
661
+ /** Read-only, like `evaluate`. */
662
+ idempotent: true,
663
+ ...options
498
664
  });
499
665
  }
500
666
  /**
@@ -502,9 +668,12 @@ var Permissions = class {
502
668
  * it was inherited from. For debugging an unexpected allow or deny, not for
503
669
  * the enforcement path.
504
670
  */
505
- explain(input) {
671
+ explain(input, options = {}) {
506
672
  return this.client.request("POST", "/api/v1/permissions/evaluate/explain", {
507
- body: input
673
+ body: input,
674
+ /** Read-only, like `evaluate`. */
675
+ idempotent: true,
676
+ ...options
508
677
  });
509
678
  }
510
679
  /** Every permission in the Environment, page by page. */
@@ -524,17 +693,23 @@ var Permissions = class {
524
693
  create(input) {
525
694
  return this.client.request("POST", "/api/v1/permissions", { body: input });
526
695
  }
527
- update(id, input) {
696
+ /**
697
+ * Pass `ifMatch` with the permission's current `version` to make a
698
+ * read-modify-write safe — a concurrent edit answers 409 instead of being
699
+ * silently overwritten.
700
+ */
701
+ update(id, input, options = {}) {
528
702
  return this.client.request(
529
703
  "PATCH",
530
704
  `/api/v1/permissions/${encodeURIComponent(id)}`,
531
- { body: input }
705
+ { body: input, ...withConcurrency(options) }
532
706
  );
533
707
  }
534
- delete(id) {
708
+ delete(id, options = {}) {
535
709
  return this.client.request(
536
710
  "DELETE",
537
- `/api/v1/permissions/${encodeURIComponent(id)}`
711
+ `/api/v1/permissions/${encodeURIComponent(id)}`,
712
+ withConcurrency(options)
538
713
  );
539
714
  }
540
715
  };
@@ -560,17 +735,23 @@ var Roles = class {
560
735
  create(input) {
561
736
  return this.client.request("POST", "/api/v1/roles", { body: input });
562
737
  }
563
- update(id, input) {
738
+ /**
739
+ * Pass `ifMatch` with the role's current `version` to make a
740
+ * read-modify-write safe — a concurrent edit answers 409 instead of being
741
+ * silently overwritten.
742
+ */
743
+ update(id, input, options = {}) {
564
744
  return this.client.request(
565
745
  "PATCH",
566
746
  `/api/v1/roles/${encodeURIComponent(id)}`,
567
- { body: input }
747
+ { body: input, ...withConcurrency(options) }
568
748
  );
569
749
  }
570
- delete(id) {
750
+ delete(id, options = {}) {
571
751
  return this.client.request(
572
752
  "DELETE",
573
- `/api/v1/roles/${encodeURIComponent(id)}`
753
+ `/api/v1/roles/${encodeURIComponent(id)}`,
754
+ withConcurrency(options)
574
755
  );
575
756
  }
576
757
  permissions(id) {
@@ -608,18 +789,536 @@ var Canopy = class {
608
789
  }
609
790
  };
610
791
 
792
+ // src/authorizer.ts
793
+ var DEFAULT_TTL_MS = 6e4;
794
+ var MAX_WALK_DEPTH = 256;
795
+ var LocalAuthorizer = class {
796
+ client;
797
+ ttlMs;
798
+ now;
799
+ readOptions;
800
+ grants = /* @__PURE__ */ new Map();
801
+ tree;
802
+ /**
803
+ * In-flight reads, so concurrent requests for the same thing share one call.
804
+ *
805
+ * Without this a cold start under load fans out: a hundred simultaneous
806
+ * requests for one identity would each miss the cache and each fetch, which
807
+ * is the per-request traffic this class exists to remove, concentrated into
808
+ * the worst possible moment.
809
+ */
810
+ pendingGrants = /* @__PURE__ */ new Map();
811
+ pendingTree;
812
+ stats = {
813
+ grantFetches: 0,
814
+ treeRequests: 0,
815
+ treeNotModified: 0
816
+ };
817
+ constructor(client, options = {}) {
818
+ this.client = client;
819
+ this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
820
+ this.now = options.now ?? (() => Date.now());
821
+ this.readOptions = {
822
+ ...options.timeoutMs === void 0 ? {} : { timeoutMs: options.timeoutMs },
823
+ ...options.maxRetries === void 0 ? {} : { maxRetries: options.maxRetries, maxBackoffMs: options.timeoutMs }
824
+ };
825
+ }
826
+ /**
827
+ * Whether the identity holds the permission.
828
+ *
829
+ * Shaped like the API's own evaluate so a caller can swap one for the other.
830
+ * A `node` check with no node is a denial rather than an error: a request
831
+ * whose subject cannot be established is exactly the one that must not pass.
832
+ */
833
+ async evaluate(query, options = {}) {
834
+ if (!options.signal) {
835
+ return this.decide(query);
836
+ }
837
+ const signal = options.signal;
838
+ let onAbort;
839
+ const aborted = new Promise((_resolve, reject) => {
840
+ const fail = () => {
841
+ reject(
842
+ signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason ?? "aborted"))
843
+ );
844
+ };
845
+ if (signal.aborted) {
846
+ fail();
847
+ return;
848
+ }
849
+ onAbort = fail;
850
+ signal.addEventListener("abort", fail, { once: true });
851
+ });
852
+ return Promise.race([this.decide(query), aborted]).finally(() => {
853
+ if (onAbort) {
854
+ signal.removeEventListener("abort", onAbort);
855
+ }
856
+ });
857
+ }
858
+ async decide(query) {
859
+ const roots = await this.grantRootsFor(query.identity_id);
860
+ const granted = roots.get(query.permission);
861
+ if (!granted || granted.size === 0) {
862
+ return { allowed: false };
863
+ }
864
+ if ((query.scope ?? "node") === "app_wide") {
865
+ return { allowed: true };
866
+ }
867
+ if (!query.node_id) {
868
+ return { allowed: false };
869
+ }
870
+ return { allowed: await this.holdsAtNode(granted, query.node_id) };
871
+ }
872
+ /** Counters for observability — how much traffic the cache is actually saving. */
873
+ snapshot() {
874
+ return { ...this.stats };
875
+ }
876
+ /**
877
+ * Drop everything held. Not needed in normal operation, where entries expire
878
+ * on their own; useful in tests and after a known change.
879
+ */
880
+ invalidate() {
881
+ this.grants.clear();
882
+ this.tree = void 0;
883
+ }
884
+ /** Climb from `nodeId` and look for a grant root among its ancestors. */
885
+ async holdsAtNode(granted, nodeId) {
886
+ if (granted.has(nodeId)) {
887
+ return true;
888
+ }
889
+ const parents = await this.parents();
890
+ if (!parents.has(nodeId)) {
891
+ throw new CanopyAuthorizerError(
892
+ "authorizer.hierarchy_incomplete",
893
+ `The hierarchy this client can read does not contain node "${nodeId}", so authorization at that node cannot be decided. A scoped API key needs the \`hierarchy.view\` scope to read the tree.`
894
+ );
895
+ }
896
+ let current = parents.get(nodeId);
897
+ let depth = 0;
898
+ while (current && depth < MAX_WALK_DEPTH) {
899
+ if (granted.has(current)) {
900
+ return true;
901
+ }
902
+ current = parents.get(current);
903
+ depth += 1;
904
+ }
905
+ return false;
906
+ }
907
+ async grantRootsFor(identityId) {
908
+ const cached = this.grants.get(identityId);
909
+ if (cached && cached.expiresAt > this.now()) {
910
+ return cached.roots;
911
+ }
912
+ const pending = this.pendingGrants.get(identityId);
913
+ if (pending) {
914
+ return pending;
915
+ }
916
+ const read = this.fetchGrantRoots(identityId).finally(() => {
917
+ this.pendingGrants.delete(identityId);
918
+ });
919
+ this.pendingGrants.set(identityId, read);
920
+ return read;
921
+ }
922
+ async fetchGrantRoots(identityId) {
923
+ this.stats.grantFetches += 1;
924
+ const response = await this.client.request(
925
+ "GET",
926
+ `/api/v1/identities/${encodeURIComponent(identityId)}/grants`,
927
+ this.readOptions
928
+ );
929
+ const roots = /* @__PURE__ */ new Map();
930
+ for (const row of response.items ?? []) {
931
+ roots.set(row.permission, new Set(row.nodes));
932
+ }
933
+ this.grants.set(identityId, {
934
+ roots,
935
+ expiresAt: this.now() + this.ttlMs
936
+ });
937
+ return roots;
938
+ }
939
+ async parents() {
940
+ if (this.tree && this.tree.expiresAt > this.now()) {
941
+ return this.tree.parents;
942
+ }
943
+ if (this.pendingTree) {
944
+ return this.pendingTree;
945
+ }
946
+ const read = this.fetchTree().finally(() => {
947
+ this.pendingTree = void 0;
948
+ });
949
+ this.pendingTree = read;
950
+ return read;
951
+ }
952
+ /**
953
+ * Revalidate rather than re-read. The hierarchy is the expensive half and
954
+ * the one that changes least, so the common case is a `304` and no transfer
955
+ * at all — the tree stays in memory and only its expiry moves.
956
+ */
957
+ async fetchTree() {
958
+ this.stats.treeRequests += 1;
959
+ const held = this.tree;
960
+ const result = await this.client.requestConditional(
961
+ "GET",
962
+ "/api/v1/nodes/parents",
963
+ held?.etag ?? void 0,
964
+ this.readOptions
965
+ );
966
+ if (!result.modified && held) {
967
+ this.stats.treeNotModified += 1;
968
+ this.tree = { ...held, expiresAt: this.now() + this.ttlMs };
969
+ return held.parents;
970
+ }
971
+ const parents = /* @__PURE__ */ new Map();
972
+ if (result.modified) {
973
+ for (const edge of result.data.items) {
974
+ parents.set(edge.id, edge.parent_node_id ?? null);
975
+ }
976
+ }
977
+ this.tree = {
978
+ parents,
979
+ etag: result.modified ? result.etag : held?.etag ?? null,
980
+ expiresAt: this.now() + this.ttlMs
981
+ };
982
+ return parents;
983
+ }
984
+ };
985
+
986
+ // src/verify.ts
987
+ var DEFAULT_ISSUER = "https://auth.canopy-io.com";
988
+ var DEFAULT_JWKS_CACHE_MAX_AGE_MS = 10 * 60 * 1e3;
989
+ var DEFAULT_JWKS_MIN_REFETCH_INTERVAL_MS = 30 * 1e3;
990
+ var DEFAULT_JWKS_TIMEOUT_MS = 5e3;
991
+ var PRINCIPAL_TYPES = /* @__PURE__ */ new Set(["user", "identity", "api_key", "platform"]);
992
+ var DEFAULT_CLOCK_TOLERANCE_SEC = 60;
993
+ function decodeBase64Url(value) {
994
+ const padded = value.replace(/-/g, "+").replace(/_/g, "/");
995
+ const binary = atob(padded.padEnd(Math.ceil(padded.length / 4) * 4, "="));
996
+ const bytes = new Uint8Array(new ArrayBuffer(binary.length));
997
+ for (let i = 0; i < binary.length; i++) {
998
+ bytes[i] = binary.charCodeAt(i);
999
+ }
1000
+ return bytes;
1001
+ }
1002
+ function decodeJsonSegment(segment, what) {
1003
+ try {
1004
+ return JSON.parse(new TextDecoder().decode(decodeBase64Url(segment)));
1005
+ } catch (cause) {
1006
+ throw new CanopyTokenError(
1007
+ "token.malformed",
1008
+ `Token ${what} is not valid base64url-encoded JSON.`,
1009
+ { cause }
1010
+ );
1011
+ }
1012
+ }
1013
+ var TokenVerifier = class {
1014
+ issuer;
1015
+ audience;
1016
+ jwksUri;
1017
+ jwksCacheMaxAgeMs;
1018
+ jwksMinRefetchIntervalMs;
1019
+ jwksTimeoutMs;
1020
+ clockToleranceSec;
1021
+ allowPreAuthTokens;
1022
+ fetchImpl;
1023
+ /** Imported keys by `kid`, so a repeat verification skips the import cost. */
1024
+ keys = /* @__PURE__ */ new Map();
1025
+ keysFetchedAt = 0;
1026
+ lastFetchAttemptAt = 0;
1027
+ /** In-flight fetch, so a burst of requests triggers one call, not N. */
1028
+ inFlight = null;
1029
+ constructor(options = {}) {
1030
+ this.issuer = (options.issuer ?? DEFAULT_ISSUER).replace(/\/+$/, "");
1031
+ this.audience = options.audience;
1032
+ this.jwksUri = options.jwksUri ?? `${this.issuer}/.well-known/jwks.json`;
1033
+ this.jwksCacheMaxAgeMs = options.jwksCacheMaxAgeMs ?? DEFAULT_JWKS_CACHE_MAX_AGE_MS;
1034
+ this.jwksMinRefetchIntervalMs = options.jwksMinRefetchIntervalMs ?? DEFAULT_JWKS_MIN_REFETCH_INTERVAL_MS;
1035
+ this.jwksTimeoutMs = options.jwksTimeoutMs ?? DEFAULT_JWKS_TIMEOUT_MS;
1036
+ this.clockToleranceSec = options.clockToleranceSec ?? DEFAULT_CLOCK_TOLERANCE_SEC;
1037
+ this.allowPreAuthTokens = options.allowPreAuthTokens ?? false;
1038
+ const boundFetch = options.fetch ?? globalThis.fetch;
1039
+ if (typeof boundFetch !== "function") {
1040
+ throw new TypeError(
1041
+ "TokenVerifier requires a fetch implementation. Pass `fetch` on Node runtimes without a global one."
1042
+ );
1043
+ }
1044
+ this.fetchImpl = boundFetch.bind(globalThis);
1045
+ }
1046
+ /**
1047
+ * Verify a token and return its claims. Throws {@link CanopyTokenError} on
1048
+ * anything short of a full pass — branch on `error.code`.
1049
+ *
1050
+ * Order matters: the signature is checked before any claim is believed, so
1051
+ * nothing downstream ever reads an unverified payload.
1052
+ */
1053
+ async verify(token) {
1054
+ const parts = token.split(".");
1055
+ if (parts.length !== 3) {
1056
+ throw new CanopyTokenError(
1057
+ "token.malformed",
1058
+ "Token is not a three-part JWS."
1059
+ );
1060
+ }
1061
+ const [encodedHeader, encodedPayload, encodedSignature] = parts;
1062
+ const header = decodeJsonSegment(encodedHeader, "header");
1063
+ if (header.alg !== "RS256") {
1064
+ throw new CanopyTokenError(
1065
+ "token.unsupported_algorithm",
1066
+ `Token is signed with "${header.alg ?? "none"}"; Canopy issues RS256.`
1067
+ );
1068
+ }
1069
+ const key = await this.resolveKey(header.kid);
1070
+ const signed = new TextEncoder().encode(
1071
+ `${encodedHeader}.${encodedPayload}`
1072
+ );
1073
+ let signatureValid;
1074
+ try {
1075
+ signatureValid = await crypto.subtle.verify(
1076
+ "RSASSA-PKCS1-v1_5",
1077
+ key,
1078
+ decodeBase64Url(encodedSignature),
1079
+ signed
1080
+ );
1081
+ } catch (cause) {
1082
+ throw new CanopyTokenError(
1083
+ "token.malformed",
1084
+ "Token signature is not decodable.",
1085
+ { cause }
1086
+ );
1087
+ }
1088
+ if (!signatureValid) {
1089
+ throw new CanopyTokenError(
1090
+ "token.signature_invalid",
1091
+ "Token signature does not match Canopy's signing key."
1092
+ );
1093
+ }
1094
+ const claims = decodeJsonSegment(
1095
+ encodedPayload,
1096
+ "payload"
1097
+ );
1098
+ this.assertClaims(claims);
1099
+ return claims;
1100
+ }
1101
+ /** Everything checked after the signature is known good. */
1102
+ assertClaims(claims) {
1103
+ if (typeof claims.sub !== "string" || claims.sub === "") {
1104
+ throw new CanopyTokenError(
1105
+ "token.malformed",
1106
+ "Token has no `sub` claim, so it identifies no one."
1107
+ );
1108
+ }
1109
+ if (!PRINCIPAL_TYPES.has(claims.type)) {
1110
+ throw new CanopyTokenError(
1111
+ "token.malformed",
1112
+ `Token has an unrecognised \`type\` claim: ${JSON.stringify(claims.type)}.`
1113
+ );
1114
+ }
1115
+ if (claims.iss !== this.issuer) {
1116
+ throw new CanopyTokenError(
1117
+ "token.issuer_mismatch",
1118
+ `Token was issued by "${String(claims.iss)}", not "${this.issuer}".`
1119
+ );
1120
+ }
1121
+ const now = Math.floor(Date.now() / 1e3);
1122
+ if (typeof claims.exp !== "number") {
1123
+ throw new CanopyTokenError(
1124
+ "token.malformed",
1125
+ "Token has no `exp` claim."
1126
+ );
1127
+ }
1128
+ if (now > claims.exp + this.clockToleranceSec) {
1129
+ throw new CanopyTokenError("token.expired", "Token has expired.");
1130
+ }
1131
+ if (typeof claims.nbf === "number" && now < claims.nbf - this.clockToleranceSec) {
1132
+ throw new CanopyTokenError(
1133
+ "token.not_yet_valid",
1134
+ "Token is not valid yet."
1135
+ );
1136
+ }
1137
+ this.assertAudience(claims);
1138
+ if (claims.token_type === "preauth" && !this.allowPreAuthTokens) {
1139
+ throw new CanopyTokenError(
1140
+ "token.preauth_not_allowed",
1141
+ "This is a pre-auth token: the user authenticated but has not selected an Account, so it grants no access. Set `allowPreAuthTokens: true` only if you are building the account picker."
1142
+ );
1143
+ }
1144
+ }
1145
+ /**
1146
+ * `aud` is checked when either side mentions it.
1147
+ *
1148
+ * The case worth stating: a token carries `aud` but the verifier was not
1149
+ * configured with one. That is not "no audience to check" — it is an OAuth
1150
+ * token being verified by something that never said which client it is, and
1151
+ * ignoring it would accept a token minted for a different client. So it
1152
+ * throws and names the option.
1153
+ */
1154
+ assertAudience(claims) {
1155
+ const audiences = claims.aud === void 0 ? [] : Array.isArray(claims.aud) ? claims.aud : [claims.aud];
1156
+ if (this.audience === void 0) {
1157
+ if (audiences.length > 0) {
1158
+ throw new CanopyTokenError(
1159
+ "token.audience_unverified",
1160
+ "Token carries an `aud` claim but the verifier has no `audience` configured. Set `audience` to your OAuth client id; Direct API tokens carry no audience and need no option."
1161
+ );
1162
+ }
1163
+ return;
1164
+ }
1165
+ if (!audiences.includes(this.audience)) {
1166
+ throw new CanopyTokenError(
1167
+ "token.audience_mismatch",
1168
+ `Token audience does not include "${this.audience}".`
1169
+ );
1170
+ }
1171
+ }
1172
+ /**
1173
+ * The signing key for a `kid`, fetching the key set when it is stale or when
1174
+ * the `kid` is unknown — the latter is how key rotation is picked up
1175
+ * mid-process, bounded by `jwksMinRefetchIntervalMs`.
1176
+ */
1177
+ async resolveKey(kid) {
1178
+ if (kid === void 0) {
1179
+ throw new CanopyTokenError(
1180
+ "token.malformed",
1181
+ "Token header has no `kid`, so its signing key cannot be identified."
1182
+ );
1183
+ }
1184
+ const stale = Date.now() - this.keysFetchedAt > this.jwksCacheMaxAgeMs;
1185
+ if (this.keys.size === 0 || stale) {
1186
+ if (this.shouldRefresh()) {
1187
+ await this.refreshKeys();
1188
+ } else if (this.keys.size === 0) {
1189
+ throw new CanopyTokenError(
1190
+ "token.jwks_unavailable",
1191
+ `Signing keys at ${this.jwksUri} are unavailable; backing off before retrying.`
1192
+ );
1193
+ }
1194
+ }
1195
+ const cached = this.keys.get(kid);
1196
+ if (cached) {
1197
+ return cached;
1198
+ }
1199
+ if (this.shouldRefresh()) {
1200
+ await this.refreshKeys();
1201
+ }
1202
+ const rotated = this.keys.get(kid);
1203
+ if (rotated) {
1204
+ return rotated;
1205
+ }
1206
+ throw new CanopyTokenError(
1207
+ "token.key_not_found",
1208
+ `No signing key matches kid "${kid}".`
1209
+ );
1210
+ }
1211
+ /**
1212
+ * Whether to await a key-set refresh.
1213
+ *
1214
+ * Two ways to qualify, and the first matters as much as the second. A read
1215
+ * already in flight is joined regardless of the floor: it costs no extra
1216
+ * outbound request, and it is what lets a concurrent burst share one fetch
1217
+ * instead of one caller winning and the rest being turned away.
1218
+ *
1219
+ * Otherwise the floor applies — the same one for every refetch path, so they
1220
+ * cannot drift into having different amplification properties.
1221
+ */
1222
+ shouldRefresh() {
1223
+ if (this.inFlight) {
1224
+ return true;
1225
+ }
1226
+ return Date.now() - this.lastFetchAttemptAt >= this.jwksMinRefetchIntervalMs;
1227
+ }
1228
+ async refreshKeys() {
1229
+ this.inFlight ??= this.fetchKeys().finally(() => {
1230
+ this.inFlight = null;
1231
+ });
1232
+ await this.inFlight;
1233
+ }
1234
+ async fetchKeys() {
1235
+ this.lastFetchAttemptAt = Date.now();
1236
+ let response;
1237
+ const controller = new AbortController();
1238
+ const timer = this.jwksTimeoutMs > 0 ? setTimeout(() => controller.abort(), this.jwksTimeoutMs) : void 0;
1239
+ try {
1240
+ response = await this.fetchImpl(this.jwksUri, {
1241
+ headers: { accept: "application/json" },
1242
+ signal: controller.signal
1243
+ });
1244
+ } catch (cause) {
1245
+ throw new CanopyTokenError(
1246
+ "token.jwks_unavailable",
1247
+ `Could not reach the signing keys at ${this.jwksUri}.`,
1248
+ { cause }
1249
+ );
1250
+ } finally {
1251
+ clearTimeout(timer);
1252
+ }
1253
+ if (!response.ok) {
1254
+ throw new CanopyTokenError(
1255
+ "token.jwks_unavailable",
1256
+ `Signing keys at ${this.jwksUri} returned HTTP ${response.status}.`
1257
+ );
1258
+ }
1259
+ let document;
1260
+ try {
1261
+ document = await response.json();
1262
+ } catch (cause) {
1263
+ throw new CanopyTokenError(
1264
+ "token.jwks_unavailable",
1265
+ `Signing keys at ${this.jwksUri} were not valid JSON.`,
1266
+ { cause }
1267
+ );
1268
+ }
1269
+ const imported = /* @__PURE__ */ new Map();
1270
+ for (const jwk of document.keys ?? []) {
1271
+ if (jwk.kty !== "RSA" || jwk.kid === void 0 || jwk.n === void 0 || jwk.e === void 0) {
1272
+ continue;
1273
+ }
1274
+ if (jwk.alg !== void 0 && jwk.alg !== "RS256") {
1275
+ continue;
1276
+ }
1277
+ try {
1278
+ imported.set(
1279
+ jwk.kid,
1280
+ await crypto.subtle.importKey(
1281
+ "jwk",
1282
+ { kty: "RSA", n: jwk.n, e: jwk.e, alg: "RS256", ext: true },
1283
+ { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
1284
+ false,
1285
+ ["verify"]
1286
+ )
1287
+ );
1288
+ } catch {
1289
+ continue;
1290
+ }
1291
+ }
1292
+ if (imported.size === 0) {
1293
+ throw new CanopyTokenError(
1294
+ "token.jwks_unavailable",
1295
+ `Signing keys at ${this.jwksUri} contained no usable RS256 key.`
1296
+ );
1297
+ }
1298
+ this.keys = imported;
1299
+ this.keysFetchedAt = Date.now();
1300
+ }
1301
+ };
1302
+
611
1303
  exports.Assignments = Assignments;
612
1304
  exports.Canopy = Canopy;
1305
+ exports.CanopyAuthorizerError = CanopyAuthorizerError;
613
1306
  exports.CanopyClient = CanopyClient;
614
1307
  exports.CanopyConnectionError = CanopyConnectionError;
615
1308
  exports.CanopyError = CanopyError;
1309
+ exports.CanopyTokenError = CanopyTokenError;
616
1310
  exports.Identities = Identities;
1311
+ exports.LocalAuthorizer = LocalAuthorizer;
617
1312
  exports.Paginator = Paginator;
618
1313
  exports.Permissions = Permissions;
619
1314
  exports.Roles = Roles;
1315
+ exports.TokenVerifier = TokenVerifier;
1316
+ exports.isCanopyAuthorizerError = isCanopyAuthorizerError;
620
1317
  exports.isCanopyConnectionError = isCanopyConnectionError;
621
1318
  exports.isCanopyError = isCanopyError;
1319
+ exports.isCanopyTokenError = isCanopyTokenError;
622
1320
  exports.isCursorPagination = isCursorPagination;
623
1321
  exports.paginate = paginate;
1322
+ exports.withConcurrency = withConcurrency;
624
1323
  //# sourceMappingURL=index.cjs.map
625
1324
  //# sourceMappingURL=index.cjs.map