@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,130 @@
1
+ /* oxlint-disable require-await -- layer wrappers satisfy async interfaces without awaiting */
2
+ import { describe, expect, test } from 'bun:test';
3
+
4
+ import { Result, trail } from '@ontrails/core';
5
+ import type { TrailContext } from '@ontrails/core';
6
+ import { z } from 'zod';
7
+
8
+ import { authLayer } from '../auth-layer';
9
+ import { PermitError } from '../errors';
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Helpers
13
+ // ---------------------------------------------------------------------------
14
+
15
+ const makeCtx = (permit?: {
16
+ id: string;
17
+ scopes: readonly string[];
18
+ }): TrailContext => ({
19
+ permit,
20
+ requestId: 'test-auth',
21
+ signal: AbortSignal.timeout(5000),
22
+ });
23
+
24
+ const okImpl = async () => Result.ok({ done: true });
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Tests
28
+ // ---------------------------------------------------------------------------
29
+
30
+ describe('authLayer', () => {
31
+ test('has correct name and description', () => {
32
+ expect(authLayer.name).toBe('auth');
33
+ expect(authLayer.description).toBeDefined();
34
+ });
35
+
36
+ describe('pass-through cases', () => {
37
+ test('passes through when trail has no permit field', async () => {
38
+ const t = trail('test.nopermit', {
39
+ input: z.object({}),
40
+ output: z.object({ done: z.boolean() }),
41
+ run: okImpl,
42
+ });
43
+
44
+ const wrapped = authLayer.wrap(t, okImpl);
45
+ const result = await wrapped({}, makeCtx());
46
+
47
+ expect(result.isOk()).toBe(true);
48
+ expect(result.unwrap()).toEqual({ done: true });
49
+ });
50
+
51
+ test('passes through when trail permit is public', async () => {
52
+ const t = trail('test.public', {
53
+ input: z.object({}),
54
+ output: z.object({ done: z.boolean() }),
55
+ permit: 'public',
56
+ run: okImpl,
57
+ });
58
+
59
+ const wrapped = authLayer.wrap(t, okImpl);
60
+ const result = await wrapped({}, makeCtx());
61
+
62
+ expect(result.isOk()).toBe(true);
63
+ expect(result.unwrap()).toEqual({ done: true });
64
+ });
65
+ });
66
+
67
+ describe('scope enforcement', () => {
68
+ const scopedTrail = trail('test.scoped', {
69
+ input: z.object({}),
70
+ output: z.object({ done: z.boolean() }),
71
+ permit: { scopes: ['user:read'] },
72
+ run: okImpl,
73
+ });
74
+
75
+ test('passes when ctx.permit has matching scopes', async () => {
76
+ const wrapped = authLayer.wrap(scopedTrail, okImpl);
77
+ const result = await wrapped(
78
+ {},
79
+ makeCtx({ id: 'usr-1', scopes: ['user:read'] })
80
+ );
81
+
82
+ expect(result.isOk()).toBe(true);
83
+ expect(result.unwrap()).toEqual({ done: true });
84
+ });
85
+
86
+ test('returns error when ctx has no permit', async () => {
87
+ const wrapped = authLayer.wrap(scopedTrail, okImpl);
88
+ const result = await wrapped({}, makeCtx());
89
+
90
+ expect(result.isErr()).toBe(true);
91
+ const err = (result as unknown as { error: PermitError }).error;
92
+ expect(err).toBeInstanceOf(PermitError);
93
+ expect(err.message).toContain('No permit');
94
+ });
95
+
96
+ test('returns error when permit is missing required scopes', async () => {
97
+ const multiScopeTrail = trail('test.multi', {
98
+ input: z.object({}),
99
+ output: z.object({ done: z.boolean() }),
100
+ permit: { scopes: ['user:read', 'user:write'] },
101
+ run: okImpl,
102
+ });
103
+
104
+ const wrapped = authLayer.wrap(multiScopeTrail, okImpl);
105
+ const result = await wrapped(
106
+ {},
107
+ makeCtx({ id: 'usr-1', scopes: ['user:read'] })
108
+ );
109
+
110
+ expect(result.isErr()).toBe(true);
111
+ const err = (result as unknown as { error: PermitError }).error;
112
+ expect(err).toBeInstanceOf(PermitError);
113
+ expect(err.message).toContain('user:write');
114
+ });
115
+
116
+ test('passes when permit has superset of required scopes', async () => {
117
+ const wrapped = authLayer.wrap(scopedTrail, okImpl);
118
+ const result = await wrapped(
119
+ {},
120
+ makeCtx({
121
+ id: 'usr-1',
122
+ scopes: ['user:read', 'user:write', 'admin'],
123
+ })
124
+ );
125
+
126
+ expect(result.isOk()).toBe(true);
127
+ expect(result.unwrap()).toEqual({ done: true });
128
+ });
129
+ });
130
+ });
@@ -0,0 +1,62 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import type { ServiceContext } from '@ontrails/core';
4
+
5
+ import type { AuthAdapter } from '../adapter.js';
6
+ import { authService } from '../auth-service.js';
7
+ import type { PermitExtractionInput } from '../extraction.js';
8
+
9
+ /** Minimal extraction input for tests. */
10
+ const testInput = (
11
+ overrides?: Partial<PermitExtractionInput>
12
+ ): PermitExtractionInput => ({
13
+ requestId: 'test-svc-req',
14
+ surface: 'http',
15
+ ...overrides,
16
+ });
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Helpers
20
+ // ---------------------------------------------------------------------------
21
+
22
+ const testSvcCtx: ServiceContext = {
23
+ config: undefined,
24
+ cwd: '/tmp',
25
+ env: {},
26
+ workspaceRoot: '/tmp',
27
+ };
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Tests
31
+ // ---------------------------------------------------------------------------
32
+
33
+ describe('authService', () => {
34
+ test('has correct id and kind', () => {
35
+ expect(authService.id).toBe('auth');
36
+ expect(authService.kind).toBe('service');
37
+ });
38
+
39
+ test('has infrastructure metadata', () => {
40
+ expect(authService.metadata).toEqual({ category: 'infrastructure' });
41
+ });
42
+
43
+ test('mock returns an AuthAdapter', async () => {
44
+ const mock = authService.mock?.();
45
+ expect(mock).toBeDefined();
46
+
47
+ const adapter = mock as AuthAdapter;
48
+ const result = await adapter.authenticate(testInput());
49
+ expect(result.isOk()).toBe(true);
50
+ expect(result.unwrap()).toBeNull();
51
+ });
52
+
53
+ test('create returns Result.ok with an AuthAdapter', async () => {
54
+ const result = await authService.create(testSvcCtx);
55
+ expect(result.isOk()).toBe(true);
56
+
57
+ const adapter = result.unwrap() as AuthAdapter;
58
+ const authResult = await adapter.authenticate(testInput());
59
+ expect(authResult.isOk()).toBe(true);
60
+ expect(authResult.unwrap()).toBeNull();
61
+ });
62
+ });
@@ -0,0 +1,277 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import {
4
+ Result,
5
+ SURFACE_KEY,
6
+ ValidationError,
7
+ executeTrail,
8
+ } from '@ontrails/core';
9
+
10
+ import type { AuthAdapter } from '../adapter.js';
11
+ import { authService } from '../auth-service.js';
12
+ import { createJwtAdapter } from '../adapters/jwt.js';
13
+ import type { Permit } from '../permit.js';
14
+ import { authVerify } from '../trails/auth-verify.js';
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Test helper: sign a JWT with HMAC-SHA256 using crypto.subtle
18
+ // ---------------------------------------------------------------------------
19
+
20
+ const base64url = (buf: ArrayBuffer): string => {
21
+ const bytes = new Uint8Array(buf);
22
+ let binary = '';
23
+ for (const b of bytes) {
24
+ binary += String.fromCodePoint(b);
25
+ }
26
+ return btoa(binary)
27
+ .replaceAll('+', '-')
28
+ .replaceAll('/', '_')
29
+ .replace(/=+$/, '');
30
+ };
31
+
32
+ const base64urlEncode = (str: string): string => {
33
+ const encoder = new TextEncoder();
34
+ return base64url(encoder.encode(str).buffer as ArrayBuffer);
35
+ };
36
+
37
+ const signJwt = async (
38
+ payload: Record<string, unknown>,
39
+ secret: string
40
+ ): Promise<string> => {
41
+ const header = base64urlEncode(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
42
+ const body = base64urlEncode(JSON.stringify(payload));
43
+ const data = `${header}.${body}`;
44
+ const encoder = new TextEncoder();
45
+ const key = await crypto.subtle.importKey(
46
+ 'raw',
47
+ encoder.encode(secret),
48
+ { hash: 'SHA-256', name: 'HMAC' },
49
+ false,
50
+ ['sign']
51
+ );
52
+ const sig = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
53
+ return `${data}.${base64url(sig)}`;
54
+ };
55
+
56
+ const TEST_SECRET = 'test-secret-for-hmac-256';
57
+
58
+ /** Create an AuthAdapter wired to a JWT secret. */
59
+ const jwtAdapter = (): AuthAdapter => createJwtAdapter({ secret: TEST_SECRET });
60
+
61
+ /** Execute auth.verify with a given adapter injected as the auth service. */
62
+ const runVerify = async (
63
+ token: string,
64
+ adapter: AuthAdapter,
65
+ options?: {
66
+ surface?: 'http' | 'mcp' | 'cli';
67
+ }
68
+ ): Promise<
69
+ Result<
70
+ {
71
+ error?: string;
72
+ errorCode?:
73
+ | 'expired_token'
74
+ | 'insufficient_scope'
75
+ | 'invalid_token'
76
+ | 'missing_credentials';
77
+ permit?: {
78
+ id: string;
79
+ metadata?: Record<string, unknown>;
80
+ roles?: string[];
81
+ scopes: string[];
82
+ tenantId?: string;
83
+ };
84
+ valid: boolean;
85
+ },
86
+ Error
87
+ >
88
+ > => {
89
+ const result = await executeTrail(
90
+ authVerify,
91
+ { token },
92
+ {
93
+ ctx:
94
+ options?.surface === undefined
95
+ ? undefined
96
+ : { extensions: { [SURFACE_KEY]: options.surface } },
97
+ services: { [authService.id]: adapter },
98
+ }
99
+ );
100
+ return result as Result<
101
+ {
102
+ error?: string;
103
+ permit?: {
104
+ id: string;
105
+ metadata?: Record<string, unknown>;
106
+ roles?: string[];
107
+ scopes: string[];
108
+ tenantId?: string;
109
+ };
110
+ valid: boolean;
111
+ },
112
+ Error
113
+ >;
114
+ };
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Tests
118
+ // ---------------------------------------------------------------------------
119
+
120
+ describe('auth.verify trail', () => {
121
+ describe('contract', () => {
122
+ test('has correct id and intent', () => {
123
+ expect(authVerify.id).toBe('auth.verify');
124
+ expect(authVerify.intent).toBe('read');
125
+ });
126
+
127
+ test('has infrastructure metadata', () => {
128
+ expect(authVerify.metadata).toEqual({ category: 'infrastructure' });
129
+ });
130
+
131
+ test('has examples', () => {
132
+ expect(authVerify.examples).toBeDefined();
133
+ expect(authVerify.examples?.length).toBeGreaterThan(0);
134
+ });
135
+
136
+ test('declares authService dependency', () => {
137
+ expect(authVerify.services).toHaveLength(1);
138
+ expect(authVerify.services[0]?.id).toBe('auth');
139
+ });
140
+ });
141
+
142
+ describe('with mock adapter (no credentials)', () => {
143
+ test('returns valid: false with error message', async () => {
144
+ const noopAdapter: AuthAdapter = {
145
+ // oxlint-disable-next-line require-await -- satisfies async interface
146
+ authenticate: async () => Result.ok(null),
147
+ };
148
+
149
+ const result = await runVerify('some-token', noopAdapter);
150
+
151
+ expect(result.isOk()).toBe(true);
152
+ const value = result.unwrap();
153
+ expect(value.valid).toBe(false);
154
+ expect(value.error).toBe('No credentials');
155
+ expect(value.errorCode).toBe('missing_credentials');
156
+ expect(value.permit).toBeUndefined();
157
+ });
158
+ });
159
+
160
+ describe('with valid token and secret', () => {
161
+ test('returns valid: true with permit', async () => {
162
+ const now = Math.floor(Date.now() / 1000);
163
+ const token = await signJwt(
164
+ { exp: now + 3600, scope: 'read write', sub: 'user-42' },
165
+ TEST_SECRET
166
+ );
167
+
168
+ const result = await runVerify(token, jwtAdapter());
169
+
170
+ expect(result.isOk()).toBe(true);
171
+ const value = result.unwrap();
172
+ expect(value.valid).toBe(true);
173
+ expect(value.permit).toEqual({
174
+ id: 'user-42',
175
+ scopes: ['read', 'write'],
176
+ });
177
+ expect(value.error).toBeUndefined();
178
+ });
179
+
180
+ test('returns the full permit payload from the adapter', async () => {
181
+ const permit: Permit = {
182
+ id: 'user-42',
183
+ metadata: { plan: 'pro' },
184
+ roles: ['admin'],
185
+ scopes: ['read', 'write'],
186
+ tenantId: 'tenant-1',
187
+ };
188
+ const adapter: AuthAdapter = {
189
+ // oxlint-disable-next-line require-await -- satisfies async interface
190
+ authenticate: async () => Result.ok(permit),
191
+ };
192
+
193
+ const result = await runVerify('full-permit-token', adapter);
194
+
195
+ expect(result.isOk()).toBe(true);
196
+ expect(result.unwrap().permit).toEqual({
197
+ id: 'user-42',
198
+ metadata: { plan: 'pro' },
199
+ roles: ['admin'],
200
+ scopes: ['read', 'write'],
201
+ tenantId: 'tenant-1',
202
+ });
203
+ });
204
+
205
+ test('forwards the invoking surface from trail context', async () => {
206
+ let seenSurface: string | undefined;
207
+ const adapter: AuthAdapter = {
208
+ // oxlint-disable-next-line require-await -- captures adapter input
209
+ authenticate: async (input) => {
210
+ seenSurface = input.surface;
211
+ return Result.ok({
212
+ id: 'user-42',
213
+ scopes: ['read'],
214
+ });
215
+ },
216
+ };
217
+
218
+ const result = await runVerify('surface-aware-token', adapter, {
219
+ surface: 'mcp',
220
+ });
221
+
222
+ expect(result.isOk()).toBe(true);
223
+ expect(seenSurface).toBe('mcp');
224
+ });
225
+ });
226
+
227
+ describe('with invalid token', () => {
228
+ test('returns valid: false with error for bad signature', async () => {
229
+ const now = Math.floor(Date.now() / 1000);
230
+ const token = await signJwt(
231
+ { exp: now + 3600, sub: 'user-bad' },
232
+ 'wrong-secret'
233
+ );
234
+
235
+ const result = await runVerify(token, jwtAdapter());
236
+
237
+ expect(result.isOk()).toBe(true);
238
+ const value = result.unwrap();
239
+ expect(value.valid).toBe(false);
240
+ expect(value.error).toBeDefined();
241
+ expect(value.errorCode).toBe('invalid_token');
242
+ expect(value.permit).toBeUndefined();
243
+ });
244
+
245
+ test('returns valid: false for expired token', async () => {
246
+ const past = Math.floor(Date.now() / 1000) - 3600;
247
+ const token = await signJwt(
248
+ { exp: past, sub: 'user-expired' },
249
+ TEST_SECRET
250
+ );
251
+
252
+ const result = await runVerify(token, jwtAdapter());
253
+
254
+ expect(result.isOk()).toBe(true);
255
+ const value = result.unwrap();
256
+ expect(value.valid).toBe(false);
257
+ expect(value.error).toBeDefined();
258
+ expect(value.errorCode).toBe('expired_token');
259
+ expect(value.permit).toBeUndefined();
260
+ });
261
+ });
262
+
263
+ describe('input validation', () => {
264
+ test('rejects empty bearer tokens at the boundary', async () => {
265
+ const result = await executeTrail(
266
+ authVerify,
267
+ { token: '' },
268
+ {
269
+ services: { [authService.id]: jwtAdapter() },
270
+ }
271
+ );
272
+
273
+ expect(result.isErr()).toBe(true);
274
+ expect(result.error).toBeInstanceOf(ValidationError);
275
+ });
276
+ });
277
+ });
@@ -0,0 +1,122 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import type { Permit, PermitExtractionInput } from '../index';
4
+ import { getPermit } from '../index';
5
+
6
+ describe('Permit type', () => {
7
+ test('accepts a valid permit with required fields only', () => {
8
+ const permit: Permit = {
9
+ id: 'usr_abc123',
10
+ scopes: ['user:read', 'user:write'],
11
+ };
12
+ expect(permit.id).toBe('usr_abc123');
13
+ expect(permit.scopes).toEqual(['user:read', 'user:write']);
14
+ });
15
+
16
+ test('accepts a permit with all optional fields', () => {
17
+ const permit: Permit = {
18
+ id: 'usr_full',
19
+ metadata: { plan: 'pro', provider: 'clerk' },
20
+ roles: ['admin', 'editor'],
21
+ scopes: ['entity:read'],
22
+ tenantId: 'tenant_xyz',
23
+ };
24
+ expect(permit.roles).toEqual(['admin', 'editor']);
25
+ expect(permit.tenantId).toBe('tenant_xyz');
26
+ expect(permit.metadata).toEqual({ plan: 'pro', provider: 'clerk' });
27
+ });
28
+
29
+ test('scopes and roles arrays are readonly', () => {
30
+ const permit: Permit = {
31
+ id: 'usr_ro',
32
+ roles: ['viewer'],
33
+ scopes: ['read'],
34
+ };
35
+ // Structural check: readonly arrays are assignable to readonly string[]
36
+ const { scopes } = permit;
37
+ const { roles } = permit;
38
+ expect(scopes).toEqual(['read']);
39
+ expect(roles).toEqual(['viewer']);
40
+ });
41
+ });
42
+
43
+ describe('getPermit()', () => {
44
+ test('returns Permit from a context with a permit', () => {
45
+ const permit: Permit = { id: 'usr_1', scopes: ['user:read'] };
46
+ const ctx = { permit, requestId: 'req-1' };
47
+ const result = getPermit(ctx);
48
+ expect(result).toEqual(permit);
49
+ });
50
+
51
+ test('returns undefined when context has no permit', () => {
52
+ const ctx = { requestId: 'req-2' };
53
+ const result = getPermit(ctx);
54
+ expect(result).toBeUndefined();
55
+ });
56
+
57
+ test('returns undefined when permit is explicitly undefined', () => {
58
+ const ctx = { permit: undefined, requestId: 'req-3' };
59
+ const result = getPermit(ctx);
60
+ expect(result).toBeUndefined();
61
+ });
62
+
63
+ test('preserves extended permit fields from the auth layer', () => {
64
+ const ctx = {
65
+ permit: {
66
+ id: 'usr_2',
67
+ metadata: { plan: 'pro' },
68
+ roles: ['admin'],
69
+ scopes: ['user:read'],
70
+ tenantId: 'tenant-1',
71
+ } satisfies Permit,
72
+ requestId: 'req-4',
73
+ };
74
+ const result = getPermit(ctx);
75
+ expect(result).toEqual(ctx.permit);
76
+ });
77
+ });
78
+
79
+ describe('PermitExtractionInput', () => {
80
+ test('accepts HTTP surface extraction', () => {
81
+ const input: PermitExtractionInput = {
82
+ bearerToken: 'eyJhbGciOiJSUzI1NiJ9.test',
83
+ headers: new Headers({
84
+ authorization: 'Bearer eyJhbGciOiJSUzI1NiJ9.test',
85
+ }),
86
+ requestId: 'req-http-1',
87
+ surface: 'http',
88
+ };
89
+ expect(input.surface).toBe('http');
90
+ expect(input.bearerToken).toBeDefined();
91
+ });
92
+
93
+ test('accepts MCP surface extraction', () => {
94
+ const input: PermitExtractionInput = {
95
+ requestId: 'req-mcp-1',
96
+ sessionId: 'mcp-session-abc',
97
+ surface: 'mcp',
98
+ };
99
+ expect(input.surface).toBe('mcp');
100
+ expect(input.sessionId).toBe('mcp-session-abc');
101
+ });
102
+
103
+ test('accepts CLI surface extraction', () => {
104
+ const input: PermitExtractionInput = {
105
+ bearerToken: 'cli-token-from-keyring',
106
+ requestId: 'req-cli-1',
107
+ surface: 'cli',
108
+ };
109
+ expect(input.surface).toBe('cli');
110
+ });
111
+
112
+ test('accepts minimal extraction with only required fields', () => {
113
+ const input: PermitExtractionInput = {
114
+ requestId: 'req-minimal',
115
+ surface: 'http',
116
+ };
117
+ expect(input.requestId).toBe('req-minimal');
118
+ expect(input.bearerToken).toBeUndefined();
119
+ expect(input.sessionId).toBeUndefined();
120
+ expect(input.headers).toBeUndefined();
121
+ });
122
+ });