create-principles-disciple 1.130.3 → 1.131.1
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.
|
@@ -10,6 +10,7 @@ import { migrateLegacyExtensionBackups, reservePdBackupDestination, resolvePdBac
|
|
|
10
10
|
import { resolveExtensionsDir, resolveUpdateLayout, resolvePluginDir, readCurrentVersion, } from '../utils/installed-layout.js';
|
|
11
11
|
import { ActivationCompatibilityReadModel } from '@principles/core/runtime-v2';
|
|
12
12
|
import { collectFileDepLinkSpecs } from '../utils/update-links.js';
|
|
13
|
+
import { updateMutationController, LEGACY_MUTATION_AUTHORITY, } from '../update/mutation-controller.js';
|
|
13
14
|
/**
|
|
14
15
|
* Legacy rule contract preflight (2026-08-19): refuse to swap the runtime
|
|
15
16
|
* while an ACTIVE owner-approved rule still depends on a RuleHost contract
|
|
@@ -100,6 +101,40 @@ function logLegacyBackupMigration(source) {
|
|
|
100
101
|
// ---------------------------------------------------------------------------
|
|
101
102
|
// Core update operations (inline to avoid cross-package import)
|
|
102
103
|
// ---------------------------------------------------------------------------
|
|
104
|
+
/**
|
|
105
|
+
* Read the identity of a staged release package.
|
|
106
|
+
*
|
|
107
|
+
* Returns undefined when the file is missing, malformed, or when the package
|
|
108
|
+
* does not self-identify as principles-disciple with a valid semver version
|
|
109
|
+
* (rc-1/rc-2: unknown JSON is validated through guards, never a type
|
|
110
|
+
* assertion).
|
|
111
|
+
*
|
|
112
|
+
* Refusing an identity-less staged package BEFORE any production copy turns
|
|
113
|
+
* silent runtime corruption into a loud, structured error (rc-9). Regression
|
|
114
|
+
* from 2026-09-03: a stub tarball carrying only a fake version with no
|
|
115
|
+
* package name was copied into the real ~/.pd/runtime, which made the update
|
|
116
|
+
* page report a false "already latest" and blocked every future update. A
|
|
117
|
+
* real principles-disciple / create-principles-disciple tarball always
|
|
118
|
+
* carries name + valid semver.
|
|
119
|
+
*/
|
|
120
|
+
function readStagedPackageIdentity(pkgPath) {
|
|
121
|
+
try {
|
|
122
|
+
const raw = fs.readFileSync(pkgPath, 'utf-8');
|
|
123
|
+
const parsed = JSON.parse(raw);
|
|
124
|
+
if (!isRecord(parsed))
|
|
125
|
+
return undefined;
|
|
126
|
+
if (parsed.name !== 'principles-disciple')
|
|
127
|
+
return undefined;
|
|
128
|
+
if (typeof parsed.version !== 'string' || semver.valid(parsed.version) === null)
|
|
129
|
+
return undefined;
|
|
130
|
+
return { name: parsed.name, version: parsed.version };
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const STAGED_PACKAGE_REFUSAL_MESSAGE = 'Update source is not a valid principles-disciple release (package missing identity or valid version).';
|
|
137
|
+
const STAGED_PACKAGE_REFUSAL_NEXT_ACTION = 'Try the update again later, or run the official installer (npx create-principles-disciple) to repair PD.';
|
|
103
138
|
/**
|
|
104
139
|
* Recursively copy a directory tree.
|
|
105
140
|
*
|
|
@@ -495,6 +530,30 @@ async function doApplyUpdate(options, workspaceDir) {
|
|
|
495
530
|
// not support GNU tar's --force-local, so relative path is the universal fix.
|
|
496
531
|
execFileSync('tar', ['xzf', 'package.tgz', '--strip-components=1'], { cwd: tempDir, stdio: 'pipe' });
|
|
497
532
|
fs.unlinkSync(tarballPath);
|
|
533
|
+
// 3.5 Staged-package identity guard (2026-09-03 regression): refuse a
|
|
534
|
+
// tarball that does not name itself principles-disciple with a valid
|
|
535
|
+
// version BEFORE any production byte is copied — the incident stub
|
|
536
|
+
// carried no name. A real release always carries both.
|
|
537
|
+
if (readStagedPackageIdentity(path.join(tempDir, 'package.json')) === undefined) {
|
|
538
|
+
if (fs.existsSync(tempDir))
|
|
539
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
540
|
+
if (backupPath && fs.existsSync(backupPath))
|
|
541
|
+
fs.rmSync(backupPath, { recursive: true, force: true });
|
|
542
|
+
appendUpdateHistory(workspaceDir, {
|
|
543
|
+
fromVersion,
|
|
544
|
+
toVersion: toVersion ?? 'unknown',
|
|
545
|
+
success: false,
|
|
546
|
+
kind: 'refusal',
|
|
547
|
+
reason: 'staged_package_invalid',
|
|
548
|
+
nextAction: STAGED_PACKAGE_REFUSAL_NEXT_ACTION,
|
|
549
|
+
});
|
|
550
|
+
return {
|
|
551
|
+
success: false,
|
|
552
|
+
message: STAGED_PACKAGE_REFUSAL_MESSAGE,
|
|
553
|
+
reason: 'staged_package_invalid',
|
|
554
|
+
nextAction: STAGED_PACKAGE_REFUSAL_NEXT_ACTION,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
498
557
|
// 4. Compute diff and apply.
|
|
499
558
|
// Fix 3: we ONLY apply modified + added files. We deliberately skip ALL
|
|
500
559
|
// deletions — the tarball (principles-disciple) only contains dist/,
|
|
@@ -858,6 +917,29 @@ async function doInlineFullUpdate(workspaceDir) {
|
|
|
858
917
|
// gateway is stopped or any production file is copied.
|
|
859
918
|
const newPkgPath = path.join(tempDir, 'plugin', 'package.json');
|
|
860
919
|
const stagedVersion = readCurrentVersion(path.join(tempDir, 'plugin'));
|
|
920
|
+
// Staged-package identity guard (2026-09-03 regression): the staged plugin
|
|
921
|
+
// must self-identify as principles-disciple with a valid version before
|
|
922
|
+
// the gateway is stopped or any production file is copied. The incident
|
|
923
|
+
// stub carried only a fake version with no package name.
|
|
924
|
+
if (readStagedPackageIdentity(path.join(tempDir, 'plugin', 'package.json')) === undefined) {
|
|
925
|
+
if (tempDir && fs.existsSync(tempDir))
|
|
926
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
927
|
+
appendUpdateHistory(workspaceDir, {
|
|
928
|
+
fromVersion,
|
|
929
|
+
toVersion: stagedVersion ?? 'unknown',
|
|
930
|
+
success: false,
|
|
931
|
+
kind: 'refusal',
|
|
932
|
+
reason: 'staged_package_invalid',
|
|
933
|
+
nextAction: STAGED_PACKAGE_REFUSAL_NEXT_ACTION,
|
|
934
|
+
});
|
|
935
|
+
return {
|
|
936
|
+
success: false,
|
|
937
|
+
message: STAGED_PACKAGE_REFUSAL_MESSAGE,
|
|
938
|
+
reason: 'staged_package_invalid',
|
|
939
|
+
nextAction: STAGED_PACKAGE_REFUSAL_NEXT_ACTION,
|
|
940
|
+
requiresRestart: false,
|
|
941
|
+
};
|
|
942
|
+
}
|
|
861
943
|
const progressed = fromVersion !== 'unknown' &&
|
|
862
944
|
stagedVersion !== undefined &&
|
|
863
945
|
semver.valid(fromVersion) !== null &&
|
|
@@ -1081,10 +1163,19 @@ async function doInlineFullUpdate(workspaceDir) {
|
|
|
1081
1163
|
// ---------------------------------------------------------------------------
|
|
1082
1164
|
// Route handler
|
|
1083
1165
|
// ---------------------------------------------------------------------------
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1166
|
+
/**
|
|
1167
|
+
* PRI-659: the four mutation kinds below are the legacy console updater's
|
|
1168
|
+
* implementations, registered into the MutationController (ADR-0023/0024
|
|
1169
|
+
* migration boundary). handleUpdateRoute is now a thin dispatcher: it maps
|
|
1170
|
+
* the URL subPath to a mutation kind and lets the controller resolve the
|
|
1171
|
+
* authority. The implementation stays here verbatim (replace-then-delete —
|
|
1172
|
+
* no third updater, no logic duplication); when ReleaseManager matures it
|
|
1173
|
+
* registers under `release-manager` for the same kinds and this route layer
|
|
1174
|
+
* needs no further change.
|
|
1175
|
+
*/
|
|
1176
|
+
function legacyCheckMutation(req, res, ctx) {
|
|
1177
|
+
return (async () => {
|
|
1178
|
+
const pluginDir = resolvePluginDir(ctx.workspaceDir);
|
|
1088
1179
|
if (req.method !== 'GET') {
|
|
1089
1180
|
sendMethodNotAllowed(res);
|
|
1090
1181
|
return;
|
|
@@ -1111,10 +1202,11 @@ export async function handleUpdateRoute(req, res, workspaceDir, subPath) {
|
|
|
1111
1202
|
catch (err) {
|
|
1112
1203
|
sendError(res, 500, 'update_check_error', err instanceof Error ? err.message : 'Unknown error');
|
|
1113
1204
|
}
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1205
|
+
})();
|
|
1206
|
+
}
|
|
1207
|
+
function legacyApplyMutation(req, res, ctx) {
|
|
1208
|
+
return (async () => {
|
|
1209
|
+
const pluginDir = resolvePluginDir(ctx.workspaceDir);
|
|
1118
1210
|
if (req.method !== 'POST') {
|
|
1119
1211
|
sendMethodNotAllowed(res);
|
|
1120
1212
|
return;
|
|
@@ -1146,7 +1238,7 @@ export async function handleUpdateRoute(req, res, workspaceDir, subPath) {
|
|
|
1146
1238
|
return;
|
|
1147
1239
|
}
|
|
1148
1240
|
// Path traversal validation
|
|
1149
|
-
if (!validatePathInWorkspace(targetDir, workspaceDir)) {
|
|
1241
|
+
if (!validatePathInWorkspace(targetDir, ctx.workspaceDir)) {
|
|
1150
1242
|
sendBadRequest(res, 'targetDir must be within workspace or extensions directory');
|
|
1151
1243
|
return;
|
|
1152
1244
|
}
|
|
@@ -1154,7 +1246,7 @@ export async function handleUpdateRoute(req, res, workspaceDir, subPath) {
|
|
|
1154
1246
|
targetDir,
|
|
1155
1247
|
mergeStrategy,
|
|
1156
1248
|
createBackup,
|
|
1157
|
-
}, workspaceDir);
|
|
1249
|
+
}, ctx.workspaceDir);
|
|
1158
1250
|
sendSuccess(res, result);
|
|
1159
1251
|
}
|
|
1160
1252
|
catch (err) {
|
|
@@ -1164,10 +1256,11 @@ export async function handleUpdateRoute(req, res, workspaceDir, subPath) {
|
|
|
1164
1256
|
}
|
|
1165
1257
|
sendError(res, 500, 'update_apply_error', err instanceof Error ? err.message : 'Unknown error');
|
|
1166
1258
|
}
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1259
|
+
})();
|
|
1260
|
+
}
|
|
1261
|
+
function legacyRollbackMutation(req, res, ctx) {
|
|
1262
|
+
return (async () => {
|
|
1263
|
+
const pluginDir = resolvePluginDir(ctx.workspaceDir);
|
|
1171
1264
|
if (req.method !== 'POST') {
|
|
1172
1265
|
sendMethodNotAllowed(res);
|
|
1173
1266
|
return;
|
|
@@ -1189,15 +1282,15 @@ export async function handleUpdateRoute(req, res, workspaceDir, subPath) {
|
|
|
1189
1282
|
return;
|
|
1190
1283
|
}
|
|
1191
1284
|
// Path traversal validation
|
|
1192
|
-
if (!validatePathInWorkspace(targetDir, workspaceDir)) {
|
|
1285
|
+
if (!validatePathInWorkspace(targetDir, ctx.workspaceDir)) {
|
|
1193
1286
|
sendBadRequest(res, 'targetDir must be within workspace or extensions directory');
|
|
1194
1287
|
return;
|
|
1195
1288
|
}
|
|
1196
|
-
if (!validatePathInWorkspace(backupDir, workspaceDir)) {
|
|
1289
|
+
if (!validatePathInWorkspace(backupDir, ctx.workspaceDir)) {
|
|
1197
1290
|
sendBadRequest(res, 'backupDir must be within the workspace, extensions directory, or PD backups directory');
|
|
1198
1291
|
return;
|
|
1199
1292
|
}
|
|
1200
|
-
const result = await doRollbackUpdate({ targetDir, backupDir }, workspaceDir);
|
|
1293
|
+
const result = await doRollbackUpdate({ targetDir, backupDir }, ctx.workspaceDir);
|
|
1201
1294
|
sendSuccess(res, result);
|
|
1202
1295
|
}
|
|
1203
1296
|
catch (err) {
|
|
@@ -1207,22 +1300,42 @@ export async function handleUpdateRoute(req, res, workspaceDir, subPath) {
|
|
|
1207
1300
|
}
|
|
1208
1301
|
sendError(res, 500, 'update_rollback_error', err instanceof Error ? err.message : 'Unknown error');
|
|
1209
1302
|
}
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1303
|
+
})();
|
|
1304
|
+
}
|
|
1305
|
+
function legacyApplyFullMutation(req, res, ctx) {
|
|
1306
|
+
return (async () => {
|
|
1214
1307
|
if (req.method !== 'POST') {
|
|
1215
1308
|
sendMethodNotAllowed(res);
|
|
1216
1309
|
return;
|
|
1217
1310
|
}
|
|
1218
1311
|
try {
|
|
1219
|
-
const result = await doInlineFullUpdate(workspaceDir);
|
|
1312
|
+
const result = await doInlineFullUpdate(ctx.workspaceDir);
|
|
1220
1313
|
sendSuccess(res, result);
|
|
1221
1314
|
}
|
|
1222
1315
|
catch (err) {
|
|
1223
1316
|
sendError(res, 500, 'update_apply_full_error', err instanceof Error ? err.message : 'Unknown error');
|
|
1224
1317
|
}
|
|
1318
|
+
})();
|
|
1319
|
+
}
|
|
1320
|
+
// PRI-659: register the legacy authority (ADR-0024 D-1 fallback authority)
|
|
1321
|
+
// for every mutation kind. Registration is the ONLY coupling between this
|
|
1322
|
+
// implementation and the controller — no other module needs to know where
|
|
1323
|
+
// the legacy implementation lives.
|
|
1324
|
+
updateMutationController.register('check', { name: LEGACY_MUTATION_AUTHORITY, handler: legacyCheckMutation });
|
|
1325
|
+
updateMutationController.register('apply', { name: LEGACY_MUTATION_AUTHORITY, handler: legacyApplyMutation });
|
|
1326
|
+
updateMutationController.register('apply-full', { name: LEGACY_MUTATION_AUTHORITY, handler: legacyApplyFullMutation });
|
|
1327
|
+
updateMutationController.register('rollback', { name: LEGACY_MUTATION_AUTHORITY, handler: legacyRollbackMutation });
|
|
1328
|
+
const UPDATE_MUTATION_KINDS = new Map([
|
|
1329
|
+
['/check', 'check'],
|
|
1330
|
+
['/apply', 'apply'],
|
|
1331
|
+
['/apply-full', 'apply-full'],
|
|
1332
|
+
['/rollback', 'rollback'],
|
|
1333
|
+
]);
|
|
1334
|
+
export async function handleUpdateRoute(req, res, workspaceDir, subPath) {
|
|
1335
|
+
const kind = UPDATE_MUTATION_KINDS.get(subPath);
|
|
1336
|
+
if (kind === undefined) {
|
|
1337
|
+
sendNotFound(res, `Update route not found: ${subPath}`);
|
|
1225
1338
|
return;
|
|
1226
1339
|
}
|
|
1227
|
-
|
|
1340
|
+
await updateMutationController.dispatch(req, res, { workspaceDir }, kind);
|
|
1228
1341
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mutation Controller — PRI-659 migration boundary.
|
|
3
|
+
*
|
|
4
|
+
* Governed by:
|
|
5
|
+
* - ADR-0023 (PD Installation Architecture Decisions) — runtime is written
|
|
6
|
+
* only by sanctioned authorities.
|
|
7
|
+
* - ADR-0024 (PD Runtime Mutation Governance, D-1) — the Console Web updater
|
|
8
|
+
* must converge into a trigger/presentation layer over ReleaseManager; it
|
|
9
|
+
* must not remain an independent mutation authority long-term.
|
|
10
|
+
*
|
|
11
|
+
* Role:
|
|
12
|
+
* The controller is the SINGLE routing point between the HTTP surface
|
|
13
|
+
* (`/api/update/*`) and whichever authority actually performs the mutation.
|
|
14
|
+
* It owns:
|
|
15
|
+
* - the mutation-kind registry (check / apply / apply-full / rollback),
|
|
16
|
+
* - authority resolution with an explicit preferred authority
|
|
17
|
+
* (`release-manager`, ADR-0024 D-1) and a safe fallback
|
|
18
|
+
* (`legacy-console-updater`) until ReleaseManager leaves shadow mode,
|
|
19
|
+
* - observability: every dispatched response carries the
|
|
20
|
+
* `X-PD-Mutation-Authority` header so operators can see which
|
|
21
|
+
* authority served a mutation.
|
|
22
|
+
*
|
|
23
|
+
* Non-goals (deliberate):
|
|
24
|
+
* - The controller performs NO file mutation itself. It only routes. It is
|
|
25
|
+
* therefore not a new mutation authority (asserted by migration tests).
|
|
26
|
+
* - It does not wrap, copy, or replace the legacy updater implementation.
|
|
27
|
+
* The legacy implementation stays in `routes/update.ts` and registers
|
|
28
|
+
* itself here. When ReleaseManager matures, it registers under
|
|
29
|
+
* `RELEASE_MANAGER_AUTHORITY` for the same kinds and the route layer
|
|
30
|
+
* needs no further change.
|
|
31
|
+
*/
|
|
32
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
33
|
+
/** Authority that exists today: the inline console updater (routes/update.ts). */
|
|
34
|
+
export declare const LEGACY_MUTATION_AUTHORITY: 'legacy-console-updater';
|
|
35
|
+
/** Authority designated by ADR-0024 D-1 as the long-term mutation authority. */
|
|
36
|
+
export declare const RELEASE_MANAGER_AUTHORITY: 'release-manager';
|
|
37
|
+
/**
|
|
38
|
+
* Preferred authority for every mutation kind. Until ReleaseManager exits
|
|
39
|
+
* shadow mode (its apply()/rollback() still refuse with
|
|
40
|
+
* `shadow_mode_read_only`) nothing registers under this name and resolution
|
|
41
|
+
* falls back to the legacy authority — preserving current capability
|
|
42
|
+
* (replace-then-delete, not delete-then-rebuild).
|
|
43
|
+
*/
|
|
44
|
+
export declare const PREFERRED_MUTATION_AUTHORITY: "release-manager";
|
|
45
|
+
export type MutationKind = 'check' | 'apply' | 'apply-full' | 'rollback';
|
|
46
|
+
export declare const MUTATION_KINDS: readonly MutationKind[];
|
|
47
|
+
export interface MutationContext {
|
|
48
|
+
readonly workspaceDir: string;
|
|
49
|
+
}
|
|
50
|
+
export type MutationHandler = (req: IncomingMessage, res: ServerResponse, ctx: MutationContext) => Promise<void>;
|
|
51
|
+
export interface MutationAuthority {
|
|
52
|
+
readonly name: string;
|
|
53
|
+
readonly handler: MutationHandler;
|
|
54
|
+
}
|
|
55
|
+
export interface MutationGovernanceInfo {
|
|
56
|
+
readonly active: string;
|
|
57
|
+
readonly preferred: string;
|
|
58
|
+
readonly fallback: boolean;
|
|
59
|
+
readonly available: readonly string[];
|
|
60
|
+
}
|
|
61
|
+
export interface ResolvedAuthority {
|
|
62
|
+
readonly authority: MutationAuthority;
|
|
63
|
+
readonly fallback: boolean;
|
|
64
|
+
}
|
|
65
|
+
export declare class MutationController {
|
|
66
|
+
private readonly authorities;
|
|
67
|
+
register(kind: MutationKind, authority: MutationAuthority): void;
|
|
68
|
+
unregister(kind: MutationKind, name: string): boolean;
|
|
69
|
+
hasAuthority(kind: MutationKind, name: string): boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Resolve which authority serves `kind`: the preferred authority when
|
|
72
|
+
* registered, otherwise the legacy authority as an explicit fallback.
|
|
73
|
+
* Throws when nothing is registered — an unregistered kind must fail loud,
|
|
74
|
+
* not silently mutate.
|
|
75
|
+
*/
|
|
76
|
+
resolveAuthority(kind: MutationKind): ResolvedAuthority;
|
|
77
|
+
/**
|
|
78
|
+
* Route one mutation request. Response body contract is owned by the
|
|
79
|
+
* authority handler; the controller only annotates the response with the
|
|
80
|
+
* resolved authority so governance state is observable per request.
|
|
81
|
+
*/
|
|
82
|
+
dispatch(req: IncomingMessage, res: ServerResponse, ctx: MutationContext, kind: MutationKind): Promise<void>;
|
|
83
|
+
/** Governance snapshot for observability and migration tests. */
|
|
84
|
+
describeGovernance(): Record<MutationKind, MutationGovernanceInfo>;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Process-wide controller for the `/api/update/*` surface. The legacy
|
|
88
|
+
* updater registers its handlers at module load of `routes/update.ts`.
|
|
89
|
+
*/
|
|
90
|
+
export declare const updateMutationController: MutationController;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/** Authority that exists today: the inline console updater (routes/update.ts). */
|
|
2
|
+
export const LEGACY_MUTATION_AUTHORITY = 'legacy-console-updater';
|
|
3
|
+
/** Authority designated by ADR-0024 D-1 as the long-term mutation authority. */
|
|
4
|
+
export const RELEASE_MANAGER_AUTHORITY = 'release-manager';
|
|
5
|
+
/**
|
|
6
|
+
* Preferred authority for every mutation kind. Until ReleaseManager exits
|
|
7
|
+
* shadow mode (its apply()/rollback() still refuse with
|
|
8
|
+
* `shadow_mode_read_only`) nothing registers under this name and resolution
|
|
9
|
+
* falls back to the legacy authority — preserving current capability
|
|
10
|
+
* (replace-then-delete, not delete-then-rebuild).
|
|
11
|
+
*/
|
|
12
|
+
export const PREFERRED_MUTATION_AUTHORITY = RELEASE_MANAGER_AUTHORITY;
|
|
13
|
+
export const MUTATION_KINDS = ['check', 'apply', 'apply-full', 'rollback'];
|
|
14
|
+
export class MutationController {
|
|
15
|
+
authorities = new Map();
|
|
16
|
+
register(kind, authority) {
|
|
17
|
+
let table = this.authorities.get(kind);
|
|
18
|
+
if (table === undefined) {
|
|
19
|
+
table = new Map();
|
|
20
|
+
this.authorities.set(kind, table);
|
|
21
|
+
}
|
|
22
|
+
table.set(authority.name, authority);
|
|
23
|
+
}
|
|
24
|
+
unregister(kind, name) {
|
|
25
|
+
return this.authorities.get(kind)?.delete(name) ?? false;
|
|
26
|
+
}
|
|
27
|
+
hasAuthority(kind, name) {
|
|
28
|
+
return this.authorities.get(kind)?.has(name) ?? false;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Resolve which authority serves `kind`: the preferred authority when
|
|
32
|
+
* registered, otherwise the legacy authority as an explicit fallback.
|
|
33
|
+
* Throws when nothing is registered — an unregistered kind must fail loud,
|
|
34
|
+
* not silently mutate.
|
|
35
|
+
*/
|
|
36
|
+
resolveAuthority(kind) {
|
|
37
|
+
const table = this.authorities.get(kind);
|
|
38
|
+
if (table === undefined || table.size === 0) {
|
|
39
|
+
throw new Error(`No mutation authority registered for kind: ${kind}`);
|
|
40
|
+
}
|
|
41
|
+
const preferred = table.get(PREFERRED_MUTATION_AUTHORITY);
|
|
42
|
+
if (preferred !== undefined) {
|
|
43
|
+
return { authority: preferred, fallback: false };
|
|
44
|
+
}
|
|
45
|
+
const legacy = table.get(LEGACY_MUTATION_AUTHORITY);
|
|
46
|
+
if (legacy !== undefined) {
|
|
47
|
+
return { authority: legacy, fallback: true };
|
|
48
|
+
}
|
|
49
|
+
// Multiple non-preferred, non-legacy authorities: refuse to guess.
|
|
50
|
+
throw new Error(`No preferred (${PREFERRED_MUTATION_AUTHORITY}) or legacy (${LEGACY_MUTATION_AUTHORITY}) authority registered for kind: ${kind}`);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Route one mutation request. Response body contract is owned by the
|
|
54
|
+
* authority handler; the controller only annotates the response with the
|
|
55
|
+
* resolved authority so governance state is observable per request.
|
|
56
|
+
*/
|
|
57
|
+
// eslint-disable-next-line @typescript-eslint/max-params -- (req, res) mirrors the Node handler shape used across this server
|
|
58
|
+
async dispatch(req, res, ctx, kind) {
|
|
59
|
+
const { authority, fallback } = this.resolveAuthority(kind);
|
|
60
|
+
const headerValue = fallback
|
|
61
|
+
? `${authority.name} (preferred: ${PREFERRED_MUTATION_AUTHORITY} not yet available)`
|
|
62
|
+
: authority.name;
|
|
63
|
+
res.setHeader('X-PD-Mutation-Authority', headerValue);
|
|
64
|
+
await authority.handler(req, res, ctx);
|
|
65
|
+
}
|
|
66
|
+
/** Governance snapshot for observability and migration tests. */
|
|
67
|
+
describeGovernance() {
|
|
68
|
+
const snapshot = {};
|
|
69
|
+
for (const kind of MUTATION_KINDS) {
|
|
70
|
+
const table = this.authorities.get(kind);
|
|
71
|
+
const available = table !== undefined ? [...table.keys()] : [];
|
|
72
|
+
let info;
|
|
73
|
+
if (table === undefined || table.size === 0) {
|
|
74
|
+
info = { active: 'none', preferred: PREFERRED_MUTATION_AUTHORITY, fallback: false, available };
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
const { authority, fallback } = this.resolveAuthority(kind);
|
|
78
|
+
info = { active: authority.name, preferred: PREFERRED_MUTATION_AUTHORITY, fallback, available };
|
|
79
|
+
}
|
|
80
|
+
snapshot[kind] = info;
|
|
81
|
+
}
|
|
82
|
+
return snapshot;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Process-wide controller for the `/api/update/*` surface. The legacy
|
|
87
|
+
* updater registers its handlers at module load of `routes/update.ts`.
|
|
88
|
+
*/
|
|
89
|
+
export const updateMutationController = new MutationController();
|