@canopy-io/node 0.3.1 → 0.3.3

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,204 @@
1
+ # @canopy-io/node
2
+
3
+ ## 0.3.3
4
+
5
+ ### Patch Changes
6
+
7
+ - b6b1dd0: Ship the changelog with the package, and create a GitHub Release per published
8
+ version.
9
+
10
+ The changelog was written on every release and reachable by nobody: `files`
11
+ omitted it, so npm showed none, and the release workflow pushed tags without
12
+ creating releases, so the Releases page sat empty. A consumer deciding whether
13
+ to take a bump had nothing to read. `CHANGELOG.md` is now in the tarball, each
14
+ README links it and notes that a pre-1.0 minor may break, and the workflow
15
+ opens a release whose body is that version's changelog section.
16
+
17
+ ## 0.3.2
18
+
19
+ ### Patch Changes
20
+
21
+ - 7e71cc7: Regenerate the request/response types from Canopy's OpenAPI document.
22
+ Additive, no existing caller shape changed:
23
+
24
+ - MFA factor entries carry `webauthn_rp_id`: for a passkey, the relying-party
25
+ id it was created under and the only domain it answers on (the
26
+ Environment's own `webauthn_rp_id`, or Canopy's when the Environment names
27
+ none). `null` for every other factor type.
28
+
29
+ ## 0.3.1
30
+
31
+ ### Patch Changes
32
+
33
+ - 5b06ccf: Regenerate the request/response types from Canopy's OpenAPI document.
34
+ Additive, no existing caller shape changed:
35
+
36
+ - Organizations carry `external_id` (your own id, unique per Environment)
37
+ on the create, update, list, and detail shapes, and the list accepts
38
+ `external_id` as an exact-match query.
39
+ - Webhook event-type entries carry `scope` (`"environment"` or
40
+ `"account"`), and `GET /api/v1/webhooks/event-types` now lists every
41
+ event with the scope that subscribes to it, so an environment API key
42
+ learns which events need an account-scoped subscription.
43
+
44
+ ## 0.3.0
45
+
46
+ ### Minor Changes
47
+
48
+ - 8ad7dd0: Organizations support. Identity tokens minted in an Environment with the
49
+ organizations container on carry `org_id` and `org_role`; both are now
50
+ declared on `CanopyTokenClaims`, and the new `orgContext(claims)` helper
51
+ returns the verified pair (or `null` for a token acting in no organization,
52
+ refusing a half-present pair). `@RequirePermission` gains `scope: "org"`: the
53
+ guard evaluates at the token's `org_id` node through the existing
54
+ `LocalAuthorizer` — an organization is a hierarchy node and a membership is a
55
+ role assignment at it — reading the claim off what `CanopyTokenGuard`
56
+ attached, overridable with the new `resolveOrg` module option. A caller
57
+ acting in no organization is denied without a network call.
58
+ - e4ca3ef: Organizations as a container: `canopy.organizations` wraps the tenant API
59
+ (organizations, members, invitations, the per-organization sign-in policy,
60
+ and SSO connection binding), `CanopyTokenClaims` declares `amr`, types are
61
+ regenerated against the current spec, and the wording no longer describes
62
+ organizations as an access model. The published spec now carries 102 operations (was 84), all additive.
63
+
64
+ ## 0.2.0
65
+
66
+ ### Minor Changes
67
+
68
+ - 13ecf63: `LocalAuthorizer.invalidate(identityId?)` — with an identity id, only that
69
+ identity's cached grants are dropped; the hierarchy and every other identity
70
+ stay warm. This is the shape an assignment webhook wants: invalidate exactly
71
+ the identity the event names. With no argument the behaviour is unchanged
72
+ (drop everything). The jsdoc now also states the multi-instance guarantee
73
+ honestly: an invalidation reaches one process, so behind a load balancer the
74
+ fleet-wide revocation guarantee remains the TTL.
75
+
76
+ ### Patch Changes
77
+
78
+ - b873daa: Fix type resolution for CommonJS consumers on `node16`/`nodenext`: the
79
+ `exports` map now declares per-condition `types`, pointing `require` at the
80
+ `index.d.cts` the build already emitted. Previously a `require()` that worked
81
+ at runtime was rejected by TypeScript (TS1479), forcing dynamic-import and
82
+ `resolution-mode` workarounds. Verified with `arethetypeswrong` across
83
+ node10, node16-CJS, node16-ESM and bundler resolution.
84
+ - 003761a: Regenerate the request/response types from the published OpenAPI document.
85
+ Additive: picks up the permission-usage endpoint
86
+ (`GET /api/v1/permissions/{id}/usage`) and refreshed operation descriptions.
87
+ No existing caller shape changed.
88
+
89
+ ## 0.1.1
90
+
91
+ ### Patch Changes
92
+
93
+ - 89f0a46: Answer permission checks in-process instead of calling Canopy on every request.
94
+
95
+ **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.
96
+
97
+ 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.
98
+
99
+ ```ts
100
+ CanopyModule.forRoot({
101
+ apiKey: process.env.CANOPY_API_KEY,
102
+ authorizationTtlMs: 60_000, // the default
103
+ resolveIdentity: (request) => request.user?.sub,
104
+ });
105
+ ```
106
+
107
+ **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.
108
+
109
+ 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.
110
+
111
+ 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.
112
+
113
+ **`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.
114
+
115
+ **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.
116
+
117
+ `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.
118
+
119
+ - 89f0a46: Verify Canopy access tokens locally, and guard a route with the result.
120
+
121
+ `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.
122
+
123
+ ```ts
124
+ const verifier = new TokenVerifier();
125
+ const claims = await verifier.verify(bearerToken);
126
+ ```
127
+
128
+ 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.
129
+
130
+ `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.
131
+
132
+ **`CanopyTokenGuard`** (`@canopy-io/nestjs`) applies this to a route and attaches the claims, for applications with no auth layer in front:
133
+
134
+ ```ts
135
+ @UseGuards(CanopyTokenGuard, CanopyGuard)
136
+ @RequirePermission("documents.read")
137
+ findAll() {}
138
+ ```
139
+
140
+ 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.
141
+
142
+ 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.
143
+
144
+ - 41241ca: Cancellation, per-request headers, per-call deadlines, and two correctness fixes on the authorization path.
145
+
146
+ **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.
147
+
148
+ ```ts
149
+ // before — first page only
150
+ const { items } = await canopy.identities.assignments(id);
151
+
152
+ // after — every page
153
+ const all = await canopy.identities.assignments(id).all();
154
+ for await (const assignment of canopy.identities.assignments(id)) {
155
+ }
156
+ ```
157
+
158
+ **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.
159
+
160
+ **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.
161
+
162
+ Timeouts and transport failures are unchanged: they still throw `CanopyConnectionError` and are still retried where the method allows it.
163
+
164
+ If you branch on `isCanopyConnectionError` to handle cancellation, match on the abort instead:
165
+
166
+ ```ts
167
+ catch (error) {
168
+ if ((error as Error).name === "AbortError") {
169
+ // the caller cancelled
170
+ }
171
+ }
172
+ ```
173
+
174
+ **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.
175
+
176
+ The four wrapped operations whose spec declares `If-Match` take it directly:
177
+
178
+ ```ts
179
+ await canopy.roles.update(id, { name: "Editor" }, { ifMatch: role.version });
180
+ await canopy.roles.delete(id, { ifMatch: role.version });
181
+ await canopy.permissions.update(
182
+ id,
183
+ { name: "Read" },
184
+ { ifMatch: permission.version },
185
+ );
186
+ await canopy.permissions.delete(id, { ifMatch: permission.version });
187
+ ```
188
+
189
+ **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.
190
+
191
+ **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.
192
+
193
+ **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".
194
+
195
+ `CanopyError.retryAfterMs` is now a declared, typed field. It was previously attached at runtime and invisible to TypeScript.
196
+
197
+ ## 0.1.0
198
+
199
+ ### Minor Changes
200
+
201
+ - 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.
202
+ - f1a510e: Add the client core: authentication, response-envelope unwrapping, typed errors, and a retry policy that will not repeat a non-idempotent request.
203
+ - 345dae8: Add `paginate` — one iterator that walks both offset and cursor pagination, inferring the style from the server's response.
204
+ - 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.
package/dist/index.d.cts CHANGED
@@ -2055,6 +2055,11 @@ interface components {
2055
2055
  * @description Last time this factor satisfied a challenge. `null` until the first use.
2056
2056
  */
2057
2057
  last_used_at: string | null;
2058
+ /**
2059
+ * @description For a passkey, the relying-party id it was created under and the only domain it answers on: the Environment's `webauthn_rp_id`, or Canopy's own when the Environment names none. `null` for every other factor type.
2060
+ * @example acme.com
2061
+ */
2062
+ webauthn_rp_id: string | null;
2058
2063
  };
2059
2064
  AccountIdentityMfaResponseDto: {
2060
2065
  factors: components["schemas"]["MfaFactorResponseDto"][];
package/dist/index.d.ts CHANGED
@@ -2055,6 +2055,11 @@ interface components {
2055
2055
  * @description Last time this factor satisfied a challenge. `null` until the first use.
2056
2056
  */
2057
2057
  last_used_at: string | null;
2058
+ /**
2059
+ * @description For a passkey, the relying-party id it was created under and the only domain it answers on: the Environment's `webauthn_rp_id`, or Canopy's own when the Environment names none. `null` for every other factor type.
2060
+ * @example acme.com
2061
+ */
2062
+ webauthn_rp_id: string | null;
2058
2063
  };
2059
2064
  AccountIdentityMfaResponseDto: {
2060
2065
  factors: components["schemas"]["MfaFactorResponseDto"][];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canopy-io/node",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "Official TypeScript SDK for Canopy — hierarchical identity and access management.",
5
5
  "license": "MIT",
6
6
  "author": "Canopy Identity Inc.",
@@ -15,9 +15,14 @@
15
15
  "canopy",
16
16
  "authorization",
17
17
  "rbac",
18
+ "hierarchical-rbac",
19
+ "access-control",
18
20
  "iam",
19
21
  "permissions",
20
22
  "identity",
23
+ "multi-tenant",
24
+ "b2b-saas",
25
+ "sso",
21
26
  "sdk"
22
27
  ],
23
28
  "type": "module",
@@ -41,6 +46,7 @@
41
46
  "files": [
42
47
  "dist",
43
48
  "README.md",
49
+ "CHANGELOG.md",
44
50
  "LICENSE"
45
51
  ],
46
52
  "engines": {