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