@plantops/auth-kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +7 -0
  2. package/dist/adapters/fetch/index.d.ts +38 -0
  3. package/dist/adapters/fetch/index.d.ts.map +1 -0
  4. package/dist/adapters/fetch/index.js +52 -0
  5. package/dist/adapters/nestjs/auth.guard.d.ts +120 -0
  6. package/dist/adapters/nestjs/auth.guard.d.ts.map +1 -0
  7. package/dist/adapters/nestjs/auth.guard.js +165 -0
  8. package/dist/adapters/nestjs/index.d.ts +10 -0
  9. package/dist/adapters/nestjs/index.d.ts.map +1 -0
  10. package/dist/adapters/nestjs/index.js +12 -0
  11. package/dist/adapters/nestjs/permission.guard.d.ts +115 -0
  12. package/dist/adapters/nestjs/permission.guard.d.ts.map +1 -0
  13. package/dist/adapters/nestjs/permission.guard.js +167 -0
  14. package/dist/adapters/nestjs/require-permission.decorator.d.ts +31 -0
  15. package/dist/adapters/nestjs/require-permission.decorator.d.ts.map +1 -0
  16. package/dist/adapters/nestjs/require-permission.decorator.js +41 -0
  17. package/dist/adapters/nestjs/scope-resolver.d.ts +12 -0
  18. package/dist/adapters/nestjs/scope-resolver.d.ts.map +1 -0
  19. package/dist/adapters/nestjs/scope-resolver.js +24 -0
  20. package/dist/core/claims.d.ts +114 -0
  21. package/dist/core/claims.d.ts.map +1 -0
  22. package/dist/core/claims.js +183 -0
  23. package/dist/core/index.d.ts +13 -0
  24. package/dist/core/index.d.ts.map +1 -0
  25. package/dist/core/index.js +15 -0
  26. package/dist/core/jwks-verifier.d.ts +78 -0
  27. package/dist/core/jwks-verifier.d.ts.map +1 -0
  28. package/dist/core/jwks-verifier.js +183 -0
  29. package/dist/core/jws.d.ts +96 -0
  30. package/dist/core/jws.d.ts.map +1 -0
  31. package/dist/core/jws.js +183 -0
  32. package/dist/core/revocation-cache.d.ts +79 -0
  33. package/dist/core/revocation-cache.d.ts.map +1 -0
  34. package/dist/core/revocation-cache.js +68 -0
  35. package/dist/core/scope-resolver.d.ts +235 -0
  36. package/dist/core/scope-resolver.d.ts.map +1 -0
  37. package/dist/core/scope-resolver.js +206 -0
  38. package/dist/index.d.ts +16 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/index.js +18 -0
  41. package/dist/tsconfig.lib.tsbuildinfo +1 -0
  42. package/package.json +64 -0
@@ -0,0 +1,68 @@
1
+ "use strict";
2
+ /**
3
+ * The revoked-`sid` cache (Doc 03 §6).
4
+ *
5
+ * Access tokens are verified by signature, which means a revoked session's
6
+ * token keeps verifying until it expires. That is unacceptable for the case the
7
+ * spec singles out — a shared gate terminal logged out at shift end — so every
8
+ * request also asks whether its `sid` has been killed. The answer has to be
9
+ * cheap enough to ask on every request, which rules out the database.
10
+ *
11
+ * ## One key per revocation, not one set
12
+ *
13
+ * Doc 03 §6 says "a Redis set / short-TTL cache". A set is the wrong half of
14
+ * that: it grows forever and has no per-member expiry, so it must be pruned by
15
+ * something, and the day that something stops running is the day the set is
16
+ * either unbounded or silently emptied. One key per revoked `sid`, with a TTL,
17
+ * prunes itself.
18
+ *
19
+ * The TTL is the **remaining exposure**, not the session lifetime. Once every
20
+ * token bearing a `sid` has expired, the revocation entry protects nothing:
21
+ * such a token is already refused for `exp`. So the entry lives for one access
22
+ * token lifetime plus the clock-skew leeway, and then disappears. This is what
23
+ * keeps the cache proportional to *recent* revocations rather than to all of
24
+ * them.
25
+ *
26
+ * ## What a cache miss means, and what an outage means
27
+ *
28
+ * A miss means "not revoked" — that is the happy path and it is DB-free, which
29
+ * is the whole design. An *error* means something different and must not be
30
+ * confused with it: a Redis outage that reads as "not revoked" silently
31
+ * un-revokes every session in the system for the duration. So this class throws
32
+ * on failure rather than returning `false`, and {@link AuthGuard} decides —
33
+ * with the database as the authority where one is reachable.
34
+ *
35
+ * There remains one honest gap: if Redis is *up* but has lost the key (an
36
+ * eviction, a flush, a restart without persistence), a revoked session works
37
+ * until its access token expires. Doc 03 §6 accepts exactly that bound —
38
+ * "because access tokens are short-lived, the revocation window is bounded even
39
+ * without a per-request DB hit". The database row stays authoritative, and the
40
+ * next refresh (Session 9) consults it.
41
+ */
42
+ Object.defineProperty(exports, "__esModule", { value: true });
43
+ exports.RevocationCache = void 0;
44
+ const contracts_1 = require("@plantops/contracts");
45
+ class RevocationCache {
46
+ constructor(store, options) {
47
+ this.store = store;
48
+ this.options = options;
49
+ }
50
+ /**
51
+ * Marks a session dead for every verifier sharing this cache.
52
+ *
53
+ * Call **after** the database write commits. Publishing first would let a
54
+ * concurrent reader see a revoked session that a rollback then restores — the
55
+ * same post-commit ordering the scope-move invalidation requires (Doc 07 §7).
56
+ */
57
+ async revoke(sessionId) {
58
+ // The value is a marker; only the key's existence carries meaning. `EX`
59
+ // rather than a separate `EXPIRE` so a connection lost between two commands
60
+ // cannot leave an immortal entry behind.
61
+ await this.store.set((0, contracts_1.revokedSessionKey)(sessionId), '1', 'EX', this.options.ttlSeconds);
62
+ }
63
+ /** @throws when the store cannot answer — see the class comment. */
64
+ async isRevoked(sessionId) {
65
+ return (await this.store.exists((0, contracts_1.revokedSessionKey)(sessionId))) > 0;
66
+ }
67
+ }
68
+ exports.RevocationCache = RevocationCache;
@@ -0,0 +1,235 @@
1
+ /**
2
+ * The WHERE dimension, as functions — coverage, query narrowing, and the
3
+ * decision {@link PermissionGuard} enforces (Doc 04 §3–5, §9, Doc 08 §4).
4
+ *
5
+ * ## Why the algorithm lives in a library and not in the IAM
6
+ *
7
+ * Doc 08 §7 singles this out: *"`auth-kit`: unit tests for coverage/scope logic
8
+ * (the highest-risk code — test the `covers()` prefix logic and deny-by-default
9
+ * thoroughly)"*. The reason is that the IAM is not the only process that runs
10
+ * it. `apps/gatepass-api` will answer "may this guard approve at this gate?"
11
+ * from a `ResolvedGrants` it fetched over HTTP, with no database and no IAM code
12
+ * in the request path — and if it re-implements coverage, the two disagree about
13
+ * what lies beneath what, and the artefact of the disagreement is a subject
14
+ * reaching a gate nobody granted them.
15
+ *
16
+ * So the prefix test, the point check and the `allowedPaths` projection are
17
+ * here, as pure functions over the published {@link ResolvedGrants} shape, and
18
+ * everything that needs a database sits behind {@link GrantsSource}.
19
+ *
20
+ * ## Coverage is label-wise, always
21
+ *
22
+ * `covers(N_b, N_t) ⇔ N_t.path <@ N_b.path` (Doc 04 §3). A character-wise
23
+ * `startsWith` would report `n_ab.n_cd` as living under `n_a` — the class of bug
24
+ * that grants access to a sibling subtree — so {@link pathIsWithin} compares on
25
+ * the separator, exactly as ltree's `<@` and migration 0006's GiST index do.
26
+ * `apps/iam-api`'s `scopes/path.util.ts` delegates to this function rather than
27
+ * carrying a second copy.
28
+ *
29
+ * ## The asymmetry of Doc 04 §9 is visible in the code that implements it
30
+ *
31
+ * {@link coversPath} asks two questions in the order the spec asks them: the
32
+ * permission dimension by **exact membership** (holding `dc.approve` says
33
+ * nothing about `dc.create`), and only then the scope dimension, which
34
+ * **inherits** (an ancestor covers its subtree). Merging them into one pass over
35
+ * `scopes` would return the same booleans and would stop the asymmetry being
36
+ * legible — which is how it gets simplified away later.
37
+ *
38
+ * ## Deny-by-default is the absence of a special case
39
+ *
40
+ * A subject with no bindings resolves to `{ permissions: [], scopes: {} }`, and
41
+ * every function below answers `false` or `[]` for it without a branch that says
42
+ * so. That is deliberate: a deny-by-default written as an explicit early return
43
+ * is a deny-by-default somebody can delete.
44
+ */
45
+ import type { JwtClaims, PermissionKey, ResolvedGrants, ScopePath } from '@plantops/contracts';
46
+ /**
47
+ * The part of a token authorization actually reads: who, of what type, in which
48
+ * tenant, on which session.
49
+ *
50
+ * Narrower than {@link JwtClaims} on purpose. `iss`, `iat` and `exp` are the
51
+ * verifier's business and are finished with by the time this runs, and a port
52
+ * that asked for them would oblige a host to carry them further than it needs
53
+ * to — the IAM in particular hands its RLS layer exactly these four fields
54
+ * (Doc 07 §5) and nothing else.
55
+ */
56
+ export type SubjectClaims = Pick<JwtClaims, 'sub' | 'sty' | 'cid' | 'sid'>;
57
+ /**
58
+ * `path <@ prefix` — is `path` the prefix itself, or a descendant of it?
59
+ *
60
+ * The one definition of "beneath" in the workspace.
61
+ */
62
+ export declare function pathIsWithin(path: ScopePath, prefix: ScopePath): boolean;
63
+ /** Does the subject hold `permission` at all — the WHAT, on its own? */
64
+ export declare function holdsPermission(grants: ResolvedGrants, permission: PermissionKey): boolean;
65
+ /**
66
+ * `can(subject, permissionKey, targetPath)` — Doc 04 §4.2's point check.
67
+ *
68
+ * O(covering paths for that permission), which the minimization Doc 04 §4.1
69
+ * requires keeps small.
70
+ */
71
+ export declare function coversPath(grants: ResolvedGrants, permission: PermissionKey, targetPath: ScopePath): boolean;
72
+ /**
73
+ * The covered subtree roots for one permission — Doc 04 §5's query narrowing.
74
+ *
75
+ * ```sql
76
+ * WHERE visitor.gate_path <@ ANY($1)
77
+ * ```
78
+ *
79
+ * This is the practical payoff of storing scope as a path: a module filters a
80
+ * list endpoint with one predicate instead of asking the IAM per row. The array
81
+ * is already minimal (Doc 04 §4.1), so it is short even for a subject with many
82
+ * bindings, and an empty one is the honest answer for a subject who may see
83
+ * nothing — a module must treat `[]` as "no rows", never as "no filter".
84
+ */
85
+ export declare function allowedPathsFor(grants: ResolvedGrants, permission: PermissionKey): ScopePath[];
86
+ /** What the resolver concluded about one request. */
87
+ export declare const AuthorizationOutcome: {
88
+ readonly ALLOWED: "allowed";
89
+ /** The subject does not hold the permission anywhere. */
90
+ readonly PERMISSION_DENIED: "permission_denied";
91
+ /** They hold it, but not over the node the request named. */
92
+ readonly SCOPE_DENIED: "scope_denied";
93
+ };
94
+ export type AuthorizationOutcome = (typeof AuthorizationOutcome)[keyof typeof AuthorizationOutcome];
95
+ export interface AuthorizationDecision {
96
+ outcome: AuthorizationOutcome;
97
+ /**
98
+ * What was asked for — carried through so the denial audit can name it.
99
+ *
100
+ * A list because a route may admit any one of several keys
101
+ * (`require-permission.decorator.ts`), which on this surface is Doc 06 §12
102
+ * and nothing else. Which keys are named depends on where the decision was
103
+ * reached, and the difference is what makes the trail readable:
104
+ *
105
+ * - **`permission_denied`** — every key the route would have accepted, since
106
+ * the subject held none of them and all of them are the answer to "what
107
+ * would have let this through".
108
+ * - **`scope_denied`** — only the keys the subject actually *holds*, because
109
+ * those are the ones the coverage test ran against; listing a key they
110
+ * never held would misreport a scope refusal as being about a permission
111
+ * that was never in play.
112
+ * - **`allowed`** — the key that admitted, alone.
113
+ */
114
+ permissions: readonly PermissionKey[];
115
+ /** The scope node the request named, where it named one. */
116
+ scopeNodeId?: string;
117
+ }
118
+ /**
119
+ * A subject's grants, and the path of the node a request named.
120
+ *
121
+ * Both come back from one call because they come from one connection: in the
122
+ * IAM the grants may need a resolve against Postgres and the path always does,
123
+ * and opening two connections for one authorization decision is two acquisitions
124
+ * on the hot path (`docs/adr/0001-permission-guard-connection-strategy.md`).
125
+ */
126
+ export interface AuthorizationSnapshot {
127
+ grants: ResolvedGrants;
128
+ /**
129
+ * Present only when a `scopeNodeId` was asked for. `null` means no such node
130
+ * is visible to this subject — which is deliberately the same answer for a
131
+ * node that does not exist and a node belonging to another tenant, so that a
132
+ * 403 cannot be used as a cross-tenant existence oracle (Doc 06 §2).
133
+ */
134
+ targetPath?: ScopePath | null;
135
+ }
136
+ /**
137
+ * Where resolved grants come from.
138
+ *
139
+ * The IAM binds the resolution engine of Doc 04 §4 directly; a future module
140
+ * binds a cached `iam-client` call to `/iam/permissions/resolve` (Doc 08 §4).
141
+ * Neither shape is visible from here, which is the whole point of the port —
142
+ * `auth-kit` may depend on `@plantops/contracts` and nothing else (Doc 08 §2),
143
+ * so it cannot name a `DataSource` or an HTTP client even if it wanted to.
144
+ */
145
+ export interface GrantsSource {
146
+ /**
147
+ * @param claims the verified claims of the request being authorized.
148
+ * @param scopeNodeId when given, also resolve this node's path under the
149
+ * subject's own tenant.
150
+ */
151
+ authorize(claims: SubjectClaims, scopeNodeId?: string): Promise<AuthorizationSnapshot>;
152
+ }
153
+ export declare const GRANTS_SOURCE: unique symbol;
154
+ /**
155
+ * The authorization decisions Doc 04 §8 orders, answerable without any
156
+ * framework: construct it with anything that satisfies {@link GrantsSource}
157
+ * — the IAM\u2019s Nest binding, an HTTP call to `/iam/permissions/resolve`, a
158
+ * fixture in a test — and every method behaves identically.
159
+ *
160
+ * The NestJS adapter (`adapters/nestjs`) subclasses this only to receive its
161
+ * dependency through Nest\u2019s injector; the rule itself lives here, where it
162
+ * can be exercised by a plain Node script.
163
+ */
164
+ export declare class ScopeResolverCore {
165
+ private readonly source;
166
+ constructor(source: GrantsSource);
167
+ /** The subject\u2019s complete grant set (Doc 04 §4.1). */
168
+ grantsFor(claims: SubjectClaims): Promise<ResolvedGrants>;
169
+ /** Doc 04 §5\u2019s `allowedPaths`, for query narrowing. */
170
+ allowedPaths(claims: SubjectClaims, permission: PermissionKey): Promise<ScopePath[]>;
171
+ /** Doc 04 §4.2\u2019s point check against a node named by id. */
172
+ covers(claims: SubjectClaims, permission: PermissionKey, scopeNodeId: string): Promise<boolean>;
173
+ /**
174
+ * Hold the permission first, then cover the node — Doc 04 §8\u2019s order, which
175
+ * decides which denial the caller gets: PERMISSION_DENIED for a subject who
176
+ * holds nothing, SCOPE_DENIED for one who holds it elsewhere.
177
+ *
178
+ * A node invisible under this subject\u2019s own RLS context reports
179
+ * `targetPath: null`, and the decision is ALLOWED: invisibility is the
180
+ * handler\u2019s 404/409 to give, not a 403 that turns every scoped route into
181
+ * a cross-tenant existence oracle. What the guard refuses is exactly what
182
+ * only it can see — a reachable node that is not covered.
183
+ */
184
+ decide(claims: SubjectClaims, requirement: PermissionRequirement, scopeNodeId?: string): Promise<AuthorizationDecision>;
185
+ }
186
+ /** Where {@link PermissionGuard} reads a route\u2019s requirement from. */
187
+ export declare const REQUIRE_PERMISSION_METADATA = "auth-kit:require-permission";
188
+ /** Where it reads a route\u2019s deliberate opt-out from. */
189
+ export declare const NO_PERMISSION_METADATA = "auth-kit:no-permission";
190
+ export interface RequirePermissionOptions {
191
+ /**
192
+ * Dotted path to the target **scope node id** in the request, e.g.
193
+ * `params.id`, `body.scope_node_id`. Omitted where the route names no node.
194
+ */
195
+ scopeFrom?: string;
196
+ /**
197
+ * Treat an absent value at `scopeFrom` as "no node named" rather than as a
198
+ * denial.
199
+ *
200
+ * Off by default, and the default is the safe one: a route that says it takes
201
+ * a target and then receives none has nothing to run the coverage test
202
+ * against, and letting it through would silently downgrade a scoped check to
203
+ * an unscoped one. Turn it on only where absence is a legitimate shape of the
204
+ * request — `POST /iam/scopes` creating a tenant\u2019s first root has no parent
205
+ * to be covered by, and there is no other node it could name.
206
+ */
207
+ scopeOptional?: boolean;
208
+ }
209
+ /** What a route declares about itself. */
210
+ export interface PermissionRequirement extends RequirePermissionOptions {
211
+ /**
212
+ * The keys that admit, any one of which is enough. One on all but a single
213
+ * route — the decorator\u2019s header has the full argument.
214
+ *
215
+ * Normalized to an array by the decorator so that everything downstream —
216
+ * `ScopeResolver.decide`, the guard, the denial audit — has one shape to
217
+ * handle rather than a union it must narrow at each step.
218
+ */
219
+ permissions: readonly PermissionKey[];
220
+ }
221
+ /**
222
+ * Reads `path` out of `request`, one segment at a time.
223
+ *
224
+ * Traversal stops at anything that is not a plain object, and the well-known
225
+ * prototype keys are refused outright: `scopeFrom` is authored in a controller
226
+ * rather than sent by a caller, but a decorator string is still a string, and a
227
+ * lookup that could walk into `__proto__` is one that would resolve to
228
+ * something no request contains.
229
+ *
230
+ * Returns `undefined` for anything that is not a non-empty string, so a missing
231
+ * parameter and a `null` body field are the same "no node named" — the caller
232
+ * chose neither.
233
+ */
234
+ export declare function readScopeTarget(request: unknown, path: string): string | undefined;
235
+ //# sourceMappingURL=scope-resolver.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scope-resolver.d.ts","sourceRoot":"","sources":["../../src/core/scope-resolver.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,206 @@
1
+ "use strict";
2
+ /**
3
+ * The WHERE dimension, as functions — coverage, query narrowing, and the
4
+ * decision {@link PermissionGuard} enforces (Doc 04 §3–5, §9, Doc 08 §4).
5
+ *
6
+ * ## Why the algorithm lives in a library and not in the IAM
7
+ *
8
+ * Doc 08 §7 singles this out: *"`auth-kit`: unit tests for coverage/scope logic
9
+ * (the highest-risk code — test the `covers()` prefix logic and deny-by-default
10
+ * thoroughly)"*. The reason is that the IAM is not the only process that runs
11
+ * it. `apps/gatepass-api` will answer "may this guard approve at this gate?"
12
+ * from a `ResolvedGrants` it fetched over HTTP, with no database and no IAM code
13
+ * in the request path — and if it re-implements coverage, the two disagree about
14
+ * what lies beneath what, and the artefact of the disagreement is a subject
15
+ * reaching a gate nobody granted them.
16
+ *
17
+ * So the prefix test, the point check and the `allowedPaths` projection are
18
+ * here, as pure functions over the published {@link ResolvedGrants} shape, and
19
+ * everything that needs a database sits behind {@link GrantsSource}.
20
+ *
21
+ * ## Coverage is label-wise, always
22
+ *
23
+ * `covers(N_b, N_t) ⇔ N_t.path <@ N_b.path` (Doc 04 §3). A character-wise
24
+ * `startsWith` would report `n_ab.n_cd` as living under `n_a` — the class of bug
25
+ * that grants access to a sibling subtree — so {@link pathIsWithin} compares on
26
+ * the separator, exactly as ltree's `<@` and migration 0006's GiST index do.
27
+ * `apps/iam-api`'s `scopes/path.util.ts` delegates to this function rather than
28
+ * carrying a second copy.
29
+ *
30
+ * ## The asymmetry of Doc 04 §9 is visible in the code that implements it
31
+ *
32
+ * {@link coversPath} asks two questions in the order the spec asks them: the
33
+ * permission dimension by **exact membership** (holding `dc.approve` says
34
+ * nothing about `dc.create`), and only then the scope dimension, which
35
+ * **inherits** (an ancestor covers its subtree). Merging them into one pass over
36
+ * `scopes` would return the same booleans and would stop the asymmetry being
37
+ * legible — which is how it gets simplified away later.
38
+ *
39
+ * ## Deny-by-default is the absence of a special case
40
+ *
41
+ * A subject with no bindings resolves to `{ permissions: [], scopes: {} }`, and
42
+ * every function below answers `false` or `[]` for it without a branch that says
43
+ * so. That is deliberate: a deny-by-default written as an explicit early return
44
+ * is a deny-by-default somebody can delete.
45
+ */
46
+ Object.defineProperty(exports, "__esModule", { value: true });
47
+ exports.NO_PERMISSION_METADATA = exports.REQUIRE_PERMISSION_METADATA = exports.ScopeResolverCore = exports.GRANTS_SOURCE = exports.AuthorizationOutcome = void 0;
48
+ exports.pathIsWithin = pathIsWithin;
49
+ exports.holdsPermission = holdsPermission;
50
+ exports.coversPath = coversPath;
51
+ exports.allowedPathsFor = allowedPathsFor;
52
+ exports.readScopeTarget = readScopeTarget;
53
+ /** The ltree label separator. Spelled once. */
54
+ const SEPARATOR = '.';
55
+ /**
56
+ * `path <@ prefix` — is `path` the prefix itself, or a descendant of it?
57
+ *
58
+ * The one definition of "beneath" in the workspace.
59
+ */
60
+ function pathIsWithin(path, prefix) {
61
+ return path === prefix || path.startsWith(`${prefix}${SEPARATOR}`);
62
+ }
63
+ /** Does the subject hold `permission` at all — the WHAT, on its own? */
64
+ function holdsPermission(grants, permission) {
65
+ return grants.permissions.includes(permission);
66
+ }
67
+ /**
68
+ * `can(subject, permissionKey, targetPath)` — Doc 04 §4.2's point check.
69
+ *
70
+ * O(covering paths for that permission), which the minimization Doc 04 §4.1
71
+ * requires keeps small.
72
+ */
73
+ function coversPath(grants, permission, targetPath) {
74
+ if (!holdsPermission(grants, permission))
75
+ return false;
76
+ const covering = grants.scopes[permission];
77
+ return covering !== undefined && covering.some((path) => pathIsWithin(targetPath, path));
78
+ }
79
+ /**
80
+ * The covered subtree roots for one permission — Doc 04 §5's query narrowing.
81
+ *
82
+ * ```sql
83
+ * WHERE visitor.gate_path <@ ANY($1)
84
+ * ```
85
+ *
86
+ * This is the practical payoff of storing scope as a path: a module filters a
87
+ * list endpoint with one predicate instead of asking the IAM per row. The array
88
+ * is already minimal (Doc 04 §4.1), so it is short even for a subject with many
89
+ * bindings, and an empty one is the honest answer for a subject who may see
90
+ * nothing — a module must treat `[]` as "no rows", never as "no filter".
91
+ */
92
+ function allowedPathsFor(grants, permission) {
93
+ return grants.scopes[permission] ?? [];
94
+ }
95
+ /** What the resolver concluded about one request. */
96
+ exports.AuthorizationOutcome = {
97
+ ALLOWED: 'allowed',
98
+ /** The subject does not hold the permission anywhere. */
99
+ PERMISSION_DENIED: 'permission_denied',
100
+ /** They hold it, but not over the node the request named. */
101
+ SCOPE_DENIED: 'scope_denied',
102
+ };
103
+ exports.GRANTS_SOURCE = Symbol('auth-kit:GrantsSource');
104
+ /**
105
+ * The authorization decisions Doc 04 §8 orders, answerable without any
106
+ * framework: construct it with anything that satisfies {@link GrantsSource}
107
+ * — the IAM\u2019s Nest binding, an HTTP call to `/iam/permissions/resolve`, a
108
+ * fixture in a test — and every method behaves identically.
109
+ *
110
+ * The NestJS adapter (`adapters/nestjs`) subclasses this only to receive its
111
+ * dependency through Nest\u2019s injector; the rule itself lives here, where it
112
+ * can be exercised by a plain Node script.
113
+ */
114
+ class ScopeResolverCore {
115
+ constructor(source) {
116
+ this.source = source;
117
+ }
118
+ /** The subject\u2019s complete grant set (Doc 04 §4.1). */
119
+ async grantsFor(claims) {
120
+ const { grants } = await this.source.authorize(claims);
121
+ return grants;
122
+ }
123
+ /** Doc 04 §5\u2019s `allowedPaths`, for query narrowing. */
124
+ async allowedPaths(claims, permission) {
125
+ return allowedPathsFor(await this.grantsFor(claims), permission);
126
+ }
127
+ /** Doc 04 §4.2\u2019s point check against a node named by id. */
128
+ async covers(claims, permission, scopeNodeId) {
129
+ const snapshot = await this.source.authorize(claims, scopeNodeId);
130
+ const targetPath = snapshot.targetPath ?? null;
131
+ return targetPath !== null && coversPath(snapshot.grants, permission, targetPath);
132
+ }
133
+ /**
134
+ * Hold the permission first, then cover the node — Doc 04 §8\u2019s order, which
135
+ * decides which denial the caller gets: PERMISSION_DENIED for a subject who
136
+ * holds nothing, SCOPE_DENIED for one who holds it elsewhere.
137
+ *
138
+ * A node invisible under this subject\u2019s own RLS context reports
139
+ * `targetPath: null`, and the decision is ALLOWED: invisibility is the
140
+ * handler\u2019s 404/409 to give, not a 403 that turns every scoped route into
141
+ * a cross-tenant existence oracle. What the guard refuses is exactly what
142
+ * only it can see — a reachable node that is not covered.
143
+ */
144
+ async decide(claims, requirement, scopeNodeId) {
145
+ const scoped = requirement.scopeFrom !== undefined && scopeNodeId !== undefined;
146
+ const snapshot = await this.source.authorize(claims, scoped ? scopeNodeId : undefined);
147
+ const at = (scoped ? { scopeNodeId } : {});
148
+ // Held first, covered second — Doc 04 §9\u2019s asymmetry, over however many
149
+ // keys the route admits.
150
+ const held = requirement.permissions.filter((permission) => holdsPermission(snapshot.grants, permission));
151
+ if (held.length === 0) {
152
+ return {
153
+ ...at,
154
+ permissions: requirement.permissions,
155
+ outcome: exports.AuthorizationOutcome.PERMISSION_DENIED,
156
+ };
157
+ }
158
+ if (requirement.scopeFrom !== undefined && scopeNodeId === undefined) {
159
+ return requirement.scopeOptional
160
+ ? { ...at, permissions: [held[0]], outcome: exports.AuthorizationOutcome.ALLOWED }
161
+ : { ...at, permissions: held, outcome: exports.AuthorizationOutcome.SCOPE_DENIED };
162
+ }
163
+ if (!scoped) {
164
+ return { ...at, permissions: [held[0]], outcome: exports.AuthorizationOutcome.ALLOWED };
165
+ }
166
+ const targetPath = snapshot.targetPath ?? null;
167
+ if (targetPath === null) {
168
+ return { ...at, permissions: [held[0]], outcome: exports.AuthorizationOutcome.ALLOWED };
169
+ }
170
+ const covering = held.find((permission) => coversPath(snapshot.grants, permission, targetPath));
171
+ return covering === undefined
172
+ ? { ...at, permissions: held, outcome: exports.AuthorizationOutcome.SCOPE_DENIED }
173
+ : { ...at, permissions: [covering], outcome: exports.AuthorizationOutcome.ALLOWED };
174
+ }
175
+ }
176
+ exports.ScopeResolverCore = ScopeResolverCore;
177
+ /** Where {@link PermissionGuard} reads a route\u2019s requirement from. */
178
+ exports.REQUIRE_PERMISSION_METADATA = 'auth-kit:require-permission';
179
+ /** Where it reads a route\u2019s deliberate opt-out from. */
180
+ exports.NO_PERMISSION_METADATA = 'auth-kit:no-permission';
181
+ /**
182
+ * Reads `path` out of `request`, one segment at a time.
183
+ *
184
+ * Traversal stops at anything that is not a plain object, and the well-known
185
+ * prototype keys are refused outright: `scopeFrom` is authored in a controller
186
+ * rather than sent by a caller, but a decorator string is still a string, and a
187
+ * lookup that could walk into `__proto__` is one that would resolve to
188
+ * something no request contains.
189
+ *
190
+ * Returns `undefined` for anything that is not a non-empty string, so a missing
191
+ * parameter and a `null` body field are the same "no node named" — the caller
192
+ * chose neither.
193
+ */
194
+ function readScopeTarget(request, path) {
195
+ const segments = path.split('.');
196
+ let current = request;
197
+ for (const segment of segments) {
198
+ if (segment === '__proto__' || segment === 'constructor' || segment === 'prototype') {
199
+ return undefined;
200
+ }
201
+ if (typeof current !== 'object' || current === null)
202
+ return undefined;
203
+ current = current[segment];
204
+ }
205
+ return typeof current === 'string' && current.trim() !== '' ? current : undefined;
206
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * `@plantops/auth-kit` — authorization for everything that talks to the IAM
3
+ * (Doc 08 §4), in three layers after roadmap Session 50:
4
+ *
5
+ * - **`core/**`** — framework-free. Token verification, the closed claim set,
6
+ * revocation caching and the pure scope-coverage vocabulary. Runs in Nest,
7
+ * in a plain Node script, in a Next.js route handler — anywhere.
8
+ * - **`adapters/nestjs`** — the guards, decorators and injectable resolver,
9
+ * byte-for-byte the surface the IAM has always had.
10
+ * - **`adapters/fetch`** — a `Request`-in, decision-out helper for Next.js
11
+ * route handlers and any WinterCG runtime.
12
+ */
13
+ export * from './core';
14
+ export * from './adapters/nestjs';
15
+ export * from './adapters/fetch';
16
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,cAAc,QAAQ,CAAC;AACvB,cAAc,mBAAmB,CAAC;AAClC,cAAc,kBAAkB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ /**
3
+ * `@plantops/auth-kit` — authorization for everything that talks to the IAM
4
+ * (Doc 08 §4), in three layers after roadmap Session 50:
5
+ *
6
+ * - **`core/**`** — framework-free. Token verification, the closed claim set,
7
+ * revocation caching and the pure scope-coverage vocabulary. Runs in Nest,
8
+ * in a plain Node script, in a Next.js route handler — anywhere.
9
+ * - **`adapters/nestjs`** — the guards, decorators and injectable resolver,
10
+ * byte-for-byte the surface the IAM has always had.
11
+ * - **`adapters/fetch`** — a `Request`-in, decision-out helper for Next.js
12
+ * route handlers and any WinterCG runtime.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ const tslib_1 = require("tslib");
16
+ tslib_1.__exportStar(require("./core"), exports);
17
+ tslib_1.__exportStar(require("./adapters/nestjs"), exports);
18
+ tslib_1.__exportStar(require("./adapters/fetch"), exports);