@canopy-io/nestjs 0.1.2 → 0.2.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/CHANGELOG.md +164 -0
- package/README.md +24 -1
- package/dist/index.cjs +30 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +29 -2
- package/dist/index.d.ts +29 -2
- package/dist/index.js +30 -0
- package/dist/index.js.map +1 -1
- package/package.json +8 -2
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# @canopy-io/nestjs
|
|
2
|
+
|
|
3
|
+
## 0.2.1
|
|
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
|
+
- Updated dependencies [b6b1dd0]
|
|
18
|
+
- @canopy-io/node@0.3.3
|
|
19
|
+
|
|
20
|
+
## 0.2.0
|
|
21
|
+
|
|
22
|
+
### Minor Changes
|
|
23
|
+
|
|
24
|
+
- 8ad7dd0: Organizations support. Identity tokens minted in an Environment with the
|
|
25
|
+
organizations container on carry `org_id` and `org_role`; both are now
|
|
26
|
+
declared on `CanopyTokenClaims`, and the new `orgContext(claims)` helper
|
|
27
|
+
returns the verified pair (or `null` for a token acting in no organization,
|
|
28
|
+
refusing a half-present pair). `@RequirePermission` gains `scope: "org"`: the
|
|
29
|
+
guard evaluates at the token's `org_id` node through the existing
|
|
30
|
+
`LocalAuthorizer` — an organization is a hierarchy node and a membership is a
|
|
31
|
+
role assignment at it — reading the claim off what `CanopyTokenGuard`
|
|
32
|
+
attached, overridable with the new `resolveOrg` module option. A caller
|
|
33
|
+
acting in no organization is denied without a network call.
|
|
34
|
+
|
|
35
|
+
### Patch Changes
|
|
36
|
+
|
|
37
|
+
- e4ca3ef: Organizations as a container: `canopy.organizations` wraps the tenant API
|
|
38
|
+
(organizations, members, invitations, the per-organization sign-in policy,
|
|
39
|
+
and SSO connection binding), `CanopyTokenClaims` declares `amr`, types are
|
|
40
|
+
regenerated against the current spec, and the wording no longer describes
|
|
41
|
+
organizations as an access model. The published spec now carries 102 operations (was 84), all additive.
|
|
42
|
+
- Updated dependencies [8ad7dd0]
|
|
43
|
+
- Updated dependencies [e4ca3ef]
|
|
44
|
+
- @canopy-io/node@0.3.0
|
|
45
|
+
|
|
46
|
+
## 0.1.2
|
|
47
|
+
|
|
48
|
+
### Patch Changes
|
|
49
|
+
|
|
50
|
+
- b873daa: Fix type resolution for CommonJS consumers on `node16`/`nodenext`: the
|
|
51
|
+
`exports` map now declares per-condition `types`, pointing `require` at the
|
|
52
|
+
`index.d.cts` the build already emitted. Previously a `require()` that worked
|
|
53
|
+
at runtime was rejected by TypeScript (TS1479), forcing dynamic-import and
|
|
54
|
+
`resolution-mode` workarounds. Verified with `arethetypeswrong` across
|
|
55
|
+
node10, node16-CJS, node16-ESM and bundler resolution.
|
|
56
|
+
- Updated dependencies [b873daa]
|
|
57
|
+
- Updated dependencies [13ecf63]
|
|
58
|
+
- Updated dependencies [003761a]
|
|
59
|
+
- @canopy-io/node@0.2.0
|
|
60
|
+
|
|
61
|
+
## 0.1.1
|
|
62
|
+
|
|
63
|
+
### Patch Changes
|
|
64
|
+
|
|
65
|
+
- 89f0a46: Answer permission checks in-process instead of calling Canopy on every request.
|
|
66
|
+
|
|
67
|
+
**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.
|
|
68
|
+
|
|
69
|
+
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.
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
CanopyModule.forRoot({
|
|
73
|
+
apiKey: process.env.CANOPY_API_KEY,
|
|
74
|
+
authorizationTtlMs: 60_000, // the default
|
|
75
|
+
resolveIdentity: (request) => request.user?.sub,
|
|
76
|
+
});
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
**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.
|
|
80
|
+
|
|
81
|
+
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.
|
|
82
|
+
|
|
83
|
+
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.
|
|
84
|
+
|
|
85
|
+
**`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.
|
|
86
|
+
|
|
87
|
+
**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.
|
|
88
|
+
|
|
89
|
+
`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.
|
|
90
|
+
|
|
91
|
+
- 89f0a46: Verify Canopy access tokens locally, and guard a route with the result.
|
|
92
|
+
|
|
93
|
+
`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.
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
const verifier = new TokenVerifier();
|
|
97
|
+
const claims = await verifier.verify(bearerToken);
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
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.
|
|
101
|
+
|
|
102
|
+
`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.
|
|
103
|
+
|
|
104
|
+
**`CanopyTokenGuard`** (`@canopy-io/nestjs`) applies this to a route and attaches the claims, for applications with no auth layer in front:
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
@UseGuards(CanopyTokenGuard, CanopyGuard)
|
|
108
|
+
@RequirePermission("documents.read")
|
|
109
|
+
findAll() {}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
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.
|
|
113
|
+
|
|
114
|
+
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.
|
|
115
|
+
|
|
116
|
+
- bbfe8e8: Bound the permission check so a slow or unreachable Canopy cannot hold a request open.
|
|
117
|
+
|
|
118
|
+
`CanopyGuard` evaluated with the client-wide defaults — a 30s per-attempt timeout and 2 retries — so a hung call could hold an inbound request for roughly 90s plus backoff, on the authorization path of every guarded route.
|
|
119
|
+
|
|
120
|
+
The guard now evaluates with a 5s per-attempt deadline (`evaluateTimeoutMs`) and 1 retry (`evaluateMaxRetries`), both configurable and both scoped to the guard — everything else through the injected client keeps the client-wide values. It also caps each wait between attempts at one deadline, because that wait is otherwise set by Canopy's `Retry-After` header and sits outside the deadline entirely: a `Retry-After: 120` on a 429 would have held the inbound request for two minutes regardless of any timeout configured here.
|
|
121
|
+
|
|
122
|
+
Worst case is now roughly `evaluateTimeoutMs × (2 × evaluateMaxRetries + 1)` — about 15s with the defaults, against unbounded before.
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
CanopyModule.forRoot({
|
|
126
|
+
apiKey: process.env.CANOPY_API_KEY,
|
|
127
|
+
evaluateTimeoutMs: 2_000,
|
|
128
|
+
evaluateMaxRetries: 0,
|
|
129
|
+
resolveIdentity: (request) => request.user.sub,
|
|
130
|
+
});
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
**A failed check now reports what actually failed.** Every Canopy error became `503 Service Unavailable`, so a rejected or unscoped API key — a permanent misconfiguration — was reported as a temporary outage, inviting retries that could never succeed and hiding the bug. Now: an unreachable, rate-limited or `5xx` Canopy is `503`; a rejected API key or a refused question is `500`; an identity Canopy has never heard of is `403`. Every branch still fails closed.
|
|
134
|
+
|
|
135
|
+
**A resolver that throws now denies instead of crashing.** `resolveIdentity: (request) => request.user.sub` — the shape the docs suggested — throws on an unauthenticated request, which surfaced as a `500` rather than the documented `403`. Both resolvers are now called defensively: a throw is treated as "could not resolve", the same denial as returning nothing. The documented examples use `request.user?.sub`.
|
|
136
|
+
|
|
137
|
+
**Registering the guard globally is documented properly.** Nest runs global guards before controller- and route-scoped ones, so a globally-registered `CanopyGuard` runs before route-level authentication has populated the request and every guarded route denies. The README now says to register your authentication guard globally ahead of it, or to apply both at the route.
|
|
138
|
+
|
|
139
|
+
The guard also aborts the check when the caller disconnects while it is in flight, and propagates that abort rather than reporting `503` — a caller hanging up is not Canopy being unavailable, and it is no longer logged as though it were. Disconnect is detected on the response, on a close arriving before anything was written.
|
|
140
|
+
|
|
141
|
+
This is resolved per adapter: Express hands back the Node response directly, Fastify hands back a `Reply` whose real response is on `.raw`. A transport with neither, such as GraphQL, runs to the deadline as before. Both adapters are covered by end-to-end tests that boot a real Nest application and hang up a real client mid-check.
|
|
142
|
+
|
|
143
|
+
- Updated dependencies [89f0a46]
|
|
144
|
+
- Updated dependencies [89f0a46]
|
|
145
|
+
- Updated dependencies [41241ca]
|
|
146
|
+
- @canopy-io/node@0.1.1
|
|
147
|
+
|
|
148
|
+
## 0.1.0
|
|
149
|
+
|
|
150
|
+
### Minor Changes
|
|
151
|
+
|
|
152
|
+
- 481ee14: Add `@canopy-io/nestjs`, the NestJS integration.
|
|
153
|
+
|
|
154
|
+
`CanopyModule.forRoot` registers one configured client and the guard that uses
|
|
155
|
+
it; `@RequirePermission("orders.refund")` on a route turns into a single
|
|
156
|
+
evaluation against Canopy. The `node` scope is the default, so a permission is
|
|
157
|
+
checked at the node the request touches and inherited from ancestors;
|
|
158
|
+
`app_wide` is opt-in because it answers a coarser question and must not guard a
|
|
159
|
+
node-owned resource.
|
|
160
|
+
|
|
161
|
+
The guard fails closed. An unresolvable identity, a node check with no node, a
|
|
162
|
+
denial, or an unreachable API all end the request, and an unreachable API is a
|
|
163
|
+
503 rather than a 403 so "we could not decide" is never reported as "you are
|
|
164
|
+
not allowed".
|
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ Requires NestJS 10 or 11 and Node 18 or later. `@canopy-io/node` comes with it.
|
|
|
18
18
|
- **`@RequirePermission` on the route.** The check is a declaration on the handler rather than a call inside it. A route without it passes through untouched, so the guard can be registered globally and opted into.
|
|
19
19
|
- **`CanopyGuard`** answers that declaration **in your own process**, at the node the request names — inheriting a role granted on a parent without you modelling the walk.
|
|
20
20
|
- **No network call per request.** The guard holds the identity's grant roots and one shared copy of your hierarchy, refreshed at most once a minute, so a guarded route normally reaches no network at all.
|
|
21
|
-
- **
|
|
21
|
+
- **Three scopes.** `node` is the default and the strict question; `app_wide` asks only whether the identity holds the permission anywhere in the Environment; `org` asks the node question at the organization the caller's token is acting in.
|
|
22
22
|
- **`CanopyTokenGuard`** verifies a Canopy-issued bearer token locally against the published signing keys and attaches the claims, for applications with no auth layer in front. Additive — skip it if you already run Passport or your own JWT middleware.
|
|
23
23
|
- **`@InjectCanopy()`** hands you the full `@canopy-io/node` client for everything the guard does not cover.
|
|
24
24
|
- **Fails closed, with the status telling you which failure it was.** `403` for a denial, `503` for an undecidable one, `500` for a broken integration.
|
|
@@ -103,6 +103,23 @@ The default is `node`, which is the strict question: does this identity hold the
|
|
|
103
103
|
|
|
104
104
|
`app_wide` asks only whether the identity holds the permission anywhere in the Environment, and returns no effective node. It is right for deciding whether to show a menu item and wrong for guarding a resource that belongs to a node — which is why it is opt-in rather than the default.
|
|
105
105
|
|
|
106
|
+
```ts
|
|
107
|
+
@RequirePermission("invoices.view", { scope: "org" })
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`org` is for Environments with the **organizations** container on, where a
|
|
111
|
+
token carries the organization the session is acting in (`org_id`) and the one
|
|
112
|
+
role held there (`org_role`). The guard reads `org_id` off the verified claims
|
|
113
|
+
`CanopyTokenGuard` attached and evaluates at that node — an organization _is_ a
|
|
114
|
+
hierarchy node, and a membership is a role assignment at it, so no `resolveNode`
|
|
115
|
+
and no node in the route's path are needed. A caller acting in no organization
|
|
116
|
+
is denied: an org-scoped route has no meaning outside one.
|
|
117
|
+
|
|
118
|
+
If your own auth layer verifies tokens and parks the claims somewhere other
|
|
119
|
+
than `attachTokenAs`, configure `resolveOrg` — and feed it only a value read
|
|
120
|
+
off a _verified_ token, because it decides which organization's grants answer
|
|
121
|
+
the check.
|
|
122
|
+
|
|
106
123
|
## If Canopy issues your tokens
|
|
107
124
|
|
|
108
125
|
`resolveIdentity` assumes something upstream already verified the caller. If
|
|
@@ -269,6 +286,12 @@ A hit costs nothing. A miss reads from Canopy on the request path and holds an i
|
|
|
269
286
|
|
|
270
287
|
The read itself is deliberately **not** cancelled: it may be shared with other requests that are still waiting on it, and it warms the cache either way.
|
|
271
288
|
|
|
289
|
+
## Changelog
|
|
290
|
+
|
|
291
|
+
[CHANGELOG.md](./CHANGELOG.md) records every published version. The package is
|
|
292
|
+
pre-1.0: while the surface is still settling, a minor bump may carry a breaking
|
|
293
|
+
change, so read the entry before taking one.
|
|
294
|
+
|
|
272
295
|
## License
|
|
273
296
|
|
|
274
297
|
MIT © Canopy Identity Inc.
|
package/dist/index.cjs
CHANGED
|
@@ -147,6 +147,20 @@ exports.CanopyGuard = class CanopyGuard {
|
|
|
147
147
|
scope: "app_wide"
|
|
148
148
|
};
|
|
149
149
|
}
|
|
150
|
+
if (requirement.scope === "org") {
|
|
151
|
+
const orgId = this.resolveOrg(request);
|
|
152
|
+
if (!orgId) {
|
|
153
|
+
throw new common.ForbiddenException(
|
|
154
|
+
`No organization on the request. ${requirement.permission} is checked inside the caller's active organization, and this caller is not acting in one.`
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
identity_id: identityId,
|
|
159
|
+
permission: requirement.permission,
|
|
160
|
+
scope: "node",
|
|
161
|
+
node_id: orgId
|
|
162
|
+
};
|
|
163
|
+
}
|
|
150
164
|
const nodeId = this.options.resolveNode ? resolveSafely(this.options.resolveNode, request) : void 0;
|
|
151
165
|
if (!nodeId) {
|
|
152
166
|
throw new common.ForbiddenException(
|
|
@@ -160,6 +174,22 @@ exports.CanopyGuard = class CanopyGuard {
|
|
|
160
174
|
node_id: nodeId
|
|
161
175
|
};
|
|
162
176
|
}
|
|
177
|
+
/**
|
|
178
|
+
* The organization the request acts in: `resolveOrg` when configured,
|
|
179
|
+
* otherwise the `org_id` claim off the verified token `CanopyTokenGuard`
|
|
180
|
+
* attached. Both sources trace back to a verified token — the org id
|
|
181
|
+
* decides whose grants answer the check, so it must never be readable off
|
|
182
|
+
* anything the caller controls directly.
|
|
183
|
+
*/
|
|
184
|
+
resolveOrg(request) {
|
|
185
|
+
if (this.options.resolveOrg) {
|
|
186
|
+
return resolveSafely(this.options.resolveOrg, request);
|
|
187
|
+
}
|
|
188
|
+
const attachedAs = this.options.attachTokenAs ?? "canopyToken";
|
|
189
|
+
const claims = request?.[attachedAs];
|
|
190
|
+
const orgId = claims?.org_id;
|
|
191
|
+
return typeof orgId === "string" && orgId !== "" ? orgId : void 0;
|
|
192
|
+
}
|
|
163
193
|
};
|
|
164
194
|
exports.CanopyGuard = __decorateClass([
|
|
165
195
|
common.Injectable(),
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/tokens.ts","../src/canopy.guard.ts","../src/canopy-token.guard.ts","../src/canopy.module.ts","../src/inject-canopy.decorator.ts","../src/require-permission.decorator.ts"],"names":["CanopyGuard","Logger","ForbiddenException","isCanopyAuthorizerError","InternalServerErrorException","isCanopyError","ServiceUnavailableException","Injectable","Reflector","CanopyTokenGuard","UnauthorizedException","isCanopyTokenError","Inject","LocalAuthorizer","CanopyModule","Canopy","TokenVerifier","Module","SetMetadata"],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * Injection tokens.\n *\n * These are strings rather than classes because esbuild does not emit\n * `design:paramtypes`, so nothing here can be injected by constructor type\n * alone — every dependency is named with an explicit `@Inject`.\n */\n\n/** The resolved `CanopyModuleOptions`. */\nexport const CANOPY_OPTIONS = \"CANOPY_OPTIONS\";\n\n/** The configured `Canopy` client. */\nexport const CANOPY_CLIENT = \"CANOPY_CLIENT\";\n\n/**\n * The configured `LocalAuthorizer`.\n *\n * Shared deliberately: it holds one copy of the hierarchy for the whole\n * process, so a second instance would double both the memory and the traffic\n * it exists to avoid.\n */\nexport const CANOPY_AUTHORIZER = \"CANOPY_AUTHORIZER\";\n\n/** The configured `TokenVerifier`, shared so its key cache is shared too. */\nexport const CANOPY_TOKEN_VERIFIER = \"CANOPY_TOKEN_VERIFIER\";\n\n/** Metadata key holding what `@RequirePermission` recorded on a handler. */\nexport const CANOPY_PERMISSION = \"CANOPY_PERMISSION\";\n","import {\n type CanActivate,\n type ExecutionContext,\n ForbiddenException,\n Inject,\n Injectable,\n InternalServerErrorException,\n Logger,\n ServiceUnavailableException,\n} from \"@nestjs/common\";\nimport { Reflector } from \"@nestjs/core\";\n\nimport {\n isCanopyAuthorizerError,\n isCanopyError,\n type LocalAuthorizer,\n type RequestBody,\n} from \"@canopy-io/node\";\n\nimport type { CanopyModuleOptions } from \"./options.js\";\nimport type { PermissionRequirement } from \"./require-permission.decorator.js\";\nimport {\n CANOPY_AUTHORIZER,\n CANOPY_OPTIONS,\n CANOPY_PERMISSION,\n} from \"./tokens.js\";\n\n/** Read off the generated operation, so it follows the API rather than restating it. */\ntype EvaluateQuery = RequestBody<\"ApiPermissionsController_evaluate\">;\n\n/**\n * Only the verdict, not the API's full explain payload.\n *\n * A decision reached in-process cannot report `granting_roles` or an effective\n * node — that reasoning lives on the server. The guard has never needed it, and\n * typing the narrow thing keeps that honest rather than implying a richer\n * answer than exists.\n */\ntype Verdict = { allowed: boolean };\n\ninterface Cancellation {\n signal: AbortSignal;\n dispose: () => void;\n}\n\n/** The Node stream underneath an Express or Fastify response. */\ninterface ClosableResponse {\n once: (event: string, listener: () => void) => unknown;\n removeListener: (event: string, listener: () => void) => unknown;\n writableEnded?: boolean;\n raw?: ClosableResponse;\n}\n\nfunction isClosable(value: unknown): value is ClosableResponse {\n return (\n typeof value === \"object\" &&\n value !== null &&\n typeof (value as ClosableResponse).once === \"function\" &&\n typeof (value as ClosableResponse).removeListener === \"function\"\n );\n}\n\n/**\n * Find the Node response stream behind whatever the adapter hands back.\n *\n * `.raw` is checked first and on its own merits, because the object Nest\n * returns is not always the stream. Express returns the `ServerResponse`\n * itself, which listens; Fastify returns a `Reply`, which is a plain object\n * with the real response on `.raw`. Testing the outer object first and\n * returning early on failure would skip `.raw` in exactly the case it exists\n * for.\n */\nfunction resolveStream(response: unknown): ClosableResponse | undefined {\n const raw = (response as { raw?: unknown } | null | undefined)?.raw;\n\n if (isClosable(raw)) {\n return raw;\n }\n\n if (isClosable(response)) {\n return response;\n }\n\n return undefined;\n}\n\n/**\n * Enforces `@RequirePermission` by asking Canopy, once, per guarded request.\n *\n * The guard fails closed at every step. An unresolvable identity, a `node`\n * check with no node, a denial, or an unreachable API all end the request —\n * there is no path through this class that allows a request whose decision is\n * unknown. That is the property worth protecting when reading this code: an\n * `allow` must be something Canopy actually said.\n *\n * A route with no requirement is allowed through untouched, so the guard can be\n * applied globally and opted into per route. Registered that way it runs before\n * controller- and route-scoped guards, so whatever authenticates the request has\n * to be registered globally ahead of it — otherwise `resolveIdentity` runs\n * before there is anything to resolve and every guarded route denies.\n */\n@Injectable()\nexport class CanopyGuard implements CanActivate {\n private readonly logger = new Logger(CanopyGuard.name);\n\n constructor(\n @Inject(Reflector) private readonly reflector: Reflector,\n @Inject(CANOPY_AUTHORIZER) private readonly authorizer: LocalAuthorizer,\n @Inject(CANOPY_OPTIONS)\n private readonly options: CanopyModuleOptions<unknown>,\n ) {}\n\n async canActivate(context: ExecutionContext): Promise<boolean> {\n const requirement = this.reflector.getAllAndOverride<\n PermissionRequirement | undefined\n >(CANOPY_PERMISSION, [context.getHandler(), context.getClass()]);\n\n if (!requirement) {\n return true;\n }\n\n const http = context.switchToHttp();\n const request: unknown = http.getRequest();\n const identityId = resolveSafely(this.options.resolveIdentity, request);\n\n if (!identityId) {\n throw new ForbiddenException(\n \"No identity on the request. Canopy cannot evaluate a permission without one.\",\n );\n }\n\n const decision = await this.evaluate(\n requirement,\n identityId,\n request,\n watchDisconnect(http),\n );\n\n if (!decision.allowed) {\n throw new ForbiddenException(\n `Identity does not hold ${requirement.permission}.`,\n );\n }\n\n return true;\n }\n\n /**\n * Asks Canopy the question the decorator recorded.\n *\n * Every failure is converted into a thrown exception rather than a `false`\n * decision, so a caller cannot mistake \"we could not tell\" for \"denied by\n * policy\" — and neither outcome lets the request proceed.\n */\n private async evaluate(\n requirement: PermissionRequirement,\n identityId: string,\n request: unknown,\n cancellation: Cancellation | undefined,\n ): Promise<Verdict> {\n const query = this.buildQuery(requirement, identityId, request);\n\n try {\n // Answered in-process. The authorizer holds the identity's grant roots\n // and a shared copy of the hierarchy, so the common case reaches no\n // network at all; only a cold or expired entry does, and its deadline\n // and retries are bounded by the same options as before.\n return await this.authorizer.evaluate(\n query,\n cancellation ? { signal: cancellation.signal } : {},\n );\n } catch (error) {\n // The caller hung up mid-check. There is no one left to answer, and this\n // is not Canopy failing — so it must not be logged or reported as though\n // it were. Rethrowing the abort ends the request without inventing a\n // decision.\n if (cancellation?.signal.aborted) {\n throw error;\n }\n\n // Deliberately not re-thrown as-is: a failure here must not reach the\n // client as though Canopy had answered.\n this.logger.error(\n `Could not evaluate ${requirement.permission} for ${identityId}`,\n error instanceof Error ? error.stack : String(error),\n );\n\n throw this.toHttpException(error);\n } finally {\n cancellation?.dispose();\n }\n }\n\n /**\n * Turn a failed check into the status that describes it.\n *\n * Every branch denies — nothing here can let a request through — but they say\n * different things, and the difference is what makes an incident diagnosable.\n * Reporting a rejected API key as \"temporarily unavailable\" invites a retry\n * that will never succeed and hides a misconfiguration behind an outage.\n */\n private toHttpException(error: unknown): Error {\n // The authorizer could not see far enough to decide — a credential that\n // cannot read the hierarchy, most likely. Not an outage: no retry fixes a\n // missing scope, and reporting it as one would bury a misconfiguration\n // under what looks like a blip.\n if (isCanopyAuthorizerError(error)) {\n return new InternalServerErrorException(\n `Authorization is misconfigured: ${error.message}`,\n );\n }\n\n if (!isCanopyError(error)) {\n // Never answered: unreachable, reset, or past the deadline.\n return new ServiceUnavailableException(\n \"Authorization is temporarily unavailable.\",\n );\n }\n\n // Canopy rejected our own credential. No retry fixes this.\n if (error.isAuthFailure) {\n return new InternalServerErrorException(\n \"Authorization is misconfigured. Check the Canopy API key and its scopes.\",\n );\n }\n\n // A 404 with no error code is Nest's own \"no such route\", which here means\n // this Canopy does not serve the endpoint the authorizer reads — an SDK\n // newer than the API it is pointed at. Denying would be catastrophic and\n // silent: every guarded route in the application would answer 403 with\n // nothing to suggest the cause is a version mismatch rather than policy.\n if (error.statusCode === 404 && error.code === null) {\n return new InternalServerErrorException(\n `Authorization is misconfigured: ${error.request.method} ${error.request.path} is not served by this Canopy. ` +\n \"The SDK requires an API that exposes the identity grants endpoint.\",\n );\n }\n\n // A 404 that does carry a code is a real answer — the identity is not in\n // this Environment. Deny rather than 500, the guard's whole thesis being\n // that an undecidable request does not proceed, but say nothing about what\n // was missing.\n if (error.statusCode === 404) {\n return new ForbiddenException(\"Could not evaluate the permission.\");\n }\n\n if (error.isRateLimited || error.statusCode >= 500) {\n return new ServiceUnavailableException(\n \"Authorization is temporarily unavailable.\",\n );\n }\n\n // A 4xx we did not anticipate is a malformed question — our bug, not a\n // transient condition.\n return new InternalServerErrorException(\n \"Authorization could not be evaluated.\",\n );\n }\n\n private buildQuery(\n requirement: PermissionRequirement,\n identityId: string,\n request: unknown,\n ): EvaluateQuery {\n if (requirement.scope === \"app_wide\") {\n return {\n identity_id: identityId,\n permission: requirement.permission,\n scope: \"app_wide\",\n };\n }\n\n const nodeId = this.options.resolveNode\n ? resolveSafely(this.options.resolveNode, request)\n : undefined;\n\n if (!nodeId) {\n throw new ForbiddenException(\n `No node on the request. ${requirement.permission} is checked at a node; configure resolveNode, or declare the route as app_wide if that is genuinely the question.`,\n );\n }\n\n return {\n identity_id: identityId,\n permission: requirement.permission,\n scope: \"node\",\n node_id: nodeId,\n };\n }\n}\n\n/**\n * Run a resolver, treating a throw as \"could not resolve\".\n *\n * Resolvers reach into the request — `request.user.sub` is the shape the docs\n * suggest and the one most applications write. On an unauthenticated request\n * `request.user` is undefined and that expression throws, which without this\n * would surface as a 500. An identity that cannot be established is exactly the\n * request that must be denied, so it becomes the same 403 as returning nothing.\n */\nfunction resolveSafely(\n resolve: (request: never) => string | null | undefined,\n request: unknown,\n): string | null | undefined {\n try {\n return (resolve as (request: unknown) => string | null | undefined)(\n request,\n );\n } catch {\n return undefined;\n }\n}\n\n/**\n * Abort the permission check if the caller hangs up while it is in flight.\n *\n * Watches the *response*, not the request. A request stream emits `close` on\n * every normal request once its body has been read, so listening there would\n * abort perfectly healthy checks; a response emits `close` before\n * `writableEnded` only when nothing was ever sent — which is exactly the\n * disconnect case. The `writableEnded` re-check at fire time keeps that true\n * even if the ordering differs by adapter.\n *\n * Returns undefined for a transport with no such response — a GraphQL or\n * microservice context — where the check simply runs to its deadline.\n */\nfunction watchDisconnect(http: {\n getResponse?: () => unknown;\n}): Cancellation | undefined {\n const raw = resolveStream(http.getResponse?.());\n\n if (!raw) {\n return undefined;\n }\n\n const controller = new AbortController();\n\n const onClose = () => {\n if (raw.writableEnded === true) {\n return;\n }\n\n controller.abort(\n new DOMException(\"The client closed the request\", \"AbortError\"),\n );\n };\n\n raw.once(\"close\", onClose);\n\n return {\n signal: controller.signal,\n dispose: () => raw.removeListener(\"close\", onClose),\n };\n}\n","import {\n type CanActivate,\n type ExecutionContext,\n Inject,\n Injectable,\n ServiceUnavailableException,\n UnauthorizedException,\n} from \"@nestjs/common\";\n\nimport {\n type CanopyTokenClaims,\n isCanopyTokenError,\n TokenVerifier,\n} from \"@canopy-io/node\";\n\nimport type { CanopyModuleOptions } from \"./options.js\";\nimport { CANOPY_OPTIONS, CANOPY_TOKEN_VERIFIER } from \"./tokens.js\";\n\n/**\n * Verifies the bearer token and attaches its claims to the request.\n *\n * This is the authentication half, and it is opt-in on purpose. `CanopyGuard`\n * answers \"may this identity do this?\"; something has to have established\n * *which* identity first, and `resolveIdentity` says as much — \"usually a\n * claim off the verified access token your auth layer already attached\".\n *\n * Use this guard when Canopy issues your tokens and you have no auth layer\n * yet. Skip it entirely if you already run Passport or your own JWT\n * middleware — it is additive, and nothing else in the package depends on it.\n *\n * ```ts\n * @UseGuards(CanopyTokenGuard, CanopyGuard)\n * @RequirePermission(\"documents.read\")\n * findAll() {}\n * ```\n *\n * Order matters: this must run first, because `CanopyGuard` reads what it\n * attaches. Nest runs guards left to right, so listing them in that order is\n * the whole of the wiring.\n *\n * Claims land on `request.canopyToken` by default, which is why\n * `resolveIdentity` is usually `(req) => req.canopyToken?.sub`. The property\n * is configurable so it cannot collide with a `request.user` an existing\n * middleware already owns.\n */\n@Injectable()\nexport class CanopyTokenGuard implements CanActivate {\n constructor(\n @Inject(CANOPY_TOKEN_VERIFIER) private readonly verifier: TokenVerifier,\n @Inject(CANOPY_OPTIONS)\n private readonly options: CanopyModuleOptions<unknown>,\n ) {}\n\n async canActivate(context: ExecutionContext): Promise<boolean> {\n const request = context.switchToHttp().getRequest<{\n headers?: Record<string, string | string[] | undefined>;\n [key: string]: unknown;\n }>();\n\n const token = readBearerToken(request.headers?.[\"authorization\"]);\n\n if (token === null) {\n throw new UnauthorizedException(\n \"Missing bearer token in the Authorization header.\",\n );\n }\n\n let claims: CanopyTokenClaims;\n\n try {\n claims = await this.verifier.verify(token);\n } catch (error) {\n // Not being able to verify is not the same as failing to verify. When\n // the key set cannot be read the caller's token may be perfectly good —\n // we simply cannot say — so this is ours to own, exactly as the\n // permission guard answers 503 rather than denying on an undecidable\n // check.\n //\n // Reporting it as 401 would be worse than merely inaccurate: a client\n // reads 401 as \"your token is stale\", discards a valid one and asks for\n // another, which aims a refresh storm at the issuer that is already\n // down. And an outage that only ever emits 4xx is invisible to anything\n // watching 5xx.\n if (\n isCanopyTokenError(error) &&\n error.code === \"token.jwks_unavailable\"\n ) {\n throw new ServiceUnavailableException(\n \"Authentication is temporarily unavailable.\",\n { cause: error },\n );\n }\n\n // Every other cause is the token itself, and a 401 whatever the detail:\n // the caller cannot fix a bad signature by being granted something. The\n // specific code stays on the error for logs rather than going to the\n // caller, who has no use for the difference and should not be told which\n // check failed.\n if (isCanopyTokenError(error)) {\n throw new UnauthorizedException(\"Invalid or expired access token.\", {\n cause: error,\n });\n }\n\n throw error;\n }\n\n request[this.options.attachTokenAs ?? \"canopyToken\"] = claims;\n\n return true;\n }\n}\n\n/**\n * The token out of an `Authorization: Bearer …` header.\n *\n * Returns null rather than throwing so the caller owns the message. The scheme\n * is matched case-insensitively because RFC 7235 says it is case-insensitive,\n * and clients do send `bearer`.\n */\nfunction readBearerToken(header: string | string[] | undefined): string | null {\n const value = Array.isArray(header) ? header[0] : header;\n\n if (typeof value !== \"string\") {\n return null;\n }\n\n const match = /^bearer\\s+(.+)$/i.exec(value.trim());\n\n if (!match?.[1]) {\n return null;\n }\n\n const token = match[1].trim();\n\n return token.length > 0 ? token : null;\n}\n","import { type DynamicModule, Module, type Provider } from \"@nestjs/common\";\n\nimport { Canopy, LocalAuthorizer, TokenVerifier } from \"@canopy-io/node\";\n\nimport { CanopyGuard } from \"./canopy.guard.js\";\nimport { CanopyTokenGuard } from \"./canopy-token.guard.js\";\nimport type {\n CanopyModuleAsyncOptions,\n CanopyModuleOptions,\n} from \"./options.js\";\nimport {\n CANOPY_AUTHORIZER,\n CANOPY_CLIENT,\n CANOPY_OPTIONS,\n CANOPY_TOKEN_VERIFIER,\n} from \"./tokens.js\";\n\n/**\n * Wires one configured `Canopy` client and the guard that uses it into a Nest\n * application.\n *\n * ```ts\n * CanopyModule.forRoot<AuthedRequest>({\n * apiKey: process.env.CANOPY_API_KEY,\n * resolveIdentity: (request) => request.user?.sub,\n * resolveNode: (request) => request.params?.orgId,\n * })\n * ```\n *\n * The client is a singleton because it holds no per-request state — it is a\n * `fetch` wrapper with credentials — so there is nothing to gain from building\n * one per request and a connection pool to lose.\n */\n\n/**\n * Per-attempt deadline for a cache miss on the request path.\n *\n * Five seconds rather than the client's 30: this call sits on the request path,\n * so its deadline is time an inbound request spends waiting. An authorization\n * service that has not answered in five seconds is not going to save the\n * request.\n */\nconst DEFAULT_EVALUATE_TIMEOUT_MS = 5_000;\n\n/**\n * Retries for a cache miss on the request path.\n *\n * One, not the client's two. A deadline bounds an attempt; this bounds how many\n * of them an inbound request can wait through. One retry still absorbs a\n * transient blip, and caps the worst case at roughly two deadlines rather than\n * three.\n */\nconst DEFAULT_EVALUATE_MAX_RETRIES = 1;\n\n/**\n * The authorizer both registration paths build.\n *\n * The guard's `evaluateTimeoutMs` / `evaluateMaxRetries` still apply, but to\n * far fewer calls: they bound a cache miss rather than every request.\n */\nfunction buildAuthorizer<TRequest>(\n client: Canopy,\n options: CanopyModuleOptions<TRequest>,\n): LocalAuthorizer {\n return new LocalAuthorizer(client.client, {\n ...(options.authorizationTtlMs === undefined\n ? {}\n : { ttlMs: options.authorizationTtlMs }),\n timeoutMs: options.evaluateTimeoutMs ?? DEFAULT_EVALUATE_TIMEOUT_MS,\n maxRetries: options.evaluateMaxRetries ?? DEFAULT_EVALUATE_MAX_RETRIES,\n });\n}\n\n@Module({})\nexport class CanopyModule {\n static forRoot<TRequest = unknown>(\n options: CanopyModuleOptions<TRequest>,\n ): DynamicModule {\n const providers: Provider[] = [\n { provide: CANOPY_OPTIONS, useValue: options },\n {\n provide: CANOPY_CLIENT,\n useFactory: () => new Canopy(options),\n },\n {\n provide: CANOPY_TOKEN_VERIFIER,\n useFactory: () => new TokenVerifier(options.verify),\n },\n {\n provide: CANOPY_AUTHORIZER,\n useFactory: (client: Canopy) => buildAuthorizer(client, options),\n inject: [CANOPY_CLIENT],\n },\n CanopyGuard,\n CanopyTokenGuard,\n ];\n\n return {\n module: CanopyModule,\n global: options.isGlobal ?? false,\n providers,\n exports: [\n CANOPY_CLIENT,\n CANOPY_OPTIONS,\n CANOPY_TOKEN_VERIFIER,\n CANOPY_AUTHORIZER,\n CanopyGuard,\n CanopyTokenGuard,\n ],\n };\n }\n\n /**\n * The same wiring when the API key comes from something injectable, such as\n * `ConfigService`, rather than being available at module-definition time.\n */\n static forRootAsync<TRequest = unknown>(\n options: CanopyModuleAsyncOptions<TRequest>,\n ): DynamicModule {\n const providers: Provider[] = [\n {\n provide: CANOPY_OPTIONS,\n useFactory: options.useFactory,\n inject: options.inject ?? [],\n },\n {\n provide: CANOPY_CLIENT,\n useFactory: (resolved: CanopyModuleOptions<TRequest>) =>\n new Canopy(resolved),\n inject: [CANOPY_OPTIONS],\n },\n {\n provide: CANOPY_TOKEN_VERIFIER,\n useFactory: (resolved: CanopyModuleOptions<TRequest>) =>\n new TokenVerifier(resolved.verify),\n inject: [CANOPY_OPTIONS],\n },\n {\n provide: CANOPY_AUTHORIZER,\n useFactory: (client: Canopy, resolved: CanopyModuleOptions<TRequest>) =>\n buildAuthorizer(client, resolved),\n inject: [CANOPY_CLIENT, CANOPY_OPTIONS],\n },\n CanopyGuard,\n CanopyTokenGuard,\n ];\n\n return {\n module: CanopyModule,\n global: options.isGlobal ?? false,\n // Spread rather than assigned: under exactOptionalPropertyTypes an\n // explicit `imports: undefined` is not the same as no `imports` at all.\n ...(options.imports ? { imports: options.imports } : {}),\n providers,\n exports: [\n CANOPY_CLIENT,\n CANOPY_OPTIONS,\n CANOPY_TOKEN_VERIFIER,\n CANOPY_AUTHORIZER,\n CanopyGuard,\n CanopyTokenGuard,\n ],\n };\n }\n}\n","import { Inject } from \"@nestjs/common\";\n\nimport { CANOPY_CLIENT } from \"./tokens.js\";\n\n/**\n * Injects the configured client.\n *\n * ```ts\n * constructor(@InjectCanopy() private readonly canopy: Canopy) {}\n * ```\n *\n * Named rather than resolved by type because this package is bundled with\n * esbuild, which emits no `design:paramtypes` — so type-only injection would\n * fail at runtime with a confusing \"cannot resolve dependency\" rather than at\n * build time.\n */\nexport const InjectCanopy = (): ParameterDecorator => Inject(CANOPY_CLIENT);\n","import { SetMetadata } from \"@nestjs/common\";\n\nimport { CANOPY_PERMISSION } from \"./tokens.js\";\n\n/**\n * Which question the guard asks Canopy.\n *\n * `node` walks the node's lineage, so a role granted on an ancestor is\n * inherited at the node being touched. `app_wide` asks only whether the\n * identity holds the permission anywhere in the Environment.\n */\nexport type PermissionScope = \"node\" | \"app_wide\";\n\n/** What `@RequirePermission` records for the guard to read back. */\nexport interface PermissionRequirement {\n readonly permission: string;\n readonly scope: PermissionScope;\n}\n\nexport interface RequirePermissionOptions {\n /**\n * Defaults to `node`.\n *\n * `app_wide` is opt-in on purpose. It answers a coarser question and returns\n * no effective node, which makes it right for deciding whether to render a\n * menu item and wrong for guarding a resource that belongs to one — a\n * distinction that is easy to get backwards if the looser check is the\n * default.\n */\n readonly scope?: PermissionScope;\n}\n\n/**\n * Require a Canopy permission before a route runs.\n *\n * ```ts\n * @UseGuards(CanopyGuard)\n * @RequirePermission(\"orders.refund\")\n * @Post(\":orderId/refund\")\n * refund() {}\n * ```\n *\n * Applies to a single handler or to a whole controller; a handler's own\n * requirement wins over its controller's.\n */\nexport const RequirePermission = (\n permission: string,\n options: RequirePermissionOptions = {},\n): MethodDecorator & ClassDecorator =>\n SetMetadata<string, PermissionRequirement>(CANOPY_PERMISSION, {\n permission,\n scope: options.scope ?? \"node\",\n });\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/tokens.ts","../src/canopy.guard.ts","../src/canopy-token.guard.ts","../src/canopy.module.ts","../src/inject-canopy.decorator.ts","../src/require-permission.decorator.ts"],"names":["CanopyGuard","Logger","ForbiddenException","isCanopyAuthorizerError","InternalServerErrorException","isCanopyError","ServiceUnavailableException","Injectable","Reflector","CanopyTokenGuard","UnauthorizedException","isCanopyTokenError","Inject","LocalAuthorizer","CanopyModule","Canopy","TokenVerifier","Module","SetMetadata"],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * Injection tokens.\n *\n * These are strings rather than classes because esbuild does not emit\n * `design:paramtypes`, so nothing here can be injected by constructor type\n * alone — every dependency is named with an explicit `@Inject`.\n */\n\n/** The resolved `CanopyModuleOptions`. */\nexport const CANOPY_OPTIONS = \"CANOPY_OPTIONS\";\n\n/** The configured `Canopy` client. */\nexport const CANOPY_CLIENT = \"CANOPY_CLIENT\";\n\n/**\n * The configured `LocalAuthorizer`.\n *\n * Shared deliberately: it holds one copy of the hierarchy for the whole\n * process, so a second instance would double both the memory and the traffic\n * it exists to avoid.\n */\nexport const CANOPY_AUTHORIZER = \"CANOPY_AUTHORIZER\";\n\n/** The configured `TokenVerifier`, shared so its key cache is shared too. */\nexport const CANOPY_TOKEN_VERIFIER = \"CANOPY_TOKEN_VERIFIER\";\n\n/** Metadata key holding what `@RequirePermission` recorded on a handler. */\nexport const CANOPY_PERMISSION = \"CANOPY_PERMISSION\";\n","import {\n type CanActivate,\n type ExecutionContext,\n ForbiddenException,\n Inject,\n Injectable,\n InternalServerErrorException,\n Logger,\n ServiceUnavailableException,\n} from \"@nestjs/common\";\nimport { Reflector } from \"@nestjs/core\";\n\nimport {\n isCanopyAuthorizerError,\n isCanopyError,\n type LocalAuthorizer,\n type RequestBody,\n} from \"@canopy-io/node\";\n\nimport type { CanopyModuleOptions } from \"./options.js\";\nimport type { PermissionRequirement } from \"./require-permission.decorator.js\";\nimport {\n CANOPY_AUTHORIZER,\n CANOPY_OPTIONS,\n CANOPY_PERMISSION,\n} from \"./tokens.js\";\n\n/** Read off the generated operation, so it follows the API rather than restating it. */\ntype EvaluateQuery = RequestBody<\"ApiPermissionsController_evaluate\">;\n\n/**\n * Only the verdict, not the API's full explain payload.\n *\n * A decision reached in-process cannot report `granting_roles` or an effective\n * node — that reasoning lives on the server. The guard has never needed it, and\n * typing the narrow thing keeps that honest rather than implying a richer\n * answer than exists.\n */\ntype Verdict = { allowed: boolean };\n\ninterface Cancellation {\n signal: AbortSignal;\n dispose: () => void;\n}\n\n/** The Node stream underneath an Express or Fastify response. */\ninterface ClosableResponse {\n once: (event: string, listener: () => void) => unknown;\n removeListener: (event: string, listener: () => void) => unknown;\n writableEnded?: boolean;\n raw?: ClosableResponse;\n}\n\nfunction isClosable(value: unknown): value is ClosableResponse {\n return (\n typeof value === \"object\" &&\n value !== null &&\n typeof (value as ClosableResponse).once === \"function\" &&\n typeof (value as ClosableResponse).removeListener === \"function\"\n );\n}\n\n/**\n * Find the Node response stream behind whatever the adapter hands back.\n *\n * `.raw` is checked first and on its own merits, because the object Nest\n * returns is not always the stream. Express returns the `ServerResponse`\n * itself, which listens; Fastify returns a `Reply`, which is a plain object\n * with the real response on `.raw`. Testing the outer object first and\n * returning early on failure would skip `.raw` in exactly the case it exists\n * for.\n */\nfunction resolveStream(response: unknown): ClosableResponse | undefined {\n const raw = (response as { raw?: unknown } | null | undefined)?.raw;\n\n if (isClosable(raw)) {\n return raw;\n }\n\n if (isClosable(response)) {\n return response;\n }\n\n return undefined;\n}\n\n/**\n * Enforces `@RequirePermission` by asking Canopy, once, per guarded request.\n *\n * The guard fails closed at every step. An unresolvable identity, a `node`\n * check with no node, a denial, or an unreachable API all end the request —\n * there is no path through this class that allows a request whose decision is\n * unknown. That is the property worth protecting when reading this code: an\n * `allow` must be something Canopy actually said.\n *\n * A route with no requirement is allowed through untouched, so the guard can be\n * applied globally and opted into per route. Registered that way it runs before\n * controller- and route-scoped guards, so whatever authenticates the request has\n * to be registered globally ahead of it — otherwise `resolveIdentity` runs\n * before there is anything to resolve and every guarded route denies.\n */\n@Injectable()\nexport class CanopyGuard implements CanActivate {\n private readonly logger = new Logger(CanopyGuard.name);\n\n constructor(\n @Inject(Reflector) private readonly reflector: Reflector,\n @Inject(CANOPY_AUTHORIZER) private readonly authorizer: LocalAuthorizer,\n @Inject(CANOPY_OPTIONS)\n private readonly options: CanopyModuleOptions<unknown>,\n ) {}\n\n async canActivate(context: ExecutionContext): Promise<boolean> {\n const requirement = this.reflector.getAllAndOverride<\n PermissionRequirement | undefined\n >(CANOPY_PERMISSION, [context.getHandler(), context.getClass()]);\n\n if (!requirement) {\n return true;\n }\n\n const http = context.switchToHttp();\n const request: unknown = http.getRequest();\n const identityId = resolveSafely(this.options.resolveIdentity, request);\n\n if (!identityId) {\n throw new ForbiddenException(\n \"No identity on the request. Canopy cannot evaluate a permission without one.\",\n );\n }\n\n const decision = await this.evaluate(\n requirement,\n identityId,\n request,\n watchDisconnect(http),\n );\n\n if (!decision.allowed) {\n throw new ForbiddenException(\n `Identity does not hold ${requirement.permission}.`,\n );\n }\n\n return true;\n }\n\n /**\n * Asks Canopy the question the decorator recorded.\n *\n * Every failure is converted into a thrown exception rather than a `false`\n * decision, so a caller cannot mistake \"we could not tell\" for \"denied by\n * policy\" — and neither outcome lets the request proceed.\n */\n private async evaluate(\n requirement: PermissionRequirement,\n identityId: string,\n request: unknown,\n cancellation: Cancellation | undefined,\n ): Promise<Verdict> {\n const query = this.buildQuery(requirement, identityId, request);\n\n try {\n // Answered in-process. The authorizer holds the identity's grant roots\n // and a shared copy of the hierarchy, so the common case reaches no\n // network at all; only a cold or expired entry does, and its deadline\n // and retries are bounded by the same options as before.\n return await this.authorizer.evaluate(\n query,\n cancellation ? { signal: cancellation.signal } : {},\n );\n } catch (error) {\n // The caller hung up mid-check. There is no one left to answer, and this\n // is not Canopy failing — so it must not be logged or reported as though\n // it were. Rethrowing the abort ends the request without inventing a\n // decision.\n if (cancellation?.signal.aborted) {\n throw error;\n }\n\n // Deliberately not re-thrown as-is: a failure here must not reach the\n // client as though Canopy had answered.\n this.logger.error(\n `Could not evaluate ${requirement.permission} for ${identityId}`,\n error instanceof Error ? error.stack : String(error),\n );\n\n throw this.toHttpException(error);\n } finally {\n cancellation?.dispose();\n }\n }\n\n /**\n * Turn a failed check into the status that describes it.\n *\n * Every branch denies — nothing here can let a request through — but they say\n * different things, and the difference is what makes an incident diagnosable.\n * Reporting a rejected API key as \"temporarily unavailable\" invites a retry\n * that will never succeed and hides a misconfiguration behind an outage.\n */\n private toHttpException(error: unknown): Error {\n // The authorizer could not see far enough to decide — a credential that\n // cannot read the hierarchy, most likely. Not an outage: no retry fixes a\n // missing scope, and reporting it as one would bury a misconfiguration\n // under what looks like a blip.\n if (isCanopyAuthorizerError(error)) {\n return new InternalServerErrorException(\n `Authorization is misconfigured: ${error.message}`,\n );\n }\n\n if (!isCanopyError(error)) {\n // Never answered: unreachable, reset, or past the deadline.\n return new ServiceUnavailableException(\n \"Authorization is temporarily unavailable.\",\n );\n }\n\n // Canopy rejected our own credential. No retry fixes this.\n if (error.isAuthFailure) {\n return new InternalServerErrorException(\n \"Authorization is misconfigured. Check the Canopy API key and its scopes.\",\n );\n }\n\n // A 404 with no error code is Nest's own \"no such route\", which here means\n // this Canopy does not serve the endpoint the authorizer reads — an SDK\n // newer than the API it is pointed at. Denying would be catastrophic and\n // silent: every guarded route in the application would answer 403 with\n // nothing to suggest the cause is a version mismatch rather than policy.\n if (error.statusCode === 404 && error.code === null) {\n return new InternalServerErrorException(\n `Authorization is misconfigured: ${error.request.method} ${error.request.path} is not served by this Canopy. ` +\n \"The SDK requires an API that exposes the identity grants endpoint.\",\n );\n }\n\n // A 404 that does carry a code is a real answer — the identity is not in\n // this Environment. Deny rather than 500, the guard's whole thesis being\n // that an undecidable request does not proceed, but say nothing about what\n // was missing.\n if (error.statusCode === 404) {\n return new ForbiddenException(\"Could not evaluate the permission.\");\n }\n\n if (error.isRateLimited || error.statusCode >= 500) {\n return new ServiceUnavailableException(\n \"Authorization is temporarily unavailable.\",\n );\n }\n\n // A 4xx we did not anticipate is a malformed question — our bug, not a\n // transient condition.\n return new InternalServerErrorException(\n \"Authorization could not be evaluated.\",\n );\n }\n\n private buildQuery(\n requirement: PermissionRequirement,\n identityId: string,\n request: unknown,\n ): EvaluateQuery {\n if (requirement.scope === \"app_wide\") {\n return {\n identity_id: identityId,\n permission: requirement.permission,\n scope: \"app_wide\",\n };\n }\n\n // An organization is a hierarchy node, and a membership is a role\n // assignment at it — so the org question IS the node question, asked at\n // the node the caller's token says it is acting in.\n if (requirement.scope === \"org\") {\n const orgId = this.resolveOrg(request);\n\n if (!orgId) {\n throw new ForbiddenException(\n `No organization on the request. ${requirement.permission} is checked inside the caller's active organization, and this caller is not acting in one.`,\n );\n }\n\n return {\n identity_id: identityId,\n permission: requirement.permission,\n scope: \"node\",\n node_id: orgId,\n };\n }\n\n const nodeId = this.options.resolveNode\n ? resolveSafely(this.options.resolveNode, request)\n : undefined;\n\n if (!nodeId) {\n throw new ForbiddenException(\n `No node on the request. ${requirement.permission} is checked at a node; configure resolveNode, or declare the route as app_wide if that is genuinely the question.`,\n );\n }\n\n return {\n identity_id: identityId,\n permission: requirement.permission,\n scope: \"node\",\n node_id: nodeId,\n };\n }\n\n /**\n * The organization the request acts in: `resolveOrg` when configured,\n * otherwise the `org_id` claim off the verified token `CanopyTokenGuard`\n * attached. Both sources trace back to a verified token — the org id\n * decides whose grants answer the check, so it must never be readable off\n * anything the caller controls directly.\n */\n private resolveOrg(request: unknown): string | null | undefined {\n if (this.options.resolveOrg) {\n return resolveSafely(this.options.resolveOrg, request);\n }\n\n const attachedAs = this.options.attachTokenAs ?? \"canopyToken\";\n const claims = (request as Record<string, unknown> | null | undefined)?.[\n attachedAs\n ];\n\n const orgId = (claims as { org_id?: unknown } | undefined)?.org_id;\n\n return typeof orgId === \"string\" && orgId !== \"\" ? orgId : undefined;\n }\n}\n\n/**\n * Run a resolver, treating a throw as \"could not resolve\".\n *\n * Resolvers reach into the request — `request.user.sub` is the shape the docs\n * suggest and the one most applications write. On an unauthenticated request\n * `request.user` is undefined and that expression throws, which without this\n * would surface as a 500. An identity that cannot be established is exactly the\n * request that must be denied, so it becomes the same 403 as returning nothing.\n */\nfunction resolveSafely(\n resolve: (request: never) => string | null | undefined,\n request: unknown,\n): string | null | undefined {\n try {\n return (resolve as (request: unknown) => string | null | undefined)(\n request,\n );\n } catch {\n return undefined;\n }\n}\n\n/**\n * Abort the permission check if the caller hangs up while it is in flight.\n *\n * Watches the *response*, not the request. A request stream emits `close` on\n * every normal request once its body has been read, so listening there would\n * abort perfectly healthy checks; a response emits `close` before\n * `writableEnded` only when nothing was ever sent — which is exactly the\n * disconnect case. The `writableEnded` re-check at fire time keeps that true\n * even if the ordering differs by adapter.\n *\n * Returns undefined for a transport with no such response — a GraphQL or\n * microservice context — where the check simply runs to its deadline.\n */\nfunction watchDisconnect(http: {\n getResponse?: () => unknown;\n}): Cancellation | undefined {\n const raw = resolveStream(http.getResponse?.());\n\n if (!raw) {\n return undefined;\n }\n\n const controller = new AbortController();\n\n const onClose = () => {\n if (raw.writableEnded === true) {\n return;\n }\n\n controller.abort(\n new DOMException(\"The client closed the request\", \"AbortError\"),\n );\n };\n\n raw.once(\"close\", onClose);\n\n return {\n signal: controller.signal,\n dispose: () => raw.removeListener(\"close\", onClose),\n };\n}\n","import {\n type CanActivate,\n type ExecutionContext,\n Inject,\n Injectable,\n ServiceUnavailableException,\n UnauthorizedException,\n} from \"@nestjs/common\";\n\nimport {\n type CanopyTokenClaims,\n isCanopyTokenError,\n TokenVerifier,\n} from \"@canopy-io/node\";\n\nimport type { CanopyModuleOptions } from \"./options.js\";\nimport { CANOPY_OPTIONS, CANOPY_TOKEN_VERIFIER } from \"./tokens.js\";\n\n/**\n * Verifies the bearer token and attaches its claims to the request.\n *\n * This is the authentication half, and it is opt-in on purpose. `CanopyGuard`\n * answers \"may this identity do this?\"; something has to have established\n * *which* identity first, and `resolveIdentity` says as much — \"usually a\n * claim off the verified access token your auth layer already attached\".\n *\n * Use this guard when Canopy issues your tokens and you have no auth layer\n * yet. Skip it entirely if you already run Passport or your own JWT\n * middleware — it is additive, and nothing else in the package depends on it.\n *\n * ```ts\n * @UseGuards(CanopyTokenGuard, CanopyGuard)\n * @RequirePermission(\"documents.read\")\n * findAll() {}\n * ```\n *\n * Order matters: this must run first, because `CanopyGuard` reads what it\n * attaches. Nest runs guards left to right, so listing them in that order is\n * the whole of the wiring.\n *\n * Claims land on `request.canopyToken` by default, which is why\n * `resolveIdentity` is usually `(req) => req.canopyToken?.sub`. The property\n * is configurable so it cannot collide with a `request.user` an existing\n * middleware already owns.\n */\n@Injectable()\nexport class CanopyTokenGuard implements CanActivate {\n constructor(\n @Inject(CANOPY_TOKEN_VERIFIER) private readonly verifier: TokenVerifier,\n @Inject(CANOPY_OPTIONS)\n private readonly options: CanopyModuleOptions<unknown>,\n ) {}\n\n async canActivate(context: ExecutionContext): Promise<boolean> {\n const request = context.switchToHttp().getRequest<{\n headers?: Record<string, string | string[] | undefined>;\n [key: string]: unknown;\n }>();\n\n const token = readBearerToken(request.headers?.[\"authorization\"]);\n\n if (token === null) {\n throw new UnauthorizedException(\n \"Missing bearer token in the Authorization header.\",\n );\n }\n\n let claims: CanopyTokenClaims;\n\n try {\n claims = await this.verifier.verify(token);\n } catch (error) {\n // Not being able to verify is not the same as failing to verify. When\n // the key set cannot be read the caller's token may be perfectly good —\n // we simply cannot say — so this is ours to own, exactly as the\n // permission guard answers 503 rather than denying on an undecidable\n // check.\n //\n // Reporting it as 401 would be worse than merely inaccurate: a client\n // reads 401 as \"your token is stale\", discards a valid one and asks for\n // another, which aims a refresh storm at the issuer that is already\n // down. And an outage that only ever emits 4xx is invisible to anything\n // watching 5xx.\n if (\n isCanopyTokenError(error) &&\n error.code === \"token.jwks_unavailable\"\n ) {\n throw new ServiceUnavailableException(\n \"Authentication is temporarily unavailable.\",\n { cause: error },\n );\n }\n\n // Every other cause is the token itself, and a 401 whatever the detail:\n // the caller cannot fix a bad signature by being granted something. The\n // specific code stays on the error for logs rather than going to the\n // caller, who has no use for the difference and should not be told which\n // check failed.\n if (isCanopyTokenError(error)) {\n throw new UnauthorizedException(\"Invalid or expired access token.\", {\n cause: error,\n });\n }\n\n throw error;\n }\n\n request[this.options.attachTokenAs ?? \"canopyToken\"] = claims;\n\n return true;\n }\n}\n\n/**\n * The token out of an `Authorization: Bearer …` header.\n *\n * Returns null rather than throwing so the caller owns the message. The scheme\n * is matched case-insensitively because RFC 7235 says it is case-insensitive,\n * and clients do send `bearer`.\n */\nfunction readBearerToken(header: string | string[] | undefined): string | null {\n const value = Array.isArray(header) ? header[0] : header;\n\n if (typeof value !== \"string\") {\n return null;\n }\n\n const match = /^bearer\\s+(.+)$/i.exec(value.trim());\n\n if (!match?.[1]) {\n return null;\n }\n\n const token = match[1].trim();\n\n return token.length > 0 ? token : null;\n}\n","import { type DynamicModule, Module, type Provider } from \"@nestjs/common\";\n\nimport { Canopy, LocalAuthorizer, TokenVerifier } from \"@canopy-io/node\";\n\nimport { CanopyGuard } from \"./canopy.guard.js\";\nimport { CanopyTokenGuard } from \"./canopy-token.guard.js\";\nimport type {\n CanopyModuleAsyncOptions,\n CanopyModuleOptions,\n} from \"./options.js\";\nimport {\n CANOPY_AUTHORIZER,\n CANOPY_CLIENT,\n CANOPY_OPTIONS,\n CANOPY_TOKEN_VERIFIER,\n} from \"./tokens.js\";\n\n/**\n * Wires one configured `Canopy` client and the guard that uses it into a Nest\n * application.\n *\n * ```ts\n * CanopyModule.forRoot<AuthedRequest>({\n * apiKey: process.env.CANOPY_API_KEY,\n * resolveIdentity: (request) => request.user?.sub,\n * resolveNode: (request) => request.params?.orgId,\n * })\n * ```\n *\n * The client is a singleton because it holds no per-request state — it is a\n * `fetch` wrapper with credentials — so there is nothing to gain from building\n * one per request and a connection pool to lose.\n */\n\n/**\n * Per-attempt deadline for a cache miss on the request path.\n *\n * Five seconds rather than the client's 30: this call sits on the request path,\n * so its deadline is time an inbound request spends waiting. An authorization\n * service that has not answered in five seconds is not going to save the\n * request.\n */\nconst DEFAULT_EVALUATE_TIMEOUT_MS = 5_000;\n\n/**\n * Retries for a cache miss on the request path.\n *\n * One, not the client's two. A deadline bounds an attempt; this bounds how many\n * of them an inbound request can wait through. One retry still absorbs a\n * transient blip, and caps the worst case at roughly two deadlines rather than\n * three.\n */\nconst DEFAULT_EVALUATE_MAX_RETRIES = 1;\n\n/**\n * The authorizer both registration paths build.\n *\n * The guard's `evaluateTimeoutMs` / `evaluateMaxRetries` still apply, but to\n * far fewer calls: they bound a cache miss rather than every request.\n */\nfunction buildAuthorizer<TRequest>(\n client: Canopy,\n options: CanopyModuleOptions<TRequest>,\n): LocalAuthorizer {\n return new LocalAuthorizer(client.client, {\n ...(options.authorizationTtlMs === undefined\n ? {}\n : { ttlMs: options.authorizationTtlMs }),\n timeoutMs: options.evaluateTimeoutMs ?? DEFAULT_EVALUATE_TIMEOUT_MS,\n maxRetries: options.evaluateMaxRetries ?? DEFAULT_EVALUATE_MAX_RETRIES,\n });\n}\n\n@Module({})\nexport class CanopyModule {\n static forRoot<TRequest = unknown>(\n options: CanopyModuleOptions<TRequest>,\n ): DynamicModule {\n const providers: Provider[] = [\n { provide: CANOPY_OPTIONS, useValue: options },\n {\n provide: CANOPY_CLIENT,\n useFactory: () => new Canopy(options),\n },\n {\n provide: CANOPY_TOKEN_VERIFIER,\n useFactory: () => new TokenVerifier(options.verify),\n },\n {\n provide: CANOPY_AUTHORIZER,\n useFactory: (client: Canopy) => buildAuthorizer(client, options),\n inject: [CANOPY_CLIENT],\n },\n CanopyGuard,\n CanopyTokenGuard,\n ];\n\n return {\n module: CanopyModule,\n global: options.isGlobal ?? false,\n providers,\n exports: [\n CANOPY_CLIENT,\n CANOPY_OPTIONS,\n CANOPY_TOKEN_VERIFIER,\n CANOPY_AUTHORIZER,\n CanopyGuard,\n CanopyTokenGuard,\n ],\n };\n }\n\n /**\n * The same wiring when the API key comes from something injectable, such as\n * `ConfigService`, rather than being available at module-definition time.\n */\n static forRootAsync<TRequest = unknown>(\n options: CanopyModuleAsyncOptions<TRequest>,\n ): DynamicModule {\n const providers: Provider[] = [\n {\n provide: CANOPY_OPTIONS,\n useFactory: options.useFactory,\n inject: options.inject ?? [],\n },\n {\n provide: CANOPY_CLIENT,\n useFactory: (resolved: CanopyModuleOptions<TRequest>) =>\n new Canopy(resolved),\n inject: [CANOPY_OPTIONS],\n },\n {\n provide: CANOPY_TOKEN_VERIFIER,\n useFactory: (resolved: CanopyModuleOptions<TRequest>) =>\n new TokenVerifier(resolved.verify),\n inject: [CANOPY_OPTIONS],\n },\n {\n provide: CANOPY_AUTHORIZER,\n useFactory: (client: Canopy, resolved: CanopyModuleOptions<TRequest>) =>\n buildAuthorizer(client, resolved),\n inject: [CANOPY_CLIENT, CANOPY_OPTIONS],\n },\n CanopyGuard,\n CanopyTokenGuard,\n ];\n\n return {\n module: CanopyModule,\n global: options.isGlobal ?? false,\n // Spread rather than assigned: under exactOptionalPropertyTypes an\n // explicit `imports: undefined` is not the same as no `imports` at all.\n ...(options.imports ? { imports: options.imports } : {}),\n providers,\n exports: [\n CANOPY_CLIENT,\n CANOPY_OPTIONS,\n CANOPY_TOKEN_VERIFIER,\n CANOPY_AUTHORIZER,\n CanopyGuard,\n CanopyTokenGuard,\n ],\n };\n }\n}\n","import { Inject } from \"@nestjs/common\";\n\nimport { CANOPY_CLIENT } from \"./tokens.js\";\n\n/**\n * Injects the configured client.\n *\n * ```ts\n * constructor(@InjectCanopy() private readonly canopy: Canopy) {}\n * ```\n *\n * Named rather than resolved by type because this package is bundled with\n * esbuild, which emits no `design:paramtypes` — so type-only injection would\n * fail at runtime with a confusing \"cannot resolve dependency\" rather than at\n * build time.\n */\nexport const InjectCanopy = (): ParameterDecorator => Inject(CANOPY_CLIENT);\n","import { SetMetadata } from \"@nestjs/common\";\n\nimport { CANOPY_PERMISSION } from \"./tokens.js\";\n\n/**\n * Which question the guard asks Canopy.\n *\n * `node` walks the node's lineage, so a role granted on an ancestor is\n * inherited at the node being touched. `app_wide` asks only whether the\n * identity holds the permission anywhere in the Environment. `org` is the\n * node question asked at the organization the caller's token is acting in —\n * the `org_id` claim — so the route needs no `resolveNode` and no node in\n * its path.\n */\nexport type PermissionScope = \"node\" | \"app_wide\" | \"org\";\n\n/** What `@RequirePermission` records for the guard to read back. */\nexport interface PermissionRequirement {\n readonly permission: string;\n readonly scope: PermissionScope;\n}\n\nexport interface RequirePermissionOptions {\n /**\n * Defaults to `node`.\n *\n * `app_wide` is opt-in on purpose. It answers a coarser question and returns\n * no effective node, which makes it right for deciding whether to render a\n * menu item and wrong for guarding a resource that belongs to one — a\n * distinction that is easy to get backwards if the looser check is the\n * default.\n *\n * `org` evaluates at the organization on the caller's verified token\n * (`org_id`), read from the claims `CanopyTokenGuard` attaches or from\n * `resolveOrg` if configured. A caller acting in no organization is denied\n * — an org-scoped route has no meaning outside one.\n */\n readonly scope?: PermissionScope;\n}\n\n/**\n * Require a Canopy permission before a route runs.\n *\n * ```ts\n * @UseGuards(CanopyGuard)\n * @RequirePermission(\"orders.refund\")\n * @Post(\":orderId/refund\")\n * refund() {}\n * ```\n *\n * Applies to a single handler or to a whole controller; a handler's own\n * requirement wins over its controller's.\n */\nexport const RequirePermission = (\n permission: string,\n options: RequirePermissionOptions = {},\n): MethodDecorator & ClassDecorator =>\n SetMetadata<string, PermissionRequirement>(CANOPY_PERMISSION, {\n permission,\n scope: options.scope ?? \"node\",\n });\n"]}
|
package/dist/index.d.cts
CHANGED
|
@@ -35,6 +35,17 @@ interface CanopyModuleOptions<TRequest = unknown> extends CanopyClientOptions {
|
|
|
35
35
|
* a `node` check with no node resolves to a denial.
|
|
36
36
|
*/
|
|
37
37
|
resolveNode?: RequestResolver<TRequest>;
|
|
38
|
+
/**
|
|
39
|
+
* Which organization the request is acting in, for `scope: "org"`.
|
|
40
|
+
*
|
|
41
|
+
* Rarely needed: by default the guard reads the `org_id` claim off the
|
|
42
|
+
* verified token that `CanopyTokenGuard` attached (honoring
|
|
43
|
+
* `attachTokenAs`). Set this only when your own auth layer verifies tokens
|
|
44
|
+
* and parks the claims somewhere else — the value must still come from a
|
|
45
|
+
* *verified* token, never from a header or body the caller controls,
|
|
46
|
+
* because it decides which organization's grants answer the check.
|
|
47
|
+
*/
|
|
48
|
+
resolveOrg?: RequestResolver<TRequest>;
|
|
38
49
|
/**
|
|
39
50
|
* Deadline for the guard's own permission check, per attempt. Defaults to
|
|
40
51
|
* 5s — far tighter than the client-wide 30s, which is sized for
|
|
@@ -155,6 +166,14 @@ declare class CanopyGuard implements CanActivate {
|
|
|
155
166
|
*/
|
|
156
167
|
private toHttpException;
|
|
157
168
|
private buildQuery;
|
|
169
|
+
/**
|
|
170
|
+
* The organization the request acts in: `resolveOrg` when configured,
|
|
171
|
+
* otherwise the `org_id` claim off the verified token `CanopyTokenGuard`
|
|
172
|
+
* attached. Both sources trace back to a verified token — the org id
|
|
173
|
+
* decides whose grants answer the check, so it must never be readable off
|
|
174
|
+
* anything the caller controls directly.
|
|
175
|
+
*/
|
|
176
|
+
private resolveOrg;
|
|
158
177
|
}
|
|
159
178
|
|
|
160
179
|
/**
|
|
@@ -210,9 +229,12 @@ declare const InjectCanopy: () => ParameterDecorator;
|
|
|
210
229
|
*
|
|
211
230
|
* `node` walks the node's lineage, so a role granted on an ancestor is
|
|
212
231
|
* inherited at the node being touched. `app_wide` asks only whether the
|
|
213
|
-
* identity holds the permission anywhere in the Environment.
|
|
232
|
+
* identity holds the permission anywhere in the Environment. `org` is the
|
|
233
|
+
* node question asked at the organization the caller's token is acting in —
|
|
234
|
+
* the `org_id` claim — so the route needs no `resolveNode` and no node in
|
|
235
|
+
* its path.
|
|
214
236
|
*/
|
|
215
|
-
type PermissionScope = "node" | "app_wide";
|
|
237
|
+
type PermissionScope = "node" | "app_wide" | "org";
|
|
216
238
|
/** What `@RequirePermission` records for the guard to read back. */
|
|
217
239
|
interface PermissionRequirement {
|
|
218
240
|
readonly permission: string;
|
|
@@ -227,6 +249,11 @@ interface RequirePermissionOptions {
|
|
|
227
249
|
* menu item and wrong for guarding a resource that belongs to one — a
|
|
228
250
|
* distinction that is easy to get backwards if the looser check is the
|
|
229
251
|
* default.
|
|
252
|
+
*
|
|
253
|
+
* `org` evaluates at the organization on the caller's verified token
|
|
254
|
+
* (`org_id`), read from the claims `CanopyTokenGuard` attaches or from
|
|
255
|
+
* `resolveOrg` if configured. A caller acting in no organization is denied
|
|
256
|
+
* — an org-scoped route has no meaning outside one.
|
|
230
257
|
*/
|
|
231
258
|
readonly scope?: PermissionScope;
|
|
232
259
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -35,6 +35,17 @@ interface CanopyModuleOptions<TRequest = unknown> extends CanopyClientOptions {
|
|
|
35
35
|
* a `node` check with no node resolves to a denial.
|
|
36
36
|
*/
|
|
37
37
|
resolveNode?: RequestResolver<TRequest>;
|
|
38
|
+
/**
|
|
39
|
+
* Which organization the request is acting in, for `scope: "org"`.
|
|
40
|
+
*
|
|
41
|
+
* Rarely needed: by default the guard reads the `org_id` claim off the
|
|
42
|
+
* verified token that `CanopyTokenGuard` attached (honoring
|
|
43
|
+
* `attachTokenAs`). Set this only when your own auth layer verifies tokens
|
|
44
|
+
* and parks the claims somewhere else — the value must still come from a
|
|
45
|
+
* *verified* token, never from a header or body the caller controls,
|
|
46
|
+
* because it decides which organization's grants answer the check.
|
|
47
|
+
*/
|
|
48
|
+
resolveOrg?: RequestResolver<TRequest>;
|
|
38
49
|
/**
|
|
39
50
|
* Deadline for the guard's own permission check, per attempt. Defaults to
|
|
40
51
|
* 5s — far tighter than the client-wide 30s, which is sized for
|
|
@@ -155,6 +166,14 @@ declare class CanopyGuard implements CanActivate {
|
|
|
155
166
|
*/
|
|
156
167
|
private toHttpException;
|
|
157
168
|
private buildQuery;
|
|
169
|
+
/**
|
|
170
|
+
* The organization the request acts in: `resolveOrg` when configured,
|
|
171
|
+
* otherwise the `org_id` claim off the verified token `CanopyTokenGuard`
|
|
172
|
+
* attached. Both sources trace back to a verified token — the org id
|
|
173
|
+
* decides whose grants answer the check, so it must never be readable off
|
|
174
|
+
* anything the caller controls directly.
|
|
175
|
+
*/
|
|
176
|
+
private resolveOrg;
|
|
158
177
|
}
|
|
159
178
|
|
|
160
179
|
/**
|
|
@@ -210,9 +229,12 @@ declare const InjectCanopy: () => ParameterDecorator;
|
|
|
210
229
|
*
|
|
211
230
|
* `node` walks the node's lineage, so a role granted on an ancestor is
|
|
212
231
|
* inherited at the node being touched. `app_wide` asks only whether the
|
|
213
|
-
* identity holds the permission anywhere in the Environment.
|
|
232
|
+
* identity holds the permission anywhere in the Environment. `org` is the
|
|
233
|
+
* node question asked at the organization the caller's token is acting in —
|
|
234
|
+
* the `org_id` claim — so the route needs no `resolveNode` and no node in
|
|
235
|
+
* its path.
|
|
214
236
|
*/
|
|
215
|
-
type PermissionScope = "node" | "app_wide";
|
|
237
|
+
type PermissionScope = "node" | "app_wide" | "org";
|
|
216
238
|
/** What `@RequirePermission` records for the guard to read back. */
|
|
217
239
|
interface PermissionRequirement {
|
|
218
240
|
readonly permission: string;
|
|
@@ -227,6 +249,11 @@ interface RequirePermissionOptions {
|
|
|
227
249
|
* menu item and wrong for guarding a resource that belongs to one — a
|
|
228
250
|
* distinction that is easy to get backwards if the looser check is the
|
|
229
251
|
* default.
|
|
252
|
+
*
|
|
253
|
+
* `org` evaluates at the organization on the caller's verified token
|
|
254
|
+
* (`org_id`), read from the claims `CanopyTokenGuard` attaches or from
|
|
255
|
+
* `resolveOrg` if configured. A caller acting in no organization is denied
|
|
256
|
+
* — an org-scoped route has no meaning outside one.
|
|
230
257
|
*/
|
|
231
258
|
readonly scope?: PermissionScope;
|
|
232
259
|
}
|
package/dist/index.js
CHANGED
|
@@ -145,6 +145,20 @@ var CanopyGuard = class {
|
|
|
145
145
|
scope: "app_wide"
|
|
146
146
|
};
|
|
147
147
|
}
|
|
148
|
+
if (requirement.scope === "org") {
|
|
149
|
+
const orgId = this.resolveOrg(request);
|
|
150
|
+
if (!orgId) {
|
|
151
|
+
throw new ForbiddenException(
|
|
152
|
+
`No organization on the request. ${requirement.permission} is checked inside the caller's active organization, and this caller is not acting in one.`
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
identity_id: identityId,
|
|
157
|
+
permission: requirement.permission,
|
|
158
|
+
scope: "node",
|
|
159
|
+
node_id: orgId
|
|
160
|
+
};
|
|
161
|
+
}
|
|
148
162
|
const nodeId = this.options.resolveNode ? resolveSafely(this.options.resolveNode, request) : void 0;
|
|
149
163
|
if (!nodeId) {
|
|
150
164
|
throw new ForbiddenException(
|
|
@@ -158,6 +172,22 @@ var CanopyGuard = class {
|
|
|
158
172
|
node_id: nodeId
|
|
159
173
|
};
|
|
160
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* The organization the request acts in: `resolveOrg` when configured,
|
|
177
|
+
* otherwise the `org_id` claim off the verified token `CanopyTokenGuard`
|
|
178
|
+
* attached. Both sources trace back to a verified token — the org id
|
|
179
|
+
* decides whose grants answer the check, so it must never be readable off
|
|
180
|
+
* anything the caller controls directly.
|
|
181
|
+
*/
|
|
182
|
+
resolveOrg(request) {
|
|
183
|
+
if (this.options.resolveOrg) {
|
|
184
|
+
return resolveSafely(this.options.resolveOrg, request);
|
|
185
|
+
}
|
|
186
|
+
const attachedAs = this.options.attachTokenAs ?? "canopyToken";
|
|
187
|
+
const claims = request?.[attachedAs];
|
|
188
|
+
const orgId = claims?.org_id;
|
|
189
|
+
return typeof orgId === "string" && orgId !== "" ? orgId : void 0;
|
|
190
|
+
}
|
|
161
191
|
};
|
|
162
192
|
CanopyGuard = __decorateClass([
|
|
163
193
|
Injectable(),
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/tokens.ts","../src/canopy.guard.ts","../src/canopy-token.guard.ts","../src/canopy.module.ts","../src/inject-canopy.decorator.ts","../src/require-permission.decorator.ts"],"names":["ServiceUnavailableException","Injectable","Inject","TokenVerifier"],"mappings":";;;;;;;;;;;;;;;AASO,IAAM,cAAA,GAAiB;AAGvB,IAAM,aAAA,GAAgB;AAStB,IAAM,iBAAA,GAAoB;AAG1B,IAAM,qBAAA,GAAwB;AAG9B,IAAM,iBAAA,GAAoB;;;AC0BjC,SAAS,WAAW,KAAA,EAA2C;AAC7D,EAAA,OACE,OAAO,KAAA,KAAU,QAAA,IACjB,KAAA,KAAU,IAAA,IACV,OAAQ,KAAA,CAA2B,IAAA,KAAS,UAAA,IAC5C,OAAQ,KAAA,CAA2B,cAAA,KAAmB,UAAA;AAE1D;AAYA,SAAS,cAAc,QAAA,EAAiD;AACtE,EAAA,MAAM,MAAO,QAAA,EAAmD,GAAA;AAEhE,EAAA,IAAI,UAAA,CAAW,GAAG,CAAA,EAAG;AACnB,IAAA,OAAO,GAAA;AAAA,EACT;AAEA,EAAA,IAAI,UAAA,CAAW,QAAQ,CAAA,EAAG;AACxB,IAAA,OAAO,QAAA;AAAA,EACT;AAEA,EAAA,OAAO,MAAA;AACT;AAkBO,IAAM,cAAN,MAAyC;AAAA,EAG9C,WAAA,CACsC,SAAA,EACQ,UAAA,EAE3B,OAAA,EACjB;AAJoC,IAAA,IAAA,CAAA,SAAA,GAAA,SAAA;AACQ,IAAA,IAAA,CAAA,UAAA,GAAA,UAAA;AAE3B,IAAA,IAAA,CAAA,OAAA,GAAA,OAAA;AAAA,EAChB;AAAA,EAJmC,SAAA;AAAA,EACQ,UAAA;AAAA,EAE3B,OAAA;AAAA,EANF,MAAA,GAAS,IAAI,MAAA,CAAO,WAAA,CAAY,IAAI,CAAA;AAAA,EASrD,MAAM,YAAY,OAAA,EAA6C;AAC7D,IAAA,MAAM,WAAA,GAAc,IAAA,CAAK,SAAA,CAAU,iBAAA,CAEjC,iBAAA,EAAmB,CAAC,OAAA,CAAQ,UAAA,EAAW,EAAG,OAAA,CAAQ,QAAA,EAAU,CAAC,CAAA;AAE/D,IAAA,IAAI,CAAC,WAAA,EAAa;AAChB,MAAA,OAAO,IAAA;AAAA,IACT;AAEA,IAAA,MAAM,IAAA,GAAO,QAAQ,YAAA,EAAa;AAClC,IAAA,MAAM,OAAA,GAAmB,KAAK,UAAA,EAAW;AACzC,IAAA,MAAM,UAAA,GAAa,aAAA,CAAc,IAAA,CAAK,OAAA,CAAQ,iBAAiB,OAAO,CAAA;AAEtE,IAAA,IAAI,CAAC,UAAA,EAAY;AACf,MAAA,MAAM,IAAI,kBAAA;AAAA,QACR;AAAA,OACF;AAAA,IACF;AAEA,IAAA,MAAM,QAAA,GAAW,MAAM,IAAA,CAAK,QAAA;AAAA,MAC1B,WAAA;AAAA,MACA,UAAA;AAAA,MACA,OAAA;AAAA,MACA,gBAAgB,IAAI;AAAA,KACtB;AAEA,IAAA,IAAI,CAAC,SAAS,OAAA,EAAS;AACrB,MAAA,MAAM,IAAI,kBAAA;AAAA,QACR,CAAA,uBAAA,EAA0B,YAAY,UAAU,CAAA,CAAA;AAAA,OAClD;AAAA,IACF;AAEA,IAAA,OAAO,IAAA;AAAA,EACT;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASA,MAAc,QAAA,CACZ,WAAA,EACA,UAAA,EACA,SACA,YAAA,EACkB;AAClB,IAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,UAAA,CAAW,WAAA,EAAa,YAAY,OAAO,CAAA;AAE9D,IAAA,IAAI;AAKF,MAAA,OAAO,MAAM,KAAK,UAAA,CAAW,QAAA;AAAA,QAC3B,KAAA;AAAA,QACA,eAAe,EAAE,MAAA,EAAQ,YAAA,CAAa,MAAA,KAAW;AAAC,OACpD;AAAA,IACF,SAAS,KAAA,EAAO;AAKd,MAAA,IAAI,YAAA,EAAc,OAAO,OAAA,EAAS;AAChC,QAAA,MAAM,KAAA;AAAA,MACR;AAIA,MAAA,IAAA,CAAK,MAAA,CAAO,KAAA;AAAA,QACV,CAAA,mBAAA,EAAsB,WAAA,CAAY,UAAU,CAAA,KAAA,EAAQ,UAAU,CAAA,CAAA;AAAA,QAC9D,KAAA,YAAiB,KAAA,GAAQ,KAAA,CAAM,KAAA,GAAQ,OAAO,KAAK;AAAA,OACrD;AAEA,MAAA,MAAM,IAAA,CAAK,gBAAgB,KAAK,CAAA;AAAA,IAClC,CAAA,SAAE;AACA,MAAA,YAAA,EAAc,OAAA,EAAQ;AAAA,IACxB;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUQ,gBAAgB,KAAA,EAAuB;AAK7C,IAAA,IAAI,uBAAA,CAAwB,KAAK,CAAA,EAAG;AAClC,MAAA,OAAO,IAAI,4BAAA;AAAA,QACT,CAAA,gCAAA,EAAmC,MAAM,OAAO,CAAA;AAAA,OAClD;AAAA,IACF;AAEA,IAAA,IAAI,CAAC,aAAA,CAAc,KAAK,CAAA,EAAG;AAEzB,MAAA,OAAO,IAAI,2BAAA;AAAA,QACT;AAAA,OACF;AAAA,IACF;AAGA,IAAA,IAAI,MAAM,aAAA,EAAe;AACvB,MAAA,OAAO,IAAI,4BAAA;AAAA,QACT;AAAA,OACF;AAAA,IACF;AAOA,IAAA,IAAI,KAAA,CAAM,UAAA,KAAe,GAAA,IAAO,KAAA,CAAM,SAAS,IAAA,EAAM;AACnD,MAAA,OAAO,IAAI,4BAAA;AAAA,QACT,mCAAmC,KAAA,CAAM,OAAA,CAAQ,MAAM,CAAA,CAAA,EAAI,KAAA,CAAM,QAAQ,IAAI,CAAA,iGAAA;AAAA,OAE/E;AAAA,IACF;AAMA,IAAA,IAAI,KAAA,CAAM,eAAe,GAAA,EAAK;AAC5B,MAAA,OAAO,IAAI,mBAAmB,oCAAoC,CAAA;AAAA,IACpE;AAEA,IAAA,IAAI,KAAA,CAAM,aAAA,IAAiB,KAAA,CAAM,UAAA,IAAc,GAAA,EAAK;AAClD,MAAA,OAAO,IAAI,2BAAA;AAAA,QACT;AAAA,OACF;AAAA,IACF;AAIA,IAAA,OAAO,IAAI,4BAAA;AAAA,MACT;AAAA,KACF;AAAA,EACF;AAAA,EAEQ,UAAA,CACN,WAAA,EACA,UAAA,EACA,OAAA,EACe;AACf,IAAA,IAAI,WAAA,CAAY,UAAU,UAAA,EAAY;AACpC,MAAA,OAAO;AAAA,QACL,WAAA,EAAa,UAAA;AAAA,QACb,YAAY,WAAA,CAAY,UAAA;AAAA,QACxB,KAAA,EAAO;AAAA,OACT;AAAA,IACF;AAEA,IAAA,MAAM,MAAA,GAAS,KAAK,OAAA,CAAQ,WAAA,GACxB,cAAc,IAAA,CAAK,OAAA,CAAQ,WAAA,EAAa,OAAO,CAAA,GAC/C,MAAA;AAEJ,IAAA,IAAI,CAAC,MAAA,EAAQ;AACX,MAAA,MAAM,IAAI,kBAAA;AAAA,QACR,CAAA,wBAAA,EAA2B,YAAY,UAAU,CAAA,iHAAA;AAAA,OACnD;AAAA,IACF;AAEA,IAAA,OAAO;AAAA,MACL,WAAA,EAAa,UAAA;AAAA,MACb,YAAY,WAAA,CAAY,UAAA;AAAA,MACxB,KAAA,EAAO,MAAA;AAAA,MACP,OAAA,EAAS;AAAA,KACX;AAAA,EACF;AACF;AA3La,WAAA,GAAN,eAAA,CAAA;AAAA,EADN,UAAA,EAAW;AAAA,EAKP,0BAAO,SAAS,CAAA,CAAA;AAAA,EAChB,0BAAO,iBAAiB,CAAA,CAAA;AAAA,EACxB,0BAAO,cAAc,CAAA;AAAA,CAAA,EANb,WAAA,CAAA;AAsMb,SAAS,aAAA,CACP,SACA,OAAA,EAC2B;AAC3B,EAAA,IAAI;AACF,IAAA,OAAQ,OAAA;AAAA,MACN;AAAA,KACF;AAAA,EACF,CAAA,CAAA,MAAQ;AACN,IAAA,OAAO,MAAA;AAAA,EACT;AACF;AAeA,SAAS,gBAAgB,IAAA,EAEI;AAC3B,EAAA,MAAM,GAAA,GAAM,aAAA,CAAc,IAAA,CAAK,WAAA,IAAe,CAAA;AAE9C,EAAA,IAAI,CAAC,GAAA,EAAK;AACR,IAAA,OAAO,MAAA;AAAA,EACT;AAEA,EAAA,MAAM,UAAA,GAAa,IAAI,eAAA,EAAgB;AAEvC,EAAA,MAAM,UAAU,MAAM;AACpB,IAAA,IAAI,GAAA,CAAI,kBAAkB,IAAA,EAAM;AAC9B,MAAA;AAAA,IACF;AAEA,IAAA,UAAA,CAAW,KAAA;AAAA,MACT,IAAI,YAAA,CAAa,+BAAA,EAAiC,YAAY;AAAA,KAChE;AAAA,EACF,CAAA;AAEA,EAAA,GAAA,CAAI,IAAA,CAAK,SAAS,OAAO,CAAA;AAEzB,EAAA,OAAO;AAAA,IACL,QAAQ,UAAA,CAAW,MAAA;AAAA,IACnB,OAAA,EAAS,MAAM,GAAA,CAAI,cAAA,CAAe,SAAS,OAAO;AAAA,GACpD;AACF;ACnTO,IAAM,mBAAN,MAA8C;AAAA,EACnD,WAAA,CACkD,UAE/B,OAAA,EACjB;AAHgD,IAAA,IAAA,CAAA,QAAA,GAAA,QAAA;AAE/B,IAAA,IAAA,CAAA,OAAA,GAAA,OAAA;AAAA,EAChB;AAAA,EAH+C,QAAA;AAAA,EAE/B,OAAA;AAAA,EAGnB,MAAM,YAAY,OAAA,EAA6C;AAC7D,IAAA,MAAM,OAAA,GAAU,OAAA,CAAQ,YAAA,EAAa,CAAE,UAAA,EAGpC;AAEH,IAAA,MAAM,KAAA,GAAQ,eAAA,CAAgB,OAAA,CAAQ,OAAA,GAAU,eAAe,CAAC,CAAA;AAEhE,IAAA,IAAI,UAAU,IAAA,EAAM;AAClB,MAAA,MAAM,IAAI,qBAAA;AAAA,QACR;AAAA,OACF;AAAA,IACF;AAEA,IAAA,IAAI,MAAA;AAEJ,IAAA,IAAI;AACF,MAAA,MAAA,GAAS,MAAM,IAAA,CAAK,QAAA,CAAS,MAAA,CAAO,KAAK,CAAA;AAAA,IAC3C,SAAS,KAAA,EAAO;AAYd,MAAA,IACE,kBAAA,CAAmB,KAAK,CAAA,IACxB,KAAA,CAAM,SAAS,wBAAA,EACf;AACA,QAAA,MAAM,IAAIA,2BAAAA;AAAA,UACR,4CAAA;AAAA,UACA,EAAE,OAAO,KAAA;AAAM,SACjB;AAAA,MACF;AAOA,MAAA,IAAI,kBAAA,CAAmB,KAAK,CAAA,EAAG;AAC7B,QAAA,MAAM,IAAI,sBAAsB,kCAAA,EAAoC;AAAA,UAClE,KAAA,EAAO;AAAA,SACR,CAAA;AAAA,MACH;AAEA,MAAA,MAAM,KAAA;AAAA,IACR;AAEA,IAAA,OAAA,CAAQ,IAAA,CAAK,OAAA,CAAQ,aAAA,IAAiB,aAAa,CAAA,GAAI,MAAA;AAEvD,IAAA,OAAO,IAAA;AAAA,EACT;AACF;AAjEa,gBAAA,GAAN,eAAA,CAAA;AAAA,EADNC,UAAAA,EAAW;AAAA,EAGP,eAAA,CAAA,CAAA,EAAAC,OAAO,qBAAqB,CAAA,CAAA;AAAA,EAC5B,eAAA,CAAA,CAAA,EAAAA,OAAO,cAAc,CAAA;AAAA,CAAA,EAHb,gBAAA,CAAA;AA0Eb,SAAS,gBAAgB,MAAA,EAAsD;AAC7E,EAAA,MAAM,QAAQ,KAAA,CAAM,OAAA,CAAQ,MAAM,CAAA,GAAI,MAAA,CAAO,CAAC,CAAA,GAAI,MAAA;AAElD,EAAA,IAAI,OAAO,UAAU,QAAA,EAAU;AAC7B,IAAA,OAAO,IAAA;AAAA,EACT;AAEA,EAAA,MAAM,KAAA,GAAQ,kBAAA,CAAmB,IAAA,CAAK,KAAA,CAAM,MAAM,CAAA;AAElD,EAAA,IAAI,CAAC,KAAA,GAAQ,CAAC,CAAA,EAAG;AACf,IAAA,OAAO,IAAA;AAAA,EACT;AAEA,EAAA,MAAM,KAAA,GAAQ,KAAA,CAAM,CAAC,CAAA,CAAE,IAAA,EAAK;AAE5B,EAAA,OAAO,KAAA,CAAM,MAAA,GAAS,CAAA,GAAI,KAAA,GAAQ,IAAA;AACpC;;;AC9FA,IAAM,2BAAA,GAA8B,GAAA;AAUpC,IAAM,4BAAA,GAA+B,CAAA;AAQrC,SAAS,eAAA,CACP,QACA,OAAA,EACiB;AACjB,EAAA,OAAO,IAAI,eAAA,CAAgB,MAAA,CAAO,MAAA,EAAQ;AAAA,IACxC,GAAI,QAAQ,kBAAA,KAAuB,MAAA,GAC/B,EAAC,GACD,EAAE,KAAA,EAAO,OAAA,CAAQ,kBAAA,EAAmB;AAAA,IACxC,SAAA,EAAW,QAAQ,iBAAA,IAAqB,2BAAA;AAAA,IACxC,UAAA,EAAY,QAAQ,kBAAA,IAAsB;AAAA,GAC3C,CAAA;AACH;AAGO,IAAM,eAAN,MAAmB;AAAA,EACxB,OAAO,QACL,OAAA,EACe;AACf,IAAA,MAAM,SAAA,GAAwB;AAAA,MAC5B,EAAE,OAAA,EAAS,cAAA,EAAgB,QAAA,EAAU,OAAA,EAAQ;AAAA,MAC7C;AAAA,QACE,OAAA,EAAS,aAAA;AAAA,QACT,UAAA,EAAY,MAAM,IAAI,MAAA,CAAO,OAAO;AAAA,OACtC;AAAA,MACA;AAAA,QACE,OAAA,EAAS,qBAAA;AAAA,QACT,UAAA,EAAY,MAAM,IAAIC,aAAAA,CAAc,QAAQ,MAAM;AAAA,OACpD;AAAA,MACA;AAAA,QACE,OAAA,EAAS,iBAAA;AAAA,QACT,UAAA,EAAY,CAAC,MAAA,KAAmB,eAAA,CAAgB,QAAQ,OAAO,CAAA;AAAA,QAC/D,MAAA,EAAQ,CAAC,aAAa;AAAA,OACxB;AAAA,MACA,WAAA;AAAA,MACA;AAAA,KACF;AAEA,IAAA,OAAO;AAAA,MACL,MAAA,EAAQ,YAAA;AAAA,MACR,MAAA,EAAQ,QAAQ,QAAA,IAAY,KAAA;AAAA,MAC5B,SAAA;AAAA,MACA,OAAA,EAAS;AAAA,QACP,aAAA;AAAA,QACA,cAAA;AAAA,QACA,qBAAA;AAAA,QACA,iBAAA;AAAA,QACA,WAAA;AAAA,QACA;AAAA;AACF,KACF;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,OAAO,aACL,OAAA,EACe;AACf,IAAA,MAAM,SAAA,GAAwB;AAAA,MAC5B;AAAA,QACE,OAAA,EAAS,cAAA;AAAA,QACT,YAAY,OAAA,CAAQ,UAAA;AAAA,QACpB,MAAA,EAAQ,OAAA,CAAQ,MAAA,IAAU;AAAC,OAC7B;AAAA,MACA;AAAA,QACE,OAAA,EAAS,aAAA;AAAA,QACT,UAAA,EAAY,CAAC,QAAA,KACX,IAAI,OAAO,QAAQ,CAAA;AAAA,QACrB,MAAA,EAAQ,CAAC,cAAc;AAAA,OACzB;AAAA,MACA;AAAA,QACE,OAAA,EAAS,qBAAA;AAAA,QACT,YAAY,CAAC,QAAA,KACX,IAAIA,aAAAA,CAAc,SAAS,MAAM,CAAA;AAAA,QACnC,MAAA,EAAQ,CAAC,cAAc;AAAA,OACzB;AAAA,MACA;AAAA,QACE,OAAA,EAAS,iBAAA;AAAA,QACT,YAAY,CAAC,MAAA,EAAgB,QAAA,KAC3B,eAAA,CAAgB,QAAQ,QAAQ,CAAA;AAAA,QAClC,MAAA,EAAQ,CAAC,aAAA,EAAe,cAAc;AAAA,OACxC;AAAA,MACA,WAAA;AAAA,MACA;AAAA,KACF;AAEA,IAAA,OAAO;AAAA,MACL,MAAA,EAAQ,YAAA;AAAA,MACR,MAAA,EAAQ,QAAQ,QAAA,IAAY,KAAA;AAAA;AAAA;AAAA,MAG5B,GAAI,QAAQ,OAAA,GAAU,EAAE,SAAS,OAAA,CAAQ,OAAA,KAAY,EAAC;AAAA,MACtD,SAAA;AAAA,MACA,OAAA,EAAS;AAAA,QACP,aAAA;AAAA,QACA,cAAA;AAAA,QACA,qBAAA;AAAA,QACA,iBAAA;AAAA,QACA,WAAA;AAAA,QACA;AAAA;AACF,KACF;AAAA,EACF;AACF;AA1Fa,YAAA,GAAN,eAAA,CAAA;AAAA,EADN,MAAA,CAAO,EAAE;AAAA,CAAA,EACG,YAAA,CAAA;AC1DN,IAAM,YAAA,GAAe,MAA0BD,MAAAA,CAAO,aAAa;AC6BnE,IAAM,oBAAoB,CAC/B,UAAA,EACA,UAAoC,EAAC,KAErC,YAA2C,iBAAA,EAAmB;AAAA,EAC5D,UAAA;AAAA,EACA,KAAA,EAAO,QAAQ,KAAA,IAAS;AAC1B,CAAC","file":"index.js","sourcesContent":["/**\n * Injection tokens.\n *\n * These are strings rather than classes because esbuild does not emit\n * `design:paramtypes`, so nothing here can be injected by constructor type\n * alone — every dependency is named with an explicit `@Inject`.\n */\n\n/** The resolved `CanopyModuleOptions`. */\nexport const CANOPY_OPTIONS = \"CANOPY_OPTIONS\";\n\n/** The configured `Canopy` client. */\nexport const CANOPY_CLIENT = \"CANOPY_CLIENT\";\n\n/**\n * The configured `LocalAuthorizer`.\n *\n * Shared deliberately: it holds one copy of the hierarchy for the whole\n * process, so a second instance would double both the memory and the traffic\n * it exists to avoid.\n */\nexport const CANOPY_AUTHORIZER = \"CANOPY_AUTHORIZER\";\n\n/** The configured `TokenVerifier`, shared so its key cache is shared too. */\nexport const CANOPY_TOKEN_VERIFIER = \"CANOPY_TOKEN_VERIFIER\";\n\n/** Metadata key holding what `@RequirePermission` recorded on a handler. */\nexport const CANOPY_PERMISSION = \"CANOPY_PERMISSION\";\n","import {\n type CanActivate,\n type ExecutionContext,\n ForbiddenException,\n Inject,\n Injectable,\n InternalServerErrorException,\n Logger,\n ServiceUnavailableException,\n} from \"@nestjs/common\";\nimport { Reflector } from \"@nestjs/core\";\n\nimport {\n isCanopyAuthorizerError,\n isCanopyError,\n type LocalAuthorizer,\n type RequestBody,\n} from \"@canopy-io/node\";\n\nimport type { CanopyModuleOptions } from \"./options.js\";\nimport type { PermissionRequirement } from \"./require-permission.decorator.js\";\nimport {\n CANOPY_AUTHORIZER,\n CANOPY_OPTIONS,\n CANOPY_PERMISSION,\n} from \"./tokens.js\";\n\n/** Read off the generated operation, so it follows the API rather than restating it. */\ntype EvaluateQuery = RequestBody<\"ApiPermissionsController_evaluate\">;\n\n/**\n * Only the verdict, not the API's full explain payload.\n *\n * A decision reached in-process cannot report `granting_roles` or an effective\n * node — that reasoning lives on the server. The guard has never needed it, and\n * typing the narrow thing keeps that honest rather than implying a richer\n * answer than exists.\n */\ntype Verdict = { allowed: boolean };\n\ninterface Cancellation {\n signal: AbortSignal;\n dispose: () => void;\n}\n\n/** The Node stream underneath an Express or Fastify response. */\ninterface ClosableResponse {\n once: (event: string, listener: () => void) => unknown;\n removeListener: (event: string, listener: () => void) => unknown;\n writableEnded?: boolean;\n raw?: ClosableResponse;\n}\n\nfunction isClosable(value: unknown): value is ClosableResponse {\n return (\n typeof value === \"object\" &&\n value !== null &&\n typeof (value as ClosableResponse).once === \"function\" &&\n typeof (value as ClosableResponse).removeListener === \"function\"\n );\n}\n\n/**\n * Find the Node response stream behind whatever the adapter hands back.\n *\n * `.raw` is checked first and on its own merits, because the object Nest\n * returns is not always the stream. Express returns the `ServerResponse`\n * itself, which listens; Fastify returns a `Reply`, which is a plain object\n * with the real response on `.raw`. Testing the outer object first and\n * returning early on failure would skip `.raw` in exactly the case it exists\n * for.\n */\nfunction resolveStream(response: unknown): ClosableResponse | undefined {\n const raw = (response as { raw?: unknown } | null | undefined)?.raw;\n\n if (isClosable(raw)) {\n return raw;\n }\n\n if (isClosable(response)) {\n return response;\n }\n\n return undefined;\n}\n\n/**\n * Enforces `@RequirePermission` by asking Canopy, once, per guarded request.\n *\n * The guard fails closed at every step. An unresolvable identity, a `node`\n * check with no node, a denial, or an unreachable API all end the request —\n * there is no path through this class that allows a request whose decision is\n * unknown. That is the property worth protecting when reading this code: an\n * `allow` must be something Canopy actually said.\n *\n * A route with no requirement is allowed through untouched, so the guard can be\n * applied globally and opted into per route. Registered that way it runs before\n * controller- and route-scoped guards, so whatever authenticates the request has\n * to be registered globally ahead of it — otherwise `resolveIdentity` runs\n * before there is anything to resolve and every guarded route denies.\n */\n@Injectable()\nexport class CanopyGuard implements CanActivate {\n private readonly logger = new Logger(CanopyGuard.name);\n\n constructor(\n @Inject(Reflector) private readonly reflector: Reflector,\n @Inject(CANOPY_AUTHORIZER) private readonly authorizer: LocalAuthorizer,\n @Inject(CANOPY_OPTIONS)\n private readonly options: CanopyModuleOptions<unknown>,\n ) {}\n\n async canActivate(context: ExecutionContext): Promise<boolean> {\n const requirement = this.reflector.getAllAndOverride<\n PermissionRequirement | undefined\n >(CANOPY_PERMISSION, [context.getHandler(), context.getClass()]);\n\n if (!requirement) {\n return true;\n }\n\n const http = context.switchToHttp();\n const request: unknown = http.getRequest();\n const identityId = resolveSafely(this.options.resolveIdentity, request);\n\n if (!identityId) {\n throw new ForbiddenException(\n \"No identity on the request. Canopy cannot evaluate a permission without one.\",\n );\n }\n\n const decision = await this.evaluate(\n requirement,\n identityId,\n request,\n watchDisconnect(http),\n );\n\n if (!decision.allowed) {\n throw new ForbiddenException(\n `Identity does not hold ${requirement.permission}.`,\n );\n }\n\n return true;\n }\n\n /**\n * Asks Canopy the question the decorator recorded.\n *\n * Every failure is converted into a thrown exception rather than a `false`\n * decision, so a caller cannot mistake \"we could not tell\" for \"denied by\n * policy\" — and neither outcome lets the request proceed.\n */\n private async evaluate(\n requirement: PermissionRequirement,\n identityId: string,\n request: unknown,\n cancellation: Cancellation | undefined,\n ): Promise<Verdict> {\n const query = this.buildQuery(requirement, identityId, request);\n\n try {\n // Answered in-process. The authorizer holds the identity's grant roots\n // and a shared copy of the hierarchy, so the common case reaches no\n // network at all; only a cold or expired entry does, and its deadline\n // and retries are bounded by the same options as before.\n return await this.authorizer.evaluate(\n query,\n cancellation ? { signal: cancellation.signal } : {},\n );\n } catch (error) {\n // The caller hung up mid-check. There is no one left to answer, and this\n // is not Canopy failing — so it must not be logged or reported as though\n // it were. Rethrowing the abort ends the request without inventing a\n // decision.\n if (cancellation?.signal.aborted) {\n throw error;\n }\n\n // Deliberately not re-thrown as-is: a failure here must not reach the\n // client as though Canopy had answered.\n this.logger.error(\n `Could not evaluate ${requirement.permission} for ${identityId}`,\n error instanceof Error ? error.stack : String(error),\n );\n\n throw this.toHttpException(error);\n } finally {\n cancellation?.dispose();\n }\n }\n\n /**\n * Turn a failed check into the status that describes it.\n *\n * Every branch denies — nothing here can let a request through — but they say\n * different things, and the difference is what makes an incident diagnosable.\n * Reporting a rejected API key as \"temporarily unavailable\" invites a retry\n * that will never succeed and hides a misconfiguration behind an outage.\n */\n private toHttpException(error: unknown): Error {\n // The authorizer could not see far enough to decide — a credential that\n // cannot read the hierarchy, most likely. Not an outage: no retry fixes a\n // missing scope, and reporting it as one would bury a misconfiguration\n // under what looks like a blip.\n if (isCanopyAuthorizerError(error)) {\n return new InternalServerErrorException(\n `Authorization is misconfigured: ${error.message}`,\n );\n }\n\n if (!isCanopyError(error)) {\n // Never answered: unreachable, reset, or past the deadline.\n return new ServiceUnavailableException(\n \"Authorization is temporarily unavailable.\",\n );\n }\n\n // Canopy rejected our own credential. No retry fixes this.\n if (error.isAuthFailure) {\n return new InternalServerErrorException(\n \"Authorization is misconfigured. Check the Canopy API key and its scopes.\",\n );\n }\n\n // A 404 with no error code is Nest's own \"no such route\", which here means\n // this Canopy does not serve the endpoint the authorizer reads — an SDK\n // newer than the API it is pointed at. Denying would be catastrophic and\n // silent: every guarded route in the application would answer 403 with\n // nothing to suggest the cause is a version mismatch rather than policy.\n if (error.statusCode === 404 && error.code === null) {\n return new InternalServerErrorException(\n `Authorization is misconfigured: ${error.request.method} ${error.request.path} is not served by this Canopy. ` +\n \"The SDK requires an API that exposes the identity grants endpoint.\",\n );\n }\n\n // A 404 that does carry a code is a real answer — the identity is not in\n // this Environment. Deny rather than 500, the guard's whole thesis being\n // that an undecidable request does not proceed, but say nothing about what\n // was missing.\n if (error.statusCode === 404) {\n return new ForbiddenException(\"Could not evaluate the permission.\");\n }\n\n if (error.isRateLimited || error.statusCode >= 500) {\n return new ServiceUnavailableException(\n \"Authorization is temporarily unavailable.\",\n );\n }\n\n // A 4xx we did not anticipate is a malformed question — our bug, not a\n // transient condition.\n return new InternalServerErrorException(\n \"Authorization could not be evaluated.\",\n );\n }\n\n private buildQuery(\n requirement: PermissionRequirement,\n identityId: string,\n request: unknown,\n ): EvaluateQuery {\n if (requirement.scope === \"app_wide\") {\n return {\n identity_id: identityId,\n permission: requirement.permission,\n scope: \"app_wide\",\n };\n }\n\n const nodeId = this.options.resolveNode\n ? resolveSafely(this.options.resolveNode, request)\n : undefined;\n\n if (!nodeId) {\n throw new ForbiddenException(\n `No node on the request. ${requirement.permission} is checked at a node; configure resolveNode, or declare the route as app_wide if that is genuinely the question.`,\n );\n }\n\n return {\n identity_id: identityId,\n permission: requirement.permission,\n scope: \"node\",\n node_id: nodeId,\n };\n }\n}\n\n/**\n * Run a resolver, treating a throw as \"could not resolve\".\n *\n * Resolvers reach into the request — `request.user.sub` is the shape the docs\n * suggest and the one most applications write. On an unauthenticated request\n * `request.user` is undefined and that expression throws, which without this\n * would surface as a 500. An identity that cannot be established is exactly the\n * request that must be denied, so it becomes the same 403 as returning nothing.\n */\nfunction resolveSafely(\n resolve: (request: never) => string | null | undefined,\n request: unknown,\n): string | null | undefined {\n try {\n return (resolve as (request: unknown) => string | null | undefined)(\n request,\n );\n } catch {\n return undefined;\n }\n}\n\n/**\n * Abort the permission check if the caller hangs up while it is in flight.\n *\n * Watches the *response*, not the request. A request stream emits `close` on\n * every normal request once its body has been read, so listening there would\n * abort perfectly healthy checks; a response emits `close` before\n * `writableEnded` only when nothing was ever sent — which is exactly the\n * disconnect case. The `writableEnded` re-check at fire time keeps that true\n * even if the ordering differs by adapter.\n *\n * Returns undefined for a transport with no such response — a GraphQL or\n * microservice context — where the check simply runs to its deadline.\n */\nfunction watchDisconnect(http: {\n getResponse?: () => unknown;\n}): Cancellation | undefined {\n const raw = resolveStream(http.getResponse?.());\n\n if (!raw) {\n return undefined;\n }\n\n const controller = new AbortController();\n\n const onClose = () => {\n if (raw.writableEnded === true) {\n return;\n }\n\n controller.abort(\n new DOMException(\"The client closed the request\", \"AbortError\"),\n );\n };\n\n raw.once(\"close\", onClose);\n\n return {\n signal: controller.signal,\n dispose: () => raw.removeListener(\"close\", onClose),\n };\n}\n","import {\n type CanActivate,\n type ExecutionContext,\n Inject,\n Injectable,\n ServiceUnavailableException,\n UnauthorizedException,\n} from \"@nestjs/common\";\n\nimport {\n type CanopyTokenClaims,\n isCanopyTokenError,\n TokenVerifier,\n} from \"@canopy-io/node\";\n\nimport type { CanopyModuleOptions } from \"./options.js\";\nimport { CANOPY_OPTIONS, CANOPY_TOKEN_VERIFIER } from \"./tokens.js\";\n\n/**\n * Verifies the bearer token and attaches its claims to the request.\n *\n * This is the authentication half, and it is opt-in on purpose. `CanopyGuard`\n * answers \"may this identity do this?\"; something has to have established\n * *which* identity first, and `resolveIdentity` says as much — \"usually a\n * claim off the verified access token your auth layer already attached\".\n *\n * Use this guard when Canopy issues your tokens and you have no auth layer\n * yet. Skip it entirely if you already run Passport or your own JWT\n * middleware — it is additive, and nothing else in the package depends on it.\n *\n * ```ts\n * @UseGuards(CanopyTokenGuard, CanopyGuard)\n * @RequirePermission(\"documents.read\")\n * findAll() {}\n * ```\n *\n * Order matters: this must run first, because `CanopyGuard` reads what it\n * attaches. Nest runs guards left to right, so listing them in that order is\n * the whole of the wiring.\n *\n * Claims land on `request.canopyToken` by default, which is why\n * `resolveIdentity` is usually `(req) => req.canopyToken?.sub`. The property\n * is configurable so it cannot collide with a `request.user` an existing\n * middleware already owns.\n */\n@Injectable()\nexport class CanopyTokenGuard implements CanActivate {\n constructor(\n @Inject(CANOPY_TOKEN_VERIFIER) private readonly verifier: TokenVerifier,\n @Inject(CANOPY_OPTIONS)\n private readonly options: CanopyModuleOptions<unknown>,\n ) {}\n\n async canActivate(context: ExecutionContext): Promise<boolean> {\n const request = context.switchToHttp().getRequest<{\n headers?: Record<string, string | string[] | undefined>;\n [key: string]: unknown;\n }>();\n\n const token = readBearerToken(request.headers?.[\"authorization\"]);\n\n if (token === null) {\n throw new UnauthorizedException(\n \"Missing bearer token in the Authorization header.\",\n );\n }\n\n let claims: CanopyTokenClaims;\n\n try {\n claims = await this.verifier.verify(token);\n } catch (error) {\n // Not being able to verify is not the same as failing to verify. When\n // the key set cannot be read the caller's token may be perfectly good —\n // we simply cannot say — so this is ours to own, exactly as the\n // permission guard answers 503 rather than denying on an undecidable\n // check.\n //\n // Reporting it as 401 would be worse than merely inaccurate: a client\n // reads 401 as \"your token is stale\", discards a valid one and asks for\n // another, which aims a refresh storm at the issuer that is already\n // down. And an outage that only ever emits 4xx is invisible to anything\n // watching 5xx.\n if (\n isCanopyTokenError(error) &&\n error.code === \"token.jwks_unavailable\"\n ) {\n throw new ServiceUnavailableException(\n \"Authentication is temporarily unavailable.\",\n { cause: error },\n );\n }\n\n // Every other cause is the token itself, and a 401 whatever the detail:\n // the caller cannot fix a bad signature by being granted something. The\n // specific code stays on the error for logs rather than going to the\n // caller, who has no use for the difference and should not be told which\n // check failed.\n if (isCanopyTokenError(error)) {\n throw new UnauthorizedException(\"Invalid or expired access token.\", {\n cause: error,\n });\n }\n\n throw error;\n }\n\n request[this.options.attachTokenAs ?? \"canopyToken\"] = claims;\n\n return true;\n }\n}\n\n/**\n * The token out of an `Authorization: Bearer …` header.\n *\n * Returns null rather than throwing so the caller owns the message. The scheme\n * is matched case-insensitively because RFC 7235 says it is case-insensitive,\n * and clients do send `bearer`.\n */\nfunction readBearerToken(header: string | string[] | undefined): string | null {\n const value = Array.isArray(header) ? header[0] : header;\n\n if (typeof value !== \"string\") {\n return null;\n }\n\n const match = /^bearer\\s+(.+)$/i.exec(value.trim());\n\n if (!match?.[1]) {\n return null;\n }\n\n const token = match[1].trim();\n\n return token.length > 0 ? token : null;\n}\n","import { type DynamicModule, Module, type Provider } from \"@nestjs/common\";\n\nimport { Canopy, LocalAuthorizer, TokenVerifier } from \"@canopy-io/node\";\n\nimport { CanopyGuard } from \"./canopy.guard.js\";\nimport { CanopyTokenGuard } from \"./canopy-token.guard.js\";\nimport type {\n CanopyModuleAsyncOptions,\n CanopyModuleOptions,\n} from \"./options.js\";\nimport {\n CANOPY_AUTHORIZER,\n CANOPY_CLIENT,\n CANOPY_OPTIONS,\n CANOPY_TOKEN_VERIFIER,\n} from \"./tokens.js\";\n\n/**\n * Wires one configured `Canopy` client and the guard that uses it into a Nest\n * application.\n *\n * ```ts\n * CanopyModule.forRoot<AuthedRequest>({\n * apiKey: process.env.CANOPY_API_KEY,\n * resolveIdentity: (request) => request.user?.sub,\n * resolveNode: (request) => request.params?.orgId,\n * })\n * ```\n *\n * The client is a singleton because it holds no per-request state — it is a\n * `fetch` wrapper with credentials — so there is nothing to gain from building\n * one per request and a connection pool to lose.\n */\n\n/**\n * Per-attempt deadline for a cache miss on the request path.\n *\n * Five seconds rather than the client's 30: this call sits on the request path,\n * so its deadline is time an inbound request spends waiting. An authorization\n * service that has not answered in five seconds is not going to save the\n * request.\n */\nconst DEFAULT_EVALUATE_TIMEOUT_MS = 5_000;\n\n/**\n * Retries for a cache miss on the request path.\n *\n * One, not the client's two. A deadline bounds an attempt; this bounds how many\n * of them an inbound request can wait through. One retry still absorbs a\n * transient blip, and caps the worst case at roughly two deadlines rather than\n * three.\n */\nconst DEFAULT_EVALUATE_MAX_RETRIES = 1;\n\n/**\n * The authorizer both registration paths build.\n *\n * The guard's `evaluateTimeoutMs` / `evaluateMaxRetries` still apply, but to\n * far fewer calls: they bound a cache miss rather than every request.\n */\nfunction buildAuthorizer<TRequest>(\n client: Canopy,\n options: CanopyModuleOptions<TRequest>,\n): LocalAuthorizer {\n return new LocalAuthorizer(client.client, {\n ...(options.authorizationTtlMs === undefined\n ? {}\n : { ttlMs: options.authorizationTtlMs }),\n timeoutMs: options.evaluateTimeoutMs ?? DEFAULT_EVALUATE_TIMEOUT_MS,\n maxRetries: options.evaluateMaxRetries ?? DEFAULT_EVALUATE_MAX_RETRIES,\n });\n}\n\n@Module({})\nexport class CanopyModule {\n static forRoot<TRequest = unknown>(\n options: CanopyModuleOptions<TRequest>,\n ): DynamicModule {\n const providers: Provider[] = [\n { provide: CANOPY_OPTIONS, useValue: options },\n {\n provide: CANOPY_CLIENT,\n useFactory: () => new Canopy(options),\n },\n {\n provide: CANOPY_TOKEN_VERIFIER,\n useFactory: () => new TokenVerifier(options.verify),\n },\n {\n provide: CANOPY_AUTHORIZER,\n useFactory: (client: Canopy) => buildAuthorizer(client, options),\n inject: [CANOPY_CLIENT],\n },\n CanopyGuard,\n CanopyTokenGuard,\n ];\n\n return {\n module: CanopyModule,\n global: options.isGlobal ?? false,\n providers,\n exports: [\n CANOPY_CLIENT,\n CANOPY_OPTIONS,\n CANOPY_TOKEN_VERIFIER,\n CANOPY_AUTHORIZER,\n CanopyGuard,\n CanopyTokenGuard,\n ],\n };\n }\n\n /**\n * The same wiring when the API key comes from something injectable, such as\n * `ConfigService`, rather than being available at module-definition time.\n */\n static forRootAsync<TRequest = unknown>(\n options: CanopyModuleAsyncOptions<TRequest>,\n ): DynamicModule {\n const providers: Provider[] = [\n {\n provide: CANOPY_OPTIONS,\n useFactory: options.useFactory,\n inject: options.inject ?? [],\n },\n {\n provide: CANOPY_CLIENT,\n useFactory: (resolved: CanopyModuleOptions<TRequest>) =>\n new Canopy(resolved),\n inject: [CANOPY_OPTIONS],\n },\n {\n provide: CANOPY_TOKEN_VERIFIER,\n useFactory: (resolved: CanopyModuleOptions<TRequest>) =>\n new TokenVerifier(resolved.verify),\n inject: [CANOPY_OPTIONS],\n },\n {\n provide: CANOPY_AUTHORIZER,\n useFactory: (client: Canopy, resolved: CanopyModuleOptions<TRequest>) =>\n buildAuthorizer(client, resolved),\n inject: [CANOPY_CLIENT, CANOPY_OPTIONS],\n },\n CanopyGuard,\n CanopyTokenGuard,\n ];\n\n return {\n module: CanopyModule,\n global: options.isGlobal ?? false,\n // Spread rather than assigned: under exactOptionalPropertyTypes an\n // explicit `imports: undefined` is not the same as no `imports` at all.\n ...(options.imports ? { imports: options.imports } : {}),\n providers,\n exports: [\n CANOPY_CLIENT,\n CANOPY_OPTIONS,\n CANOPY_TOKEN_VERIFIER,\n CANOPY_AUTHORIZER,\n CanopyGuard,\n CanopyTokenGuard,\n ],\n };\n }\n}\n","import { Inject } from \"@nestjs/common\";\n\nimport { CANOPY_CLIENT } from \"./tokens.js\";\n\n/**\n * Injects the configured client.\n *\n * ```ts\n * constructor(@InjectCanopy() private readonly canopy: Canopy) {}\n * ```\n *\n * Named rather than resolved by type because this package is bundled with\n * esbuild, which emits no `design:paramtypes` — so type-only injection would\n * fail at runtime with a confusing \"cannot resolve dependency\" rather than at\n * build time.\n */\nexport const InjectCanopy = (): ParameterDecorator => Inject(CANOPY_CLIENT);\n","import { SetMetadata } from \"@nestjs/common\";\n\nimport { CANOPY_PERMISSION } from \"./tokens.js\";\n\n/**\n * Which question the guard asks Canopy.\n *\n * `node` walks the node's lineage, so a role granted on an ancestor is\n * inherited at the node being touched. `app_wide` asks only whether the\n * identity holds the permission anywhere in the Environment.\n */\nexport type PermissionScope = \"node\" | \"app_wide\";\n\n/** What `@RequirePermission` records for the guard to read back. */\nexport interface PermissionRequirement {\n readonly permission: string;\n readonly scope: PermissionScope;\n}\n\nexport interface RequirePermissionOptions {\n /**\n * Defaults to `node`.\n *\n * `app_wide` is opt-in on purpose. It answers a coarser question and returns\n * no effective node, which makes it right for deciding whether to render a\n * menu item and wrong for guarding a resource that belongs to one — a\n * distinction that is easy to get backwards if the looser check is the\n * default.\n */\n readonly scope?: PermissionScope;\n}\n\n/**\n * Require a Canopy permission before a route runs.\n *\n * ```ts\n * @UseGuards(CanopyGuard)\n * @RequirePermission(\"orders.refund\")\n * @Post(\":orderId/refund\")\n * refund() {}\n * ```\n *\n * Applies to a single handler or to a whole controller; a handler's own\n * requirement wins over its controller's.\n */\nexport const RequirePermission = (\n permission: string,\n options: RequirePermissionOptions = {},\n): MethodDecorator & ClassDecorator =>\n SetMetadata<string, PermissionRequirement>(CANOPY_PERMISSION, {\n permission,\n scope: options.scope ?? \"node\",\n });\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/tokens.ts","../src/canopy.guard.ts","../src/canopy-token.guard.ts","../src/canopy.module.ts","../src/inject-canopy.decorator.ts","../src/require-permission.decorator.ts"],"names":["ServiceUnavailableException","Injectable","Inject","TokenVerifier"],"mappings":";;;;;;;;;;;;;;;AASO,IAAM,cAAA,GAAiB;AAGvB,IAAM,aAAA,GAAgB;AAStB,IAAM,iBAAA,GAAoB;AAG1B,IAAM,qBAAA,GAAwB;AAG9B,IAAM,iBAAA,GAAoB;;;AC0BjC,SAAS,WAAW,KAAA,EAA2C;AAC7D,EAAA,OACE,OAAO,KAAA,KAAU,QAAA,IACjB,KAAA,KAAU,IAAA,IACV,OAAQ,KAAA,CAA2B,IAAA,KAAS,UAAA,IAC5C,OAAQ,KAAA,CAA2B,cAAA,KAAmB,UAAA;AAE1D;AAYA,SAAS,cAAc,QAAA,EAAiD;AACtE,EAAA,MAAM,MAAO,QAAA,EAAmD,GAAA;AAEhE,EAAA,IAAI,UAAA,CAAW,GAAG,CAAA,EAAG;AACnB,IAAA,OAAO,GAAA;AAAA,EACT;AAEA,EAAA,IAAI,UAAA,CAAW,QAAQ,CAAA,EAAG;AACxB,IAAA,OAAO,QAAA;AAAA,EACT;AAEA,EAAA,OAAO,MAAA;AACT;AAkBO,IAAM,cAAN,MAAyC;AAAA,EAG9C,WAAA,CACsC,SAAA,EACQ,UAAA,EAE3B,OAAA,EACjB;AAJoC,IAAA,IAAA,CAAA,SAAA,GAAA,SAAA;AACQ,IAAA,IAAA,CAAA,UAAA,GAAA,UAAA;AAE3B,IAAA,IAAA,CAAA,OAAA,GAAA,OAAA;AAAA,EAChB;AAAA,EAJmC,SAAA;AAAA,EACQ,UAAA;AAAA,EAE3B,OAAA;AAAA,EANF,MAAA,GAAS,IAAI,MAAA,CAAO,WAAA,CAAY,IAAI,CAAA;AAAA,EASrD,MAAM,YAAY,OAAA,EAA6C;AAC7D,IAAA,MAAM,WAAA,GAAc,IAAA,CAAK,SAAA,CAAU,iBAAA,CAEjC,iBAAA,EAAmB,CAAC,OAAA,CAAQ,UAAA,EAAW,EAAG,OAAA,CAAQ,QAAA,EAAU,CAAC,CAAA;AAE/D,IAAA,IAAI,CAAC,WAAA,EAAa;AAChB,MAAA,OAAO,IAAA;AAAA,IACT;AAEA,IAAA,MAAM,IAAA,GAAO,QAAQ,YAAA,EAAa;AAClC,IAAA,MAAM,OAAA,GAAmB,KAAK,UAAA,EAAW;AACzC,IAAA,MAAM,UAAA,GAAa,aAAA,CAAc,IAAA,CAAK,OAAA,CAAQ,iBAAiB,OAAO,CAAA;AAEtE,IAAA,IAAI,CAAC,UAAA,EAAY;AACf,MAAA,MAAM,IAAI,kBAAA;AAAA,QACR;AAAA,OACF;AAAA,IACF;AAEA,IAAA,MAAM,QAAA,GAAW,MAAM,IAAA,CAAK,QAAA;AAAA,MAC1B,WAAA;AAAA,MACA,UAAA;AAAA,MACA,OAAA;AAAA,MACA,gBAAgB,IAAI;AAAA,KACtB;AAEA,IAAA,IAAI,CAAC,SAAS,OAAA,EAAS;AACrB,MAAA,MAAM,IAAI,kBAAA;AAAA,QACR,CAAA,uBAAA,EAA0B,YAAY,UAAU,CAAA,CAAA;AAAA,OAClD;AAAA,IACF;AAEA,IAAA,OAAO,IAAA;AAAA,EACT;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASA,MAAc,QAAA,CACZ,WAAA,EACA,UAAA,EACA,SACA,YAAA,EACkB;AAClB,IAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,UAAA,CAAW,WAAA,EAAa,YAAY,OAAO,CAAA;AAE9D,IAAA,IAAI;AAKF,MAAA,OAAO,MAAM,KAAK,UAAA,CAAW,QAAA;AAAA,QAC3B,KAAA;AAAA,QACA,eAAe,EAAE,MAAA,EAAQ,YAAA,CAAa,MAAA,KAAW;AAAC,OACpD;AAAA,IACF,SAAS,KAAA,EAAO;AAKd,MAAA,IAAI,YAAA,EAAc,OAAO,OAAA,EAAS;AAChC,QAAA,MAAM,KAAA;AAAA,MACR;AAIA,MAAA,IAAA,CAAK,MAAA,CAAO,KAAA;AAAA,QACV,CAAA,mBAAA,EAAsB,WAAA,CAAY,UAAU,CAAA,KAAA,EAAQ,UAAU,CAAA,CAAA;AAAA,QAC9D,KAAA,YAAiB,KAAA,GAAQ,KAAA,CAAM,KAAA,GAAQ,OAAO,KAAK;AAAA,OACrD;AAEA,MAAA,MAAM,IAAA,CAAK,gBAAgB,KAAK,CAAA;AAAA,IAClC,CAAA,SAAE;AACA,MAAA,YAAA,EAAc,OAAA,EAAQ;AAAA,IACxB;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUQ,gBAAgB,KAAA,EAAuB;AAK7C,IAAA,IAAI,uBAAA,CAAwB,KAAK,CAAA,EAAG;AAClC,MAAA,OAAO,IAAI,4BAAA;AAAA,QACT,CAAA,gCAAA,EAAmC,MAAM,OAAO,CAAA;AAAA,OAClD;AAAA,IACF;AAEA,IAAA,IAAI,CAAC,aAAA,CAAc,KAAK,CAAA,EAAG;AAEzB,MAAA,OAAO,IAAI,2BAAA;AAAA,QACT;AAAA,OACF;AAAA,IACF;AAGA,IAAA,IAAI,MAAM,aAAA,EAAe;AACvB,MAAA,OAAO,IAAI,4BAAA;AAAA,QACT;AAAA,OACF;AAAA,IACF;AAOA,IAAA,IAAI,KAAA,CAAM,UAAA,KAAe,GAAA,IAAO,KAAA,CAAM,SAAS,IAAA,EAAM;AACnD,MAAA,OAAO,IAAI,4BAAA;AAAA,QACT,mCAAmC,KAAA,CAAM,OAAA,CAAQ,MAAM,CAAA,CAAA,EAAI,KAAA,CAAM,QAAQ,IAAI,CAAA,iGAAA;AAAA,OAE/E;AAAA,IACF;AAMA,IAAA,IAAI,KAAA,CAAM,eAAe,GAAA,EAAK;AAC5B,MAAA,OAAO,IAAI,mBAAmB,oCAAoC,CAAA;AAAA,IACpE;AAEA,IAAA,IAAI,KAAA,CAAM,aAAA,IAAiB,KAAA,CAAM,UAAA,IAAc,GAAA,EAAK;AAClD,MAAA,OAAO,IAAI,2BAAA;AAAA,QACT;AAAA,OACF;AAAA,IACF;AAIA,IAAA,OAAO,IAAI,4BAAA;AAAA,MACT;AAAA,KACF;AAAA,EACF;AAAA,EAEQ,UAAA,CACN,WAAA,EACA,UAAA,EACA,OAAA,EACe;AACf,IAAA,IAAI,WAAA,CAAY,UAAU,UAAA,EAAY;AACpC,MAAA,OAAO;AAAA,QACL,WAAA,EAAa,UAAA;AAAA,QACb,YAAY,WAAA,CAAY,UAAA;AAAA,QACxB,KAAA,EAAO;AAAA,OACT;AAAA,IACF;AAKA,IAAA,IAAI,WAAA,CAAY,UAAU,KAAA,EAAO;AAC/B,MAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,UAAA,CAAW,OAAO,CAAA;AAErC,MAAA,IAAI,CAAC,KAAA,EAAO;AACV,QAAA,MAAM,IAAI,kBAAA;AAAA,UACR,CAAA,gCAAA,EAAmC,YAAY,UAAU,CAAA,0FAAA;AAAA,SAC3D;AAAA,MACF;AAEA,MAAA,OAAO;AAAA,QACL,WAAA,EAAa,UAAA;AAAA,QACb,YAAY,WAAA,CAAY,UAAA;AAAA,QACxB,KAAA,EAAO,MAAA;AAAA,QACP,OAAA,EAAS;AAAA,OACX;AAAA,IACF;AAEA,IAAA,MAAM,MAAA,GAAS,KAAK,OAAA,CAAQ,WAAA,GACxB,cAAc,IAAA,CAAK,OAAA,CAAQ,WAAA,EAAa,OAAO,CAAA,GAC/C,MAAA;AAEJ,IAAA,IAAI,CAAC,MAAA,EAAQ;AACX,MAAA,MAAM,IAAI,kBAAA;AAAA,QACR,CAAA,wBAAA,EAA2B,YAAY,UAAU,CAAA,iHAAA;AAAA,OACnD;AAAA,IACF;AAEA,IAAA,OAAO;AAAA,MACL,WAAA,EAAa,UAAA;AAAA,MACb,YAAY,WAAA,CAAY,UAAA;AAAA,MACxB,KAAA,EAAO,MAAA;AAAA,MACP,OAAA,EAAS;AAAA,KACX;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASQ,WAAW,OAAA,EAA6C;AAC9D,IAAA,IAAI,IAAA,CAAK,QAAQ,UAAA,EAAY;AAC3B,MAAA,OAAO,aAAA,CAAc,IAAA,CAAK,OAAA,CAAQ,UAAA,EAAY,OAAO,CAAA;AAAA,IACvD;AAEA,IAAA,MAAM,UAAA,GAAa,IAAA,CAAK,OAAA,CAAQ,aAAA,IAAiB,aAAA;AACjD,IAAA,MAAM,MAAA,GAAU,UACd,UACF,CAAA;AAEA,IAAA,MAAM,QAAS,MAAA,EAA6C,MAAA;AAE5D,IAAA,OAAO,OAAO,KAAA,KAAU,QAAA,IAAY,KAAA,KAAU,KAAK,KAAA,GAAQ,MAAA;AAAA,EAC7D;AACF;AArOa,WAAA,GAAN,eAAA,CAAA;AAAA,EADN,UAAA,EAAW;AAAA,EAKP,0BAAO,SAAS,CAAA,CAAA;AAAA,EAChB,0BAAO,iBAAiB,CAAA,CAAA;AAAA,EACxB,0BAAO,cAAc,CAAA;AAAA,CAAA,EANb,WAAA,CAAA;AAgPb,SAAS,aAAA,CACP,SACA,OAAA,EAC2B;AAC3B,EAAA,IAAI;AACF,IAAA,OAAQ,OAAA;AAAA,MACN;AAAA,KACF;AAAA,EACF,CAAA,CAAA,MAAQ;AACN,IAAA,OAAO,MAAA;AAAA,EACT;AACF;AAeA,SAAS,gBAAgB,IAAA,EAEI;AAC3B,EAAA,MAAM,GAAA,GAAM,aAAA,CAAc,IAAA,CAAK,WAAA,IAAe,CAAA;AAE9C,EAAA,IAAI,CAAC,GAAA,EAAK;AACR,IAAA,OAAO,MAAA;AAAA,EACT;AAEA,EAAA,MAAM,UAAA,GAAa,IAAI,eAAA,EAAgB;AAEvC,EAAA,MAAM,UAAU,MAAM;AACpB,IAAA,IAAI,GAAA,CAAI,kBAAkB,IAAA,EAAM;AAC9B,MAAA;AAAA,IACF;AAEA,IAAA,UAAA,CAAW,KAAA;AAAA,MACT,IAAI,YAAA,CAAa,+BAAA,EAAiC,YAAY;AAAA,KAChE;AAAA,EACF,CAAA;AAEA,EAAA,GAAA,CAAI,IAAA,CAAK,SAAS,OAAO,CAAA;AAEzB,EAAA,OAAO;AAAA,IACL,QAAQ,UAAA,CAAW,MAAA;AAAA,IACnB,OAAA,EAAS,MAAM,GAAA,CAAI,cAAA,CAAe,SAAS,OAAO;AAAA,GACpD;AACF;AC7VO,IAAM,mBAAN,MAA8C;AAAA,EACnD,WAAA,CACkD,UAE/B,OAAA,EACjB;AAHgD,IAAA,IAAA,CAAA,QAAA,GAAA,QAAA;AAE/B,IAAA,IAAA,CAAA,OAAA,GAAA,OAAA;AAAA,EAChB;AAAA,EAH+C,QAAA;AAAA,EAE/B,OAAA;AAAA,EAGnB,MAAM,YAAY,OAAA,EAA6C;AAC7D,IAAA,MAAM,OAAA,GAAU,OAAA,CAAQ,YAAA,EAAa,CAAE,UAAA,EAGpC;AAEH,IAAA,MAAM,KAAA,GAAQ,eAAA,CAAgB,OAAA,CAAQ,OAAA,GAAU,eAAe,CAAC,CAAA;AAEhE,IAAA,IAAI,UAAU,IAAA,EAAM;AAClB,MAAA,MAAM,IAAI,qBAAA;AAAA,QACR;AAAA,OACF;AAAA,IACF;AAEA,IAAA,IAAI,MAAA;AAEJ,IAAA,IAAI;AACF,MAAA,MAAA,GAAS,MAAM,IAAA,CAAK,QAAA,CAAS,MAAA,CAAO,KAAK,CAAA;AAAA,IAC3C,SAAS,KAAA,EAAO;AAYd,MAAA,IACE,kBAAA,CAAmB,KAAK,CAAA,IACxB,KAAA,CAAM,SAAS,wBAAA,EACf;AACA,QAAA,MAAM,IAAIA,2BAAAA;AAAA,UACR,4CAAA;AAAA,UACA,EAAE,OAAO,KAAA;AAAM,SACjB;AAAA,MACF;AAOA,MAAA,IAAI,kBAAA,CAAmB,KAAK,CAAA,EAAG;AAC7B,QAAA,MAAM,IAAI,sBAAsB,kCAAA,EAAoC;AAAA,UAClE,KAAA,EAAO;AAAA,SACR,CAAA;AAAA,MACH;AAEA,MAAA,MAAM,KAAA;AAAA,IACR;AAEA,IAAA,OAAA,CAAQ,IAAA,CAAK,OAAA,CAAQ,aAAA,IAAiB,aAAa,CAAA,GAAI,MAAA;AAEvD,IAAA,OAAO,IAAA;AAAA,EACT;AACF;AAjEa,gBAAA,GAAN,eAAA,CAAA;AAAA,EADNC,UAAAA,EAAW;AAAA,EAGP,eAAA,CAAA,CAAA,EAAAC,OAAO,qBAAqB,CAAA,CAAA;AAAA,EAC5B,eAAA,CAAA,CAAA,EAAAA,OAAO,cAAc,CAAA;AAAA,CAAA,EAHb,gBAAA,CAAA;AA0Eb,SAAS,gBAAgB,MAAA,EAAsD;AAC7E,EAAA,MAAM,QAAQ,KAAA,CAAM,OAAA,CAAQ,MAAM,CAAA,GAAI,MAAA,CAAO,CAAC,CAAA,GAAI,MAAA;AAElD,EAAA,IAAI,OAAO,UAAU,QAAA,EAAU;AAC7B,IAAA,OAAO,IAAA;AAAA,EACT;AAEA,EAAA,MAAM,KAAA,GAAQ,kBAAA,CAAmB,IAAA,CAAK,KAAA,CAAM,MAAM,CAAA;AAElD,EAAA,IAAI,CAAC,KAAA,GAAQ,CAAC,CAAA,EAAG;AACf,IAAA,OAAO,IAAA;AAAA,EACT;AAEA,EAAA,MAAM,KAAA,GAAQ,KAAA,CAAM,CAAC,CAAA,CAAE,IAAA,EAAK;AAE5B,EAAA,OAAO,KAAA,CAAM,MAAA,GAAS,CAAA,GAAI,KAAA,GAAQ,IAAA;AACpC;;;AC9FA,IAAM,2BAAA,GAA8B,GAAA;AAUpC,IAAM,4BAAA,GAA+B,CAAA;AAQrC,SAAS,eAAA,CACP,QACA,OAAA,EACiB;AACjB,EAAA,OAAO,IAAI,eAAA,CAAgB,MAAA,CAAO,MAAA,EAAQ;AAAA,IACxC,GAAI,QAAQ,kBAAA,KAAuB,MAAA,GAC/B,EAAC,GACD,EAAE,KAAA,EAAO,OAAA,CAAQ,kBAAA,EAAmB;AAAA,IACxC,SAAA,EAAW,QAAQ,iBAAA,IAAqB,2BAAA;AAAA,IACxC,UAAA,EAAY,QAAQ,kBAAA,IAAsB;AAAA,GAC3C,CAAA;AACH;AAGO,IAAM,eAAN,MAAmB;AAAA,EACxB,OAAO,QACL,OAAA,EACe;AACf,IAAA,MAAM,SAAA,GAAwB;AAAA,MAC5B,EAAE,OAAA,EAAS,cAAA,EAAgB,QAAA,EAAU,OAAA,EAAQ;AAAA,MAC7C;AAAA,QACE,OAAA,EAAS,aAAA;AAAA,QACT,UAAA,EAAY,MAAM,IAAI,MAAA,CAAO,OAAO;AAAA,OACtC;AAAA,MACA;AAAA,QACE,OAAA,EAAS,qBAAA;AAAA,QACT,UAAA,EAAY,MAAM,IAAIC,aAAAA,CAAc,QAAQ,MAAM;AAAA,OACpD;AAAA,MACA;AAAA,QACE,OAAA,EAAS,iBAAA;AAAA,QACT,UAAA,EAAY,CAAC,MAAA,KAAmB,eAAA,CAAgB,QAAQ,OAAO,CAAA;AAAA,QAC/D,MAAA,EAAQ,CAAC,aAAa;AAAA,OACxB;AAAA,MACA,WAAA;AAAA,MACA;AAAA,KACF;AAEA,IAAA,OAAO;AAAA,MACL,MAAA,EAAQ,YAAA;AAAA,MACR,MAAA,EAAQ,QAAQ,QAAA,IAAY,KAAA;AAAA,MAC5B,SAAA;AAAA,MACA,OAAA,EAAS;AAAA,QACP,aAAA;AAAA,QACA,cAAA;AAAA,QACA,qBAAA;AAAA,QACA,iBAAA;AAAA,QACA,WAAA;AAAA,QACA;AAAA;AACF,KACF;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,OAAO,aACL,OAAA,EACe;AACf,IAAA,MAAM,SAAA,GAAwB;AAAA,MAC5B;AAAA,QACE,OAAA,EAAS,cAAA;AAAA,QACT,YAAY,OAAA,CAAQ,UAAA;AAAA,QACpB,MAAA,EAAQ,OAAA,CAAQ,MAAA,IAAU;AAAC,OAC7B;AAAA,MACA;AAAA,QACE,OAAA,EAAS,aAAA;AAAA,QACT,UAAA,EAAY,CAAC,QAAA,KACX,IAAI,OAAO,QAAQ,CAAA;AAAA,QACrB,MAAA,EAAQ,CAAC,cAAc;AAAA,OACzB;AAAA,MACA;AAAA,QACE,OAAA,EAAS,qBAAA;AAAA,QACT,YAAY,CAAC,QAAA,KACX,IAAIA,aAAAA,CAAc,SAAS,MAAM,CAAA;AAAA,QACnC,MAAA,EAAQ,CAAC,cAAc;AAAA,OACzB;AAAA,MACA;AAAA,QACE,OAAA,EAAS,iBAAA;AAAA,QACT,YAAY,CAAC,MAAA,EAAgB,QAAA,KAC3B,eAAA,CAAgB,QAAQ,QAAQ,CAAA;AAAA,QAClC,MAAA,EAAQ,CAAC,aAAA,EAAe,cAAc;AAAA,OACxC;AAAA,MACA,WAAA;AAAA,MACA;AAAA,KACF;AAEA,IAAA,OAAO;AAAA,MACL,MAAA,EAAQ,YAAA;AAAA,MACR,MAAA,EAAQ,QAAQ,QAAA,IAAY,KAAA;AAAA;AAAA;AAAA,MAG5B,GAAI,QAAQ,OAAA,GAAU,EAAE,SAAS,OAAA,CAAQ,OAAA,KAAY,EAAC;AAAA,MACtD,SAAA;AAAA,MACA,OAAA,EAAS;AAAA,QACP,aAAA;AAAA,QACA,cAAA;AAAA,QACA,qBAAA;AAAA,QACA,iBAAA;AAAA,QACA,WAAA;AAAA,QACA;AAAA;AACF,KACF;AAAA,EACF;AACF;AA1Fa,YAAA,GAAN,eAAA,CAAA;AAAA,EADN,MAAA,CAAO,EAAE;AAAA,CAAA,EACG,YAAA,CAAA;AC1DN,IAAM,YAAA,GAAe,MAA0BD,MAAAA,CAAO,aAAa;ACqCnE,IAAM,oBAAoB,CAC/B,UAAA,EACA,UAAoC,EAAC,KAErC,YAA2C,iBAAA,EAAmB;AAAA,EAC5D,UAAA;AAAA,EACA,KAAA,EAAO,QAAQ,KAAA,IAAS;AAC1B,CAAC","file":"index.js","sourcesContent":["/**\n * Injection tokens.\n *\n * These are strings rather than classes because esbuild does not emit\n * `design:paramtypes`, so nothing here can be injected by constructor type\n * alone — every dependency is named with an explicit `@Inject`.\n */\n\n/** The resolved `CanopyModuleOptions`. */\nexport const CANOPY_OPTIONS = \"CANOPY_OPTIONS\";\n\n/** The configured `Canopy` client. */\nexport const CANOPY_CLIENT = \"CANOPY_CLIENT\";\n\n/**\n * The configured `LocalAuthorizer`.\n *\n * Shared deliberately: it holds one copy of the hierarchy for the whole\n * process, so a second instance would double both the memory and the traffic\n * it exists to avoid.\n */\nexport const CANOPY_AUTHORIZER = \"CANOPY_AUTHORIZER\";\n\n/** The configured `TokenVerifier`, shared so its key cache is shared too. */\nexport const CANOPY_TOKEN_VERIFIER = \"CANOPY_TOKEN_VERIFIER\";\n\n/** Metadata key holding what `@RequirePermission` recorded on a handler. */\nexport const CANOPY_PERMISSION = \"CANOPY_PERMISSION\";\n","import {\n type CanActivate,\n type ExecutionContext,\n ForbiddenException,\n Inject,\n Injectable,\n InternalServerErrorException,\n Logger,\n ServiceUnavailableException,\n} from \"@nestjs/common\";\nimport { Reflector } from \"@nestjs/core\";\n\nimport {\n isCanopyAuthorizerError,\n isCanopyError,\n type LocalAuthorizer,\n type RequestBody,\n} from \"@canopy-io/node\";\n\nimport type { CanopyModuleOptions } from \"./options.js\";\nimport type { PermissionRequirement } from \"./require-permission.decorator.js\";\nimport {\n CANOPY_AUTHORIZER,\n CANOPY_OPTIONS,\n CANOPY_PERMISSION,\n} from \"./tokens.js\";\n\n/** Read off the generated operation, so it follows the API rather than restating it. */\ntype EvaluateQuery = RequestBody<\"ApiPermissionsController_evaluate\">;\n\n/**\n * Only the verdict, not the API's full explain payload.\n *\n * A decision reached in-process cannot report `granting_roles` or an effective\n * node — that reasoning lives on the server. The guard has never needed it, and\n * typing the narrow thing keeps that honest rather than implying a richer\n * answer than exists.\n */\ntype Verdict = { allowed: boolean };\n\ninterface Cancellation {\n signal: AbortSignal;\n dispose: () => void;\n}\n\n/** The Node stream underneath an Express or Fastify response. */\ninterface ClosableResponse {\n once: (event: string, listener: () => void) => unknown;\n removeListener: (event: string, listener: () => void) => unknown;\n writableEnded?: boolean;\n raw?: ClosableResponse;\n}\n\nfunction isClosable(value: unknown): value is ClosableResponse {\n return (\n typeof value === \"object\" &&\n value !== null &&\n typeof (value as ClosableResponse).once === \"function\" &&\n typeof (value as ClosableResponse).removeListener === \"function\"\n );\n}\n\n/**\n * Find the Node response stream behind whatever the adapter hands back.\n *\n * `.raw` is checked first and on its own merits, because the object Nest\n * returns is not always the stream. Express returns the `ServerResponse`\n * itself, which listens; Fastify returns a `Reply`, which is a plain object\n * with the real response on `.raw`. Testing the outer object first and\n * returning early on failure would skip `.raw` in exactly the case it exists\n * for.\n */\nfunction resolveStream(response: unknown): ClosableResponse | undefined {\n const raw = (response as { raw?: unknown } | null | undefined)?.raw;\n\n if (isClosable(raw)) {\n return raw;\n }\n\n if (isClosable(response)) {\n return response;\n }\n\n return undefined;\n}\n\n/**\n * Enforces `@RequirePermission` by asking Canopy, once, per guarded request.\n *\n * The guard fails closed at every step. An unresolvable identity, a `node`\n * check with no node, a denial, or an unreachable API all end the request —\n * there is no path through this class that allows a request whose decision is\n * unknown. That is the property worth protecting when reading this code: an\n * `allow` must be something Canopy actually said.\n *\n * A route with no requirement is allowed through untouched, so the guard can be\n * applied globally and opted into per route. Registered that way it runs before\n * controller- and route-scoped guards, so whatever authenticates the request has\n * to be registered globally ahead of it — otherwise `resolveIdentity` runs\n * before there is anything to resolve and every guarded route denies.\n */\n@Injectable()\nexport class CanopyGuard implements CanActivate {\n private readonly logger = new Logger(CanopyGuard.name);\n\n constructor(\n @Inject(Reflector) private readonly reflector: Reflector,\n @Inject(CANOPY_AUTHORIZER) private readonly authorizer: LocalAuthorizer,\n @Inject(CANOPY_OPTIONS)\n private readonly options: CanopyModuleOptions<unknown>,\n ) {}\n\n async canActivate(context: ExecutionContext): Promise<boolean> {\n const requirement = this.reflector.getAllAndOverride<\n PermissionRequirement | undefined\n >(CANOPY_PERMISSION, [context.getHandler(), context.getClass()]);\n\n if (!requirement) {\n return true;\n }\n\n const http = context.switchToHttp();\n const request: unknown = http.getRequest();\n const identityId = resolveSafely(this.options.resolveIdentity, request);\n\n if (!identityId) {\n throw new ForbiddenException(\n \"No identity on the request. Canopy cannot evaluate a permission without one.\",\n );\n }\n\n const decision = await this.evaluate(\n requirement,\n identityId,\n request,\n watchDisconnect(http),\n );\n\n if (!decision.allowed) {\n throw new ForbiddenException(\n `Identity does not hold ${requirement.permission}.`,\n );\n }\n\n return true;\n }\n\n /**\n * Asks Canopy the question the decorator recorded.\n *\n * Every failure is converted into a thrown exception rather than a `false`\n * decision, so a caller cannot mistake \"we could not tell\" for \"denied by\n * policy\" — and neither outcome lets the request proceed.\n */\n private async evaluate(\n requirement: PermissionRequirement,\n identityId: string,\n request: unknown,\n cancellation: Cancellation | undefined,\n ): Promise<Verdict> {\n const query = this.buildQuery(requirement, identityId, request);\n\n try {\n // Answered in-process. The authorizer holds the identity's grant roots\n // and a shared copy of the hierarchy, so the common case reaches no\n // network at all; only a cold or expired entry does, and its deadline\n // and retries are bounded by the same options as before.\n return await this.authorizer.evaluate(\n query,\n cancellation ? { signal: cancellation.signal } : {},\n );\n } catch (error) {\n // The caller hung up mid-check. There is no one left to answer, and this\n // is not Canopy failing — so it must not be logged or reported as though\n // it were. Rethrowing the abort ends the request without inventing a\n // decision.\n if (cancellation?.signal.aborted) {\n throw error;\n }\n\n // Deliberately not re-thrown as-is: a failure here must not reach the\n // client as though Canopy had answered.\n this.logger.error(\n `Could not evaluate ${requirement.permission} for ${identityId}`,\n error instanceof Error ? error.stack : String(error),\n );\n\n throw this.toHttpException(error);\n } finally {\n cancellation?.dispose();\n }\n }\n\n /**\n * Turn a failed check into the status that describes it.\n *\n * Every branch denies — nothing here can let a request through — but they say\n * different things, and the difference is what makes an incident diagnosable.\n * Reporting a rejected API key as \"temporarily unavailable\" invites a retry\n * that will never succeed and hides a misconfiguration behind an outage.\n */\n private toHttpException(error: unknown): Error {\n // The authorizer could not see far enough to decide — a credential that\n // cannot read the hierarchy, most likely. Not an outage: no retry fixes a\n // missing scope, and reporting it as one would bury a misconfiguration\n // under what looks like a blip.\n if (isCanopyAuthorizerError(error)) {\n return new InternalServerErrorException(\n `Authorization is misconfigured: ${error.message}`,\n );\n }\n\n if (!isCanopyError(error)) {\n // Never answered: unreachable, reset, or past the deadline.\n return new ServiceUnavailableException(\n \"Authorization is temporarily unavailable.\",\n );\n }\n\n // Canopy rejected our own credential. No retry fixes this.\n if (error.isAuthFailure) {\n return new InternalServerErrorException(\n \"Authorization is misconfigured. Check the Canopy API key and its scopes.\",\n );\n }\n\n // A 404 with no error code is Nest's own \"no such route\", which here means\n // this Canopy does not serve the endpoint the authorizer reads — an SDK\n // newer than the API it is pointed at. Denying would be catastrophic and\n // silent: every guarded route in the application would answer 403 with\n // nothing to suggest the cause is a version mismatch rather than policy.\n if (error.statusCode === 404 && error.code === null) {\n return new InternalServerErrorException(\n `Authorization is misconfigured: ${error.request.method} ${error.request.path} is not served by this Canopy. ` +\n \"The SDK requires an API that exposes the identity grants endpoint.\",\n );\n }\n\n // A 404 that does carry a code is a real answer — the identity is not in\n // this Environment. Deny rather than 500, the guard's whole thesis being\n // that an undecidable request does not proceed, but say nothing about what\n // was missing.\n if (error.statusCode === 404) {\n return new ForbiddenException(\"Could not evaluate the permission.\");\n }\n\n if (error.isRateLimited || error.statusCode >= 500) {\n return new ServiceUnavailableException(\n \"Authorization is temporarily unavailable.\",\n );\n }\n\n // A 4xx we did not anticipate is a malformed question — our bug, not a\n // transient condition.\n return new InternalServerErrorException(\n \"Authorization could not be evaluated.\",\n );\n }\n\n private buildQuery(\n requirement: PermissionRequirement,\n identityId: string,\n request: unknown,\n ): EvaluateQuery {\n if (requirement.scope === \"app_wide\") {\n return {\n identity_id: identityId,\n permission: requirement.permission,\n scope: \"app_wide\",\n };\n }\n\n // An organization is a hierarchy node, and a membership is a role\n // assignment at it — so the org question IS the node question, asked at\n // the node the caller's token says it is acting in.\n if (requirement.scope === \"org\") {\n const orgId = this.resolveOrg(request);\n\n if (!orgId) {\n throw new ForbiddenException(\n `No organization on the request. ${requirement.permission} is checked inside the caller's active organization, and this caller is not acting in one.`,\n );\n }\n\n return {\n identity_id: identityId,\n permission: requirement.permission,\n scope: \"node\",\n node_id: orgId,\n };\n }\n\n const nodeId = this.options.resolveNode\n ? resolveSafely(this.options.resolveNode, request)\n : undefined;\n\n if (!nodeId) {\n throw new ForbiddenException(\n `No node on the request. ${requirement.permission} is checked at a node; configure resolveNode, or declare the route as app_wide if that is genuinely the question.`,\n );\n }\n\n return {\n identity_id: identityId,\n permission: requirement.permission,\n scope: \"node\",\n node_id: nodeId,\n };\n }\n\n /**\n * The organization the request acts in: `resolveOrg` when configured,\n * otherwise the `org_id` claim off the verified token `CanopyTokenGuard`\n * attached. Both sources trace back to a verified token — the org id\n * decides whose grants answer the check, so it must never be readable off\n * anything the caller controls directly.\n */\n private resolveOrg(request: unknown): string | null | undefined {\n if (this.options.resolveOrg) {\n return resolveSafely(this.options.resolveOrg, request);\n }\n\n const attachedAs = this.options.attachTokenAs ?? \"canopyToken\";\n const claims = (request as Record<string, unknown> | null | undefined)?.[\n attachedAs\n ];\n\n const orgId = (claims as { org_id?: unknown } | undefined)?.org_id;\n\n return typeof orgId === \"string\" && orgId !== \"\" ? orgId : undefined;\n }\n}\n\n/**\n * Run a resolver, treating a throw as \"could not resolve\".\n *\n * Resolvers reach into the request — `request.user.sub` is the shape the docs\n * suggest and the one most applications write. On an unauthenticated request\n * `request.user` is undefined and that expression throws, which without this\n * would surface as a 500. An identity that cannot be established is exactly the\n * request that must be denied, so it becomes the same 403 as returning nothing.\n */\nfunction resolveSafely(\n resolve: (request: never) => string | null | undefined,\n request: unknown,\n): string | null | undefined {\n try {\n return (resolve as (request: unknown) => string | null | undefined)(\n request,\n );\n } catch {\n return undefined;\n }\n}\n\n/**\n * Abort the permission check if the caller hangs up while it is in flight.\n *\n * Watches the *response*, not the request. A request stream emits `close` on\n * every normal request once its body has been read, so listening there would\n * abort perfectly healthy checks; a response emits `close` before\n * `writableEnded` only when nothing was ever sent — which is exactly the\n * disconnect case. The `writableEnded` re-check at fire time keeps that true\n * even if the ordering differs by adapter.\n *\n * Returns undefined for a transport with no such response — a GraphQL or\n * microservice context — where the check simply runs to its deadline.\n */\nfunction watchDisconnect(http: {\n getResponse?: () => unknown;\n}): Cancellation | undefined {\n const raw = resolveStream(http.getResponse?.());\n\n if (!raw) {\n return undefined;\n }\n\n const controller = new AbortController();\n\n const onClose = () => {\n if (raw.writableEnded === true) {\n return;\n }\n\n controller.abort(\n new DOMException(\"The client closed the request\", \"AbortError\"),\n );\n };\n\n raw.once(\"close\", onClose);\n\n return {\n signal: controller.signal,\n dispose: () => raw.removeListener(\"close\", onClose),\n };\n}\n","import {\n type CanActivate,\n type ExecutionContext,\n Inject,\n Injectable,\n ServiceUnavailableException,\n UnauthorizedException,\n} from \"@nestjs/common\";\n\nimport {\n type CanopyTokenClaims,\n isCanopyTokenError,\n TokenVerifier,\n} from \"@canopy-io/node\";\n\nimport type { CanopyModuleOptions } from \"./options.js\";\nimport { CANOPY_OPTIONS, CANOPY_TOKEN_VERIFIER } from \"./tokens.js\";\n\n/**\n * Verifies the bearer token and attaches its claims to the request.\n *\n * This is the authentication half, and it is opt-in on purpose. `CanopyGuard`\n * answers \"may this identity do this?\"; something has to have established\n * *which* identity first, and `resolveIdentity` says as much — \"usually a\n * claim off the verified access token your auth layer already attached\".\n *\n * Use this guard when Canopy issues your tokens and you have no auth layer\n * yet. Skip it entirely if you already run Passport or your own JWT\n * middleware — it is additive, and nothing else in the package depends on it.\n *\n * ```ts\n * @UseGuards(CanopyTokenGuard, CanopyGuard)\n * @RequirePermission(\"documents.read\")\n * findAll() {}\n * ```\n *\n * Order matters: this must run first, because `CanopyGuard` reads what it\n * attaches. Nest runs guards left to right, so listing them in that order is\n * the whole of the wiring.\n *\n * Claims land on `request.canopyToken` by default, which is why\n * `resolveIdentity` is usually `(req) => req.canopyToken?.sub`. The property\n * is configurable so it cannot collide with a `request.user` an existing\n * middleware already owns.\n */\n@Injectable()\nexport class CanopyTokenGuard implements CanActivate {\n constructor(\n @Inject(CANOPY_TOKEN_VERIFIER) private readonly verifier: TokenVerifier,\n @Inject(CANOPY_OPTIONS)\n private readonly options: CanopyModuleOptions<unknown>,\n ) {}\n\n async canActivate(context: ExecutionContext): Promise<boolean> {\n const request = context.switchToHttp().getRequest<{\n headers?: Record<string, string | string[] | undefined>;\n [key: string]: unknown;\n }>();\n\n const token = readBearerToken(request.headers?.[\"authorization\"]);\n\n if (token === null) {\n throw new UnauthorizedException(\n \"Missing bearer token in the Authorization header.\",\n );\n }\n\n let claims: CanopyTokenClaims;\n\n try {\n claims = await this.verifier.verify(token);\n } catch (error) {\n // Not being able to verify is not the same as failing to verify. When\n // the key set cannot be read the caller's token may be perfectly good —\n // we simply cannot say — so this is ours to own, exactly as the\n // permission guard answers 503 rather than denying on an undecidable\n // check.\n //\n // Reporting it as 401 would be worse than merely inaccurate: a client\n // reads 401 as \"your token is stale\", discards a valid one and asks for\n // another, which aims a refresh storm at the issuer that is already\n // down. And an outage that only ever emits 4xx is invisible to anything\n // watching 5xx.\n if (\n isCanopyTokenError(error) &&\n error.code === \"token.jwks_unavailable\"\n ) {\n throw new ServiceUnavailableException(\n \"Authentication is temporarily unavailable.\",\n { cause: error },\n );\n }\n\n // Every other cause is the token itself, and a 401 whatever the detail:\n // the caller cannot fix a bad signature by being granted something. The\n // specific code stays on the error for logs rather than going to the\n // caller, who has no use for the difference and should not be told which\n // check failed.\n if (isCanopyTokenError(error)) {\n throw new UnauthorizedException(\"Invalid or expired access token.\", {\n cause: error,\n });\n }\n\n throw error;\n }\n\n request[this.options.attachTokenAs ?? \"canopyToken\"] = claims;\n\n return true;\n }\n}\n\n/**\n * The token out of an `Authorization: Bearer …` header.\n *\n * Returns null rather than throwing so the caller owns the message. The scheme\n * is matched case-insensitively because RFC 7235 says it is case-insensitive,\n * and clients do send `bearer`.\n */\nfunction readBearerToken(header: string | string[] | undefined): string | null {\n const value = Array.isArray(header) ? header[0] : header;\n\n if (typeof value !== \"string\") {\n return null;\n }\n\n const match = /^bearer\\s+(.+)$/i.exec(value.trim());\n\n if (!match?.[1]) {\n return null;\n }\n\n const token = match[1].trim();\n\n return token.length > 0 ? token : null;\n}\n","import { type DynamicModule, Module, type Provider } from \"@nestjs/common\";\n\nimport { Canopy, LocalAuthorizer, TokenVerifier } from \"@canopy-io/node\";\n\nimport { CanopyGuard } from \"./canopy.guard.js\";\nimport { CanopyTokenGuard } from \"./canopy-token.guard.js\";\nimport type {\n CanopyModuleAsyncOptions,\n CanopyModuleOptions,\n} from \"./options.js\";\nimport {\n CANOPY_AUTHORIZER,\n CANOPY_CLIENT,\n CANOPY_OPTIONS,\n CANOPY_TOKEN_VERIFIER,\n} from \"./tokens.js\";\n\n/**\n * Wires one configured `Canopy` client and the guard that uses it into a Nest\n * application.\n *\n * ```ts\n * CanopyModule.forRoot<AuthedRequest>({\n * apiKey: process.env.CANOPY_API_KEY,\n * resolveIdentity: (request) => request.user?.sub,\n * resolveNode: (request) => request.params?.orgId,\n * })\n * ```\n *\n * The client is a singleton because it holds no per-request state — it is a\n * `fetch` wrapper with credentials — so there is nothing to gain from building\n * one per request and a connection pool to lose.\n */\n\n/**\n * Per-attempt deadline for a cache miss on the request path.\n *\n * Five seconds rather than the client's 30: this call sits on the request path,\n * so its deadline is time an inbound request spends waiting. An authorization\n * service that has not answered in five seconds is not going to save the\n * request.\n */\nconst DEFAULT_EVALUATE_TIMEOUT_MS = 5_000;\n\n/**\n * Retries for a cache miss on the request path.\n *\n * One, not the client's two. A deadline bounds an attempt; this bounds how many\n * of them an inbound request can wait through. One retry still absorbs a\n * transient blip, and caps the worst case at roughly two deadlines rather than\n * three.\n */\nconst DEFAULT_EVALUATE_MAX_RETRIES = 1;\n\n/**\n * The authorizer both registration paths build.\n *\n * The guard's `evaluateTimeoutMs` / `evaluateMaxRetries` still apply, but to\n * far fewer calls: they bound a cache miss rather than every request.\n */\nfunction buildAuthorizer<TRequest>(\n client: Canopy,\n options: CanopyModuleOptions<TRequest>,\n): LocalAuthorizer {\n return new LocalAuthorizer(client.client, {\n ...(options.authorizationTtlMs === undefined\n ? {}\n : { ttlMs: options.authorizationTtlMs }),\n timeoutMs: options.evaluateTimeoutMs ?? DEFAULT_EVALUATE_TIMEOUT_MS,\n maxRetries: options.evaluateMaxRetries ?? DEFAULT_EVALUATE_MAX_RETRIES,\n });\n}\n\n@Module({})\nexport class CanopyModule {\n static forRoot<TRequest = unknown>(\n options: CanopyModuleOptions<TRequest>,\n ): DynamicModule {\n const providers: Provider[] = [\n { provide: CANOPY_OPTIONS, useValue: options },\n {\n provide: CANOPY_CLIENT,\n useFactory: () => new Canopy(options),\n },\n {\n provide: CANOPY_TOKEN_VERIFIER,\n useFactory: () => new TokenVerifier(options.verify),\n },\n {\n provide: CANOPY_AUTHORIZER,\n useFactory: (client: Canopy) => buildAuthorizer(client, options),\n inject: [CANOPY_CLIENT],\n },\n CanopyGuard,\n CanopyTokenGuard,\n ];\n\n return {\n module: CanopyModule,\n global: options.isGlobal ?? false,\n providers,\n exports: [\n CANOPY_CLIENT,\n CANOPY_OPTIONS,\n CANOPY_TOKEN_VERIFIER,\n CANOPY_AUTHORIZER,\n CanopyGuard,\n CanopyTokenGuard,\n ],\n };\n }\n\n /**\n * The same wiring when the API key comes from something injectable, such as\n * `ConfigService`, rather than being available at module-definition time.\n */\n static forRootAsync<TRequest = unknown>(\n options: CanopyModuleAsyncOptions<TRequest>,\n ): DynamicModule {\n const providers: Provider[] = [\n {\n provide: CANOPY_OPTIONS,\n useFactory: options.useFactory,\n inject: options.inject ?? [],\n },\n {\n provide: CANOPY_CLIENT,\n useFactory: (resolved: CanopyModuleOptions<TRequest>) =>\n new Canopy(resolved),\n inject: [CANOPY_OPTIONS],\n },\n {\n provide: CANOPY_TOKEN_VERIFIER,\n useFactory: (resolved: CanopyModuleOptions<TRequest>) =>\n new TokenVerifier(resolved.verify),\n inject: [CANOPY_OPTIONS],\n },\n {\n provide: CANOPY_AUTHORIZER,\n useFactory: (client: Canopy, resolved: CanopyModuleOptions<TRequest>) =>\n buildAuthorizer(client, resolved),\n inject: [CANOPY_CLIENT, CANOPY_OPTIONS],\n },\n CanopyGuard,\n CanopyTokenGuard,\n ];\n\n return {\n module: CanopyModule,\n global: options.isGlobal ?? false,\n // Spread rather than assigned: under exactOptionalPropertyTypes an\n // explicit `imports: undefined` is not the same as no `imports` at all.\n ...(options.imports ? { imports: options.imports } : {}),\n providers,\n exports: [\n CANOPY_CLIENT,\n CANOPY_OPTIONS,\n CANOPY_TOKEN_VERIFIER,\n CANOPY_AUTHORIZER,\n CanopyGuard,\n CanopyTokenGuard,\n ],\n };\n }\n}\n","import { Inject } from \"@nestjs/common\";\n\nimport { CANOPY_CLIENT } from \"./tokens.js\";\n\n/**\n * Injects the configured client.\n *\n * ```ts\n * constructor(@InjectCanopy() private readonly canopy: Canopy) {}\n * ```\n *\n * Named rather than resolved by type because this package is bundled with\n * esbuild, which emits no `design:paramtypes` — so type-only injection would\n * fail at runtime with a confusing \"cannot resolve dependency\" rather than at\n * build time.\n */\nexport const InjectCanopy = (): ParameterDecorator => Inject(CANOPY_CLIENT);\n","import { SetMetadata } from \"@nestjs/common\";\n\nimport { CANOPY_PERMISSION } from \"./tokens.js\";\n\n/**\n * Which question the guard asks Canopy.\n *\n * `node` walks the node's lineage, so a role granted on an ancestor is\n * inherited at the node being touched. `app_wide` asks only whether the\n * identity holds the permission anywhere in the Environment. `org` is the\n * node question asked at the organization the caller's token is acting in —\n * the `org_id` claim — so the route needs no `resolveNode` and no node in\n * its path.\n */\nexport type PermissionScope = \"node\" | \"app_wide\" | \"org\";\n\n/** What `@RequirePermission` records for the guard to read back. */\nexport interface PermissionRequirement {\n readonly permission: string;\n readonly scope: PermissionScope;\n}\n\nexport interface RequirePermissionOptions {\n /**\n * Defaults to `node`.\n *\n * `app_wide` is opt-in on purpose. It answers a coarser question and returns\n * no effective node, which makes it right for deciding whether to render a\n * menu item and wrong for guarding a resource that belongs to one — a\n * distinction that is easy to get backwards if the looser check is the\n * default.\n *\n * `org` evaluates at the organization on the caller's verified token\n * (`org_id`), read from the claims `CanopyTokenGuard` attaches or from\n * `resolveOrg` if configured. A caller acting in no organization is denied\n * — an org-scoped route has no meaning outside one.\n */\n readonly scope?: PermissionScope;\n}\n\n/**\n * Require a Canopy permission before a route runs.\n *\n * ```ts\n * @UseGuards(CanopyGuard)\n * @RequirePermission(\"orders.refund\")\n * @Post(\":orderId/refund\")\n * refund() {}\n * ```\n *\n * Applies to a single handler or to a whole controller; a handler's own\n * requirement wins over its controller's.\n */\nexport const RequirePermission = (\n permission: string,\n options: RequirePermissionOptions = {},\n): MethodDecorator & ClassDecorator =>\n SetMetadata<string, PermissionRequirement>(CANOPY_PERMISSION, {\n permission,\n scope: options.scope ?? \"node\",\n });\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@canopy-io/nestjs",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Official NestJS integration for Canopy — hierarchical identity and access management.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Canopy Identity Inc.",
|
|
@@ -16,8 +16,13 @@
|
|
|
16
16
|
"nestjs",
|
|
17
17
|
"authorization",
|
|
18
18
|
"rbac",
|
|
19
|
+
"hierarchical-rbac",
|
|
20
|
+
"access-control",
|
|
19
21
|
"iam",
|
|
20
22
|
"permissions",
|
|
23
|
+
"multi-tenant",
|
|
24
|
+
"b2b-saas",
|
|
25
|
+
"sso",
|
|
21
26
|
"guard"
|
|
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": {
|
|
@@ -56,7 +62,7 @@
|
|
|
56
62
|
"test:watch": "vitest"
|
|
57
63
|
},
|
|
58
64
|
"dependencies": {
|
|
59
|
-
"@canopy-io/node": "^0.
|
|
65
|
+
"@canopy-io/node": "^0.3.3"
|
|
60
66
|
},
|
|
61
67
|
"peerDependencies": {
|
|
62
68
|
"@nestjs/common": "^10.0.0 || ^11.0.0",
|