@ontrails/permits 1.0.0-beta.12

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 (69) hide show
  1. package/.turbo/turbo-build.log +1 -0
  2. package/.turbo/turbo-lint.log +3 -0
  3. package/.turbo/turbo-typecheck.log +1 -0
  4. package/CHANGELOG.md +19 -0
  5. package/dist/adapter.d.ts +26 -0
  6. package/dist/adapter.d.ts.map +1 -0
  7. package/dist/adapter.js +2 -0
  8. package/dist/adapter.js.map +1 -0
  9. package/dist/adapters/jwt.d.ts +25 -0
  10. package/dist/adapters/jwt.d.ts.map +1 -0
  11. package/dist/adapters/jwt.js +148 -0
  12. package/dist/adapters/jwt.js.map +1 -0
  13. package/dist/auth-layer.d.ts +18 -0
  14. package/dist/auth-layer.d.ts.map +1 -0
  15. package/dist/auth-layer.js +56 -0
  16. package/dist/auth-layer.js.map +1 -0
  17. package/dist/auth-service.d.ts +10 -0
  18. package/dist/auth-service.d.ts.map +1 -0
  19. package/dist/auth-service.js +21 -0
  20. package/dist/auth-service.js.map +1 -0
  21. package/dist/errors.d.ts +15 -0
  22. package/dist/errors.d.ts.map +1 -0
  23. package/dist/errors.js +15 -0
  24. package/dist/errors.js.map +1 -0
  25. package/dist/extraction.d.ts +20 -0
  26. package/dist/extraction.d.ts.map +1 -0
  27. package/dist/extraction.js +2 -0
  28. package/dist/extraction.js.map +1 -0
  29. package/dist/index.d.ts +11 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +11 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/permit.d.ts +26 -0
  34. package/dist/permit.d.ts.map +1 -0
  35. package/dist/permit.js +17 -0
  36. package/dist/permit.js.map +1 -0
  37. package/dist/rules.d.ts +47 -0
  38. package/dist/rules.d.ts.map +1 -0
  39. package/dist/rules.js +127 -0
  40. package/dist/rules.js.map +1 -0
  41. package/dist/testing.d.ts +20 -0
  42. package/dist/testing.d.ts.map +1 -0
  43. package/dist/testing.js +22 -0
  44. package/dist/testing.js.map +1 -0
  45. package/dist/trails/auth-verify.d.ts +22 -0
  46. package/dist/trails/auth-verify.d.ts.map +1 -0
  47. package/dist/trails/auth-verify.js +85 -0
  48. package/dist/trails/auth-verify.js.map +1 -0
  49. package/package.json +21 -0
  50. package/src/__tests__/adapter.test.ts +338 -0
  51. package/src/__tests__/auth-layer.test.ts +130 -0
  52. package/src/__tests__/auth-service.test.ts +62 -0
  53. package/src/__tests__/auth-verify.test.ts +277 -0
  54. package/src/__tests__/permit.test.ts +122 -0
  55. package/src/__tests__/rules.test.ts +239 -0
  56. package/src/__tests__/testing.test.ts +57 -0
  57. package/src/adapter.ts +35 -0
  58. package/src/adapters/jwt.ts +230 -0
  59. package/src/auth-layer.ts +80 -0
  60. package/src/auth-service.ts +25 -0
  61. package/src/errors.ts +18 -0
  62. package/src/extraction.ts +19 -0
  63. package/src/index.ts +14 -0
  64. package/src/permit.ts +26 -0
  65. package/src/rules.ts +183 -0
  66. package/src/testing.ts +34 -0
  67. package/src/trails/auth-verify.ts +97 -0
  68. package/tsconfig.json +9 -0
  69. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,239 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { trail, Result } from '@ontrails/core';
3
+ import { z } from 'zod';
4
+
5
+ import {
6
+ destroyWithoutPermit,
7
+ writeWithoutPermit,
8
+ scopeNamingConsistency,
9
+ orphanScopeDetection,
10
+ validatePermits,
11
+ } from '../rules.js';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Helpers
15
+ // ---------------------------------------------------------------------------
16
+
17
+ const emptyInput = z.object({});
18
+ const noopRun = () => Result.ok({});
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // destroyWithoutPermit
22
+ // ---------------------------------------------------------------------------
23
+
24
+ describe('destroyWithoutPermit', () => {
25
+ test('error when destroy trail has no permit', () => {
26
+ const t = trail('user.delete', {
27
+ input: emptyInput,
28
+ intent: 'destroy',
29
+ run: noopRun,
30
+ });
31
+ const diagnostics = destroyWithoutPermit([t]);
32
+ expect(diagnostics).toHaveLength(1);
33
+ expect(diagnostics[0]).toMatchObject({
34
+ message: expect.stringContaining('destroy'),
35
+ rule: 'destroyWithoutPermit',
36
+ severity: 'error',
37
+ trailId: 'user.delete',
38
+ });
39
+ });
40
+
41
+ test('no diagnostic when destroy trail has a scoped permit', () => {
42
+ const t = trail('user.delete', {
43
+ input: emptyInput,
44
+ intent: 'destroy',
45
+ permit: { scopes: ['user:delete'] },
46
+ run: noopRun,
47
+ });
48
+ const diagnostics = destroyWithoutPermit([t]);
49
+ expect(diagnostics).toHaveLength(0);
50
+ });
51
+
52
+ test('error when destroy trail has permit: public', () => {
53
+ const t = trail('user.delete', {
54
+ input: emptyInput,
55
+ intent: 'destroy',
56
+ permit: 'public',
57
+ run: noopRun,
58
+ });
59
+ const diagnostics = destroyWithoutPermit([t]);
60
+ expect(diagnostics).toHaveLength(1);
61
+ expect(diagnostics[0]).toMatchObject({
62
+ rule: 'destroyWithoutPermit',
63
+ severity: 'error',
64
+ trailId: 'user.delete',
65
+ });
66
+ });
67
+ });
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // writeWithoutPermit
71
+ // ---------------------------------------------------------------------------
72
+
73
+ describe('writeWithoutPermit', () => {
74
+ test('warning when write trail has no permit', () => {
75
+ const t = trail('user.create', {
76
+ input: emptyInput,
77
+ run: noopRun,
78
+ });
79
+ const diagnostics = writeWithoutPermit([t]);
80
+ expect(diagnostics).toHaveLength(1);
81
+ expect(diagnostics[0]).toMatchObject({
82
+ rule: 'writeWithoutPermit',
83
+ severity: 'warning',
84
+ trailId: 'user.create',
85
+ });
86
+ });
87
+
88
+ test('no warning when write trail has permit: public', () => {
89
+ const t = trail('user.create', {
90
+ input: emptyInput,
91
+ permit: 'public',
92
+ run: noopRun,
93
+ });
94
+ const diagnostics = writeWithoutPermit([t]);
95
+ expect(diagnostics).toHaveLength(0);
96
+ });
97
+
98
+ test('warning when trail has no intent (defaults to write)', () => {
99
+ const t = trail('user.update', {
100
+ input: emptyInput,
101
+ run: noopRun,
102
+ });
103
+ // Override intent to undefined to simulate a manually constructed trail
104
+ const noIntent = { ...t, intent: undefined } as unknown as ReturnType<
105
+ typeof trail
106
+ >;
107
+ const diagnostics = writeWithoutPermit([noIntent]);
108
+ expect(diagnostics).toHaveLength(1);
109
+ expect(diagnostics[0]).toMatchObject({
110
+ rule: 'writeWithoutPermit',
111
+ severity: 'warning',
112
+ trailId: 'user.update',
113
+ });
114
+ });
115
+
116
+ test('no diagnostic for read trail without permit', () => {
117
+ const t = trail('user.list', {
118
+ input: emptyInput,
119
+ intent: 'read',
120
+ run: noopRun,
121
+ });
122
+ const diagnostics = writeWithoutPermit([t]);
123
+ expect(diagnostics).toHaveLength(0);
124
+ });
125
+ });
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // scopeNamingConsistency
129
+ // ---------------------------------------------------------------------------
130
+
131
+ describe('scopeNamingConsistency', () => {
132
+ test('scope user:write passes naming check', () => {
133
+ const t = trail('user.update', {
134
+ input: emptyInput,
135
+ permit: { scopes: ['user:write'] },
136
+ run: noopRun,
137
+ });
138
+ const diagnostics = scopeNamingConsistency([t]);
139
+ expect(diagnostics).toHaveLength(0);
140
+ });
141
+
142
+ test('warning for scope without colon', () => {
143
+ const t = trail('admin.panel', {
144
+ input: emptyInput,
145
+ permit: { scopes: ['admin'] },
146
+ run: noopRun,
147
+ });
148
+ const diagnostics = scopeNamingConsistency([t]);
149
+ expect(diagnostics).toHaveLength(1);
150
+ expect(diagnostics[0]).toMatchObject({
151
+ message: expect.stringContaining('admin'),
152
+ rule: 'scopeNamingConsistency',
153
+ severity: 'warning',
154
+ trailId: 'admin.panel',
155
+ });
156
+ });
157
+ });
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // orphanScopeDetection
161
+ // ---------------------------------------------------------------------------
162
+
163
+ describe('orphanScopeDetection', () => {
164
+ test('warning for orphan scope (typo)', () => {
165
+ const t1 = trail('user.read', {
166
+ input: emptyInput,
167
+ permit: { scopes: ['user:read'] },
168
+ run: noopRun,
169
+ });
170
+ const t2 = trail('user.write', {
171
+ input: emptyInput,
172
+ permit: { scopes: ['user:wirte'] },
173
+ run: noopRun,
174
+ });
175
+ const diagnostics = orphanScopeDetection([t1, t2]);
176
+ // Both scopes are unique (appear in only 1 trail each)
177
+ expect(diagnostics).toHaveLength(2);
178
+ const messages = diagnostics.map((d) => d.message);
179
+ expect(messages.some((m) => m.includes('user:wirte'))).toBe(true);
180
+ });
181
+
182
+ test('no warning for shared scopes', () => {
183
+ const t1 = trail('user.read', {
184
+ input: emptyInput,
185
+ permit: { scopes: ['user:read'] },
186
+ run: noopRun,
187
+ });
188
+ const t2 = trail('user.profile', {
189
+ input: emptyInput,
190
+ permit: { scopes: ['user:read'] },
191
+ run: noopRun,
192
+ });
193
+ const diagnostics = orphanScopeDetection([t1, t2]);
194
+ expect(diagnostics).toHaveLength(0);
195
+ });
196
+ });
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // validatePermits
200
+ // ---------------------------------------------------------------------------
201
+
202
+ /* oxlint-disable max-statements -- integration test validates all rules fire */
203
+ describe('validatePermits', () => {
204
+ test('runs all rules and aggregates diagnostics', () => {
205
+ const destroyNoPerm = trail('user.delete', {
206
+ input: emptyInput,
207
+ intent: 'destroy',
208
+ run: noopRun,
209
+ });
210
+ const writeNoPerm = trail('user.create', {
211
+ input: emptyInput,
212
+ run: noopRun,
213
+ });
214
+ const badScope = trail('admin.panel', {
215
+ input: emptyInput,
216
+ permit: { scopes: ['admin'] },
217
+ run: noopRun,
218
+ });
219
+ const orphanScope = trail('analytics.export', {
220
+ input: emptyInput,
221
+ permit: { scopes: ['analytics:exportt'] },
222
+ run: noopRun,
223
+ });
224
+
225
+ const diagnostics = validatePermits([
226
+ destroyNoPerm,
227
+ writeNoPerm,
228
+ badScope,
229
+ orphanScope,
230
+ ]);
231
+
232
+ const rules = diagnostics.map((d) => d.rule);
233
+ expect(rules).toContain('destroyWithoutPermit');
234
+ expect(rules).toContain('writeWithoutPermit');
235
+ expect(rules).toContain('scopeNamingConsistency');
236
+ expect(rules).toContain('orphanScopeDetection');
237
+ expect(diagnostics.length).toBeGreaterThanOrEqual(4);
238
+ });
239
+ });
@@ -0,0 +1,57 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { mintPermitForTrail, mintTestPermit } from '../testing';
4
+
5
+ describe('mintTestPermit()', () => {
6
+ test('returns a Permit with the given scopes', () => {
7
+ const permit = mintTestPermit({ scopes: ['user:read', 'user:write'] });
8
+ expect(permit.scopes).toEqual(['user:read', 'user:write']);
9
+ });
10
+
11
+ test('generates a unique id when not specified', () => {
12
+ const a = mintTestPermit();
13
+ const b = mintTestPermit();
14
+ expect(a.id).not.toBe(b.id);
15
+ });
16
+
17
+ test('uses the provided id when specified', () => {
18
+ const permit = mintTestPermit({ id: 'custom-id' });
19
+ expect(permit.id).toBe('custom-id');
20
+ });
21
+
22
+ test('returns empty scopes when no options provided', () => {
23
+ const permit = mintTestPermit();
24
+ expect(permit.scopes).toEqual([]);
25
+ });
26
+
27
+ test('includes roles when provided', () => {
28
+ const permit = mintTestPermit({ roles: ['admin', 'editor'] });
29
+ expect(permit.roles).toEqual(['admin', 'editor']);
30
+ });
31
+
32
+ test('includes tenantId when provided', () => {
33
+ const permit = mintTestPermit({ tenantId: 'tenant_abc' });
34
+ expect(permit.tenantId).toBe('tenant_abc');
35
+ });
36
+ });
37
+
38
+ describe('mintPermitForTrail()', () => {
39
+ test('extracts scopes from trail permit requirement', () => {
40
+ const trail = { permit: { scopes: ['entity:read', 'entity:write'] } };
41
+ const permit = mintPermitForTrail(trail);
42
+ expect(permit).toBeDefined();
43
+ expect(permit?.scopes).toEqual(['entity:read', 'entity:write']);
44
+ });
45
+
46
+ test('returns undefined for public trails', () => {
47
+ const trail = { permit: 'public' as const };
48
+ const permit = mintPermitForTrail(trail);
49
+ expect(permit).toBeUndefined();
50
+ });
51
+
52
+ test('returns undefined when no permit declared', () => {
53
+ const trail = {};
54
+ const permit = mintPermitForTrail(trail);
55
+ expect(permit).toBeUndefined();
56
+ });
57
+ });
package/src/adapter.ts ADDED
@@ -0,0 +1,35 @@
1
+ import type { Result } from '@ontrails/core';
2
+
3
+ import type { PermitExtractionInput } from './extraction.js';
4
+ import type { Permit } from './permit.js';
5
+
6
+ /**
7
+ * @deprecated Use {@link PermitExtractionInput} instead. Kept as an alias
8
+ * for backward compatibility during migration.
9
+ */
10
+ export type AuthCredentials = PermitExtractionInput;
11
+
12
+ /** Errors from auth adapters. */
13
+ export interface AuthError {
14
+ readonly code:
15
+ | 'expired_token'
16
+ | 'insufficient_scope'
17
+ | 'invalid_token'
18
+ | 'missing_credentials';
19
+ readonly message: string;
20
+ }
21
+
22
+ /**
23
+ * Auth adapter port. Given extraction input, produce a permit or an error.
24
+ *
25
+ * The adapter receives the full {@link PermitExtractionInput} — surface,
26
+ * headers, requestId, and credential fields — so it can make richer
27
+ * decisions (e.g., rate-limit by surface or correlate via requestId).
28
+ *
29
+ * Deliberately narrow — no session management, no token refresh.
30
+ */
31
+ export interface AuthAdapter {
32
+ readonly authenticate: (
33
+ input: PermitExtractionInput
34
+ ) => Promise<Result<Permit | null, AuthError>>;
35
+ }
@@ -0,0 +1,230 @@
1
+ import { Result } from '@ontrails/core';
2
+
3
+ import type { AuthAdapter, AuthError } from '../adapter.js';
4
+ import type { PermitExtractionInput } from '../extraction.js';
5
+ import type { Permit } from '../permit.js';
6
+
7
+ /** Configuration for the JWT auth adapter. */
8
+ export interface JwtAdapterOptions {
9
+ /** HMAC secret for HS256 verification. */
10
+ readonly secret?: string;
11
+ /** JWKS endpoint for RS256/ES256 (not yet implemented). */
12
+ readonly jwksUrl?: string;
13
+ /** Expected issuer claim. */
14
+ readonly issuer?: string;
15
+ /** Expected audience claim. */
16
+ readonly audience?: string;
17
+ /** Claim containing scopes (default: 'scope'). */
18
+ readonly scopesClaim?: string;
19
+ /** Claim containing roles (default: 'roles'). */
20
+ readonly rolesClaim?: string;
21
+ }
22
+
23
+ /** JWT payload with standard claims. */
24
+ interface JwtPayload {
25
+ readonly sub?: string;
26
+ readonly iss?: string;
27
+ readonly aud?: string | readonly string[];
28
+ readonly exp?: number;
29
+ readonly [key: string]: unknown;
30
+ }
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Helpers (defined before callers)
34
+ // ---------------------------------------------------------------------------
35
+
36
+ const authErr = (
37
+ code: AuthError['code'],
38
+ message: string
39
+ ): Result<never, AuthError> => Result.err({ code, message });
40
+
41
+ /** Base64url-decode a string to bytes. */
42
+ const base64urlDecode = (input: string): Uint8Array => {
43
+ const padded = input
44
+ .replaceAll('-', '+')
45
+ .replaceAll('_', '/')
46
+ .padEnd(input.length + ((4 - (input.length % 4)) % 4), '=');
47
+ const binary = atob(padded);
48
+ const bytes = new Uint8Array(binary.length);
49
+ for (let i = 0; i < binary.length; i += 1) {
50
+ bytes[i] = binary.codePointAt(i) ?? 0;
51
+ }
52
+ return bytes;
53
+ };
54
+
55
+ /** Decode a JWT payload without verifying the signature. */
56
+ const decodePayload = (token: string): JwtPayload | undefined => {
57
+ const parts = token.split('.');
58
+ if (parts.length !== 3) {
59
+ return undefined;
60
+ }
61
+ try {
62
+ const json = new TextDecoder().decode(base64urlDecode(parts[1] ?? ''));
63
+ return JSON.parse(json) as JwtPayload;
64
+ } catch {
65
+ return undefined;
66
+ }
67
+ };
68
+
69
+ /** Import a secret as an HMAC CryptoKey. */
70
+ const importHmacKey = (secret: string): Promise<CryptoKey> => {
71
+ const encoder = new TextEncoder();
72
+ return crypto.subtle.importKey(
73
+ 'raw',
74
+ encoder.encode(secret),
75
+ { hash: 'SHA-256', name: 'HMAC' },
76
+ false,
77
+ ['verify']
78
+ );
79
+ };
80
+
81
+ /** Verify the HMAC-SHA256 signature of a JWT. */
82
+ const verifyHmacSignature = (
83
+ token: string,
84
+ key: CryptoKey
85
+ ): Promise<boolean> => {
86
+ const lastDot = token.lastIndexOf('.');
87
+ if (lastDot === -1) {
88
+ return Promise.resolve(false);
89
+ }
90
+ const data = token.slice(0, lastDot);
91
+ const signature = base64urlDecode(token.slice(lastDot + 1));
92
+ const encoder = new TextEncoder();
93
+ return crypto.subtle.verify(
94
+ 'HMAC',
95
+ key,
96
+ signature.buffer as ArrayBuffer,
97
+ encoder.encode(data)
98
+ );
99
+ };
100
+
101
+ /** Validate standard claims (exp, iss, aud). */
102
+ const validateClaims = (
103
+ payload: JwtPayload,
104
+ options: JwtAdapterOptions
105
+ ): AuthError | undefined => {
106
+ if (
107
+ payload.exp !== undefined &&
108
+ payload.exp < Math.floor(Date.now() / 1000)
109
+ ) {
110
+ return { code: 'expired_token', message: 'Token has expired' };
111
+ }
112
+ if (options.issuer && payload.iss !== options.issuer) {
113
+ return { code: 'invalid_token', message: 'Issuer mismatch' };
114
+ }
115
+ if (options.audience) {
116
+ const { aud } = payload;
117
+ const matches = Array.isArray(aud)
118
+ ? aud.includes(options.audience)
119
+ : aud === options.audience;
120
+ if (!matches) {
121
+ return { code: 'invalid_token', message: 'Audience mismatch' };
122
+ }
123
+ }
124
+ return undefined;
125
+ };
126
+
127
+ /** Extract scopes from a payload claim (space-separated string or array). */
128
+ const extractScopes = (
129
+ payload: JwtPayload,
130
+ claim: string
131
+ ): readonly string[] => {
132
+ const raw = payload[claim];
133
+ if (typeof raw === 'string') {
134
+ return raw.split(' ').filter(Boolean);
135
+ }
136
+ if (Array.isArray(raw)) {
137
+ return raw.filter(
138
+ (s): s is string => typeof s === 'string' && s.length > 0
139
+ );
140
+ }
141
+ return [];
142
+ };
143
+
144
+ /** Extract roles from a payload claim (string array). */
145
+ const extractRoles = (
146
+ payload: JwtPayload,
147
+ claim: string
148
+ ): readonly string[] | undefined => {
149
+ const raw = payload[claim];
150
+ if (!Array.isArray(raw)) {
151
+ return undefined;
152
+ }
153
+ return raw.filter((r): r is string => typeof r === 'string');
154
+ };
155
+
156
+ /** Build a Permit from a validated JWT payload. */
157
+ const buildPermit = (
158
+ payload: JwtPayload,
159
+ options: JwtAdapterOptions
160
+ ): Result<Permit, AuthError> => {
161
+ if (!payload.sub) {
162
+ return authErr('invalid_token', 'Missing subject claim (sub)');
163
+ }
164
+ const roles = extractRoles(payload, options.rolesClaim ?? 'roles');
165
+ return Result.ok({
166
+ id: payload.sub,
167
+ scopes: extractScopes(payload, options.scopesClaim ?? 'scope'),
168
+ ...(roles ? { roles } : {}),
169
+ });
170
+ };
171
+
172
+ /** Verify the signature and return the decoded payload, or an error. */
173
+ const decodeAndVerify = async (
174
+ token: string,
175
+ secret: string
176
+ ): Promise<Result<JwtPayload, AuthError>> => {
177
+ const payload = decodePayload(token);
178
+ if (!payload) {
179
+ return authErr('invalid_token', 'Malformed JWT');
180
+ }
181
+ try {
182
+ const key = await importHmacKey(secret);
183
+ const valid = await verifyHmacSignature(token, key);
184
+ return valid
185
+ ? Result.ok(payload)
186
+ : authErr('invalid_token', 'Invalid signature');
187
+ } catch {
188
+ return authErr('invalid_token', 'Malformed token signature');
189
+ }
190
+ };
191
+
192
+ /** Validate claims and build a permit from a verified payload. */
193
+ const payloadToPermit = (
194
+ payload: JwtPayload,
195
+ options: JwtAdapterOptions
196
+ ): Result<Permit, AuthError> => {
197
+ const claimError = validateClaims(payload, options);
198
+ if (claimError) {
199
+ return Result.err(claimError);
200
+ }
201
+ return buildPermit(payload, options);
202
+ };
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Factory
206
+ // ---------------------------------------------------------------------------
207
+
208
+ /**
209
+ * Create a JWT auth adapter using Bun's native crypto.
210
+ *
211
+ * Verifies HS256-signed JWTs, extracts claims into a Permit, and checks
212
+ * issuer/audience when configured. Returns `Result.ok(null)` when no
213
+ * credentials are provided.
214
+ */
215
+ export const createJwtAdapter = (options: JwtAdapterOptions): AuthAdapter => {
216
+ const authenticate = async (
217
+ input: PermitExtractionInput
218
+ ): Promise<Result<Permit | null, AuthError>> => {
219
+ if (!input.bearerToken) {
220
+ return Result.ok(null);
221
+ }
222
+ if (!options.secret) {
223
+ return authErr('invalid_token', 'No secret configured');
224
+ }
225
+ const decoded = await decodeAndVerify(input.bearerToken, options.secret);
226
+ return decoded.isErr() ? decoded : payloadToPermit(decoded.value, options);
227
+ };
228
+
229
+ return { authenticate };
230
+ };
@@ -0,0 +1,80 @@
1
+ import { Result } from '@ontrails/core';
2
+ import type { Layer } from '@ontrails/core';
3
+
4
+ import { PermitError } from './errors.js';
5
+ import { getPermit } from './permit.js';
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Helpers (defined before callers — no use-before-define)
9
+ // ---------------------------------------------------------------------------
10
+
11
+ /**
12
+ * Returns `true` when the permit requirement means "no enforcement needed."
13
+ * Either the trail hasn't declared a permit posture or has explicitly
14
+ * opted out with `'public'`.
15
+ */
16
+ const isPassThrough = (
17
+ requirement: unknown
18
+ ): requirement is undefined | 'public' =>
19
+ requirement === undefined || requirement === 'public';
20
+
21
+ /** Returns scopes present in `required` but absent from `held`. */
22
+ const findMissing = (
23
+ required: readonly string[],
24
+ held: readonly string[]
25
+ ): readonly string[] => required.filter((s) => !held.includes(s));
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Auth layer
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /**
32
+ * A {@link Layer} that enforces permit scopes declared on trails.
33
+ *
34
+ * The layer reads the trail's `permit` field (a `PermitRequirement`):
35
+ *
36
+ * - If `permit` is `'public'` or `undefined` the layer passes through.
37
+ * - If `permit` has `scopes`, the layer checks that `ctx.permit` contains
38
+ * all required scopes. A superset is fine; missing scopes produce a
39
+ * `PermitError`.
40
+ *
41
+ * Because `ctx.follow()` re-enters `executeTrail` (which applies layers),
42
+ * this layer automatically re-checks on every invocation in a follow chain.
43
+ * No special follow-chain handling is needed — it is built into the
44
+ * architecture.
45
+ */
46
+ export const authLayer: Layer = {
47
+ description: 'Enforces permit scopes declared on trails',
48
+ name: 'auth',
49
+ wrap: (_trail, impl) => {
50
+ const requirement = _trail.permit;
51
+
52
+ if (isPassThrough(requirement)) {
53
+ return impl;
54
+ }
55
+
56
+ return (input, ctx) => {
57
+ const permit = getPermit(ctx);
58
+
59
+ if (!permit) {
60
+ return Promise.resolve(
61
+ Result.err(new PermitError('No permit provided'))
62
+ );
63
+ }
64
+
65
+ const missing = findMissing(requirement.scopes, permit.scopes);
66
+
67
+ if (missing.length > 0) {
68
+ return Promise.resolve(
69
+ Result.err(
70
+ new PermitError(`Missing scopes: ${missing.join(', ')}`, {
71
+ context: { missing, required: requirement.scopes },
72
+ })
73
+ )
74
+ );
75
+ }
76
+
77
+ return Promise.resolve(impl(input, ctx));
78
+ };
79
+ },
80
+ };
@@ -0,0 +1,25 @@
1
+ import { Result, service } from '@ontrails/core';
2
+
3
+ import type { AuthAdapter } from './adapter.js';
4
+
5
+ /**
6
+ * Auth service — manages the auth adapter lifecycle.
7
+ *
8
+ * The v1 factory returns a no-op adapter that always succeeds (null permit).
9
+ * Real adapter configuration will come through `ServiceSpec.config` (TRL-91).
10
+ * The mock factory provides a synthetic adapter that always succeeds.
11
+ */
12
+ export const authService = service<AuthAdapter>('auth', {
13
+ create: (_svc) =>
14
+ Result.ok({
15
+ // oxlint-disable-next-line require-await -- stub adapter satisfies async interface
16
+ authenticate: async () => Result.ok(null),
17
+ } satisfies AuthAdapter),
18
+ description: 'Authentication adapter',
19
+ metadata: { category: 'infrastructure' },
20
+ mock: () =>
21
+ ({
22
+ // oxlint-disable-next-line require-await -- mock adapter satisfies async interface
23
+ authenticate: async () => Result.ok(null),
24
+ }) satisfies AuthAdapter,
25
+ });
package/src/errors.ts ADDED
@@ -0,0 +1,18 @@
1
+ import { PermissionError } from '@ontrails/core';
2
+
3
+ /**
4
+ * Error returned when permit scope enforcement fails.
5
+ *
6
+ * Extends `PermissionError` (category `'permission'`, HTTP 403) because
7
+ * it represents an *authorization* failure — the caller's identity is known
8
+ * but lacks the required scopes.
9
+ */
10
+ export class PermitError extends PermissionError {
11
+ constructor(
12
+ message: string,
13
+ options?: { cause?: Error; context?: Record<string, unknown> }
14
+ ) {
15
+ super(message, options);
16
+ this.name = 'PermitError';
17
+ }
18
+ }