@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,19 @@
1
+ /**
2
+ * Normalized input for auth adapters.
3
+ *
4
+ * Each surface extracts raw credentials from its transport and normalizes
5
+ * them into this shape. No surface types (Request, McpSession, etc.) cross
6
+ * into core — only this interface.
7
+ */
8
+ export interface PermitExtractionInput {
9
+ /** Which surface produced this extraction */
10
+ readonly surface: 'http' | 'mcp' | 'cli';
11
+ /** Bearer token from Authorization header or equivalent */
12
+ readonly bearerToken?: string;
13
+ /** Session identifier from transport handshake */
14
+ readonly sessionId?: string;
15
+ /** Raw headers (HTTP surface only, typically) */
16
+ readonly headers?: Headers;
17
+ /** Correlation ID for tracing */
18
+ readonly requestId: string;
19
+ }
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ export {
2
+ type AuthAdapter,
3
+ type AuthCredentials,
4
+ type AuthError,
5
+ } from './adapter.js';
6
+ export { createJwtAdapter, type JwtAdapterOptions } from './adapters/jwt.js';
7
+ export { authLayer } from './auth-layer.js';
8
+ export { authService } from './auth-service.js';
9
+ export { authVerify } from './trails/auth-verify.js';
10
+ export { PermitError } from './errors.js';
11
+ export { type PermitExtractionInput } from './extraction.js';
12
+ export { type Permit, getPermit } from './permit.js';
13
+ export { validatePermits, type PermitDiagnostic } from './rules.js';
14
+ export { mintTestPermit, mintPermitForTrail } from './testing.js';
package/src/permit.ts ADDED
@@ -0,0 +1,26 @@
1
+ import type { BasePermit } from '@ontrails/core';
2
+
3
+ /** The resolved identity and scopes from a successful authentication. */
4
+ export interface Permit extends BasePermit {
5
+ readonly roles?: readonly string[];
6
+ readonly tenantId?: string;
7
+ readonly metadata?: Readonly<Record<string, unknown>>;
8
+ }
9
+
10
+ /**
11
+ * Type-safe accessor for `ctx.permit` with a downcast to `Permit`.
12
+ *
13
+ * `TrailContext.permit` is typed as `BasePermit` (id + scopes). This accessor
14
+ * returns the full `Permit` when the auth layer has set one. Safe because
15
+ * the auth layer is the only writer and always sets a full `Permit`.
16
+ *
17
+ * @example
18
+ * ```typescript
19
+ * const permit = getPermit(ctx);
20
+ * if (permit) {
21
+ * console.log(permit.roles, permit.tenantId);
22
+ * }
23
+ * ```
24
+ */
25
+ export const getPermit = (ctx: { permit?: BasePermit }): Permit | undefined =>
26
+ ctx.permit === undefined ? undefined : (ctx.permit as Permit);
package/src/rules.ts ADDED
@@ -0,0 +1,183 @@
1
+ import type { Trail } from '@ontrails/core';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Diagnostic type
5
+ // ---------------------------------------------------------------------------
6
+
7
+ /** A single governance finding from a permit rule. */
8
+ export interface PermitDiagnostic {
9
+ readonly trailId: string;
10
+ readonly rule: string;
11
+ readonly severity: 'error' | 'warning';
12
+ readonly message: string;
13
+ }
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Helpers
17
+ // ---------------------------------------------------------------------------
18
+
19
+ type AnyTrail = Trail<unknown, unknown>;
20
+ type Rule = (trails: readonly AnyTrail[]) => readonly PermitDiagnostic[];
21
+
22
+ /** Check whether a trail has any permit declaration (scopes object or 'public'). */
23
+ const hasPermit = (t: AnyTrail): boolean => t.permit !== undefined;
24
+
25
+ /** Extract scopes from a trail's permit declaration, or empty array. */
26
+ const getScopes = (t: AnyTrail): readonly string[] => {
27
+ if (t.permit !== undefined && t.permit !== 'public') {
28
+ return t.permit.scopes;
29
+ }
30
+ return [];
31
+ };
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Rule: destroyWithoutPermit
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /**
38
+ * Destructive trails without a real permit declaration are a governance failure.
39
+ *
40
+ * Reports an error for every trail with `intent: 'destroy'` that has no
41
+ * `permit` field or explicitly opts out with `permit: 'public'`.
42
+ */
43
+ export const destroyWithoutPermit: Rule = (trails) =>
44
+ trails
45
+ .filter(
46
+ (t) => t.intent === 'destroy' && (!hasPermit(t) || t.permit === 'public')
47
+ )
48
+ .map((t) => ({
49
+ message: `Trail "${t.id}" has intent 'destroy' but no permit declaration`,
50
+ rule: 'destroyWithoutPermit',
51
+ severity: 'error' as const,
52
+ trailId: t.id,
53
+ }));
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Rule: writeWithoutPermit
57
+ // ---------------------------------------------------------------------------
58
+
59
+ /**
60
+ * Write trails without a permit declaration get a warning.
61
+ *
62
+ * Trails with `intent: 'write'` (or no intent, which defaults to write) that
63
+ * lack a permit are flagged unless `permit: 'public'` is explicitly set.
64
+ */
65
+ export const writeWithoutPermit: Rule = (trails) =>
66
+ trails
67
+ .filter(
68
+ (t) => (t.intent === 'write' || t.intent === undefined) && !hasPermit(t)
69
+ )
70
+ .map((t) => ({
71
+ message: `Trail "${t.id}" has write intent but no permit declaration`,
72
+ rule: 'writeWithoutPermit',
73
+ severity: 'warning' as const,
74
+ trailId: t.id,
75
+ }));
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Rule: scopeNamingConsistency
79
+ // ---------------------------------------------------------------------------
80
+
81
+ /** Returns true when a scope follows the `entity:action` convention. */
82
+ const isValidScopeFormat = (scope: string): boolean => {
83
+ const parts = scope.split(':');
84
+ return (
85
+ parts.length === 2 &&
86
+ (parts[0]?.length ?? 0) > 0 &&
87
+ (parts[1]?.length ?? 0) > 0
88
+ );
89
+ };
90
+
91
+ /**
92
+ * Warns for scopes that don't follow the `entity:action` convention.
93
+ *
94
+ * A valid scope contains exactly one colon separating a non-empty entity
95
+ * and a non-empty action.
96
+ */
97
+ export const scopeNamingConsistency: Rule = (trails) =>
98
+ trails.flatMap((t) =>
99
+ getScopes(t)
100
+ .filter((scope) => !isValidScopeFormat(scope))
101
+ .map((scope) => ({
102
+ message: `Scope "${scope}" on trail "${t.id}" does not follow entity:action convention`,
103
+ rule: 'scopeNamingConsistency',
104
+ severity: 'warning' as const,
105
+ trailId: t.id,
106
+ }))
107
+ );
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Rule: orphanScopeDetection
111
+ // ---------------------------------------------------------------------------
112
+
113
+ /** Build a map of scope -> set of trail IDs that declare it. */
114
+ const buildScopeMap = (
115
+ trails: readonly AnyTrail[]
116
+ ): ReadonlyMap<string, ReadonlySet<string>> => {
117
+ const map = new Map<string, Set<string>>();
118
+ for (const t of trails) {
119
+ for (const scope of getScopes(t)) {
120
+ const existing = map.get(scope);
121
+ if (existing) {
122
+ existing.add(t.id);
123
+ } else {
124
+ map.set(scope, new Set([t.id]));
125
+ }
126
+ }
127
+ }
128
+ return map;
129
+ };
130
+
131
+ /** Filter trails that have a scoped (non-public) permit declaration. */
132
+ const trailsWithScopedPermit = (
133
+ trails: readonly AnyTrail[]
134
+ ): readonly AnyTrail[] =>
135
+ trails.filter((t) => t.permit !== undefined && t.permit !== 'public');
136
+
137
+ /** Convert orphan scope map entries into diagnostics. */
138
+ const orphanDiagnostics = (
139
+ scopeMap: ReadonlyMap<string, ReadonlySet<string>>
140
+ ): readonly PermitDiagnostic[] =>
141
+ [...scopeMap.entries()]
142
+ .filter(([, ids]) => ids.size === 1)
143
+ .map(([scope, ids]) => ({
144
+ message: `Scope "${scope}" appears only on trail "${[...ids][0]}" — possible typo`,
145
+ rule: 'orphanScopeDetection',
146
+ severity: 'warning' as const,
147
+ trailId: [...ids][0] ?? '',
148
+ }));
149
+
150
+ /**
151
+ * Warns for scopes that appear in only one trail's permit.
152
+ *
153
+ * Catches typos like `user:wirte` by surfacing scopes not shared with any
154
+ * other trail. Only runs when at least 2 trails have permit declarations.
155
+ */
156
+ export const orphanScopeDetection: Rule = (trails) => {
157
+ const scoped = trailsWithScopedPermit(trails);
158
+ if (scoped.length < 2) {
159
+ return [];
160
+ }
161
+ return orphanDiagnostics(buildScopeMap(scoped));
162
+ };
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Top-level validator
166
+ // ---------------------------------------------------------------------------
167
+
168
+ const allRules: readonly Rule[] = [
169
+ destroyWithoutPermit,
170
+ writeWithoutPermit,
171
+ scopeNamingConsistency,
172
+ orphanScopeDetection,
173
+ ];
174
+
175
+ /**
176
+ * Run all permit governance rules against a set of trails.
177
+ *
178
+ * Returns a flat array of diagnostics from every rule. An empty array
179
+ * means the topo passes all permit governance checks.
180
+ */
181
+ export const validatePermits = (
182
+ trails: readonly Trail<unknown, unknown>[]
183
+ ): readonly PermitDiagnostic[] => allRules.flatMap((rule) => rule(trails));
package/src/testing.ts ADDED
@@ -0,0 +1,34 @@
1
+ import type { PermitRequirement } from '@ontrails/core';
2
+
3
+ import type { Permit } from './permit.js';
4
+
5
+ /**
6
+ * Mint a synthetic test permit with exactly the declared scopes.
7
+ * No admin permit, no wildcard — tests get only what the trail declares.
8
+ */
9
+ export const mintTestPermit = (options?: {
10
+ readonly id?: string;
11
+ readonly scopes?: readonly string[];
12
+ readonly roles?: readonly string[];
13
+ readonly tenantId?: string;
14
+ }): Permit => ({
15
+ id:
16
+ options?.id ??
17
+ `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
18
+ scopes: [...(options?.scopes ?? [])],
19
+ ...(options?.roles === undefined ? {} : { roles: [...options.roles] }),
20
+ ...(options?.tenantId === undefined ? {} : { tenantId: options.tenantId }),
21
+ });
22
+
23
+ /**
24
+ * Create a test permit matching a trail's permit requirement.
25
+ * Extracts scopes from the requirement and mints a permit with exactly those scopes.
26
+ */
27
+ export const mintPermitForTrail = (trail: {
28
+ readonly permit?: PermitRequirement | undefined;
29
+ }): Permit | undefined => {
30
+ if (!trail.permit || trail.permit === 'public') {
31
+ return undefined;
32
+ }
33
+ return mintTestPermit({ scopes: trail.permit.scopes });
34
+ };
@@ -0,0 +1,97 @@
1
+ import { Result, SURFACE_KEY, trail } from '@ontrails/core';
2
+ import type { TrailContext } from '@ontrails/core';
3
+ import { z } from 'zod';
4
+
5
+ import { authService } from '../auth-service.js';
6
+ import type { PermitExtractionInput } from '../extraction.js';
7
+ import type { Permit } from '../permit.js';
8
+
9
+ const permitSchema = z.object({
10
+ id: z.string(),
11
+ metadata: z.record(z.string(), z.unknown()).optional(),
12
+ roles: z.array(z.string()).optional(),
13
+ scopes: z.array(z.string()),
14
+ tenantId: z.string().optional(),
15
+ });
16
+ const authErrorCodeSchema = z.enum([
17
+ 'expired_token',
18
+ 'insufficient_scope',
19
+ 'invalid_token',
20
+ 'missing_credentials',
21
+ ]);
22
+
23
+ const toOutputPermit = (permit: Permit) => ({
24
+ ...(permit.metadata === undefined
25
+ ? {}
26
+ : { metadata: { ...permit.metadata } }),
27
+ ...(permit.roles === undefined ? {} : { roles: [...permit.roles] }),
28
+ ...(permit.tenantId === undefined ? {} : { tenantId: permit.tenantId }),
29
+ id: permit.id,
30
+ scopes: [...permit.scopes],
31
+ });
32
+
33
+ const isSurface = (value: unknown): value is PermitExtractionInput['surface'] =>
34
+ value === 'http' || value === 'mcp' || value === 'cli';
35
+
36
+ const getSurface = (ctx: TrailContext): PermitExtractionInput['surface'] => {
37
+ const surface = ctx.extensions?.[SURFACE_KEY];
38
+ return isSurface(surface) ? surface : 'http';
39
+ };
40
+
41
+ /**
42
+ * Infrastructure trail that verifies a bearer token and returns the resolved permit.
43
+ *
44
+ * Reads the auth adapter from `authService` — the adapter is configured at
45
+ * bootstrap (e.g. JWT with HMAC secret). The mock adapter always succeeds with
46
+ * a null permit, so `testAll(app)` works without configuration.
47
+ */
48
+ export const authVerify = trail('auth.verify', {
49
+ examples: [
50
+ {
51
+ input: { token: 'test-token' },
52
+ name: 'Verify a token',
53
+ },
54
+ ],
55
+ input: z.object({
56
+ token: z.string().min(1).describe('Bearer token to verify'),
57
+ }),
58
+ intent: 'read',
59
+ metadata: { category: 'infrastructure' },
60
+ output: z.object({
61
+ error: z.string().optional(),
62
+ errorCode: authErrorCodeSchema.optional(),
63
+ permit: permitSchema.optional(),
64
+ valid: z.boolean(),
65
+ }),
66
+ run: async (input, ctx) => {
67
+ const adapter = authService.from(ctx);
68
+ const result = await adapter.authenticate({
69
+ bearerToken: input.token,
70
+ requestId: ctx.requestId,
71
+ surface: getSurface(ctx),
72
+ });
73
+
74
+ if (result.isErr()) {
75
+ return Result.ok({
76
+ error: result.error.message,
77
+ errorCode: result.error.code,
78
+ valid: false,
79
+ });
80
+ }
81
+
82
+ const permit = result.value;
83
+ if (!permit) {
84
+ return Result.ok({
85
+ error: 'No credentials',
86
+ errorCode: 'missing_credentials',
87
+ valid: false,
88
+ });
89
+ }
90
+
91
+ return Result.ok({
92
+ permit: toOutputPermit(permit),
93
+ valid: true,
94
+ });
95
+ },
96
+ services: [authService],
97
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src"
6
+ },
7
+ "include": ["src"],
8
+ "exclude": ["**/__tests__/**", "**/*.test.ts", "dist"]
9
+ }
@@ -0,0 +1 @@
1
+ {"root":["./src/adapter.ts","./src/auth-layer.ts","./src/auth-service.ts","./src/errors.ts","./src/extraction.ts","./src/index.ts","./src/permit.ts","./src/rules.ts","./src/testing.ts","./src/adapters/jwt.ts","./src/trails/auth-verify.ts"],"version":"5.9.3"}