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