@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.d.cts CHANGED
@@ -22,6 +22,20 @@ interface CursorPagination {
22
22
  type Pagination = OffsetPagination | CursorPagination;
23
23
  declare function isCursorPagination(pagination: Pagination): pagination is CursorPagination;
24
24
  /** A collection response, paginated or not. */
25
+ /**
26
+ * The answer to a conditional read.
27
+ *
28
+ * A discriminated union rather than an optional body, so a caller cannot
29
+ * mistake "nothing changed" for "changed to nothing" — the two are opposite
30
+ * instructions to whatever is holding the cached copy.
31
+ */
32
+ type ConditionalResult<T> = {
33
+ modified: true;
34
+ data: T;
35
+ etag: string | null;
36
+ } | {
37
+ modified: false;
38
+ };
25
39
  interface Collection<T> {
26
40
  items: T[];
27
41
  pagination?: Pagination;
@@ -59,6 +73,14 @@ interface CanopyClientOptions {
59
73
  timeoutMs?: number;
60
74
  /** Retries after the first attempt. Defaults to 2. */
61
75
  maxRetries?: number;
76
+ /**
77
+ * Ceiling on a single wait between attempts. Defaults to 30s.
78
+ *
79
+ * The server's `Retry-After` is honoured up to this, then clamped. Without a
80
+ * cap the wait is whatever a response header asks for, which sits outside
81
+ * `timeoutMs` and can hold a caller far longer than its own budget allows.
82
+ */
83
+ maxBackoffMs?: number;
62
84
  /** Extra headers on every request. */
63
85
  headers?: Record<string, string>;
64
86
  /** Injectable for tests and for runtimes with a non-global fetch. */
@@ -68,17 +90,67 @@ interface RequestOptions {
68
90
  query?: Record<string, string | number | boolean | undefined | null>;
69
91
  body?: unknown;
70
92
  signal?: AbortSignal;
93
+ /**
94
+ * Per-attempt deadline for this call only, overriding the client-wide
95
+ * `timeoutMs`. 0 disables it.
96
+ *
97
+ * The client-wide default suits administrative CRUD. A latency-critical call
98
+ * on a request path — an authorization check on every inbound request — wants
99
+ * a much tighter one, because the total time at risk is this deadline times
100
+ * the attempts, and it is spent holding an inbound request open.
101
+ */
102
+ timeoutMs?: number;
103
+ /**
104
+ * Retries after the first attempt for this call only, overriding the
105
+ * client-wide `maxRetries`. 0 disables retrying.
106
+ *
107
+ * Set this together with `timeoutMs` when a call needs a bounded worst case:
108
+ * the two multiply. A deadline alone still permits `maxRetries + 1` of them
109
+ * back to back, which is the difference between a slow call and a request
110
+ * held open long past the point the answer was useful.
111
+ */
112
+ maxRetries?: number;
113
+ /**
114
+ * Ceiling on a single wait between attempts, for this call only.
115
+ *
116
+ * Set it alongside `timeoutMs` and `maxRetries` when a call needs a real
117
+ * worst case: the deadline bounds an attempt, `maxRetries` bounds how many,
118
+ * and this bounds the waiting in between — which a server's `Retry-After`
119
+ * would otherwise control.
120
+ */
121
+ maxBackoffMs?: number;
122
+ /**
123
+ * Headers for this call only, overriding the client-wide `headers`.
124
+ *
125
+ * This is how the per-request protocol headers are sent: `If-Match`, carrying
126
+ * a resource's current `version` for optimistic concurrency (a stale value
127
+ * answers 409), and `Idempotency-Key`, which makes a replayed bulk create
128
+ * return the original result instead of creating rows twice.
129
+ *
130
+ * The credential is not overridable this way — auth is settled by the client.
131
+ */
132
+ headers?: Record<string, string>;
71
133
  /**
72
134
  * Declares this call safe to repeat, which allows retrying a 5xx.
73
135
  *
74
- * Needed because the OpenAPI document only marks one operation
75
- * `x-canopy-idempotent`, so the spec cannot drive this. GET, HEAD, PUT and
76
- * DELETE are treated as idempotent by HTTP definition; POST is not, and a
77
- * blind retry there can create a second role assignment or a second
78
- * invitation. Set this only when the endpoint genuinely tolerates a repeat.
136
+ * Needed because the generated types carry no idempotency marker, so the
137
+ * spec cannot drive this. GET, HEAD, PUT and DELETE are treated as
138
+ * idempotent by HTTP definition; POST is not, and a blind retry there can
139
+ * create a second role assignment or a second invitation.
140
+ *
141
+ * Set it where a POST is a read in disguise — the permission evaluations
142
+ * take a body and so must be POSTs, but they compute an answer and write
143
+ * nothing. Do not set it on anything that creates.
79
144
  */
80
145
  idempotent?: boolean;
81
146
  }
147
+ /**
148
+ * The per-call knobs a resource method accepts.
149
+ *
150
+ * Deliberately excludes `body` and `query`, which the method itself owns, and
151
+ * `idempotent`, which is a property of the endpoint rather than the call site.
152
+ */
153
+ type CallOptions = Pick<RequestOptions, "signal" | "timeoutMs" | "maxRetries" | "maxBackoffMs" | "headers">;
82
154
  /**
83
155
  * The transport every resource is built on: one place that knows how to
84
156
  * authenticate, unwrap Canopy's response envelope, turn a failure into a typed
@@ -91,6 +163,7 @@ declare class CanopyClient {
91
163
  private readonly baseUrl;
92
164
  private readonly timeoutMs;
93
165
  private readonly maxRetries;
166
+ private readonly maxBackoffMs;
94
167
  private readonly authHeaders;
95
168
  private readonly extraHeaders;
96
169
  private readonly fetchImpl;
@@ -107,6 +180,29 @@ declare class CanopyClient {
107
180
  * `CanopyConnectionError`.
108
181
  */
109
182
  request<T>(method: string, path: string, options?: RequestOptions): Promise<T>;
183
+ /**
184
+ * A conditional read: send the validator you already hold, and find out
185
+ * whether anything changed.
186
+ *
187
+ * The sibling of `If-Match`, which this client already sends for optimistic
188
+ * concurrency. `304 Not Modified` is a *success* — it means the copy you
189
+ * have is current — but it is not a 2xx, so `request` would raise it as an
190
+ * error. Hence a separate entry point with a return type that says which
191
+ * happened rather than one that has to be inspected.
192
+ *
193
+ * Use it to hold something expensive and revalidate cheaply — the hierarchy
194
+ * behind local authorization is the case this exists for.
195
+ */
196
+ requestConditional<T>(method: string, path: string, etag: string | undefined, options?: RequestOptions): Promise<ConditionalResult<T>>;
197
+ /**
198
+ * Everything up to the response: retries, backoff, cancellation and the
199
+ * status check, without deciding what the body means.
200
+ *
201
+ * Split out so a conditional read can accept `304` where an ordinary one
202
+ * must not, rather than either duplicating the retry policy or teaching
203
+ * `unwrap` about statuses that carry no body.
204
+ */
205
+ private perform;
110
206
  private shouldRetry;
111
207
  private send;
112
208
  private buildUrl;
@@ -643,6 +739,26 @@ interface paths {
643
739
  patch?: never;
644
740
  trace?: never;
645
741
  };
742
+ "/api/v1/identities/{id}/grants": {
743
+ parameters: {
744
+ query?: never;
745
+ header?: never;
746
+ path?: never;
747
+ cookie?: never;
748
+ };
749
+ /**
750
+ * Get where an identity holds each permission
751
+ * @description Returns, for the current Environment, every permission the identity holds mapped to the hierarchy nodes its grants were made at — the nodes themselves, **not** expanded through their descendants. A grant already means "this node and everything beneath it", so a caller answers a node-scoped question by walking up from the node in question and looking for one of these roots, using a copy of the tree fetched once from `GET /api/v1/nodes` and shared across identities. Answers both scopes without a further call: the permission appearing at all is the Application-wide answer, and the walk is the node-scoped one. Scheduled assignments outside their effective window are excluded. Returns `404` when the identity has no membership in this Environment.
752
+ */
753
+ get: operations["ApiIdentitiesController_getIdentityGrants"];
754
+ put?: never;
755
+ post?: never;
756
+ delete?: never;
757
+ options?: never;
758
+ head?: never;
759
+ patch?: never;
760
+ trace?: never;
761
+ };
646
762
  "/api/v1/identity-invites": {
647
763
  parameters: {
648
764
  query?: never;
@@ -771,6 +887,26 @@ interface paths {
771
887
  patch?: never;
772
888
  trace?: never;
773
889
  };
890
+ "/api/v1/nodes/parents": {
891
+ parameters: {
892
+ query?: never;
893
+ header?: never;
894
+ path?: never;
895
+ cookie?: never;
896
+ };
897
+ /**
898
+ * List the hierarchy as parent edges
899
+ * @description Returns the same hierarchy `GET /api/v1/nodes` describes, scoped identically, but as a flat list of `{ id, parent_node_id }` and nothing else. Intended for a client that evaluates authorization locally: it walks upward from a node and reads none of the tree's names, statuses, access flags or counts. At fifty thousand nodes the tree is roughly 18.6 MB against 4 MB of edges, on a read that runs at every consuming process's startup. Supports the same `If-None-Match` revalidation, with a validator distinct from the tree's — a tag from one representation never satisfies a request for the other.
900
+ */
901
+ get: operations["ApiNodesController_listNodeParents"];
902
+ put?: never;
903
+ post?: never;
904
+ delete?: never;
905
+ options?: never;
906
+ head?: never;
907
+ patch?: never;
908
+ trace?: never;
909
+ };
774
910
  "/api/v1/nodes/{id}": {
775
911
  parameters: {
776
912
  query?: never;
@@ -1714,6 +1850,12 @@ interface components {
1714
1850
  /** Format: date-time */
1715
1851
  updated_at: string;
1716
1852
  };
1853
+ IdentityGrantResponseDto: {
1854
+ /** @example reports.view */
1855
+ permission: string;
1856
+ /** @description Hierarchy node ids the permission was granted at. Not expanded through descendants. */
1857
+ nodes: string[];
1858
+ };
1717
1859
  IdentityInviteResponseDto: {
1718
1860
  id: string;
1719
1861
  email: string;
@@ -1871,6 +2013,11 @@ interface components {
1871
2013
  tree: components["schemas"]["HierarchyTreeNodeDto"][];
1872
2014
  scope: components["schemas"]["HierarchyScopeDto"];
1873
2015
  };
2016
+ NodeParentEdgeResponseDto: {
2017
+ id: string;
2018
+ /** @description Null for the root node, which has no parent. */
2019
+ parent_node_id?: string | null;
2020
+ };
1874
2021
  NodeAccessResponseDto: {
1875
2022
  id: string;
1876
2023
  application_id: string;
@@ -4724,6 +4871,114 @@ interface operations {
4724
4871
  "application/json": components["schemas"]["ErrorResponseDto"];
4725
4872
  };
4726
4873
  };
4874
+ /** @description Identity not found */
4875
+ 404: {
4876
+ headers: {
4877
+ [name: string]: unknown;
4878
+ };
4879
+ content: {
4880
+ /**
4881
+ * @example {
4882
+ * "error": {
4883
+ * "statusCode": 404,
4884
+ * "code": null,
4885
+ * "message": "Identity not found",
4886
+ * "timestamp": "2026-04-20T12:00:00.000Z",
4887
+ * "path": "/api/v1/identities/{id}/permissions",
4888
+ * "method": "GET"
4889
+ * }
4890
+ * }
4891
+ */
4892
+ "application/json": components["schemas"]["ErrorResponseDto"];
4893
+ };
4894
+ };
4895
+ };
4896
+ };
4897
+ ApiIdentitiesController_getIdentityGrants: {
4898
+ parameters: {
4899
+ query?: never;
4900
+ header?: never;
4901
+ path: {
4902
+ id: string;
4903
+ };
4904
+ cookie?: never;
4905
+ };
4906
+ requestBody?: never;
4907
+ responses: {
4908
+ /** @description Grant map returned */
4909
+ 200: {
4910
+ headers: {
4911
+ [name: string]: unknown;
4912
+ };
4913
+ content: {
4914
+ "application/json": {
4915
+ items: components["schemas"]["IdentityGrantResponseDto"][];
4916
+ };
4917
+ };
4918
+ };
4919
+ /** @description Invalid or expired token */
4920
+ 401: {
4921
+ headers: {
4922
+ [name: string]: unknown;
4923
+ };
4924
+ content: {
4925
+ /**
4926
+ * @example {
4927
+ * "error": {
4928
+ * "statusCode": 401,
4929
+ * "code": null,
4930
+ * "message": "Invalid or expired token",
4931
+ * "timestamp": "2026-04-20T12:00:00.000Z",
4932
+ * "path": "/api/v1/identities",
4933
+ * "method": "GET"
4934
+ * }
4935
+ * }
4936
+ */
4937
+ "application/json": components["schemas"]["ErrorResponseDto"];
4938
+ };
4939
+ };
4940
+ /** @description This token is not authorized for this endpoint (wrong principal type — e.g., admin token on identity-only endpoint, or vice versa) */
4941
+ 403: {
4942
+ headers: {
4943
+ [name: string]: unknown;
4944
+ };
4945
+ content: {
4946
+ /**
4947
+ * @example {
4948
+ * "error": {
4949
+ * "statusCode": 403,
4950
+ * "code": null,
4951
+ * "message": "This token is not authorized for this endpoint (wrong principal type — e.g., admin token on identity-only endpoint, or vice versa)",
4952
+ * "timestamp": "2026-04-20T12:00:00.000Z",
4953
+ * "path": "/api/v1/identities",
4954
+ * "method": "GET"
4955
+ * }
4956
+ * }
4957
+ */
4958
+ "application/json": components["schemas"]["ErrorResponseDto"];
4959
+ };
4960
+ };
4961
+ /** @description Identity not found */
4962
+ 404: {
4963
+ headers: {
4964
+ [name: string]: unknown;
4965
+ };
4966
+ content: {
4967
+ /**
4968
+ * @example {
4969
+ * "error": {
4970
+ * "statusCode": 404,
4971
+ * "code": null,
4972
+ * "message": "Identity not found",
4973
+ * "timestamp": "2026-04-20T12:00:00.000Z",
4974
+ * "path": "/api/v1/identities/{id}/grants",
4975
+ * "method": "GET"
4976
+ * }
4977
+ * }
4978
+ */
4979
+ "application/json": components["schemas"]["ErrorResponseDto"];
4980
+ };
4981
+ };
4727
4982
  };
4728
4983
  };
4729
4984
  ApiIdentityInvitesController_listInvites: {
@@ -5271,7 +5526,10 @@ interface operations {
5271
5526
  ApiNodesController_listNodes: {
5272
5527
  parameters: {
5273
5528
  query?: never;
5274
- header?: never;
5529
+ header?: {
5530
+ /** @description An `ETag` from a previous response. Answers `304 Not Modified` when the hierarchy has not changed since, so a client holding a cached tree can revalidate on a short interval without re-reading it. */
5531
+ "If-None-Match"?: string;
5532
+ };
5275
5533
  path?: never;
5276
5534
  cookie?: never;
5277
5535
  };
@@ -5286,6 +5544,13 @@ interface operations {
5286
5544
  "application/json": components["schemas"]["ScopedHierarchyTreeResponseDto"];
5287
5545
  };
5288
5546
  };
5547
+ /** @description The hierarchy is unchanged since the supplied `ETag`. No body; keep using the cached tree. */
5548
+ 304: {
5549
+ headers: {
5550
+ [name: string]: unknown;
5551
+ };
5552
+ content?: never;
5553
+ };
5289
5554
  /** @description Invalid or expired token */
5290
5555
  401: {
5291
5556
  headers: {
@@ -5438,6 +5703,80 @@ interface operations {
5438
5703
  };
5439
5704
  };
5440
5705
  };
5706
+ ApiNodesController_listNodeParents: {
5707
+ parameters: {
5708
+ query?: never;
5709
+ header?: {
5710
+ /** @description An `ETag` from a previous response. Answers `304 Not Modified` when the hierarchy has not changed since, so a client holding a cached tree can revalidate on a short interval without re-reading it. */
5711
+ "If-None-Match"?: string;
5712
+ };
5713
+ path?: never;
5714
+ cookie?: never;
5715
+ };
5716
+ requestBody?: never;
5717
+ responses: {
5718
+ /** @description Parent edges returned */
5719
+ 200: {
5720
+ headers: {
5721
+ [name: string]: unknown;
5722
+ };
5723
+ content: {
5724
+ "application/json": {
5725
+ items: components["schemas"]["NodeParentEdgeResponseDto"][];
5726
+ };
5727
+ };
5728
+ };
5729
+ /** @description The hierarchy is unchanged since the supplied `ETag`. No body; keep using the cached tree. */
5730
+ 304: {
5731
+ headers: {
5732
+ [name: string]: unknown;
5733
+ };
5734
+ content?: never;
5735
+ };
5736
+ /** @description Invalid or expired token */
5737
+ 401: {
5738
+ headers: {
5739
+ [name: string]: unknown;
5740
+ };
5741
+ content: {
5742
+ /**
5743
+ * @example {
5744
+ * "error": {
5745
+ * "statusCode": 401,
5746
+ * "code": null,
5747
+ * "message": "Invalid or expired token",
5748
+ * "timestamp": "2026-04-20T12:00:00.000Z",
5749
+ * "path": "/api/v1/nodes",
5750
+ * "method": "GET"
5751
+ * }
5752
+ * }
5753
+ */
5754
+ "application/json": components["schemas"]["ErrorResponseDto"];
5755
+ };
5756
+ };
5757
+ /** @description This token is not authorized for this endpoint (wrong principal type — e.g., admin token on identity-only endpoint, or vice versa) */
5758
+ 403: {
5759
+ headers: {
5760
+ [name: string]: unknown;
5761
+ };
5762
+ content: {
5763
+ /**
5764
+ * @example {
5765
+ * "error": {
5766
+ * "statusCode": 403,
5767
+ * "code": null,
5768
+ * "message": "This token is not authorized for this endpoint (wrong principal type — e.g., admin token on identity-only endpoint, or vice versa)",
5769
+ * "timestamp": "2026-04-20T12:00:00.000Z",
5770
+ * "path": "/api/v1/nodes",
5771
+ * "method": "GET"
5772
+ * }
5773
+ * }
5774
+ */
5775
+ "application/json": components["schemas"]["ErrorResponseDto"];
5776
+ };
5777
+ };
5778
+ };
5779
+ };
5441
5780
  ApiNodesController_getNode: {
5442
5781
  parameters: {
5443
5782
  query?: never;
@@ -9436,6 +9775,27 @@ type QueryParams<Id extends OperationId> = operations[Id] extends {
9436
9775
  } ? NonNullable<Query> : Record<string, never>;
9437
9776
  /** Shorthand for a named schema, e.g. `Schema<"EvaluateResponseDto">`. */
9438
9777
  type Schema<Name extends keyof components["schemas"]> = components["schemas"][Name];
9778
+ /**
9779
+ * Optimistic concurrency, on the operations whose spec declares `If-Match`.
9780
+ *
9781
+ * Pass the `version` you read off the resource. If someone else has changed it
9782
+ * since, the API answers 409 rather than letting the write silently clobber
9783
+ * theirs — so this is how a read-modify-write is made safe.
9784
+ */
9785
+ interface ConcurrencyOptions {
9786
+ ifMatch?: string;
9787
+ }
9788
+ /**
9789
+ * The `If-Match` header for a call that supports it, shaped to be spread into
9790
+ * `RequestOptions`.
9791
+ *
9792
+ * Spreadable rather than returning `headers: undefined`, which
9793
+ * `exactOptionalPropertyTypes` rejects — an absent property and one explicitly
9794
+ * set to undefined are not the same thing here.
9795
+ */
9796
+ declare function withConcurrency(options?: ConcurrencyOptions): {
9797
+ headers: Record<string, string>;
9798
+ } | Record<string, never>;
9439
9799
 
9440
9800
  /**
9441
9801
  * Role grants: which identity holds which role, at which node.
@@ -9490,8 +9850,18 @@ declare class Identities {
9490
9850
  */
9491
9851
  deactivate(id: string): Promise<ResponseBody<"ApiIdentitiesController_deactivateIdentity">>;
9492
9852
  activate(id: string): Promise<ResponseBody<"ApiIdentitiesController_activateIdentity">>;
9493
- /** Every role this identity holds, and where. */
9494
- assignments(id: string): Promise<ResponseBody<"ApiIdentitiesController_getIdentityAssignments">>;
9853
+ /**
9854
+ * Every role this identity holds, and where — across all pages.
9855
+ *
9856
+ * Paginated (20 per page by default), so this returns a `Paginator` rather
9857
+ * than one response. Reading a single page here would under-report what an
9858
+ * identity can do, which is the dangerous direction to be wrong in.
9859
+ *
9860
+ * ```ts
9861
+ * for await (const assignment of canopy.identities.assignments(id)) { … }
9862
+ * ```
9863
+ */
9864
+ assignments(id: string, query?: QueryParams<"ApiIdentitiesController_getIdentityAssignments">): Paginator<IdentityAssignmentItem>;
9495
9865
  /**
9496
9866
  * The permissions this identity effectively holds, inheritance resolved.
9497
9867
  *
@@ -9502,6 +9872,8 @@ declare class Identities {
9502
9872
  permissions(id: string): Promise<ResponseBody<"ApiIdentitiesController_getIdentityPermissions">>;
9503
9873
  }
9504
9874
  type IdentityItem = ItemOf$2<ResponseBody<"ApiIdentitiesController_listIdentities">>;
9875
+ /** One role grant from an identity's assignments collection. */
9876
+ type IdentityAssignmentItem = ItemOf$2<ResponseBody<"ApiIdentitiesController_getIdentityAssignments">>;
9505
9877
  type ItemOf$2<T> = T extends {
9506
9878
  items: (infer Item)[];
9507
9879
  } ? Item : never;
@@ -9524,28 +9896,37 @@ declare class Permissions {
9524
9896
  * `effective_node_id: null` — that answer must never be used to guard a
9525
9897
  * resource that belongs to a specific node, which is why the scope is a
9526
9898
  * required field rather than a default.
9899
+ *
9900
+ * This runs on the request path, so it is the call most worth passing
9901
+ * `signal` and a tight `timeoutMs` to: without them a slow answer here holds
9902
+ * an inbound request open for the client-wide deadline on every attempt.
9527
9903
  */
9528
- evaluate(input: RequestBody<"ApiPermissionsController_evaluate">): Promise<ResponseBody<"ApiPermissionsController_evaluate">>;
9904
+ evaluate(input: RequestBody<"ApiPermissionsController_evaluate">, options?: CallOptions): Promise<ResponseBody<"ApiPermissionsController_evaluate">>;
9529
9905
  /**
9530
9906
  * Evaluate many decisions in one round trip.
9531
9907
  *
9532
9908
  * Prefer this to a loop over `evaluate` when rendering a screen: the checks
9533
9909
  * are answered together instead of paying request latency for each.
9534
9910
  */
9535
- evaluateBulk(input: RequestBody<"ApiPermissionsController_evaluateBulk">): Promise<ResponseBody<"ApiPermissionsController_evaluateBulk">>;
9911
+ evaluateBulk(input: RequestBody<"ApiPermissionsController_evaluateBulk">, options?: CallOptions): Promise<ResponseBody<"ApiPermissionsController_evaluateBulk">>;
9536
9912
  /**
9537
9913
  * The same decision with its reasoning — which role granted it, which node
9538
9914
  * it was inherited from. For debugging an unexpected allow or deny, not for
9539
9915
  * the enforcement path.
9540
9916
  */
9541
- explain(input: RequestBody<"ApiPermissionsController_explain">): Promise<ResponseBody<"ApiPermissionsController_explain">>;
9917
+ explain(input: RequestBody<"ApiPermissionsController_explain">, options?: CallOptions): Promise<ResponseBody<"ApiPermissionsController_explain">>;
9542
9918
  /** Every permission in the Environment, page by page. */
9543
9919
  list(query?: QueryParams<"ApiPermissionsController_listPermissions">): Paginator<PermissionItem>;
9544
9920
  get(id: string): Promise<ResponseBody<"ApiPermissionsController_getPermission">>;
9545
9921
  /** Define permissions. The request takes a batch, not a single key. */
9546
9922
  create(input: RequestBody<"ApiPermissionsController_createPermissions">): Promise<ResponseBody<"ApiPermissionsController_createPermissions">>;
9547
- update(id: string, input: RequestBody<"ApiPermissionsController_updatePermission">): Promise<ResponseBody<"ApiPermissionsController_updatePermission">>;
9548
- delete(id: string): Promise<void>;
9923
+ /**
9924
+ * Pass `ifMatch` with the permission's current `version` to make a
9925
+ * read-modify-write safe — a concurrent edit answers 409 instead of being
9926
+ * silently overwritten.
9927
+ */
9928
+ update(id: string, input: RequestBody<"ApiPermissionsController_updatePermission">, options?: ConcurrencyOptions): Promise<ResponseBody<"ApiPermissionsController_updatePermission">>;
9929
+ delete(id: string, options?: ConcurrencyOptions): Promise<void>;
9549
9930
  }
9550
9931
  /**
9551
9932
  * One item from the permissions collection.
@@ -9565,8 +9946,13 @@ declare class Roles {
9565
9946
  list(query?: QueryParams<"ApiRolesController_listRoles">): Paginator<RoleItem>;
9566
9947
  get(id: string): Promise<ResponseBody<"ApiRolesController_getRole">>;
9567
9948
  create(input: RequestBody<"ApiRolesController_createRole">): Promise<ResponseBody<"ApiRolesController_createRole">>;
9568
- update(id: string, input: RequestBody<"ApiRolesController_updateRole">): Promise<ResponseBody<"ApiRolesController_updateRole">>;
9569
- delete(id: string): Promise<void>;
9949
+ /**
9950
+ * Pass `ifMatch` with the role's current `version` to make a
9951
+ * read-modify-write safe — a concurrent edit answers 409 instead of being
9952
+ * silently overwritten.
9953
+ */
9954
+ update(id: string, input: RequestBody<"ApiRolesController_updateRole">, options?: ConcurrencyOptions): Promise<ResponseBody<"ApiRolesController_updateRole">>;
9955
+ delete(id: string, options?: ConcurrencyOptions): Promise<void>;
9570
9956
  permissions(id: string): Promise<ResponseBody<"ApiRolesController_getRolePermissions">>;
9571
9957
  /**
9572
9958
  * Replaces the role's permissions wholesale — this is a PUT, so anything
@@ -9608,13 +9994,113 @@ declare class Canopy {
9608
9994
  constructor(options: CanopyClientOptions);
9609
9995
  }
9610
9996
 
9997
+ interface LocalAuthorizerOptions {
9998
+ /**
9999
+ * How long grant roots and the hierarchy are reused, in milliseconds.
10000
+ * Defaults to 60s.
10001
+ *
10002
+ * This is a staleness budget, not a performance dial. Shortening it makes
10003
+ * revocation take effect sooner and costs more calls — and below the gap
10004
+ * between a user's requests it stops saving anything at all, because every
10005
+ * request finds the cache expired and refetches. Human-paced traffic has
10006
+ * multi-second gaps, so a value of a few seconds can cost full price for no
10007
+ * benefit.
10008
+ *
10009
+ * **`0` disables caching**, reading fresh on every check. That is the escape
10010
+ * for a caller who cannot accept a stale allow at all — it costs a round trip
10011
+ * per request, which is what this class exists to avoid, so reach for it
10012
+ * knowingly rather than as a default.
10013
+ */
10014
+ ttlMs?: number;
10015
+ /**
10016
+ * Per-attempt deadline for the reads this class makes.
10017
+ *
10018
+ * A cache hit costs nothing, but a miss happens on the request path and
10019
+ * holds an inbound request open exactly as a per-request check used to — so
10020
+ * the bound still matters, it just applies far less often.
10021
+ */
10022
+ timeoutMs?: number;
10023
+ /** Retries for those reads. */
10024
+ maxRetries?: number;
10025
+ /** Injectable clock, for tests. */
10026
+ now?: () => number;
10027
+ }
10028
+ interface LocalAuthorizationQuery {
10029
+ identity_id: string;
10030
+ permission: string;
10031
+ /** Defaults to `node`, which is the strict question. */
10032
+ scope?: "node" | "app_wide";
10033
+ node_id?: string;
10034
+ }
10035
+ interface LocalAuthorizerStats {
10036
+ /** Calls made to read an identity's grant roots. */
10037
+ grantFetches: number;
10038
+ /** Calls made to read the hierarchy, including revalidations. */
10039
+ treeRequests: number;
10040
+ /** Revalidations the server answered `304`, so no tree was transferred. */
10041
+ treeNotModified: number;
10042
+ }
10043
+ declare class LocalAuthorizer {
10044
+ private readonly client;
10045
+ private readonly ttlMs;
10046
+ private readonly now;
10047
+ private readonly readOptions;
10048
+ private readonly grants;
10049
+ private tree;
10050
+ /**
10051
+ * In-flight reads, so concurrent requests for the same thing share one call.
10052
+ *
10053
+ * Without this a cold start under load fans out: a hundred simultaneous
10054
+ * requests for one identity would each miss the cache and each fetch, which
10055
+ * is the per-request traffic this class exists to remove, concentrated into
10056
+ * the worst possible moment.
10057
+ */
10058
+ private readonly pendingGrants;
10059
+ private pendingTree;
10060
+ private stats;
10061
+ constructor(client: CanopyClient, options?: LocalAuthorizerOptions);
10062
+ /**
10063
+ * Whether the identity holds the permission.
10064
+ *
10065
+ * Shaped like the API's own evaluate so a caller can swap one for the other.
10066
+ * A `node` check with no node is a denial rather than an error: a request
10067
+ * whose subject cannot be established is exactly the one that must not pass.
10068
+ */
10069
+ evaluate(query: LocalAuthorizationQuery, options?: {
10070
+ signal?: AbortSignal;
10071
+ }): Promise<{
10072
+ allowed: boolean;
10073
+ }>;
10074
+ private decide;
10075
+ /** Counters for observability — how much traffic the cache is actually saving. */
10076
+ snapshot(): LocalAuthorizerStats;
10077
+ /**
10078
+ * Drop everything held. Not needed in normal operation, where entries expire
10079
+ * on their own; useful in tests and after a known change.
10080
+ */
10081
+ invalidate(): void;
10082
+ /** Climb from `nodeId` and look for a grant root among its ancestors. */
10083
+ private holdsAtNode;
10084
+ private grantRootsFor;
10085
+ private fetchGrantRoots;
10086
+ private parents;
10087
+ /**
10088
+ * Revalidate rather than re-read. The hierarchy is the expensive half and
10089
+ * the one that changes least, so the common case is a `304` and no transfer
10090
+ * at all — the tree stays in memory and only its expiry moves.
10091
+ */
10092
+ private fetchTree;
10093
+ }
10094
+
9611
10095
  /**
9612
10096
  * Everything this client throws.
9613
10097
  *
9614
- * Two classes, because callers act on the distinction: `CanopyError` means the
9615
- * API answered and said no, `CanopyConnectionError` means it never answered.
9616
- * The first is a decision you may need to surface to a user; the second is
9617
- * usually worth retrying or alerting on.
10098
+ * Three classes, because callers act on the distinction: `CanopyError` means
10099
+ * the API answered and said no, `CanopyConnectionError` means it never
10100
+ * answered, and `CanopyTokenError` means a token failed local verification
10101
+ * without anything being asked of the API at all. The first is a decision you
10102
+ * may need to surface to a user; the second is usually worth retrying or
10103
+ * alerting on; the third is a 401 for the caller who presented the token.
9618
10104
  */
9619
10105
  /** The `error` object inside Canopy's error envelope. */
9620
10106
  interface CanopyErrorBody {
@@ -9651,18 +10137,29 @@ declare class CanopyError extends Error {
9651
10137
  method: string;
9652
10138
  path: string;
9653
10139
  };
10140
+ /**
10141
+ * How long to wait before retrying, in milliseconds, when the server said so
10142
+ * via `Retry-After` — populated on a 429, and on any other response that
10143
+ * carries the header. Undefined when the server gave no guidance.
10144
+ */
10145
+ readonly retryAfterMs: number | undefined;
9654
10146
  constructor(body: CanopyErrorBody, request: {
9655
10147
  method: string;
9656
10148
  path: string;
9657
- });
10149
+ }, retryAfterMs?: number);
9658
10150
  /** A 429. `retryAfterMs` is populated when the server said how long to wait. */
9659
10151
  get isRateLimited(): boolean;
9660
10152
  /** 401 or 403 — the credential is wrong or not permitted here. */
9661
10153
  get isAuthFailure(): boolean;
9662
10154
  }
9663
10155
  /**
9664
- * The request never produced a response — DNS failure, connection reset,
9665
- * timeout, or an aborted signal.
10156
+ * The request never produced a response — DNS failure, connection reset, or a
10157
+ * timeout.
10158
+ *
10159
+ * Not this: a caller's own cancellation. Aborting the `signal` passed to a
10160
+ * request rejects with that abort (`AbortError`, or whatever `signal.reason`
10161
+ * holds) and is never retried, because the caller stopping is an intent rather
10162
+ * than a failure to report.
9666
10163
  *
9667
10164
  * Kept separate from `CanopyError` because there is no status code and no
9668
10165
  * server opinion to report: nothing is known about whether the operation
@@ -9681,8 +10178,202 @@ declare class CanopyConnectionError extends Error {
9681
10178
  cause?: unknown;
9682
10179
  });
9683
10180
  }
10181
+ /**
10182
+ * A token failed verification.
10183
+ *
10184
+ * Separate from `CanopyError` because nothing was asked of the API: the token
10185
+ * was checked here, against keys already held. There is no status code to
10186
+ * report and no server opinion to relay — the decision was local, which is the
10187
+ * whole point of verifying a signature rather than calling an endpoint.
10188
+ *
10189
+ * Branch on `code`; it follows the same dot-notation contract as the API's own
10190
+ * codes. Every one of them means the same thing to an HTTP caller — 401 — so
10191
+ * the code is for your logs and your tests, not usually for the response.
10192
+ */
10193
+ declare class CanopyTokenError extends Error {
10194
+ readonly name = "CanopyTokenError";
10195
+ /**
10196
+ * One of:
10197
+ *
10198
+ * - `token.malformed` — not a JWS, or a segment would not decode
10199
+ * - `token.unsupported_algorithm` — not RS256; Canopy issues only RS256
10200
+ * - `token.key_not_found` — no published key matches the token's `kid`
10201
+ * - `token.jwks_unavailable` — the key set could not be fetched or parsed
10202
+ * - `token.signature_invalid` — signature does not match the signing key
10203
+ * - `token.expired` / `token.not_yet_valid` — outside its validity window
10204
+ * - `token.issuer_mismatch` — `iss` is not the configured issuer
10205
+ * - `token.audience_mismatch` — `aud` does not include the configured audience
10206
+ * - `token.audience_unverified` — token has an `aud` but none was configured
10207
+ * - `token.preauth_not_allowed` — a pre-auth token, which grants no access
10208
+ */
10209
+ readonly code: string;
10210
+ constructor(code: string, message: string, options?: {
10211
+ cause?: unknown;
10212
+ });
10213
+ }
10214
+ /**
10215
+ * The local authorizer could not answer, and must not pretend otherwise.
10216
+ *
10217
+ * Distinct from a denial. It is raised when the data a decision needs is not
10218
+ * merely absent but *unreadable* — most often a credential that cannot read the
10219
+ * hierarchy, which comes back as an empty tree rather than an error. Denying
10220
+ * there would be a silent, total false-deny on every node-scoped route while
10221
+ * every response still looked healthy.
10222
+ *
10223
+ * No retry fixes it, so callers should treat it as a misconfiguration rather
10224
+ * than an outage.
10225
+ */
10226
+ declare class CanopyAuthorizerError extends Error {
10227
+ readonly name = "CanopyAuthorizerError";
10228
+ readonly code: string;
10229
+ constructor(code: string, message: string, options?: {
10230
+ cause?: unknown;
10231
+ });
10232
+ }
10233
+ declare function isCanopyAuthorizerError(error: unknown): error is CanopyAuthorizerError;
9684
10234
  /** Narrowing helper that survives bundling and duplicate copies of the package. */
9685
10235
  declare function isCanopyError(error: unknown): error is CanopyError;
9686
10236
  declare function isCanopyConnectionError(error: unknown): error is CanopyConnectionError;
10237
+ declare function isCanopyTokenError(error: unknown): error is CanopyTokenError;
10238
+
10239
+ interface TokenVerifierOptions {
10240
+ /**
10241
+ * The issuer whose tokens are accepted, matched against `iss` exactly.
10242
+ * Defaults to Canopy's hosted issuer; set it for a self-hosted instance.
10243
+ *
10244
+ * Safe to default because it fails closed: pointed at the wrong issuer, a
10245
+ * token is rejected rather than accepted.
10246
+ */
10247
+ issuer?: string;
10248
+ /**
10249
+ * Required audience, matched against `aud`.
10250
+ *
10251
+ * Hosted Login (OAuth) tokens carry `aud` — your client id — and Direct API
10252
+ * identity tokens do not. Verifying an OAuth token without setting this
10253
+ * throws rather than ignoring the claim, because `aud` is what stops a token
10254
+ * minted for one client being replayed at another.
10255
+ */
10256
+ audience?: string;
10257
+ /** Where the signing keys live. Defaults to `${issuer}/.well-known/jwks.json`. */
10258
+ jwksUri?: string;
10259
+ /** How long a fetched key set is reused. Defaults to 10 minutes. */
10260
+ jwksCacheMaxAgeMs?: number;
10261
+ /** Floor between refetches provoked by an unknown `kid`. Defaults to 30s. */
10262
+ jwksMinRefetchIntervalMs?: number;
10263
+ /** Leeway on `exp` and `nbf`, in seconds. Defaults to 60. */
10264
+ clockToleranceSec?: number;
10265
+ /**
10266
+ * Accept pre-auth tokens. Defaults to `false`, and should stay false unless
10267
+ * you are building an account picker.
10268
+ *
10269
+ * A pre-auth token is a genuine, correctly-signed Canopy token issued
10270
+ * partway through a multi-account login: the person proved their password
10271
+ * but has not yet chosen an Account, so the token carries no Account context
10272
+ * and grants nothing. Accepting one as a session is a privilege escalation,
10273
+ * and it is the failure a hand-rolled verifier is most likely to miss —
10274
+ * every signature check passes.
10275
+ */
10276
+ allowPreAuthTokens?: boolean;
10277
+ /**
10278
+ * Deadline for a single JWKS read, in milliseconds. Defaults to 5s.
10279
+ *
10280
+ * Without one, an issuer that accepts a connection and then never answers
10281
+ * holds every inbound request that needs a key — the verification sits on the
10282
+ * request path, so an unbounded read there is an unbounded request.
10283
+ */
10284
+ jwksTimeoutMs?: number;
10285
+ /** Injectable for tests and for runtimes with a non-global fetch. */
10286
+ fetch?: typeof globalThis.fetch;
10287
+ }
10288
+ /**
10289
+ * The claims Canopy puts in an access token.
10290
+ *
10291
+ * Named claims are the ones the API guarantees; the index signature keeps
10292
+ * anything added later reachable without a version bump.
10293
+ */
10294
+ interface CanopyTokenClaims {
10295
+ /** The identity or user this token acts as. */
10296
+ sub: string;
10297
+ /** Which kind of principal `sub` is. */
10298
+ type: "user" | "identity" | "api_key" | "platform";
10299
+ iss: string;
10300
+ exp: number;
10301
+ iat?: number;
10302
+ nbf?: number;
10303
+ aud?: string | string[];
10304
+ account_id?: string;
10305
+ application_id?: string;
10306
+ /** Identity tokens carry this; admin tokens deliberately omit it. */
10307
+ environment_id?: string;
10308
+ account_slug?: string;
10309
+ application_slug?: string;
10310
+ environment_slug?: string;
10311
+ /** Present only on a pre-auth token — see `allowPreAuthTokens`. */
10312
+ token_type?: "preauth";
10313
+ console_access?: "granted" | "none";
10314
+ /** Emitted only when the `permissions` OAuth scope was granted. */
10315
+ permissions?: string[];
10316
+ /** True when `permissions` was truncated; query the API for the full set. */
10317
+ permissions_overflow?: boolean;
10318
+ [claim: string]: unknown;
10319
+ }
10320
+ declare class TokenVerifier {
10321
+ private readonly issuer;
10322
+ private readonly audience;
10323
+ private readonly jwksUri;
10324
+ private readonly jwksCacheMaxAgeMs;
10325
+ private readonly jwksMinRefetchIntervalMs;
10326
+ private readonly jwksTimeoutMs;
10327
+ private readonly clockToleranceSec;
10328
+ private readonly allowPreAuthTokens;
10329
+ private readonly fetchImpl;
10330
+ /** Imported keys by `kid`, so a repeat verification skips the import cost. */
10331
+ private keys;
10332
+ private keysFetchedAt;
10333
+ private lastFetchAttemptAt;
10334
+ /** In-flight fetch, so a burst of requests triggers one call, not N. */
10335
+ private inFlight;
10336
+ constructor(options?: TokenVerifierOptions);
10337
+ /**
10338
+ * Verify a token and return its claims. Throws {@link CanopyTokenError} on
10339
+ * anything short of a full pass — branch on `error.code`.
10340
+ *
10341
+ * Order matters: the signature is checked before any claim is believed, so
10342
+ * nothing downstream ever reads an unverified payload.
10343
+ */
10344
+ verify(token: string): Promise<CanopyTokenClaims>;
10345
+ /** Everything checked after the signature is known good. */
10346
+ private assertClaims;
10347
+ /**
10348
+ * `aud` is checked when either side mentions it.
10349
+ *
10350
+ * The case worth stating: a token carries `aud` but the verifier was not
10351
+ * configured with one. That is not "no audience to check" — it is an OAuth
10352
+ * token being verified by something that never said which client it is, and
10353
+ * ignoring it would accept a token minted for a different client. So it
10354
+ * throws and names the option.
10355
+ */
10356
+ private assertAudience;
10357
+ /**
10358
+ * The signing key for a `kid`, fetching the key set when it is stale or when
10359
+ * the `kid` is unknown — the latter is how key rotation is picked up
10360
+ * mid-process, bounded by `jwksMinRefetchIntervalMs`.
10361
+ */
10362
+ private resolveKey;
10363
+ /**
10364
+ * Whether to await a key-set refresh.
10365
+ *
10366
+ * Two ways to qualify, and the first matters as much as the second. A read
10367
+ * already in flight is joined regardless of the floor: it costs no extra
10368
+ * outbound request, and it is what lets a concurrent burst share one fetch
10369
+ * instead of one caller winning and the rest being turned away.
10370
+ *
10371
+ * Otherwise the floor applies — the same one for every refetch path, so they
10372
+ * cannot drift into having different amplification properties.
10373
+ */
10374
+ private shouldRefresh;
10375
+ private refreshKeys;
10376
+ private fetchKeys;
10377
+ }
9687
10378
 
9688
- export { type AssignmentItem, Assignments, Canopy, CanopyClient, type CanopyClientOptions, CanopyConnectionError, CanopyError, type CanopyErrorBody, type Collection, type CursorPagination, Identities, type IdentityItem, type OffsetPagination, type OperationId, type PageFetcher, type PageParams, type PaginateOptions, type Pagination, Paginator, type PartialSuccess, type PermissionItem, Permissions, type QueryParams, type RequestBody, type RequestOptions, type ResponseBody, type RoleItem, Roles, type Schema, type components, isCanopyConnectionError, isCanopyError, isCursorPagination, type operations, paginate, type paths };
10379
+ export { type AssignmentItem, Assignments, type CallOptions, Canopy, CanopyAuthorizerError, CanopyClient, type CanopyClientOptions, CanopyConnectionError, CanopyError, type CanopyErrorBody, type CanopyTokenClaims, CanopyTokenError, type Collection, type ConcurrencyOptions, type ConditionalResult, type CursorPagination, Identities, type IdentityAssignmentItem, type IdentityItem, type LocalAuthorizationQuery, LocalAuthorizer, type LocalAuthorizerOptions, type LocalAuthorizerStats, type OffsetPagination, type OperationId, type PageFetcher, type PageParams, type PaginateOptions, type Pagination, Paginator, type PartialSuccess, type PermissionItem, Permissions, type QueryParams, type RequestBody, type RequestOptions, type ResponseBody, type RoleItem, Roles, type Schema, TokenVerifier, type TokenVerifierOptions, type components, isCanopyAuthorizerError, isCanopyConnectionError, isCanopyError, isCanopyTokenError, isCursorPagination, type operations, paginate, type paths, withConcurrency };