@openparachute/hub 0.7.6 → 0.7.7-rc.13
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.
- package/README.md +7 -7
- package/package.json +8 -13
- package/src/__tests__/account-api.test.ts +798 -0
- package/src/__tests__/account-session.test.ts +252 -0
- package/src/__tests__/account-token.test.ts +316 -0
- package/src/__tests__/admin-connections-credentials.test.ts +41 -0
- package/src/__tests__/admin-handlers.test.ts +25 -0
- package/src/__tests__/admin-lock.test.ts +3 -14
- package/src/__tests__/admin-module-token.test.ts +10 -30
- package/src/__tests__/admin-surfaces.test.ts +21 -0
- package/src/__tests__/api-hub-upgrade.test.ts +11 -0
- package/src/__tests__/api-mint-token.test.ts +25 -0
- package/src/__tests__/api-modules-ops.test.ts +34 -29
- package/src/__tests__/api-modules.test.ts +50 -58
- package/src/__tests__/api-revoke-token.test.ts +23 -0
- package/src/__tests__/api-settings-hub-origin.test.ts +12 -0
- package/src/__tests__/api-settings-root-redirect.test.ts +159 -4
- package/src/__tests__/api-tokens.test.ts +44 -0
- package/src/__tests__/audience-gate.test.ts +24 -0
- package/src/__tests__/bearer-scheme-casing.test.ts +110 -0
- package/src/__tests__/chrome-strip.test.ts +18 -1
- package/src/__tests__/doctor.test.ts +10 -17
- package/src/__tests__/door-contract-parity.test.ts +46 -0
- package/src/__tests__/hub-command.test.ts +70 -2
- package/src/__tests__/hub-server.test.ts +322 -1
- package/src/__tests__/hub-settings.test.ts +110 -6
- package/src/__tests__/hub.test.ts +29 -0
- package/src/__tests__/install.test.ts +359 -5
- package/src/__tests__/migrate.test.ts +3 -1
- package/src/__tests__/notes-serve.test.ts +216 -0
- package/src/__tests__/oauth-handlers.test.ts +19 -8
- package/src/__tests__/operator-token.test.ts +1 -2
- package/src/__tests__/port-assign.test.ts +37 -17
- package/src/__tests__/root-serve.test.ts +139 -0
- package/src/__tests__/scope-explanations.test.ts +22 -2
- package/src/__tests__/serve-boot.test.ts +25 -36
- package/src/__tests__/service-spec-discovery.test.ts +30 -35
- package/src/__tests__/services-manifest.test.ts +372 -132
- package/src/__tests__/sessions.test.ts +75 -28
- package/src/__tests__/setup-wizard.test.ts +7 -10
- package/src/__tests__/setup.test.ts +13 -14
- package/src/__tests__/status.test.ts +0 -5
- package/src/__tests__/surface-notes-alias.test.ts +296 -0
- package/src/account-api.ts +677 -0
- package/src/account-session.ts +132 -0
- package/src/account-token.ts +200 -0
- package/src/admin-connections.ts +4 -2
- package/src/admin-lock.ts +1 -2
- package/src/admin-module-token.ts +13 -8
- package/src/admin-surfaces.ts +2 -1
- package/src/api-hub-upgrade.ts +2 -1
- package/src/api-invites.ts +19 -0
- package/src/api-mint-token.ts +2 -1
- package/src/api-modules-ops.ts +2 -1
- package/src/api-modules.ts +10 -8
- package/src/api-revoke-token.ts +2 -1
- package/src/api-settings-hub-origin.ts +2 -1
- package/src/api-settings-root-redirect.ts +97 -20
- package/src/api-tokens.ts +2 -1
- package/src/audience-gate.ts +2 -1
- package/src/chrome-strip.ts +16 -4
- package/src/commands/hub.ts +104 -0
- package/src/commands/install.ts +97 -8
- package/src/commands/migrate.ts +5 -1
- package/src/commands/setup.ts +9 -6
- package/src/help.ts +6 -6
- package/src/hub-server.ts +318 -55
- package/src/hub-settings.ts +162 -1
- package/src/hub.ts +64 -31
- package/src/invites.ts +42 -0
- package/src/module-ops-client.ts +2 -1
- package/src/notes-serve.ts +73 -31
- package/src/oauth-handlers.ts +1 -11
- package/src/operator-token.ts +0 -1
- package/src/origin-check.ts +2 -2
- package/src/root-serve.ts +156 -0
- package/src/scope-explanations.ts +35 -5
- package/src/service-spec.ts +129 -74
- package/src/services-manifest.ts +112 -52
- package/src/sessions.ts +66 -30
- package/src/surface-notes-alias.ts +126 -0
- package/src/__tests__/admin-agent-token.test.ts +0 -173
- package/src/admin-agent-token.ts +0 -147
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import { RETIRED_MODULES } from "../service-spec.ts";
|
|
5
6
|
import {
|
|
6
7
|
type ServiceEntry,
|
|
7
8
|
ServicesManifestError,
|
|
@@ -403,9 +404,9 @@ describe("services-manifest", () => {
|
|
|
403
404
|
slug: {
|
|
404
405
|
displayName: "S",
|
|
405
406
|
path: "/app/s",
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
//
|
|
407
|
+
// Deliberately write the pre-F legacy alias to pin the
|
|
408
|
+
// normalization boundary; the schema accepts it on read.
|
|
409
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy fixture value
|
|
409
410
|
status: "pending-oauth" as any,
|
|
410
411
|
},
|
|
411
412
|
},
|
|
@@ -871,20 +872,7 @@ describe("services-manifest", () => {
|
|
|
871
872
|
});
|
|
872
873
|
});
|
|
873
874
|
|
|
874
|
-
describe("
|
|
875
|
-
// Paraclaw was renamed to parachute-agent across the ecosystem (npm
|
|
876
|
-
// package, mount path, short name). The migration was a transitional
|
|
877
|
-
// read-time rewrite that aliased legacy `name: "claw"` rows to
|
|
878
|
-
// `name: "agent"` so operators on the old shape kept routing.
|
|
879
|
-
//
|
|
880
|
-
// History: parachute-agent (the Claude-in-containers module) was retired
|
|
881
|
-
// 2026-05-20 (hub#334 added `agent` to RETIRED_MODULES), which briefly made
|
|
882
|
-
// this a one-step retirement path (claw → agent → GC'd). The 2026-06-17
|
|
883
|
-
// channel→agent rename RE-ASSIGNED `agent`/`parachute-agent` to the renamed
|
|
884
|
-
// channel module, so those names left RETIRED_MODULES — `agent` is a live
|
|
885
|
-
// module again. The claw → agent rewrite still runs; the migrated row now
|
|
886
|
-
// PERSISTS (it routes to the live agent module). The tests below assert the
|
|
887
|
-
// rewrite + the persistence.
|
|
875
|
+
describe("historical Agent row retirement", () => {
|
|
888
876
|
const claw: ServiceEntry = {
|
|
889
877
|
name: "claw",
|
|
890
878
|
port: 1944,
|
|
@@ -892,70 +880,37 @@ describe("claw → agent migration", () => {
|
|
|
892
880
|
health: "/claw/health",
|
|
893
881
|
version: "0.1.0",
|
|
894
882
|
};
|
|
895
|
-
const agent: ServiceEntry = {
|
|
896
|
-
name: "agent",
|
|
897
|
-
port: 1944,
|
|
898
|
-
paths: ["/agent"],
|
|
899
|
-
health: "/agent/health",
|
|
900
|
-
version: "0.1.0",
|
|
901
|
-
};
|
|
902
883
|
|
|
903
|
-
test("
|
|
884
|
+
test("strict reads retire the structural claw row immediately and persist the cleanup", () => {
|
|
904
885
|
const { path, cleanup } = makeTempPath();
|
|
905
886
|
try {
|
|
906
887
|
writeFileSync(path, `${JSON.stringify({ services: [claw] }, null, 2)}\n`);
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
// rename, `agent` is once again a LIVE module (the renamed channel
|
|
911
|
-
// module), so it is NO LONGER GC'd by RETIRED_MODULES — the migrated
|
|
912
|
-
// row persists and routes to the live agent module's mount.
|
|
913
|
-
expect(got.services).toEqual([agent]);
|
|
914
|
-
const reread = JSON.parse(readFileSync(path, "utf8")) as {
|
|
915
|
-
services: ServiceEntry[];
|
|
916
|
-
};
|
|
917
|
-
expect(reread.services[0]?.name).toBe("agent");
|
|
918
|
-
expect(reread.services[0]?.paths).toEqual(["/agent"]);
|
|
919
|
-
expect(reread.services[0]?.health).toBe("/agent/health");
|
|
888
|
+
expect(readManifest(path).services).toEqual([]);
|
|
889
|
+
const persisted = JSON.parse(readFileSync(path, "utf8")) as { services: ServiceEntry[] };
|
|
890
|
+
expect(persisted.services).toEqual([]);
|
|
920
891
|
} finally {
|
|
921
892
|
cleanup();
|
|
922
893
|
}
|
|
923
894
|
});
|
|
924
895
|
|
|
925
|
-
test("
|
|
926
|
-
// Pre-rename this was a one-step retirement path (claw → agent → GC'd).
|
|
927
|
-
// After the channel→agent rename (2026-06-17) `agent`/`parachute-agent`
|
|
928
|
-
// are re-assigned to the live module, so the row is NOT dropped — a
|
|
929
|
-
// stale paraclaw row now ends up pointing at the live agent module
|
|
930
|
-
// (harmless / arguably correct, since paraclaw was the original "agent").
|
|
896
|
+
test("lenient hot-path reads also hide structural claw and bare channel rows", () => {
|
|
931
897
|
const { path, cleanup } = makeTempPath();
|
|
932
898
|
try {
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
test("an already-agent entry round-trips unchanged (agent live again post-rename)", () => {
|
|
944
|
-
// Pre-hub#334 this verified the migration was idempotent; hub#334 made it
|
|
945
|
-
// GC the agent row (agent was retired). After the channel→agent rename
|
|
946
|
-
// (2026-06-17) agent is live again, so the row round-trips unchanged —
|
|
947
|
-
// back to the original idempotent behavior.
|
|
948
|
-
const { path, cleanup } = makeTempPath();
|
|
949
|
-
try {
|
|
950
|
-
writeFileSync(path, `${JSON.stringify({ services: [agent] }, null, 2)}\n`);
|
|
951
|
-
const got = readManifest(path);
|
|
952
|
-
expect(got.services).toEqual([agent]);
|
|
899
|
+
const channel: ServiceEntry = {
|
|
900
|
+
name: "channel",
|
|
901
|
+
port: 1941,
|
|
902
|
+
paths: ["/channel"],
|
|
903
|
+
health: "/channel/health",
|
|
904
|
+
version: "0.1.0",
|
|
905
|
+
};
|
|
906
|
+
writeFileSync(path, `${JSON.stringify({ services: [claw, channel] }, null, 2)}\n`);
|
|
907
|
+
expect(readManifestLenient(path, { warn: () => {} }).services).toEqual([]);
|
|
953
908
|
} finally {
|
|
954
909
|
cleanup();
|
|
955
910
|
}
|
|
956
911
|
});
|
|
957
912
|
|
|
958
|
-
test("mixed
|
|
913
|
+
test("mixed manifests retain supported rows while dropping historical Agent shapes", () => {
|
|
959
914
|
const { path, cleanup } = makeTempPath();
|
|
960
915
|
try {
|
|
961
916
|
const scribe: ServiceEntry = {
|
|
@@ -966,38 +921,13 @@ describe("claw → agent migration", () => {
|
|
|
966
921
|
version: "0.1.0",
|
|
967
922
|
};
|
|
968
923
|
writeFileSync(path, `${JSON.stringify({ services: [vault, claw, scribe] }, null, 2)}\n`);
|
|
969
|
-
|
|
970
|
-
// First read: claw migrates to agent (retired GC didn't see `claw`
|
|
971
|
-
// on the way in). Vault + scribe round-trip unchanged.
|
|
972
|
-
expect(got.services).toHaveLength(3);
|
|
973
|
-
expect(got.services[0]).toEqual(vault);
|
|
974
|
-
expect(got.services[1]).toEqual(agent);
|
|
975
|
-
expect(got.services[2]).toEqual(scribe);
|
|
976
|
-
} finally {
|
|
977
|
-
cleanup();
|
|
978
|
-
}
|
|
979
|
-
});
|
|
980
|
-
|
|
981
|
-
test("preserves nested /claw paths when present (e.g. /claw/api)", () => {
|
|
982
|
-
const { path, cleanup } = makeTempPath();
|
|
983
|
-
try {
|
|
984
|
-
const clawNested: ServiceEntry = {
|
|
985
|
-
name: "claw",
|
|
986
|
-
port: 1944,
|
|
987
|
-
paths: ["/claw", "/claw/api"],
|
|
988
|
-
health: "/claw/api/health",
|
|
989
|
-
version: "0.1.0",
|
|
990
|
-
};
|
|
991
|
-
writeFileSync(path, `${JSON.stringify({ services: [clawNested] }, null, 2)}\n`);
|
|
992
|
-
const got = readManifest(path);
|
|
993
|
-
expect(got.services[0]?.paths).toEqual(["/agent", "/agent/api"]);
|
|
994
|
-
expect(got.services[0]?.health).toBe("/agent/api/health");
|
|
924
|
+
expect(readManifest(path).services).toEqual([vault, scribe]);
|
|
995
925
|
} finally {
|
|
996
926
|
cleanup();
|
|
997
927
|
}
|
|
998
928
|
});
|
|
999
929
|
|
|
1000
|
-
test("leaves a row alone if name is claw but mount is something else
|
|
930
|
+
test("leaves a row alone if name is claw but mount is something else", () => {
|
|
1001
931
|
const { path, cleanup } = makeTempPath();
|
|
1002
932
|
try {
|
|
1003
933
|
const oddClaw: ServiceEntry = {
|
|
@@ -1009,6 +939,7 @@ describe("claw → agent migration", () => {
|
|
|
1009
939
|
};
|
|
1010
940
|
writeFileSync(path, `${JSON.stringify({ services: [oddClaw] }, null, 2)}\n`);
|
|
1011
941
|
expect(readManifest(path).services).toEqual([oddClaw]);
|
|
942
|
+
expect(readManifestLenient(path, { warn: () => {} }).services).toEqual([oddClaw]);
|
|
1012
943
|
} finally {
|
|
1013
944
|
cleanup();
|
|
1014
945
|
}
|
|
@@ -1024,6 +955,14 @@ describe("claw → agent migration", () => {
|
|
|
1024
955
|
// file so the next read is clean.
|
|
1025
956
|
describe("legacy short-name row de-dupe (parachute-app#13 / runner#4)", () => {
|
|
1026
957
|
test("drops the short-name row when a same-port manifestName row exists", () => {
|
|
958
|
+
// Fixture uses `surface` (the ACTUAL short-name twin of `parachute-surface`
|
|
959
|
+
// per the structural rule: b.name === `parachute-${a.name}`) rather than
|
|
960
|
+
// `app`. Pre-hub-parity-P5 this test used `app` here, but that only
|
|
961
|
+
// passed because `app` was ALSO in RETIRED_MODULES at the time — the
|
|
962
|
+
// unconditional retired-module GC (not this structural short-name rule)
|
|
963
|
+
// was doing the dropping. Now that `app`/`parachute-app` are un-retired
|
|
964
|
+
// (hub-parity P5, a new unrelated module claims them), this fixture is
|
|
965
|
+
// corrected to actually exercise `dropLegacyShortNameRows`.
|
|
1027
966
|
const { path, cleanup } = makeTempPath();
|
|
1028
967
|
try {
|
|
1029
968
|
writeFileSync(
|
|
@@ -1038,7 +977,7 @@ describe("legacy short-name row de-dupe (parachute-app#13 / runner#4)", () => {
|
|
|
1038
977
|
version: "0.2.0",
|
|
1039
978
|
},
|
|
1040
979
|
{
|
|
1041
|
-
name: "
|
|
980
|
+
name: "surface",
|
|
1042
981
|
port: 1946,
|
|
1043
982
|
paths: ["/surface"],
|
|
1044
983
|
health: "/surface/healthz",
|
|
@@ -1195,6 +1134,9 @@ describe("legacy short-name row de-dupe (parachute-app#13 / runner#4)", () => {
|
|
|
1195
1134
|
});
|
|
1196
1135
|
|
|
1197
1136
|
test("idempotent — second read leaves the cleaned file alone", () => {
|
|
1137
|
+
// See the fixture note on the first test in this describe block — `app`
|
|
1138
|
+
// is renamed to `surface` here for the same reason (un-retired 2026-07-11,
|
|
1139
|
+
// hub-parity P5).
|
|
1198
1140
|
const { path, cleanup } = makeTempPath();
|
|
1199
1141
|
try {
|
|
1200
1142
|
writeFileSync(
|
|
@@ -1209,7 +1151,7 @@ describe("legacy short-name row de-dupe (parachute-app#13 / runner#4)", () => {
|
|
|
1209
1151
|
version: "0.2.0",
|
|
1210
1152
|
},
|
|
1211
1153
|
{
|
|
1212
|
-
name: "
|
|
1154
|
+
name: "surface",
|
|
1213
1155
|
port: 1946,
|
|
1214
1156
|
paths: ["/surface"],
|
|
1215
1157
|
health: "/surface/healthz",
|
|
@@ -1232,21 +1174,37 @@ describe("legacy short-name row de-dupe (parachute-app#13 / runner#4)", () => {
|
|
|
1232
1174
|
|
|
1233
1175
|
// Retired-module row cleanup (hub#334 — Aaron's actual reproducer on
|
|
1234
1176
|
// 2026-05-22). His services.json originally carried a stale `agent` row at
|
|
1235
|
-
// 1946 colliding with `parachute-app`'s canonical slot.
|
|
1236
|
-
//
|
|
1237
|
-
//
|
|
1238
|
-
//
|
|
1239
|
-
//
|
|
1240
|
-
//
|
|
1241
|
-
//
|
|
1177
|
+
// 1946 colliding with `parachute-app`'s canonical slot. The legacy-short-name
|
|
1178
|
+
// de-dupe doesn't help — a retired short isn't the short-name twin of the
|
|
1179
|
+
// colliding row. The retired-module GC fires unconditionally on rows whose
|
|
1180
|
+
// name appears in `RETIRED_MODULES`, regardless of port collision.
|
|
1181
|
+
//
|
|
1182
|
+
// NOTE (2026-07-11, hub-parity P5): the original fixtures used `agent` —
|
|
1183
|
+
// which was a RETIRED_MODULES entry until the 2026-06-17 channel→agent
|
|
1184
|
+
// rename re-assigned that name to the live (renamed-from-channel) module —
|
|
1185
|
+
// and were later updated to use `app` (parachute-app, retired 2026-05-27).
|
|
1186
|
+
// `app`/`parachute-app` are now THEMSELVES un-retired (a new, unrelated
|
|
1187
|
+
// module — the real parachute-app super-surface — claims the name for
|
|
1188
|
+
// real; see service-spec.ts's RETIRED_MODULES comment). So these tests now
|
|
1189
|
+
// inject a throwaway synthetic retired entry via a describe-scoped
|
|
1190
|
+
// beforeEach/afterEach rather than depending on production RETIRED_MODULES
|
|
1191
|
+
// having a spare non-live name to spend on test fixtures — this mechanism
|
|
1192
|
+
// has now been renamed out from under these tests twice.
|
|
1242
1193
|
describe("retired-module row de-dupe (hub#334)", () => {
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1194
|
+
const RETIRED_FIXTURE_NAME = "legacy-test-retired-module";
|
|
1195
|
+
|
|
1196
|
+
beforeEach(() => {
|
|
1197
|
+
RETIRED_MODULES[RETIRED_FIXTURE_NAME] = {
|
|
1198
|
+
retiredAt: "2020-01-01",
|
|
1199
|
+
replacement: "n/a — test-only fixture, not a real retirement",
|
|
1200
|
+
};
|
|
1201
|
+
});
|
|
1202
|
+
|
|
1203
|
+
afterEach(() => {
|
|
1204
|
+
delete RETIRED_MODULES[RETIRED_FIXTURE_NAME];
|
|
1205
|
+
});
|
|
1206
|
+
|
|
1207
|
+
test("drops a row whose name is in RETIRED_MODULES", () => {
|
|
1250
1208
|
const { path, cleanup } = makeTempPath();
|
|
1251
1209
|
try {
|
|
1252
1210
|
writeFileSync(
|
|
@@ -1254,10 +1212,10 @@ describe("retired-module row de-dupe (hub#334)", () => {
|
|
|
1254
1212
|
JSON.stringify({
|
|
1255
1213
|
services: [
|
|
1256
1214
|
{
|
|
1257
|
-
name:
|
|
1215
|
+
name: RETIRED_FIXTURE_NAME,
|
|
1258
1216
|
port: 1946,
|
|
1259
|
-
paths: ["/
|
|
1260
|
-
health: "/
|
|
1217
|
+
paths: ["/legacy"],
|
|
1218
|
+
health: "/legacy/health",
|
|
1261
1219
|
version: "0.1.4",
|
|
1262
1220
|
},
|
|
1263
1221
|
],
|
|
@@ -1275,7 +1233,7 @@ describe("retired-module row de-dupe (hub#334)", () => {
|
|
|
1275
1233
|
|
|
1276
1234
|
test("retirement is unconditional — no other rows required", () => {
|
|
1277
1235
|
// Verifies dropRetiredModuleRows doesn't depend on a collision
|
|
1278
|
-
// partner (unlike dropLegacyShortNameRows).
|
|
1236
|
+
// partner (unlike dropLegacyShortNameRows). A retired row sitting
|
|
1279
1237
|
// alone is still stale.
|
|
1280
1238
|
const { path, cleanup } = makeTempPath();
|
|
1281
1239
|
try {
|
|
@@ -1284,10 +1242,10 @@ describe("retired-module row de-dupe (hub#334)", () => {
|
|
|
1284
1242
|
JSON.stringify({
|
|
1285
1243
|
services: [
|
|
1286
1244
|
{
|
|
1287
|
-
name:
|
|
1245
|
+
name: RETIRED_FIXTURE_NAME,
|
|
1288
1246
|
port: 9999,
|
|
1289
|
-
paths: ["/
|
|
1290
|
-
health: "/
|
|
1247
|
+
paths: ["/legacy"],
|
|
1248
|
+
health: "/legacy/health",
|
|
1291
1249
|
version: "0.1.4",
|
|
1292
1250
|
},
|
|
1293
1251
|
],
|
|
@@ -1329,10 +1287,9 @@ describe("retired-module row de-dupe (hub#334)", () => {
|
|
|
1329
1287
|
});
|
|
1330
1288
|
|
|
1331
1289
|
test("Aaron's reproducer — retired row + parachute-surface at same port resolves cleanly", () => {
|
|
1332
|
-
// The motivating bug for hub#334
|
|
1333
|
-
//
|
|
1334
|
-
//
|
|
1335
|
-
// retired row is GC'd and the duplicate-port gate doesn't trip downstream.
|
|
1290
|
+
// The motivating bug for hub#334. With dropRetiredModuleRows running
|
|
1291
|
+
// before validateManifest, the stale retired row is GC'd and the
|
|
1292
|
+
// duplicate-port gate doesn't trip downstream.
|
|
1336
1293
|
const { path, cleanup } = makeTempPath();
|
|
1337
1294
|
try {
|
|
1338
1295
|
writeFileSync(
|
|
@@ -1340,10 +1297,10 @@ describe("retired-module row de-dupe (hub#334)", () => {
|
|
|
1340
1297
|
JSON.stringify({
|
|
1341
1298
|
services: [
|
|
1342
1299
|
{
|
|
1343
|
-
name:
|
|
1300
|
+
name: RETIRED_FIXTURE_NAME,
|
|
1344
1301
|
port: 1946,
|
|
1345
|
-
paths: ["/
|
|
1346
|
-
health: "/
|
|
1302
|
+
paths: ["/legacy"],
|
|
1303
|
+
health: "/legacy/health",
|
|
1347
1304
|
version: "0.1.4",
|
|
1348
1305
|
},
|
|
1349
1306
|
{
|
|
@@ -1366,12 +1323,11 @@ describe("retired-module row de-dupe (hub#334)", () => {
|
|
|
1366
1323
|
|
|
1367
1324
|
test("interaction — retired row + legacy short-name pair both cleaned, correct order", () => {
|
|
1368
1325
|
// Drop order matters: retired-module cleanup runs first, then
|
|
1369
|
-
// legacy-short-name cleanup. This test ensures both passes
|
|
1370
|
-
//
|
|
1371
|
-
//
|
|
1372
|
-
//
|
|
1373
|
-
//
|
|
1374
|
-
// dedup at port 1945.
|
|
1326
|
+
// legacy-short-name cleanup. This test ensures both passes compose
|
|
1327
|
+
// correctly on a services.json that exercises both shapes
|
|
1328
|
+
// simultaneously. The retired-fixture row is unconditional retire; the
|
|
1329
|
+
// parachute-runner + runner pair triggers legacy-short-name dedup at
|
|
1330
|
+
// port 1945.
|
|
1375
1331
|
const { path, cleanup } = makeTempPath();
|
|
1376
1332
|
try {
|
|
1377
1333
|
writeFileSync(
|
|
@@ -1379,10 +1335,10 @@ describe("retired-module row de-dupe (hub#334)", () => {
|
|
|
1379
1335
|
JSON.stringify({
|
|
1380
1336
|
services: [
|
|
1381
1337
|
{
|
|
1382
|
-
name:
|
|
1338
|
+
name: RETIRED_FIXTURE_NAME,
|
|
1383
1339
|
port: 1946,
|
|
1384
|
-
paths: ["/
|
|
1385
|
-
health: "/
|
|
1340
|
+
paths: ["/legacy"],
|
|
1341
|
+
health: "/legacy/health",
|
|
1386
1342
|
version: "0.1.4",
|
|
1387
1343
|
},
|
|
1388
1344
|
{
|
|
@@ -1411,6 +1367,290 @@ describe("retired-module row de-dupe (hub#334)", () => {
|
|
|
1411
1367
|
});
|
|
1412
1368
|
});
|
|
1413
1369
|
|
|
1370
|
+
// Stale OLD-UI-host `app`/`parachute-app` row heal (hub-parity P5, B1). The
|
|
1371
|
+
// name `parachute-app` used to belong to the UI-host that renamed to
|
|
1372
|
+
// `parachute-surface` 2026-05-27; hub-parity P5 reuses the name for the NEW
|
|
1373
|
+
// super-surface app (mount /app, port 1944), so the RETIRED_MODULES entry that
|
|
1374
|
+
// used to GC the old row was removed. `healStaleUiHostAppRows` is its
|
|
1375
|
+
// shape-gated replacement for operators upgrading DIRECTLY from a pre-rename
|
|
1376
|
+
// release who still carry the stale `/surface`/1946 row. The load-bearing
|
|
1377
|
+
// assertion: a stale old-app row does NOT survive to spawn a crash-looping
|
|
1378
|
+
// unit, while the NEW app row (paths:["/app"], port 1944) is left intact.
|
|
1379
|
+
describe("stale OLD-UI-host app row heal (hub-parity P5, B1)", () => {
|
|
1380
|
+
test("heals (drops) a stale `parachute-app` row with the old /surface + 1946 shape", () => {
|
|
1381
|
+
const { path, cleanup } = makeTempPath();
|
|
1382
|
+
try {
|
|
1383
|
+
writeFileSync(
|
|
1384
|
+
path,
|
|
1385
|
+
JSON.stringify({
|
|
1386
|
+
services: [
|
|
1387
|
+
{
|
|
1388
|
+
name: "parachute-app",
|
|
1389
|
+
port: 1946,
|
|
1390
|
+
paths: ["/surface"],
|
|
1391
|
+
health: "/surface/healthz",
|
|
1392
|
+
version: "0.1.4",
|
|
1393
|
+
},
|
|
1394
|
+
],
|
|
1395
|
+
}),
|
|
1396
|
+
);
|
|
1397
|
+
const m = readManifest(path);
|
|
1398
|
+
// The stale row is gone — it never survives to spawn a crash-looping
|
|
1399
|
+
// `--package @openparachute/parachute-app` unit the operator never
|
|
1400
|
+
// installed.
|
|
1401
|
+
expect(m.services).toHaveLength(0);
|
|
1402
|
+
// Rewritten clean on disk too.
|
|
1403
|
+
const onDisk = JSON.parse(readFileSync(path, "utf8"));
|
|
1404
|
+
expect(onDisk.services).toHaveLength(0);
|
|
1405
|
+
} finally {
|
|
1406
|
+
cleanup();
|
|
1407
|
+
}
|
|
1408
|
+
});
|
|
1409
|
+
|
|
1410
|
+
test("heals a bare-short `app` row with the old shape (the pre-rename self-register name)", () => {
|
|
1411
|
+
const { path, cleanup } = makeTempPath();
|
|
1412
|
+
try {
|
|
1413
|
+
writeFileSync(
|
|
1414
|
+
path,
|
|
1415
|
+
JSON.stringify({
|
|
1416
|
+
services: [
|
|
1417
|
+
{
|
|
1418
|
+
name: "app",
|
|
1419
|
+
port: 1946,
|
|
1420
|
+
paths: ["/surface"],
|
|
1421
|
+
health: "/surface/healthz",
|
|
1422
|
+
version: "0.1.4",
|
|
1423
|
+
},
|
|
1424
|
+
],
|
|
1425
|
+
}),
|
|
1426
|
+
);
|
|
1427
|
+
const m = readManifest(path);
|
|
1428
|
+
expect(m.services).toHaveLength(0);
|
|
1429
|
+
} finally {
|
|
1430
|
+
cleanup();
|
|
1431
|
+
}
|
|
1432
|
+
});
|
|
1433
|
+
|
|
1434
|
+
test("heals on port 1946 even if the stale row's paths got mangled (port discriminant)", () => {
|
|
1435
|
+
// Corroborating OR-clause: a stale row squatting the old canonical 1946
|
|
1436
|
+
// is healed even if paths[0] isn't /surface-rooted anymore.
|
|
1437
|
+
const { path, cleanup } = makeTempPath();
|
|
1438
|
+
try {
|
|
1439
|
+
writeFileSync(
|
|
1440
|
+
path,
|
|
1441
|
+
JSON.stringify({
|
|
1442
|
+
services: [
|
|
1443
|
+
{
|
|
1444
|
+
name: "parachute-app",
|
|
1445
|
+
port: 1946,
|
|
1446
|
+
paths: ["/whatever"],
|
|
1447
|
+
health: "/whatever/health",
|
|
1448
|
+
version: "0.1.4",
|
|
1449
|
+
},
|
|
1450
|
+
],
|
|
1451
|
+
}),
|
|
1452
|
+
);
|
|
1453
|
+
const m = readManifest(path);
|
|
1454
|
+
expect(m.services).toHaveLength(0);
|
|
1455
|
+
} finally {
|
|
1456
|
+
cleanup();
|
|
1457
|
+
}
|
|
1458
|
+
});
|
|
1459
|
+
|
|
1460
|
+
test("leaves the NEW app row (paths:['/app'], port 1944) intact — structurally disjoint", () => {
|
|
1461
|
+
const { path, cleanup } = makeTempPath();
|
|
1462
|
+
try {
|
|
1463
|
+
writeFileSync(
|
|
1464
|
+
path,
|
|
1465
|
+
JSON.stringify({
|
|
1466
|
+
services: [
|
|
1467
|
+
{
|
|
1468
|
+
name: "parachute-app",
|
|
1469
|
+
port: 1944,
|
|
1470
|
+
paths: ["/app"],
|
|
1471
|
+
health: "/app/health",
|
|
1472
|
+
version: "0.4.0",
|
|
1473
|
+
},
|
|
1474
|
+
],
|
|
1475
|
+
}),
|
|
1476
|
+
);
|
|
1477
|
+
const m = readManifest(path);
|
|
1478
|
+
expect(m.services).toHaveLength(1);
|
|
1479
|
+
expect(m.services[0]?.name).toBe("parachute-app");
|
|
1480
|
+
expect(m.services[0]?.port).toBe(1944);
|
|
1481
|
+
expect(m.services[0]?.paths).toEqual(["/app"]);
|
|
1482
|
+
} finally {
|
|
1483
|
+
cleanup();
|
|
1484
|
+
}
|
|
1485
|
+
});
|
|
1486
|
+
|
|
1487
|
+
test("never heals a /app-mounted app row, whatever its port (new-app protection)", () => {
|
|
1488
|
+
// Defensive: even if a new-app row somehow drifted onto a weird port, its
|
|
1489
|
+
// `/app` mount alone protects it from the heal.
|
|
1490
|
+
const { path, cleanup } = makeTempPath();
|
|
1491
|
+
try {
|
|
1492
|
+
writeFileSync(
|
|
1493
|
+
path,
|
|
1494
|
+
JSON.stringify({
|
|
1495
|
+
services: [
|
|
1496
|
+
{
|
|
1497
|
+
name: "app",
|
|
1498
|
+
port: 1946,
|
|
1499
|
+
paths: ["/app"],
|
|
1500
|
+
health: "/app/health",
|
|
1501
|
+
version: "0.4.0",
|
|
1502
|
+
},
|
|
1503
|
+
],
|
|
1504
|
+
}),
|
|
1505
|
+
);
|
|
1506
|
+
const m = readManifest(path);
|
|
1507
|
+
expect(m.services).toHaveLength(1);
|
|
1508
|
+
expect(m.services[0]?.paths).toEqual(["/app"]);
|
|
1509
|
+
} finally {
|
|
1510
|
+
cleanup();
|
|
1511
|
+
}
|
|
1512
|
+
});
|
|
1513
|
+
|
|
1514
|
+
test("coexistence — stale old-app + real parachute-surface both at 1946: stale healed, surface keeps /surface", () => {
|
|
1515
|
+
// The load-bearing coexistence case. Without the heal, the two rows collide
|
|
1516
|
+
// on port 1946 and trip the duplicate-port gate (or the stale row hijacks
|
|
1517
|
+
// /surface). With the heal running first, the stale row is dropped by shape
|
|
1518
|
+
// and surface survives clean.
|
|
1519
|
+
const { path, cleanup } = makeTempPath();
|
|
1520
|
+
try {
|
|
1521
|
+
writeFileSync(
|
|
1522
|
+
path,
|
|
1523
|
+
JSON.stringify({
|
|
1524
|
+
services: [
|
|
1525
|
+
{
|
|
1526
|
+
name: "parachute-app",
|
|
1527
|
+
port: 1946,
|
|
1528
|
+
paths: ["/surface"],
|
|
1529
|
+
health: "/surface/healthz",
|
|
1530
|
+
version: "0.1.4",
|
|
1531
|
+
},
|
|
1532
|
+
{
|
|
1533
|
+
name: "parachute-surface",
|
|
1534
|
+
port: 1946,
|
|
1535
|
+
paths: ["/surface"],
|
|
1536
|
+
health: "/surface/healthz",
|
|
1537
|
+
version: "0.2.0",
|
|
1538
|
+
},
|
|
1539
|
+
],
|
|
1540
|
+
}),
|
|
1541
|
+
);
|
|
1542
|
+
const m = readManifest(path);
|
|
1543
|
+
expect(m.services).toHaveLength(1);
|
|
1544
|
+
expect(m.services[0]?.name).toBe("parachute-surface");
|
|
1545
|
+
expect(m.services[0]?.paths).toEqual(["/surface"]);
|
|
1546
|
+
} finally {
|
|
1547
|
+
cleanup();
|
|
1548
|
+
}
|
|
1549
|
+
});
|
|
1550
|
+
|
|
1551
|
+
test("double-write reproducer — stale `parachute-app` + `app` both at 1946/'/surface' → both healed", () => {
|
|
1552
|
+
// A pre-rename operator hit by the 2026-05-22 double-write carries BOTH
|
|
1553
|
+
// rows. The heal drops both by shape (running ahead of dropLegacyShortNameRows,
|
|
1554
|
+
// which would otherwise keep the `parachute-app` half — still stale).
|
|
1555
|
+
const { path, cleanup } = makeTempPath();
|
|
1556
|
+
try {
|
|
1557
|
+
writeFileSync(
|
|
1558
|
+
path,
|
|
1559
|
+
JSON.stringify({
|
|
1560
|
+
services: [
|
|
1561
|
+
{
|
|
1562
|
+
name: "parachute-app",
|
|
1563
|
+
port: 1946,
|
|
1564
|
+
paths: ["/surface"],
|
|
1565
|
+
health: "/surface/healthz",
|
|
1566
|
+
version: "0.1.4",
|
|
1567
|
+
},
|
|
1568
|
+
{
|
|
1569
|
+
name: "app",
|
|
1570
|
+
port: 1946,
|
|
1571
|
+
paths: ["/surface"],
|
|
1572
|
+
health: "/surface/healthz",
|
|
1573
|
+
version: "0.1.4",
|
|
1574
|
+
},
|
|
1575
|
+
],
|
|
1576
|
+
}),
|
|
1577
|
+
);
|
|
1578
|
+
const m = readManifest(path);
|
|
1579
|
+
expect(m.services).toHaveLength(0);
|
|
1580
|
+
} finally {
|
|
1581
|
+
cleanup();
|
|
1582
|
+
}
|
|
1583
|
+
});
|
|
1584
|
+
|
|
1585
|
+
test("coexistence — NEW app (/app, 1944) + real surface (/surface, 1946): both survive", () => {
|
|
1586
|
+
// The steady-state post-P5 world: the new app and surface installed side by
|
|
1587
|
+
// side. Neither is healed; both route on their own mounts.
|
|
1588
|
+
const { path, cleanup } = makeTempPath();
|
|
1589
|
+
try {
|
|
1590
|
+
writeFileSync(
|
|
1591
|
+
path,
|
|
1592
|
+
JSON.stringify({
|
|
1593
|
+
services: [
|
|
1594
|
+
{
|
|
1595
|
+
name: "parachute-app",
|
|
1596
|
+
port: 1944,
|
|
1597
|
+
paths: ["/app"],
|
|
1598
|
+
health: "/app/health",
|
|
1599
|
+
version: "0.4.0",
|
|
1600
|
+
},
|
|
1601
|
+
{
|
|
1602
|
+
name: "parachute-surface",
|
|
1603
|
+
port: 1946,
|
|
1604
|
+
paths: ["/surface"],
|
|
1605
|
+
health: "/surface/healthz",
|
|
1606
|
+
version: "0.2.0",
|
|
1607
|
+
},
|
|
1608
|
+
],
|
|
1609
|
+
}),
|
|
1610
|
+
);
|
|
1611
|
+
const m = readManifest(path);
|
|
1612
|
+
const names = m.services.map((s) => s.name).sort();
|
|
1613
|
+
expect(names).toEqual(["parachute-app", "parachute-surface"]);
|
|
1614
|
+
} finally {
|
|
1615
|
+
cleanup();
|
|
1616
|
+
}
|
|
1617
|
+
});
|
|
1618
|
+
|
|
1619
|
+
test("readManifestLenient heals the same stale old-app shape", () => {
|
|
1620
|
+
// The lenient reader shares the same raw-JSON heal pipeline — verify the
|
|
1621
|
+
// crash-loop row is dropped there too (the supervisor's boot path reads
|
|
1622
|
+
// lenient).
|
|
1623
|
+
const { path, cleanup } = makeTempPath();
|
|
1624
|
+
try {
|
|
1625
|
+
writeFileSync(
|
|
1626
|
+
path,
|
|
1627
|
+
JSON.stringify({
|
|
1628
|
+
services: [
|
|
1629
|
+
{
|
|
1630
|
+
name: "parachute-app",
|
|
1631
|
+
port: 1946,
|
|
1632
|
+
paths: ["/surface"],
|
|
1633
|
+
health: "/surface/healthz",
|
|
1634
|
+
version: "0.1.4",
|
|
1635
|
+
},
|
|
1636
|
+
{
|
|
1637
|
+
name: "parachute-vault",
|
|
1638
|
+
port: 1940,
|
|
1639
|
+
paths: ["/vault/default"],
|
|
1640
|
+
health: "/vault/default/health",
|
|
1641
|
+
version: "0.4.8",
|
|
1642
|
+
},
|
|
1643
|
+
],
|
|
1644
|
+
}),
|
|
1645
|
+
);
|
|
1646
|
+
const m = readManifestLenient(path, { warn: () => {} });
|
|
1647
|
+
expect(m.services.map((s) => s.name)).toEqual(["parachute-vault"]);
|
|
1648
|
+
} finally {
|
|
1649
|
+
cleanup();
|
|
1650
|
+
}
|
|
1651
|
+
});
|
|
1652
|
+
});
|
|
1653
|
+
|
|
1414
1654
|
describe("readManifestLenient — skips bad entries instead of throwing (hub#406)", () => {
|
|
1415
1655
|
test("returns the healthy entries when one row has port=0 (the rc.4 app bug)", () => {
|
|
1416
1656
|
// Reproduces what hub saw 2026-05-26: a fresh deploy installed
|