@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 +102 -36
- package/dist/index.cjs +767 -48
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1106 -184
- package/dist/index.d.ts +1106 -184
- package/dist/index.js +761 -49
- package/dist/index.js.map +1 -1
- package/package.json +15 -30
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
|
-
|
|
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
|
|
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
|
-
|
|
77
|
+
### Verifying an access token
|
|
63
78
|
|
|
64
|
-
|
|
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
|
-
|
|
67
|
-
|
|
83
|
+
```ts
|
|
84
|
+
import { TokenVerifier, isCanopyTokenError } from "@canopy-io/node";
|
|
68
85
|
|
|
69
|
-
|
|
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
|
-
|
|
73
|
-
|
|
88
|
+
try {
|
|
89
|
+
const claims = await verifier.verify(bearerToken);
|
|
74
90
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
```
|
|
82
104
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
109
|
+
```ts
|
|
110
|
+
new TokenVerifier({ audience: process.env.CANOPY_OAUTH_CLIENT_ID });
|
|
111
|
+
```
|
|
91
112
|
|
|
92
|
-
|
|
113
|
+
Self-hosted instances set `issuer`. Getting it wrong fails closed: tokens are
|
|
114
|
+
rejected, never mistakenly accepted.
|
|
93
115
|
|
|
94
|
-
|
|
95
|
-
npm ci
|
|
96
|
-
npm run verify # lint, typecheck, spec drift, test, build
|
|
97
|
-
```
|
|
116
|
+
### Authorizing without a call per request
|
|
98
117
|
|
|
99
|
-
|
|
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
|
-
```
|
|
102
|
-
|
|
103
|
-
```
|
|
122
|
+
```ts
|
|
123
|
+
import { Canopy, LocalAuthorizer } from "@canopy-io/node";
|
|
104
124
|
|
|
105
|
-
|
|
125
|
+
const canopy = new Canopy({ apiKey: process.env.CANOPY_API_KEY });
|
|
126
|
+
const authorizer = new LocalAuthorizer(canopy.client);
|
|
106
127
|
|
|
107
|
-
|
|
108
|
-
|
|
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.
|