@ankhorage/doctor 0.5.0 → 0.7.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.
@@ -0,0 +1,461 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import { AUTHZ_ENGINES, AUTHZ_KINDS } from '@ankhorage/contracts';
5
+ import { type AuthFlowConfig, resolveAuthFlow } from '@ankhorage/contracts/auth';
6
+
7
+ import type {
8
+ DoctorAnalysisRequest,
9
+ DoctorAnalysisResult,
10
+ DoctorFixPlan,
11
+ DoctorPlannedChange,
12
+ } from './analysis.js';
13
+ import type {
14
+ DoctorDiagnostic,
15
+ DoctorDiagnosticCode,
16
+ DoctorPolicyProfile,
17
+ DoctorRuleId,
18
+ } from './diagnostics.js';
19
+ import { analyzeSecretStoreManifest } from './secretManifestAnalysis.js';
20
+
21
+ const MANIFEST_PROFILE = 'app-manifest' satisfies DoctorPolicyProfile;
22
+ const AUTH_ACTION_ROUTE_KEYS = [
23
+ 'signInRoute',
24
+ 'signUpRoute',
25
+ 'signOutRoute',
26
+ 'forgotPasswordRoute',
27
+ 'otpRoute',
28
+ ] as const satisfies readonly (keyof AuthFlowConfig)[];
29
+ const OPTIONAL_ROUTE_KEYS = [
30
+ 'signUpRoute',
31
+ 'signOutRoute',
32
+ 'forgotPasswordRoute',
33
+ 'otpRoute',
34
+ 'unauthorizedRoute',
35
+ ] as const satisfies readonly (keyof AuthFlowConfig)[];
36
+
37
+ type AuthActionRouteKey = (typeof AUTH_ACTION_ROUTE_KEYS)[number];
38
+ type OptionalRouteKey = (typeof OPTIONAL_ROUTE_KEYS)[number];
39
+
40
+ export async function analyzeAppManifestTarget(
41
+ request: DoctorAnalysisRequest,
42
+ ): Promise<DoctorAnalysisResult | null> {
43
+ const targetPath = path.resolve(request.cwd, request.inputPath ?? '.');
44
+ const stats = await statOrNull(targetPath);
45
+
46
+ if (
47
+ stats === null ||
48
+ !stats.isFile() ||
49
+ path.extname(targetPath).toLowerCase() !== '.json' ||
50
+ (request.mode !== 'validate' && request.mode !== 'fix')
51
+ ) {
52
+ return null;
53
+ }
54
+
55
+ const diagnostics = await analyzeAppManifestFile(targetPath);
56
+ const plannedChanges: DoctorPlannedChange[] = [];
57
+ const fixPlan: DoctorFixPlan | null =
58
+ request.mode === 'fix'
59
+ ? {
60
+ changes: plannedChanges,
61
+ diagnostics,
62
+ profile: MANIFEST_PROFILE,
63
+ targetPath,
64
+ }
65
+ : null;
66
+
67
+ return {
68
+ appliedChecks: ['manifest'],
69
+ diagnostics,
70
+ fixPlan,
71
+ hasPackageJson: false,
72
+ plannedChanges,
73
+ profile: MANIFEST_PROFILE,
74
+ repoMarkers: [],
75
+ targetPath,
76
+ };
77
+ }
78
+
79
+ export async function analyzeAppManifestFile(filePath: string): Promise<DoctorDiagnostic[]> {
80
+ let source: string;
81
+
82
+ try {
83
+ source = await fs.readFile(filePath, 'utf8');
84
+ } catch (error) {
85
+ return [
86
+ createManifestDiagnostic({
87
+ code: 'invalid-app-manifest-json',
88
+ message: `Could not read app manifest JSON: ${toErrorMessage(error)}`,
89
+ path: filePath,
90
+ ruleId: 'manifest.json.readable',
91
+ }),
92
+ ];
93
+ }
94
+
95
+ let manifest: unknown;
96
+
97
+ try {
98
+ manifest = JSON.parse(source) as unknown;
99
+ } catch (error) {
100
+ return [
101
+ createManifestDiagnostic({
102
+ code: 'invalid-app-manifest-json',
103
+ message: `App manifest must contain valid JSON: ${toErrorMessage(error)}`,
104
+ path: filePath,
105
+ ruleId: 'manifest.json.valid',
106
+ }),
107
+ ];
108
+ }
109
+
110
+ return analyzeAppManifest(manifest, filePath);
111
+ }
112
+
113
+ export function analyzeAppManifest(
114
+ manifest: unknown,
115
+ manifestPath = 'manifest.json',
116
+ ): DoctorDiagnostic[] {
117
+ if (!isRecord(manifest)) {
118
+ return [
119
+ createManifestDiagnostic({
120
+ code: 'field-invalid',
121
+ message: 'App manifest root must be a JSON object.',
122
+ path: manifestPath,
123
+ ruleId: 'manifest.root.valid-shape',
124
+ }),
125
+ ];
126
+ }
127
+
128
+ const diagnostics: DoctorDiagnostic[] = [];
129
+ const { settings } = manifest;
130
+
131
+ if (isRecord(settings) && hasOwn(settings, 'authFlow')) {
132
+ diagnostics.push(
133
+ createManifestDiagnostic({
134
+ code: 'field-invalid',
135
+ message:
136
+ 'settings.authFlow was removed. Move this configuration manually to infra.auth.flow; Doctor will not migrate it automatically.',
137
+ path: manifestPath,
138
+ ruleId: 'manifest.settings.auth-flow.removed',
139
+ }),
140
+ );
141
+ }
142
+
143
+ const { infra } = manifest;
144
+ if (infra === undefined) {
145
+ return diagnostics;
146
+ }
147
+
148
+ if (!isRecord(infra)) {
149
+ diagnostics.push(
150
+ createManifestDiagnostic({
151
+ code: 'field-invalid',
152
+ message: 'manifest.infra must be a JSON object when present.',
153
+ path: manifestPath,
154
+ ruleId: 'manifest.infra.valid-shape',
155
+ }),
156
+ );
157
+ return diagnostics;
158
+ }
159
+
160
+ diagnostics.push(...analyzeSecretStoreManifest(infra, manifestPath));
161
+
162
+ const { auth } = infra;
163
+ if (auth === undefined) {
164
+ return diagnostics;
165
+ }
166
+
167
+ if (!isRecord(auth)) {
168
+ diagnostics.push(
169
+ createManifestDiagnostic({
170
+ code: 'field-invalid',
171
+ message: 'manifest.infra.auth must be a JSON object when present.',
172
+ path: manifestPath,
173
+ ruleId: 'manifest.auth.valid-shape',
174
+ }),
175
+ );
176
+ return diagnostics;
177
+ }
178
+
179
+ const flow = parseAuthFlow(auth.flow, manifestPath, diagnostics);
180
+ if (flow !== null) {
181
+ validateAuthFlowRelationships(flow, manifestPath, diagnostics);
182
+ }
183
+
184
+ validateAuthorization(auth.authorization, manifestPath, diagnostics);
185
+
186
+ return diagnostics;
187
+ }
188
+
189
+ function parseAuthFlow(
190
+ value: unknown,
191
+ manifestPath: string,
192
+ diagnostics: DoctorDiagnostic[],
193
+ ): AuthFlowConfig | null {
194
+ if (value === undefined) {
195
+ return resolveAuthFlow();
196
+ }
197
+
198
+ if (!isRecord(value)) {
199
+ diagnostics.push(
200
+ createManifestDiagnostic({
201
+ code: 'field-invalid',
202
+ message: 'manifest.infra.auth.flow must be a JSON object when present.',
203
+ path: manifestPath,
204
+ ruleId: 'manifest.auth.flow.valid-shape',
205
+ }),
206
+ );
207
+ return null;
208
+ }
209
+
210
+ const signInRoute = readRequiredRoute(value, 'signInRoute', manifestPath, diagnostics);
211
+ const postSignInRoute = readRequiredRoute(value, 'postSignInRoute', manifestPath, diagnostics);
212
+ const optionalRoutes = readOptionalRoutes(value, manifestPath, diagnostics);
213
+
214
+ if (signInRoute === null || postSignInRoute === null || optionalRoutes === null) {
215
+ return null;
216
+ }
217
+
218
+ return resolveAuthFlow({
219
+ signInRoute,
220
+ postSignInRoute,
221
+ ...optionalRoutes,
222
+ });
223
+ }
224
+
225
+ function readRequiredRoute(
226
+ flow: Record<string, unknown>,
227
+ key: 'postSignInRoute' | 'signInRoute',
228
+ manifestPath: string,
229
+ diagnostics: DoctorDiagnostic[],
230
+ ): string | null {
231
+ const value = flow[key];
232
+
233
+ if (typeof value !== 'string' || value.trim() === '') {
234
+ diagnostics.push(
235
+ createManifestDiagnostic({
236
+ code: value === undefined ? 'field-missing' : 'field-invalid',
237
+ message: `manifest.infra.auth.flow.${key} must be a non-empty string.`,
238
+ path: manifestPath,
239
+ ruleId: 'manifest.auth.flow.route.required',
240
+ }),
241
+ );
242
+ return null;
243
+ }
244
+
245
+ return value;
246
+ }
247
+
248
+ function readOptionalRoutes(
249
+ flow: Record<string, unknown>,
250
+ manifestPath: string,
251
+ diagnostics: DoctorDiagnostic[],
252
+ ): Pick<AuthFlowConfig, OptionalRouteKey> | null {
253
+ const routes: Partial<Pick<AuthFlowConfig, OptionalRouteKey>> = {};
254
+ let valid = true;
255
+
256
+ for (const key of OPTIONAL_ROUTE_KEYS) {
257
+ const value = flow[key];
258
+ if (value === undefined) {
259
+ continue;
260
+ }
261
+
262
+ if (typeof value !== 'string' || value.trim() === '') {
263
+ valid = false;
264
+ diagnostics.push(
265
+ createManifestDiagnostic({
266
+ code: 'field-invalid',
267
+ message: `manifest.infra.auth.flow.${key} must be a non-empty string when present.`,
268
+ path: manifestPath,
269
+ ruleId: 'manifest.auth.flow.route.valid',
270
+ }),
271
+ );
272
+ continue;
273
+ }
274
+
275
+ routes[key] = value;
276
+ }
277
+
278
+ return valid ? routes : null;
279
+ }
280
+
281
+ function validateAuthFlowRelationships(
282
+ flow: AuthFlowConfig,
283
+ manifestPath: string,
284
+ diagnostics: DoctorDiagnostic[],
285
+ ): void {
286
+ const actionRoutes = AUTH_ACTION_ROUTE_KEYS.flatMap((key) => {
287
+ const value = flow[key];
288
+ return typeof value === 'string' ? [{ key, route: normalizeRoute(value) }] : [];
289
+ });
290
+ const routesByName = new Map<string, AuthActionRouteKey[]>();
291
+
292
+ for (const entry of actionRoutes) {
293
+ const keys = routesByName.get(entry.route) ?? [];
294
+ keys.push(entry.key);
295
+ routesByName.set(entry.route, keys);
296
+ }
297
+
298
+ for (const [route, keys] of routesByName) {
299
+ if (keys.length < 2) {
300
+ continue;
301
+ }
302
+
303
+ diagnostics.push(
304
+ createManifestDiagnostic({
305
+ code: 'field-invalid',
306
+ message: `Auth action routes must be unique; ${keys.join(', ')} all resolve to "${route}".`,
307
+ path: manifestPath,
308
+ ruleId: 'manifest.auth.flow.route.unique',
309
+ }),
310
+ );
311
+ }
312
+
313
+ const postSignInRoute = normalizeRoute(flow.postSignInRoute);
314
+ const collidingAction = actionRoutes.find((entry) => entry.route === postSignInRoute);
315
+ if (collidingAction !== undefined) {
316
+ diagnostics.push(
317
+ createManifestDiagnostic({
318
+ code: 'field-invalid',
319
+ message: `postSignInRoute must resolve outside the auth action routes; it collides with ${collidingAction.key}.`,
320
+ path: manifestPath,
321
+ ruleId: 'manifest.auth.flow.post-sign-in.distinct',
322
+ }),
323
+ );
324
+ }
325
+
326
+ if (flow.unauthorizedRoute === undefined) {
327
+ return;
328
+ }
329
+
330
+ const unauthorizedRoute = normalizeRoute(flow.unauthorizedRoute);
331
+ const signInRoute = normalizeRoute(flow.signInRoute);
332
+
333
+ if (unauthorizedRoute === postSignInRoute) {
334
+ diagnostics.push(
335
+ createManifestDiagnostic({
336
+ code: 'field-invalid',
337
+ message: 'unauthorizedRoute must not resolve to postSignInRoute.',
338
+ path: manifestPath,
339
+ ruleId: 'manifest.auth.flow.unauthorized-route.valid',
340
+ }),
341
+ );
342
+ }
343
+
344
+ if (unauthorizedRoute === signInRoute) {
345
+ return;
346
+ }
347
+
348
+ const collidingActionRoute = actionRoutes.find(
349
+ (entry) => entry.key !== 'signInRoute' && entry.route === unauthorizedRoute,
350
+ );
351
+ if (collidingActionRoute !== undefined) {
352
+ diagnostics.push(
353
+ createManifestDiagnostic({
354
+ code: 'field-invalid',
355
+ message: `unauthorizedRoute may alias signInRoute, but it must not collide with ${collidingActionRoute.key}.`,
356
+ path: manifestPath,
357
+ ruleId: 'manifest.auth.flow.unauthorized-route.valid',
358
+ }),
359
+ );
360
+ }
361
+ }
362
+
363
+ function validateAuthorization(
364
+ value: unknown,
365
+ manifestPath: string,
366
+ diagnostics: DoctorDiagnostic[],
367
+ ): void {
368
+ if (value === undefined) {
369
+ return;
370
+ }
371
+
372
+ if (!isRecord(value)) {
373
+ diagnostics.push(
374
+ createManifestDiagnostic({
375
+ code: 'field-invalid',
376
+ message:
377
+ 'manifest.infra.auth.authorization must be a JSON object when explicitly configured.',
378
+ path: manifestPath,
379
+ ruleId: 'manifest.auth.authorization.valid-shape',
380
+ }),
381
+ );
382
+ return;
383
+ }
384
+
385
+ validateAuthorizationField({
386
+ allowed: AUTHZ_KINDS,
387
+ diagnostics,
388
+ fieldName: 'kind',
389
+ manifestPath,
390
+ value: value.kind,
391
+ ruleId: 'manifest.auth.authorization.kind.valid',
392
+ });
393
+ validateAuthorizationField({
394
+ allowed: AUTHZ_ENGINES,
395
+ diagnostics,
396
+ fieldName: 'engine',
397
+ manifestPath,
398
+ value: value.engine,
399
+ ruleId: 'manifest.auth.authorization.engine.valid',
400
+ });
401
+ }
402
+
403
+ function validateAuthorizationField(request: {
404
+ readonly allowed: readonly string[];
405
+ readonly diagnostics: DoctorDiagnostic[];
406
+ readonly fieldName: 'engine' | 'kind';
407
+ readonly manifestPath: string;
408
+ readonly ruleId: DoctorRuleId;
409
+ readonly value: unknown;
410
+ }): void {
411
+ if (typeof request.value === 'string' && request.allowed.includes(request.value)) {
412
+ return;
413
+ }
414
+
415
+ request.diagnostics.push(
416
+ createManifestDiagnostic({
417
+ code: request.value === undefined ? 'field-missing' : 'field-invalid',
418
+ message: `Explicit authorization.${request.fieldName} must be one of: ${request.allowed.join(', ')}.`,
419
+ path: request.manifestPath,
420
+ ruleId: request.ruleId,
421
+ }),
422
+ );
423
+ }
424
+
425
+ function createManifestDiagnostic(request: {
426
+ readonly code: DoctorDiagnosticCode;
427
+ readonly message: string;
428
+ readonly path: string;
429
+ readonly ruleId: DoctorRuleId;
430
+ }): DoctorDiagnostic {
431
+ return {
432
+ ...request,
433
+ profile: MANIFEST_PROFILE,
434
+ severity: 'error',
435
+ };
436
+ }
437
+
438
+ function normalizeRoute(route: string): string {
439
+ const normalized = route.trim().replace(/^\/+/, '').replace(/\/+$/, '');
440
+ return normalized === '' ? 'index' : normalized;
441
+ }
442
+
443
+ function hasOwn(record: Record<string, unknown>, key: string): boolean {
444
+ return Object.prototype.hasOwnProperty.call(record, key);
445
+ }
446
+
447
+ function isRecord(value: unknown): value is Record<string, unknown> {
448
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
449
+ }
450
+
451
+ async function statOrNull(filePath: string) {
452
+ try {
453
+ return await fs.stat(filePath);
454
+ } catch {
455
+ return null;
456
+ }
457
+ }
458
+
459
+ function toErrorMessage(error: unknown): string {
460
+ return error instanceof Error ? error.message : String(error);
461
+ }
@@ -21,8 +21,13 @@ import { runCli } from './cli/standalone.js';
21
21
  *
22
22
  * - strict `public-package` validation for extracted public package repos
23
23
  * - light recognized `integration-monorepo` validation for `ankhorage4`
24
+ * - canonical app-manifest authentication validation for JSON file targets
24
25
  * - non-mutating `fix` plans for deterministic mechanical changes only
25
26
  *
27
+ * Manifest validation accepts `infra.auth.flow` as the only auth-flow location, rejects
28
+ * `settings.authFlow` with a manual-migration diagnostic, and permits authentication without
29
+ * an authorization block.
30
+ *
26
31
  * Still deferred:
27
32
  *
28
33
  * - GitHub checks
@@ -32,7 +37,7 @@ import { runCli } from './cli/standalone.js';
32
37
  *
33
38
  * Path handling:
34
39
  *
35
- * - pass `[path]`, or
40
+ * - pass a repo/package directory or app-manifest JSON file as `[path]`, or
36
41
  * - omit it to inspect the current working directory
37
42
  *
38
43
  * @usage