@canopy-io/node 0.3.2 → 0.3.4

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/CHANGELOG.md ADDED
@@ -0,0 +1,238 @@
1
+ # @canopy-io/node
2
+
3
+ ## 0.3.4
4
+
5
+ ### Patch Changes
6
+
7
+ - faefa0a: Type the organization sign-in surface: SSO connection, domains, recovery codes
8
+ and directory sync.
9
+
10
+ The API has let a backend configure an organization's own sign-in since
11
+ 2026-09-18, and nothing in this package said so. A consumer checking the SDK —
12
+ or the coding agent working for them — found no such operations and concluded
13
+ Canopy could not do it. The published spec now carries 132 operations (was
14
+ 102), all additive: no operation was removed or renamed, and no existing
15
+ request or response shape lost a field.
16
+
17
+ Newly typed, under `/api/v1/organizations/{id}`:
18
+
19
+ - **SSO connection** — create, read, update, remove, import SAML metadata,
20
+ activate, disable, service-provider details, recent sign-ins.
21
+ - **Domains** — list, claim (returns the TXT challenge), verify, remove.
22
+ Routing and just-in-time provisioning read only verified domains.
23
+ - **SSO recovery codes** — regenerate. Turning `require_sso` on through
24
+ `updatePolicy` issues the first batch.
25
+ - **Test sign-in** — start, and read the outcome.
26
+ - **Directory sync** — create, read, remove; mint, rotate and revoke SCIM
27
+ tokens; activity; groups and their role mappings; the roles a directory may
28
+ grant.
29
+ - **Roles** — the roles assignable in the organization.
30
+
31
+ Plus Environment branding (read and update).
32
+
33
+ These are typed and callable today through `canopy.client.request` with
34
+ `RequestBody<…>` / `ResponseBody<…>`; resource methods for them are not in this
35
+ release. No caller changes are needed to take it.
36
+
37
+ ## 0.3.3
38
+
39
+ ### Patch Changes
40
+
41
+ - b6b1dd0: Ship the changelog with the package, and create a GitHub Release per published
42
+ version.
43
+
44
+ The changelog was written on every release and reachable by nobody: `files`
45
+ omitted it, so npm showed none, and the release workflow pushed tags without
46
+ creating releases, so the Releases page sat empty. A consumer deciding whether
47
+ to take a bump had nothing to read. `CHANGELOG.md` is now in the tarball, each
48
+ README links it and notes that a pre-1.0 minor may break, and the workflow
49
+ opens a release whose body is that version's changelog section.
50
+
51
+ ## 0.3.2
52
+
53
+ ### Patch Changes
54
+
55
+ - 7e71cc7: Regenerate the request/response types from Canopy's OpenAPI document.
56
+ Additive, no existing caller shape changed:
57
+
58
+ - MFA factor entries carry `webauthn_rp_id`: for a passkey, the relying-party
59
+ id it was created under and the only domain it answers on (the
60
+ Environment's own `webauthn_rp_id`, or Canopy's when the Environment names
61
+ none). `null` for every other factor type.
62
+
63
+ ## 0.3.1
64
+
65
+ ### Patch Changes
66
+
67
+ - 5b06ccf: Regenerate the request/response types from Canopy's OpenAPI document.
68
+ Additive, no existing caller shape changed:
69
+
70
+ - Organizations carry `external_id` (your own id, unique per Environment)
71
+ on the create, update, list, and detail shapes, and the list accepts
72
+ `external_id` as an exact-match query.
73
+ - Webhook event-type entries carry `scope` (`"environment"` or
74
+ `"account"`), and `GET /api/v1/webhooks/event-types` now lists every
75
+ event with the scope that subscribes to it, so an environment API key
76
+ learns which events need an account-scoped subscription.
77
+
78
+ ## 0.3.0
79
+
80
+ ### Minor Changes
81
+
82
+ - 8ad7dd0: Organizations support. Identity tokens minted in an Environment with the
83
+ organizations container on carry `org_id` and `org_role`; both are now
84
+ declared on `CanopyTokenClaims`, and the new `orgContext(claims)` helper
85
+ returns the verified pair (or `null` for a token acting in no organization,
86
+ refusing a half-present pair). `@RequirePermission` gains `scope: "org"`: the
87
+ guard evaluates at the token's `org_id` node through the existing
88
+ `LocalAuthorizer` — an organization is a hierarchy node and a membership is a
89
+ role assignment at it — reading the claim off what `CanopyTokenGuard`
90
+ attached, overridable with the new `resolveOrg` module option. A caller
91
+ acting in no organization is denied without a network call.
92
+ - e4ca3ef: Organizations as a container: `canopy.organizations` wraps the tenant API
93
+ (organizations, members, invitations, the per-organization sign-in policy,
94
+ and SSO connection binding), `CanopyTokenClaims` declares `amr`, types are
95
+ regenerated against the current spec, and the wording no longer describes
96
+ organizations as an access model. The published spec now carries 102 operations (was 84), all additive.
97
+
98
+ ## 0.2.0
99
+
100
+ ### Minor Changes
101
+
102
+ - 13ecf63: `LocalAuthorizer.invalidate(identityId?)` — with an identity id, only that
103
+ identity's cached grants are dropped; the hierarchy and every other identity
104
+ stay warm. This is the shape an assignment webhook wants: invalidate exactly
105
+ the identity the event names. With no argument the behaviour is unchanged
106
+ (drop everything). The jsdoc now also states the multi-instance guarantee
107
+ honestly: an invalidation reaches one process, so behind a load balancer the
108
+ fleet-wide revocation guarantee remains the TTL.
109
+
110
+ ### Patch Changes
111
+
112
+ - b873daa: Fix type resolution for CommonJS consumers on `node16`/`nodenext`: the
113
+ `exports` map now declares per-condition `types`, pointing `require` at the
114
+ `index.d.cts` the build already emitted. Previously a `require()` that worked
115
+ at runtime was rejected by TypeScript (TS1479), forcing dynamic-import and
116
+ `resolution-mode` workarounds. Verified with `arethetypeswrong` across
117
+ node10, node16-CJS, node16-ESM and bundler resolution.
118
+ - 003761a: Regenerate the request/response types from the published OpenAPI document.
119
+ Additive: picks up the permission-usage endpoint
120
+ (`GET /api/v1/permissions/{id}/usage`) and refreshed operation descriptions.
121
+ No existing caller shape changed.
122
+
123
+ ## 0.1.1
124
+
125
+ ### Patch Changes
126
+
127
+ - 89f0a46: Answer permission checks in-process instead of calling Canopy on every request.
128
+
129
+ **Behavior change — `CanopyGuard` no longer makes a network call per guarded request.** It asks a different question, once: rather than "may this identity act _here_", it reads _where_ the identity may act and answers everything after that locally.
130
+
131
+ Two things are held, split by what they depend on: the identity's grant roots, and one copy of your hierarchy shared by every identity in the process. A check walks up from the node in question looking for a grant root. Grant roots are never expanded through their descendants — a grant already means "this node and everything beneath it", and expanding would restate the hierarchy once per identity, largest for the near-root grants administrators hold.
132
+
133
+ ```ts
134
+ CanopyModule.forRoot({
135
+ apiKey: process.env.CANOPY_API_KEY,
136
+ authorizationTtlMs: 60_000, // the default
137
+ resolveIdentity: (request) => request.user?.sub,
138
+ });
139
+ ```
140
+
141
+ **Both are held for at most 60 seconds, which is the delay between an access change and it taking effect.** A revoked user can still act for up to a minute. The window covers everything that can alter an answer, including a moved node — reparenting changes what an inherited grant reaches even though no grant changed, so the hierarchy is revalidated on the same cadence and not a slower one.
142
+
143
+ Set `authorizationTtlMs: 0` to switch caching off and read fresh on every check. That reinstates a round trip per request, so reach for it knowingly. For a single high-value operation, ask the API directly through the injected client instead — `canopy.permissions.evaluate` is unchanged.
144
+
145
+ Do not shorten the window below the gap between a user's requests: every request then finds the cache expired and refetches, which is the per-request traffic this removes.
146
+
147
+ **`LocalAuthorizer`** (`@canopy-io/node`) is the same machinery without NestJS. **`requestConditional`** sends a validator you already hold and reports whether anything changed, so something expensive can be revalidated cheaply.
148
+
149
+ **Requires a credential that can read both halves.** A `full_access` API key already can; a scoped one needs `identity.view` and `hierarchy.view`. Without the latter the hierarchy endpoint answers an empty tree and a `200` — so a node-scoped check raises `CanopyAuthorizerError` rather than returning `false`. Not being able to see the hierarchy is not the same as the identity lacking the permission, and answering `false` there would deny every node-scoped request while looking entirely healthy.
150
+
151
+ `CanopyGuard` still fails closed at every step. A `404` with no error code — the API not serving the grants endpoint at all — is reported as a misconfiguration rather than a denial, because denying there would take out every guarded route at once while reading as ordinary policy.
152
+
153
+ - 89f0a46: Verify Canopy access tokens locally, and guard a route with the result.
154
+
155
+ `TokenVerifier` checks Canopy's RS256 signatures against the published key set: one JWKS fetch, then a signature check per request with no network at all. Authorization asks the API a question; authentication does not need to.
156
+
157
+ ```ts
158
+ const verifier = new TokenVerifier();
159
+ const claims = await verifier.verify(bearerToken);
160
+ ```
161
+
162
+ The traps are why this ships rather than being left to each caller. The algorithm is pinned, so `alg: none` and HMAC key-confusion cannot get in. Refetches triggered by an unknown `kid` are floored, so a forged header cannot amplify one inbound request into one outbound fetch — and the same floor applies when the key set is empty or stale, which is the case an issuer outage produces. The JWKS read is bounded by a deadline, because verification sits on the request path and an unbounded read there is an unbounded inbound request. A token carrying an `aud` is refused rather than ignored when no audience was configured. And a **pre-auth** token — genuine, correctly signed, issued before the user selected an Account — is refused, because treating one as a session is a privilege escalation.
163
+
164
+ `sub` and `type` are validated rather than asserted. They are declared on `CanopyTokenClaims` but arrive as parsed JSON, and a `string` that is actually `undefined` is worse than a rejected token because it looks like an answer.
165
+
166
+ **`CanopyTokenGuard`** (`@canopy-io/nestjs`) applies this to a route and attaches the claims, for applications with no auth layer in front:
167
+
168
+ ```ts
169
+ @UseGuards(CanopyTokenGuard, CanopyGuard)
170
+ @RequirePermission("documents.read")
171
+ findAll() {}
172
+ ```
173
+
174
+ Order matters — `CanopyGuard` reads what `CanopyTokenGuard` attached. Claims land on `request.canopyToken`, not `request.user`, which belongs to Passport. Skip the guard entirely if you already run your own JWT middleware; nothing else depends on it.
175
+
176
+ A rejected token is a `401` whatever the detail. The exception is `token.jwks_unavailable`, which answers **503**: not being able to verify is not the same as failing to verify, the caller's token may be perfectly good, and telling a client its token is stale during a key-server outage only aims a refresh storm at the thing that is already down.
177
+
178
+ - 41241ca: Cancellation, per-request headers, per-call deadlines, and two correctness fixes on the authorization path.
179
+
180
+ **Behavior change — `identities.assignments` is paginated.** It now returns a `Paginator` rather than a single response, and accepts query parameters. The endpoint is paginated at 20 per page; the previous signature returned only the first page while documenting itself as "every role this identity holds", so an identity with more than 20 assignments was silently under-reported — the dangerous direction to be wrong in on an authorization surface.
181
+
182
+ ```ts
183
+ // before — first page only
184
+ const { items } = await canopy.identities.assignments(id);
185
+
186
+ // after — every page
187
+ const all = await canopy.identities.assignments(id).all();
188
+ for await (const assignment of canopy.identities.assignments(id)) {
189
+ }
190
+ ```
191
+
192
+ **The permission evaluations now retry a 5xx.** `evaluate`, `evaluateBulk` and `explain` are POSTs only because the question travels in a body — they compute a decision and write nothing. They were subject to the client's POST-is-not-idempotent rule, which made the hot path the least resilient call in the SDK. They are now marked idempotent; endpoints that create are unchanged and still never retried.
193
+
194
+ **Behavior change — caller cancellation.** Aborting the `AbortSignal` passed to a request now propagates as an abort (`AbortError`, or whatever `signal.reason` holds) and is never retried. Previously a caller's abort was wrapped as `CanopyConnectionError` and, on an idempotent request, retried — re-issuing a request the caller had already cancelled. An already-aborted signal now short-circuits before `fetch` is called at all, and an abort during retry backoff stops there.
195
+
196
+ Timeouts and transport failures are unchanged: they still throw `CanopyConnectionError` and are still retried where the method allows it.
197
+
198
+ If you branch on `isCanopyConnectionError` to handle cancellation, match on the abort instead:
199
+
200
+ ```ts
201
+ catch (error) {
202
+ if ((error as Error).name === "AbortError") {
203
+ // the caller cancelled
204
+ }
205
+ }
206
+ ```
207
+
208
+ **Per-request headers.** `RequestOptions.headers` sets headers for a single call, so the protocol headers the API documents are now reachable: `If-Match` for optimistic concurrency, and `Idempotency-Key` for safe bulk retries. Per-request headers override the client-wide `headers`; the credential is applied last and cannot be replaced by a call site.
209
+
210
+ The four wrapped operations whose spec declares `If-Match` take it directly:
211
+
212
+ ```ts
213
+ await canopy.roles.update(id, { name: "Editor" }, { ifMatch: role.version });
214
+ await canopy.roles.delete(id, { ifMatch: role.version });
215
+ await canopy.permissions.update(
216
+ id,
217
+ { name: "Read" },
218
+ { ifMatch: permission.version },
219
+ );
220
+ await canopy.permissions.delete(id, { ifMatch: permission.version });
221
+ ```
222
+
223
+ **Backoff between retries is now bounded.** `Retry-After` was honoured verbatim, so a `Retry-After: 120` on a 429 blocked the caller for two minutes — outside `timeoutMs`, which only ever bounded a single attempt. The advised delay is now clamped to `maxBackoffMs`, a new client and per-call option defaulting to 30s. A caller's abort is also observed _during_ the wait rather than after it, so cancelling no longer means cancelling once the backoff elapses.
224
+
225
+ **Per-call deadlines and retry caps.** `RequestOptions.timeoutMs` and `RequestOptions.maxRetries` override the client-wide values for a single call, and `permissions.evaluate`, `evaluateBulk` and `explain` now accept `CallOptions` (`signal`, `timeoutMs`, `maxRetries`, `headers`). Both were previously constructor-only, so a latency-critical call on the request path had to accept deadlines sized for administrative CRUD. They are worth setting together: a deadline alone still permits `maxRetries + 1` attempts back to back.
226
+
227
+ **Fixed.** A 2xx response whose body is not JSON — a gateway answering in place of the API — now throws `CanopyError` carrying the status, instead of a `SyntaxError` surfacing as a misleading "connection failed".
228
+
229
+ `CanopyError.retryAfterMs` is now a declared, typed field. It was previously attached at runtime and invisible to TypeScript.
230
+
231
+ ## 0.1.0
232
+
233
+ ### Minor Changes
234
+
235
+ - 8e2b399: Add the `Canopy` facade with `permissions`, `identities`, `roles` and `assignments` resources. Every request and response type is derived from the published OpenAPI document rather than hand-written.
236
+ - f1a510e: Add the client core: authentication, response-envelope unwrapping, typed errors, and a retry policy that will not repeat a non-idempotent request.
237
+ - 345dae8: Add `paginate` — one iterator that walks both offset and cursor pagination, inferring the style from the server's response.
238
+ - e21f27f: Add a spec-drift guard that fails when the API's operation surface changes, and relax `roles.list()` and `permissions.list()` to take no arguments now that the published spec no longer marks their filters required.
package/README.md CHANGED
@@ -211,6 +211,12 @@ Writing a `fetch` call against a documented REST API is easy, and an LLM will do
211
211
  **Zero runtime dependencies.**
212
212
  The client is `fetch` and nothing else, so it runs unchanged on Node, in browsers, on Cloudflare Workers, and on Deno — and it adds no supply-chain surface to anything that installs it. CI fails if a runtime dependency appears.
213
213
 
214
+ ## Changelog
215
+
216
+ [CHANGELOG.md](./CHANGELOG.md) records every published version. The package is
217
+ pre-1.0: while the surface is still settling, a minor bump may carry a breaking
218
+ change, so read the entry before taking one.
219
+
214
220
  ## License
215
221
 
216
222
  MIT © Canopy Identity Inc.