@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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Official TypeScript SDK for [Canopy](https://canopy-io.com) — hierarchical identity and access management for B2B SaaS.
4
4
 
5
- > **Status: 0.1.0.** The client, pagination and the four main resources are built and tested. 27 of 81 operations have a typed wrapper; the rest are reachable through `canopy.client.request` with the same envelope handling, error typing and retry policy.
5
+ This package is the client. It calls Canopy's API, and it verifies the access tokens Canopy issues.
6
6
 
7
7
  ## Install
8
8
 
@@ -14,6 +14,22 @@ Requires Node 18 or later. Ships ESM and CommonJS, and has **zero runtime depend
14
14
 
15
15
  Despite the name, it is not Node-only: the client is `fetch` and nothing else, so the same build runs in browsers, on Cloudflare Workers and on Deno.
16
16
 
17
+ ## Features
18
+
19
+ - **Typed resource wrappers** for `permissions`, `identities`, `roles` and `assignments` — the four every integration touches.
20
+ - **The whole API, typed.** Any operation without a wrapper is reachable through `canopy.client.request` with the same envelope handling, error typing and retry policy.
21
+ - **Both credential types.** An API key (`cnpy_…`, sent as `X-API-Key`) for server-to-server calls, or an identity or portal JWT sent as a bearer token.
22
+ - **Envelope unwrapping.** All five response shapes are handled, so a call returns the payload rather than a wrapper.
23
+ - **Pagination as an async iterator.** One `for await` loop covers both the offset and cursor styles, and `paginate` exposes the same machinery for anything hand-rolled.
24
+ - **A retry policy that knows what is safe to replay** — idempotent methods and 429s only, with `Retry-After` honoured and capped.
25
+ - **Concurrency and idempotency headers.** `withConcurrency` carries a resource's `version` as `If-Match`; `Idempotency-Key` is accepted on the bulk endpoints that support it.
26
+ - **Typed errors.** `CanopyError` carries a stable `code`, `CanopyConnectionError` marks a transport failure, `CanopyTokenError` a rejected token, `CanopyAuthorizerError` a check that could not be decided — each with a type guard.
27
+ - **Local access-token verification.** `TokenVerifier` checks Canopy's RS256 signatures against the published key set: one JWKS fetch, then no network on any verification after it.
28
+ - **Local authorization.** `LocalAuthorizer` answers permission checks in-process from an identity's grant roots and a shared copy of your hierarchy, so a check costs a walk up the tree rather than a round trip.
29
+ - **Conditional reads.** `requestConditional` sends the validator you already hold and tells you whether anything changed, so something expensive can be held and revalidated cheaply.
30
+ - **Deadlines and cancellation.** A per-attempt timeout and a caller's own `AbortSignal`, settable client-wide or per call.
31
+ - **Types generated from the published spec**, with CI failing on drift.
32
+
17
33
  ## Usage
18
34
 
19
35
  ```ts
@@ -50,8 +66,7 @@ try {
50
66
  }
51
67
  ```
52
68
 
53
- Anything without a typed wrapper is still reachable, with the same envelope
54
- handling and retry policy:
69
+ Anything without a typed wrapper is reachable the same way, with the same envelope handling and retry policy:
55
70
 
56
71
  ```ts
57
72
  const page = await canopy.client.request("GET", "/api/v1/audit-events", {
@@ -59,55 +74,106 @@ const page = await canopy.client.request("GET", "/api/v1/audit-events", {
59
74
  });
60
75
  ```
61
76
 
62
- ## Design
77
+ ### Verifying an access token
63
78
 
64
- Three decisions shape this package.
79
+ Authorization asks the API a question. Authentication does not: Canopy signs
80
+ access tokens RS256 and publishes the keys, so a token is checked locally —
81
+ one JWKS fetch, then no network on any verification after it.
65
82
 
66
- **Types are generated from the published spec, never hand-written.**
67
- `src/generated/types.ts` comes from <https://canopy-io.com/openapi/api.json>, the same document that renders Canopy's API reference. `npm run generate:check` fails if the committed types no longer match the live spec, so the SDK cannot silently describe an API that has moved on.
83
+ ```ts
84
+ import { TokenVerifier, isCanopyTokenError } from "@canopy-io/node";
68
85
 
69
- **Zero runtime dependencies.**
70
- 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.
86
+ const verifier = new TokenVerifier();
71
87
 
72
- **The hand-written layer exists for correctness, not convenience.**
73
- Writing a `fetch` call against a documented REST API is easy, and an LLM will do it for you. What neither gets reliably right is the part this package owns:
88
+ try {
89
+ const claims = await verifier.verify(bearerToken);
74
90
 
75
- - **Which operations are safe to retry.** GET, HEAD, PUT and DELETE are idempotent by HTTP definition and are retried on a 5xx; POST is not, and a blind retry there can create a second role assignment. A 429 is retried regardless, because the request was refused before anything happened.
76
- - **Two pagination styles behind one shape.** The audit log is cursor-paginated; everything else is offset. The top-level response is identical either way, so a hand-rolled loop silently reads only the first page of one of them — or never terminates.
77
- - **The five-shape response envelope.** `{ data }`, `{ items }`, `{ items, pagination }`, `{ summary, results }` for partial success, `{ error }`, and bare 204.
78
- - **Typed error codes.** `catch (e) { if (e.code === "rbac.assignment_conflict") }` branches on a contract rather than on a message that may be reworded.
79
- - **Webhook signature verification.** Security-critical, and easy to get subtly wrong — timing-unsafe comparison, missing timestamp check. _(Not built yet — see the roadmap.)_
91
+ claims.sub; // the identity or user id
92
+ claims.type; // "identity" | "user" | "api_key" | "platform"
93
+ claims.environment_id; // present on identity tokens
94
+ } catch (error) {
95
+ if (isCanopyTokenError(error)) {
96
+ // Every code here means 401 to the caller who presented the token.
97
+ // `error.code` is for your logs: token.expired, token.signature_invalid,
98
+ // token.issuer_mismatch, and so on.
99
+ }
80
100
 
81
- ## Roadmap
101
+ throw error;
102
+ }
103
+ ```
82
104
 
83
- - [x] Package scaffold, dual ESM/CJS build, generated types
84
- - [x] Client coreauth, envelope unwrapping, typed errors, retry
85
- - [x] Pagination one async iterator covering offset and cursor
86
- - [x] Resource wrappers — `permissions`, `identities`, `roles`, `assignments`
87
- - [x] Spec-drift guard — fails when the API's surface changes
88
- - [ ] Webhook signature verification
105
+ Hosted Login tokens carry an audience, so name your client. Direct API tokens
106
+ carry none and need no option and a token that has an `aud` is refused
107
+ rather than ignored when you have not said which client you are:
89
108
 
90
- Other languages are deliberately **not** planned here. Point your own generator at the published spec — that serves Python, Go and the rest better than a partly-maintained SDK would.
109
+ ```ts
110
+ new TokenVerifier({ audience: process.env.CANOPY_OAUTH_CLIENT_ID });
111
+ ```
91
112
 
92
- ## Contributing
113
+ Self-hosted instances set `issuer`. Getting it wrong fails closed: tokens are
114
+ rejected, never mistakenly accepted.
93
115
 
94
- ```bash
95
- npm ci
96
- npm run verify # lint, typecheck, spec drift, test, build
97
- ```
116
+ ### Authorizing without a call per request
98
117
 
99
- To work against an unreleased API, point the generator somewhere else:
118
+ Asking "may this identity act _here_" on every request puts Canopy in your
119
+ request path forever. `LocalAuthorizer` asks the other question — "**where** may
120
+ it act" — once, and answers everything after that in-process.
100
121
 
101
- ```bash
102
- CANOPY_SPEC_URL=../canopy/apps/canopy-api/spec/openapi.api.json npm run generate
103
- ```
122
+ ```ts
123
+ import { Canopy, LocalAuthorizer } from "@canopy-io/node";
104
124
 
105
- Releases use [changesets](https://github.com/changesets/changesets). Include one in your PR:
125
+ const canopy = new Canopy({ apiKey: process.env.CANOPY_API_KEY });
126
+ const authorizer = new LocalAuthorizer(canopy.client);
106
127
 
107
- ```bash
108
- npm run changeset
128
+ const { allowed } = await authorizer.evaluate({
129
+ identity_id: identityId,
130
+ permission: "reports.view",
131
+ node_id: nodeId,
132
+ });
109
133
  ```
110
134
 
135
+ It holds two things: the identity's grant roots, and one copy of your hierarchy
136
+ — read as parent edges from `GET /api/v1/nodes/parents` rather than the full
137
+ tree, which is 3.7x smaller — shared across every identity in the process. A check walks up from the node in
138
+ question looking for a grant root — a grant already means _this node and
139
+ everything beneath it_, so the roots are never expanded into their descendants.
140
+
141
+ Both are held for at most 60 seconds, which is the delay between an access
142
+ change and it taking effect, including a moved node. Set `ttlMs` to change it,
143
+ knowing that shortening it below the gap between a user's requests saves
144
+ nothing — every request then finds the cache expired and refetches.
145
+
146
+ `canopy.permissions.evaluate` remains for a decision with no staleness at all.
147
+
148
+ The credential needs to be able to read both halves. A `full_access` API key
149
+ already can; a **scoped** one must list `identity.view` (the grant roots) and
150
+ `hierarchy.view` (the tree). Without the latter the tree endpoint answers an
151
+ empty tree and a `200` — it is telling you what you may see, which is nothing —
152
+ so a node-scoped check raises `CanopyAuthorizerError` rather than returning
153
+ `false`. Not being able to see the hierarchy is not the same as the identity
154
+ lacking the permission, and answering `false` there would deny every
155
+ node-scoped request while looking entirely healthy.
156
+
157
+ ## What the hand-written layer is for
158
+
159
+ Writing a `fetch` call against a documented REST API is easy, and an LLM will do it for you. What neither gets reliably right is the part this package owns:
160
+
161
+ - **Which operations are safe to retry.** GET, HEAD, PUT and DELETE are idempotent by HTTP definition and are retried on a 5xx; POST is not, and a blind retry there can create a second role assignment. A 429 is retried regardless, because the request was refused before anything happened. A caller's own cancellation is never retried — aborting a `signal` rejects with that abort, while timeouts and transport failures throw `CanopyConnectionError`.
162
+ - **The protocol headers that make a write safe.** `If-Match` carries a resource's current `version`, so a concurrent edit answers 409 instead of being silently overwritten; `Idempotency-Key` makes a replayed bulk create return the original result rather than creating rows twice.
163
+ - **Two pagination styles behind one shape.** The audit log is cursor-paginated; everything else is offset. The top-level response is identical either way, so a hand-rolled loop silently reads only the first page of one of them — or never terminates.
164
+ - **The five-shape response envelope.** `{ data }`, `{ items }`, `{ items, pagination }`, `{ summary, results }` for partial success, `{ error }`, and bare 204.
165
+ - **Typed error codes.** `catch (e) { if (e.code === "rbac.assignment_conflict") }` branches on a contract rather than on a message that may be reworded.
166
+ - **The traps in verifying a token.** Pinning the algorithm so `alg: none` and HMAC key-confusion cannot get in, bounding `kid`-triggered refetches so a forged header cannot hammer the issuer, refusing to ignore an `aud` the token carries, and refusing a **pre-auth** token — genuine, correctly signed, and issued before the user picked an Account, so treating it as a session is a privilege escalation.
167
+ - **The traps in caching an authorization answer.** Holding grant roots rather than expanded node lists, so the cache does not grow with your tree and is not largest for the administrators who have the most access. Revalidating the hierarchy on the same cadence as the grants, because a moved node changes what an inherited grant reaches while the grant itself stays untouched. Sharing one in-flight read across concurrent requests, so a cold start under load does not fan out into the per-request traffic the cache exists to remove. And never serving a held answer past its window to ride out an outage, which would extend the revocation window without anyone choosing to.
168
+
169
+ ## Design
170
+
171
+ **Types are generated from the published spec, never hand-written.**
172
+ `src/generated/types.ts` comes from <https://canopy-io.com/openapi/api.json>, the same document that renders Canopy's API reference. `npm run generate:check` fails if the committed types no longer match the live spec, so the SDK cannot silently describe an API that has moved on.
173
+
174
+ **Zero runtime dependencies.**
175
+ 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.
176
+
111
177
  ## License
112
178
 
113
179
  MIT © Canopy Identity Inc.