@camstack/server 1.2.477 → 1.2.479

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.
@@ -569,6 +569,14 @@ function createCapRouter_addons(getProvider, _createRemoteProxy) {
569
569
  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
570
570
  return p.uninstallPackage(input);
571
571
  }),
572
+ listInstallations: trpc_middleware_js_1.adminProcedure
573
+ .input(types_10.addonsCapability.methods.listInstallations.input.loose())
574
+ .output(types_10.addonsCapability.methods.listInstallations.output)
575
+ .query(async ({ input, ctx }) => {
576
+ const p = requireCapProvider('addons', () => getProvider(ctx));
577
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
578
+ return p.listInstallations(input);
579
+ }),
572
580
  reloadPackages: trpc_middleware_js_1.adminProcedure
573
581
  .input(zod_1.z.object({ nodeId: zod_1.z.string().optional() }).optional())
574
582
  .output(types_10.addonsCapability.methods.reloadPackages.output)
@@ -2434,15 +2442,6 @@ function createCapRouter_dayNight(getProvider, createRemoteProxy) {
2434
2442
  }
2435
2443
  function createCapRouter_decoder(getProvider, createRemoteProxy) {
2436
2444
  return (0, trpc_middleware_js_1.trpcRouter)({
2437
- supportsCodec: trpc_middleware_js_1.protectedProcedure
2438
- .input(types_42.decoderCapability.methods.supportsCodec.input.loose())
2439
- .output(types_42.decoderCapability.methods.supportsCodec.output)
2440
- .query(async ({ input, ctx }) => {
2441
- const { nodeId, ...methodInput } = input;
2442
- const p = resolveProvider('decoder', nodeId, () => getProvider(ctx), createRemoteProxy);
2443
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
2444
- return p.supportsCodec(methodInput);
2445
- }),
2446
2445
  getInfo: trpc_middleware_js_1.protectedProcedure
2447
2446
  .input(zod_1.z.object({ nodeId: zod_1.z.string().optional() }).optional())
2448
2447
  .output(types_42.decoderCapability.methods.getInfo.output)
@@ -2450,103 +2449,6 @@ function createCapRouter_decoder(getProvider, createRemoteProxy) {
2450
2449
  const p = resolveProvider('decoder', input?.nodeId, () => getProvider(ctx), createRemoteProxy);
2451
2450
  return p.getInfo();
2452
2451
  }),
2453
- createSession: trpc_middleware_js_1.protectedProcedure
2454
- .input(types_42.decoderCapability.methods.createSession.input.loose())
2455
- .output(types_42.decoderCapability.methods.createSession.output)
2456
- .query(async ({ input, ctx }) => {
2457
- const { nodeId, ...methodInput } = input;
2458
- const p = resolveProvider('decoder', nodeId, () => getProvider(ctx), createRemoteProxy);
2459
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
2460
- return p.createSession(methodInput);
2461
- }),
2462
- destroySession: trpc_middleware_js_1.protectedProcedure
2463
- .input(types_42.decoderCapability.methods.destroySession.input.loose())
2464
- .output(types_42.decoderCapability.methods.destroySession.output)
2465
- .query(async ({ input, ctx }) => {
2466
- const { nodeId, ...methodInput } = input;
2467
- const p = resolveProvider('decoder', nodeId, () => getProvider(ctx), createRemoteProxy);
2468
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
2469
- return p.destroySession(methodInput);
2470
- }),
2471
- pushPacket: trpc_middleware_js_1.protectedProcedure
2472
- .input(types_42.decoderCapability.methods.pushPacket.input.loose())
2473
- .output(types_42.decoderCapability.methods.pushPacket.output)
2474
- .query(async ({ input, ctx }) => {
2475
- const { nodeId, ...methodInput } = input;
2476
- const p = resolveProvider('decoder', nodeId, () => getProvider(ctx), createRemoteProxy);
2477
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
2478
- return p.pushPacket(methodInput);
2479
- }),
2480
- openStream: trpc_middleware_js_1.protectedProcedure
2481
- .input(types_42.decoderCapability.methods.openStream.input.loose())
2482
- .output(types_42.decoderCapability.methods.openStream.output)
2483
- .query(async ({ input, ctx }) => {
2484
- const { nodeId, ...methodInput } = input;
2485
- const p = resolveProvider('decoder', nodeId, () => getProvider(ctx), createRemoteProxy);
2486
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
2487
- return p.openStream(methodInput);
2488
- }),
2489
- pullFrames: trpc_middleware_js_1.protectedProcedure
2490
- .input(types_42.decoderCapability.methods.pullFrames.input.loose())
2491
- .output(types_42.decoderCapability.methods.pullFrames.output)
2492
- .query(async ({ input, ctx }) => {
2493
- const { nodeId, ...methodInput } = input;
2494
- const p = resolveProvider('decoder', nodeId, () => getProvider(ctx), createRemoteProxy);
2495
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
2496
- return p.pullFrames(methodInput);
2497
- }),
2498
- pullHandles: trpc_middleware_js_1.protectedProcedure
2499
- .input(types_42.decoderCapability.methods.pullHandles.input.loose())
2500
- .output(types_42.decoderCapability.methods.pullHandles.output)
2501
- .query(async ({ input, ctx }) => {
2502
- const { nodeId, ...methodInput } = input;
2503
- const p = resolveProvider('decoder', nodeId, () => getProvider(ctx), createRemoteProxy);
2504
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
2505
- return p.pullHandles(methodInput);
2506
- }),
2507
- getFrame: trpc_middleware_js_1.protectedProcedure
2508
- .input(types_42.decoderCapability.methods.getFrame.input.loose())
2509
- .output(types_42.decoderCapability.methods.getFrame.output)
2510
- .query(async ({ input, ctx }) => {
2511
- const { nodeId, ...methodInput } = input;
2512
- const p = resolveProvider('decoder', nodeId, () => getProvider(ctx), createRemoteProxy);
2513
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
2514
- return p.getFrame(methodInput);
2515
- }),
2516
- getShmStats: trpc_middleware_js_1.protectedProcedure
2517
- .input(types_42.decoderCapability.methods.getShmStats.input.loose())
2518
- .output(types_42.decoderCapability.methods.getShmStats.output)
2519
- .query(async ({ input, ctx }) => {
2520
- const { nodeId, ...methodInput } = input;
2521
- const p = resolveProvider('decoder', nodeId, () => getProvider(ctx), createRemoteProxy);
2522
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
2523
- return p.getShmStats(methodInput);
2524
- }),
2525
- updateConfig: trpc_middleware_js_1.protectedProcedure
2526
- .input(types_42.decoderCapability.methods.updateConfig.input.loose())
2527
- .output(types_42.decoderCapability.methods.updateConfig.output)
2528
- .query(async ({ input, ctx }) => {
2529
- const { nodeId, ...methodInput } = input;
2530
- const p = resolveProvider('decoder', nodeId, () => getProvider(ctx), createRemoteProxy);
2531
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
2532
- return p.updateConfig(methodInput);
2533
- }),
2534
- getStats: trpc_middleware_js_1.protectedProcedure
2535
- .input(types_42.decoderCapability.methods.getStats.input.loose())
2536
- .output(types_42.decoderCapability.methods.getStats.output)
2537
- .query(async ({ input, ctx }) => {
2538
- const { nodeId, ...methodInput } = input;
2539
- const p = resolveProvider('decoder', nodeId, () => getProvider(ctx), createRemoteProxy);
2540
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
2541
- return p.getStats(methodInput);
2542
- }),
2543
- listActiveSessions: trpc_middleware_js_1.protectedProcedure
2544
- .input(zod_1.z.object({ nodeId: zod_1.z.string().optional() }).optional())
2545
- .output(types_42.decoderCapability.methods.listActiveSessions.output)
2546
- .query(async ({ input, ctx }) => {
2547
- const p = resolveProvider('decoder', input?.nodeId, () => getProvider(ctx), createRemoteProxy);
2548
- return p.listActiveSessions();
2549
- }),
2550
2452
  reprobeHwaccel: trpc_middleware_js_1.adminProcedure
2551
2453
  .input(zod_1.z.object({ nodeId: zod_1.z.string().optional() }).optional())
2552
2454
  .output(types_42.decoderCapability.methods.reprobeHwaccel.output)
@@ -10255,33 +10157,6 @@ function createCapRouter_streamBroker(getProvider, createRemoteProxy) {
10255
10157
  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
10256
10158
  return p.unsubscribeAudioChunks(methodInput);
10257
10159
  }),
10258
- subscribeFrames: trpc_middleware_js_1.protectedProcedure
10259
- .input(types_118.streamBrokerCapability.methods.subscribeFrames.input.loose())
10260
- .output(types_118.streamBrokerCapability.methods.subscribeFrames.output)
10261
- .mutation(async ({ input, ctx }) => {
10262
- const { nodeId, ...methodInput } = input;
10263
- const p = resolveProvider('stream-broker', nodeId, () => getProvider(ctx), createRemoteProxy);
10264
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
10265
- return p.subscribeFrames(methodInput);
10266
- }),
10267
- pullFrameHandles: trpc_middleware_js_1.protectedProcedure
10268
- .input(types_118.streamBrokerCapability.methods.pullFrameHandles.input.loose())
10269
- .output(types_118.streamBrokerCapability.methods.pullFrameHandles.output)
10270
- .query(async ({ input, ctx }) => {
10271
- const { nodeId, ...methodInput } = input;
10272
- const p = resolveProvider('stream-broker', nodeId, () => getProvider(ctx), createRemoteProxy);
10273
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
10274
- return p.pullFrameHandles(methodInput);
10275
- }),
10276
- unsubscribeFrames: trpc_middleware_js_1.protectedProcedure
10277
- .input(types_118.streamBrokerCapability.methods.unsubscribeFrames.input.loose())
10278
- .output(types_118.streamBrokerCapability.methods.unsubscribeFrames.output)
10279
- .mutation(async ({ input, ctx }) => {
10280
- const { nodeId, ...methodInput } = input;
10281
- const p = resolveProvider('stream-broker', nodeId, () => getProvider(ctx), createRemoteProxy);
10282
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
10283
- return p.unsubscribeFrames(methodInput);
10284
- }),
10285
10160
  setPreBufferDuration: trpc_middleware_js_1.adminProcedure
10286
10161
  .input(types_118.streamBrokerCapability.methods.setPreBufferDuration.input.loose())
10287
10162
  .output(types_118.streamBrokerCapability.methods.setPreBufferDuration.output)
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.enrichInputWithUserAgent = enrichInputWithUserAgent;
4
4
  exports.enrichInputWithRelayClass = enrichInputWithRelayClass;
5
+ exports.stripTalkbackFromRequest = stripTalkbackFromRequest;
5
6
  exports.enrichHintsWithClientClass = enrichHintsWithClientClass;
6
7
  exports.wrapWebrtcSessionProviderWithRelay = wrapWebrtcSessionProviderWithRelay;
7
8
  exports.buildAppRouter = buildAppRouter;
@@ -95,6 +96,25 @@ function enrichInputWithRelayClass(input, clientClass) {
95
96
  const relayOnly = FORCE_RELAY_REMOTE ? (0, client_ip_js_1.deriveRelayOnly)(clientClass) : false;
96
97
  return { ...input, relayOnly };
97
98
  }
99
+ /**
100
+ * Strip `talkback` from a REQUEST-bound `handleOffer` (D771).
101
+ *
102
+ * Talk-back sends the peer's microphone to the camera's speaker. Only a
103
+ * trusted, mesh-originated caller may ask for it — today the Alexa export,
104
+ * which has already checked the linked user may control the camera. A
105
+ * browser's talk permission lives on the intercom cap's own surface (share
106
+ * tokens gate it there); letting a request-bound `handleOffer` set this flag
107
+ * would be a second door to the speaker. Same rule as `relayOnly`: a
108
+ * mesh-originated call (`clientClass === null`) passes through unchanged.
109
+ *
110
+ * Immutable — builds a NEW input object, never mutates the caller's.
111
+ */
112
+ function stripTalkbackFromRequest(input, clientClass) {
113
+ if (clientClass === null || input.talkback === undefined)
114
+ return input;
115
+ const { talkback: _dropped, ...rest } = input;
116
+ return rest;
117
+ }
98
118
  /**
99
119
  * Bias used to live here (`prefersTier: 'low'` for `remote`). That pinned
100
120
  * every phone-sized session — including LAN IPv6 GUA misclassified as remote
@@ -145,7 +165,7 @@ function wrapWebrtcSessionProviderWithRelay(provider, ctx) {
145
165
  return {
146
166
  ...provider,
147
167
  createSession: (input) => provider.createSession(enrichHintsWithClientClass(enrichInputWithRelayClass(enrichInputWithUserAgent(input, userAgent), clientClass), clientClass)),
148
- handleOffer: (input) => provider.handleOffer(enrichHintsWithClientClass(enrichInputWithRelayClass(enrichInputWithUserAgent(input, userAgent), clientClass), clientClass)),
168
+ handleOffer: (input) => provider.handleOffer(enrichHintsWithClientClass(enrichInputWithRelayClass(enrichInputWithUserAgent(stripTalkbackFromRequest(input, clientClass), userAgent), clientClass), clientClass)),
149
169
  };
150
170
  }
151
171
  /**
@@ -170,7 +190,7 @@ function buildServerProviders(services) {
170
190
  nodes: wrap(() => (0, cap_providers_js_1.buildNodesProvider)(services.agentRegistry, services.moleculer, services.addonRegistry, (0, cap_providers_js_1.createNodeRootPackageLookup)(services.moleculer, services.serverUpdateService), services.loggingService.createLogger('nodes'))),
171
191
  // server-management — runtime-updatable root package (phase 1: hub only).
172
192
  'server-management': wrap(() => (0, server_management_provider_js_1.buildServerManagementProvider)(services.serverUpdateService)),
173
- addons: wrap((ctx) => (0, cap_providers_js_1.buildAddonsProvider)(services.addonRegistry, services.addonPackageService, services.loggingService, services.moleculer, services.configService, ctx)),
193
+ addons: wrap((ctx) => (0, cap_providers_js_1.buildAddonsProvider)(services.addonRegistry, services.addonPackageService, services.loggingService, services.moleculer, services.configService, ctx, services.agentRegistry)),
174
194
  // webrtc-session — the resolved broker singleton, wrapped per request to
175
195
  // inject the server-read User-Agent into signaling attribution (the broker
176
196
  // is a forked addon that can't see the HTTP request). Cross-node (agent-
@@ -0,0 +1,259 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PackageProtectedError = void 0;
4
+ exports.isHubNodeId = isHubNodeId;
5
+ exports.uninstallPackage = uninstallPackage;
6
+ exports.listInstallations = listInstallations;
7
+ /** The hub refuses before touching any node. Mapped to FORBIDDEN by the router. */
8
+ class PackageProtectedError extends Error {
9
+ constructor(packageName) {
10
+ super(`Package ${packageName} is required and cannot be uninstalled`);
11
+ this.name = 'PackageProtectedError';
12
+ }
13
+ }
14
+ exports.PackageProtectedError = PackageProtectedError;
15
+ /** A node id that refers to the hub itself (top-level or a hub group runner). */
16
+ function isHubNodeId(nodeId) {
17
+ return nodeId === 'hub' || nodeId.startsWith('hub/');
18
+ }
19
+ const OK_OUTCOMES = new Set([
20
+ 'uninstalled',
21
+ 'not-installed',
22
+ 'pending-reconnect',
23
+ ]);
24
+ function errMessage(err) {
25
+ return err instanceof Error ? err.message : String(err);
26
+ }
27
+ function nodeResult(nodeId, outcome, extra = {}) {
28
+ return {
29
+ nodeId,
30
+ outcome,
31
+ addonIds: extra.addonIds ?? [],
32
+ runnersStopped: extra.runnersStopped ?? [],
33
+ warnings: extra.warnings ?? [],
34
+ ...(extra.error !== undefined ? { error: extra.error } : {}),
35
+ };
36
+ }
37
+ function stringArray(value) {
38
+ return Array.isArray(value) ? value.filter((v) => typeof v === 'string') : [];
39
+ }
40
+ function isRecord(value) {
41
+ return typeof value === 'object' && value !== null;
42
+ }
43
+ function parseUndeployReply(reply) {
44
+ if (!isRecord(reply)) {
45
+ return {
46
+ packageScoped: false,
47
+ outcome: null,
48
+ reason: undefined,
49
+ addonIds: [],
50
+ runnersStopped: null,
51
+ warnings: [],
52
+ };
53
+ }
54
+ const outcome = reply['outcome'];
55
+ return {
56
+ packageScoped: reply['scope'] === 'package',
57
+ outcome: outcome === 'uninstalled' || outcome === 'not-installed' || outcome === 'refused'
58
+ ? outcome
59
+ : null,
60
+ reason: typeof reply['reason'] === 'string' ? reply['reason'] : undefined,
61
+ addonIds: stringArray(reply['addonIds']),
62
+ runnersStopped: Array.isArray(reply['runnersStopped'])
63
+ ? stringArray(reply['runnersStopped'])
64
+ : null,
65
+ warnings: stringArray(reply['warnings']),
66
+ };
67
+ }
68
+ /**
69
+ * An agent built before package mode answers `{ success, addonId }` and removes
70
+ * ONE addon — its folder, not its runner. Its packages are still removed (one
71
+ * call per addon id), and the result says the runner outlives it.
72
+ */
73
+ const LEGACY_AGENT_WARNING = 'this agent predates runner teardown: the addon is removed from disk but its runner keeps running until the agent restarts — update the agent';
74
+ async function uninstallFromOnlineAgent(seam, nodeId, packageName) {
75
+ let listed;
76
+ try {
77
+ listed = (await seam.agentAddons(nodeId))
78
+ .filter((a) => a.packageName === packageName)
79
+ .map((a) => a.id);
80
+ }
81
+ catch (err) {
82
+ return nodeResult(nodeId, 'failed', { error: `status read failed: ${errMessage(err)}` });
83
+ }
84
+ try {
85
+ // `addonId` is for an agent that predates package mode: it reads only that
86
+ // field. With no listed addon, the package name doubles as the dir to drop.
87
+ const first = parseUndeployReply(await seam.agentUndeploy(nodeId, { packageName, addonId: listed[0] ?? packageName }));
88
+ if (first.packageScoped && first.outcome !== null) {
89
+ if (first.outcome === 'refused') {
90
+ return nodeResult(nodeId, 'refused', { error: first.reason, addonIds: first.addonIds });
91
+ }
92
+ await seam.notePackageUndeployed(nodeId, packageName, first.addonIds);
93
+ return nodeResult(nodeId, first.outcome, {
94
+ addonIds: first.addonIds,
95
+ runnersStopped: first.runnersStopped ?? [],
96
+ warnings: first.warnings,
97
+ });
98
+ }
99
+ for (const addonId of listed.slice(1)) {
100
+ await seam.agentUndeploy(nodeId, { packageName, addonId });
101
+ }
102
+ await seam.notePackageUndeployed(nodeId, packageName, listed);
103
+ return nodeResult(nodeId, listed.length > 0 ? 'uninstalled' : 'not-installed', {
104
+ addonIds: listed,
105
+ warnings: listed.length > 0 ? [LEGACY_AGENT_WARNING] : [],
106
+ });
107
+ }
108
+ catch (err) {
109
+ return nodeResult(nodeId, 'failed', { addonIds: listed, error: errMessage(err) });
110
+ }
111
+ }
112
+ async function uninstallFromAgent(seam, node, packageName) {
113
+ if (node.isOnline)
114
+ return uninstallFromOnlineAgent(seam, node.id, packageName);
115
+ // Offline: nothing can be called. Pruning the record is what keeps the
116
+ // back-fill from returning it; the copy itself is the stale sweep's on rejoin.
117
+ try {
118
+ await seam.notePackageUndeployed(node.id, packageName, []);
119
+ return nodeResult(node.id, 'pending-reconnect', {
120
+ warnings: [
121
+ 'node is offline — its copy is removed when it reconnects, provided the hub no longer installs the package',
122
+ ],
123
+ });
124
+ }
125
+ catch (err) {
126
+ return nodeResult(node.id, 'failed', { error: errMessage(err) });
127
+ }
128
+ }
129
+ async function uninstallFromHub(seam, packageName) {
130
+ const row = seam.listHubPackages().find((p) => p.name === packageName);
131
+ if (!row)
132
+ return nodeResult('hub', 'not-installed');
133
+ try {
134
+ await seam.uninstallOnHub(packageName);
135
+ return nodeResult('hub', 'uninstalled', { addonIds: row.addonIds });
136
+ }
137
+ catch (err) {
138
+ return nodeResult('hub', 'failed', { addonIds: row.addonIds, error: errMessage(err) });
139
+ }
140
+ }
141
+ function summarize(packageName, results) {
142
+ return { success: results.every((r) => OK_OUTCOMES.has(r.outcome)), packageName, results };
143
+ }
144
+ /**
145
+ * Agents that have (or are recorded to have) the package. An online agent
146
+ * whose status read fails is still targeted — the undeploy leg reports the
147
+ * failure for that node rather than silently skipping it.
148
+ */
149
+ async function agentsHolding(seam, packageName) {
150
+ const agents = await seam.listAgents();
151
+ const holding = await Promise.all(agents.map(async (node) => {
152
+ if (node.isOnline) {
153
+ try {
154
+ const addons = await seam.agentAddons(node.id);
155
+ if (addons.some((a) => a.packageName === packageName))
156
+ return node;
157
+ }
158
+ catch {
159
+ return node;
160
+ }
161
+ }
162
+ const recorded = await seam.recordedPackages(node.id).catch(() => []);
163
+ return recorded.some((p) => p.name === packageName) ? node : null;
164
+ }));
165
+ return holding.filter((n) => n !== null);
166
+ }
167
+ async function uninstallPackage(seam, request) {
168
+ const { packageName } = request;
169
+ if (seam.isProtected(packageName))
170
+ throw new PackageProtectedError(packageName);
171
+ if (request.everywhere === true) {
172
+ const agents = await agentsHolding(seam, packageName);
173
+ // Agents first, hub last — the hub routes every call to the agents, and a
174
+ // hub uninstall that fails must not leave the agents' records already
175
+ // pruned for nothing: each leg records only its own success.
176
+ const results = [];
177
+ for (const node of agents)
178
+ results.push(await uninstallFromAgent(seam, node, packageName));
179
+ results.push(await uninstallFromHub(seam, packageName));
180
+ return summarize(packageName, results);
181
+ }
182
+ const nodeId = request.nodeId;
183
+ if (nodeId === undefined || isHubNodeId(nodeId)) {
184
+ // The original contract: hub-only, and a failure THROWS.
185
+ await seam.uninstallOnHub(packageName);
186
+ const row = seam.listHubPackages().find((p) => p.name === packageName);
187
+ return summarize(packageName, [
188
+ nodeResult('hub', 'uninstalled', { addonIds: row?.addonIds ?? [] }),
189
+ ]);
190
+ }
191
+ const node = (await seam.listAgents()).find((n) => n.id === nodeId);
192
+ if (!node) {
193
+ return summarize(packageName, [
194
+ nodeResult(nodeId, 'failed', { error: `unknown node ${nodeId}` }),
195
+ ]);
196
+ }
197
+ return summarize(packageName, [await uninstallFromAgent(seam, node, packageName)]);
198
+ }
199
+ async function listInstallations(seam, packageName) {
200
+ const wanted = (name) => packageName === undefined || name === packageName;
201
+ const hubPackages = seam.listHubPackages();
202
+ const onHub = new Set(hubPackages.map((p) => p.name));
203
+ const installations = hubPackages
204
+ .filter((p) => wanted(p.name))
205
+ .map((p) => ({
206
+ packageName: p.name,
207
+ nodeId: 'hub',
208
+ nodeName: 'Hub',
209
+ isHub: true,
210
+ isOnline: true,
211
+ version: p.version,
212
+ addonIds: p.addonIds,
213
+ source: 'live',
214
+ onHub: true,
215
+ }));
216
+ const unreachable = [];
217
+ const perAgent = await Promise.all((await seam.listAgents()).map(async (node) => {
218
+ const base = { nodeId: node.id, nodeName: node.name, isHub: false, isOnline: node.isOnline };
219
+ if (node.isOnline) {
220
+ try {
221
+ const byPackage = new Map();
222
+ for (const addon of await seam.agentAddons(node.id)) {
223
+ if (addon.packageName === undefined || !wanted(addon.packageName))
224
+ continue;
225
+ const row = byPackage.get(addon.packageName) ?? {
226
+ version: addon.version ?? null,
227
+ addonIds: [],
228
+ };
229
+ row.addonIds.push(addon.id);
230
+ byPackage.set(addon.packageName, row);
231
+ }
232
+ return [...byPackage].map(([name, row]) => ({
233
+ ...base,
234
+ packageName: name,
235
+ version: row.version,
236
+ addonIds: row.addonIds,
237
+ source: 'live',
238
+ onHub: onHub.has(name),
239
+ }));
240
+ }
241
+ catch (err) {
242
+ unreachable.push({ nodeId: node.id, error: errMessage(err) });
243
+ return [];
244
+ }
245
+ }
246
+ const recorded = await seam.recordedPackages(node.id).catch(() => []);
247
+ return recorded
248
+ .filter((p) => wanted(p.name))
249
+ .map((p) => ({
250
+ ...base,
251
+ packageName: p.name,
252
+ version: p.version,
253
+ addonIds: [],
254
+ source: 'recorded',
255
+ onHub: onHub.has(p.name),
256
+ }));
257
+ }));
258
+ return { installations: [...installations, ...perAgent.flat()], unreachable };
259
+ }
@@ -50,11 +50,11 @@ const types_1 = require("@camstack/types");
50
50
  *
51
51
  * What's left is ONLY the addon package management surface: the installer
52
52
  * + the loader + `reloadPackages` + `listAvailableAddons`. These are still
53
- * used by:
54
- * - `server/backend/src/api/addon-upload.ts` (multipart upload → install
55
- * → loader reload)
56
- * - `server/backend/src/api/bridge-addons.router.ts` (install/uninstall
57
- * package lifecycle, addon list via the loader)
53
+ * used by `server/backend/src/api/addon-upload.ts` (multipart upload →
54
+ * install → loader reload). The `bridge-addons.router.ts` that also used it
55
+ * was never mounted and was deleted on 2026-10-08 — its hard-coded REQUIRED
56
+ * set read like the uninstall guard and was not; uninstall is the `addons`
57
+ * cap's (`core/addon/addon-uninstall.ts`).
58
58
  */
59
59
  class AddonBridgeService {
60
60
  loggingService;
@@ -24,13 +24,20 @@
24
24
  * both mutations are attributable actions rather than heuristics.
25
25
  *
26
26
  * THE INTENDED VERSION
27
- * The hub's own installed version wins, falling back to the version the node
28
- * last reported. Never `latest` — resolving `latest` per node is exactly the
29
- * silent per-node drift the bootstrap installer already suffers from
30
- * (`addon-installer.ts` installs unpinned and `ensureRequiredPackages` never
31
- * re-installs, so whatever `latest` meant at a node's first boot is what that
32
- * node runs forever). A package with no resolvable version is reported, not
33
- * guessed at.
27
+ * The hub's own installed version, and only that. Never `latest` — resolving
28
+ * `latest` per node is exactly the silent per-node drift the bootstrap
29
+ * installer already suffers from (`addon-installer.ts` installs unpinned and
30
+ * `ensureRequiredPackages` never re-installs, so whatever `latest` meant at a
31
+ * node's first boot is what that node runs forever).
32
+ *
33
+ * A package the hub does NOT install is never delivered, whatever the node last
34
+ * reported. It used to fall back to the node's last-reported version, which
35
+ * made the back-fill the one path that could put an UNINSTALLED addon back: an
36
+ * operator removes `@camstack/addon-decoder-ffmpeg` from the hub and moves the
37
+ * agent's copy aside, and the next reconnect reinstalled it from npm at the
38
+ * version the node last ran — while the same pass's stale sweep exists to
39
+ * remove exactly such an addon. The two directions disagreed; now the hub's
40
+ * install set bounds both. Such packages are reported as `notOnHub`.
34
41
  *
35
42
  * BOUNDING
36
43
  * Delivery failures are counted per `(node, package, version)` and the pair is
@@ -66,7 +73,7 @@ function computeBackfillPlan(input) {
66
73
  const liveNames = new Set(input.live.map((p) => p.name));
67
74
  const targets = [];
68
75
  const retired = [];
69
- const unresolvable = [];
76
+ const notOnHub = [];
70
77
  for (const entry of input.recorded) {
71
78
  if (liveNames.has(entry.name))
72
79
  continue;
@@ -79,18 +86,13 @@ function computeBackfillPlan(input) {
79
86
  continue;
80
87
  }
81
88
  const hubVersion = input.hubVersions.get(entry.name);
82
- const version = hubVersion ?? entry.version;
83
- if (version === null || version === undefined || version.length === 0) {
84
- unresolvable.push(entry.name);
89
+ if (hubVersion === undefined) {
90
+ notOnHub.push(entry.name);
85
91
  continue;
86
92
  }
87
- targets.push({
88
- name: entry.name,
89
- version,
90
- versionSource: hubVersion === undefined ? 'node-last-reported' : 'hub-installed',
91
- });
93
+ targets.push({ name: entry.name, version: hubVersion, versionSource: 'hub-installed' });
92
94
  }
93
- return { targets, retired, unresolvable, liveCount: liveNames.size };
95
+ return { targets, retired, notOnHub, liveCount: liveNames.size };
94
96
  }
95
97
  function confirmBackfillPlan(plan, secondRead) {
96
98
  const present = new Set(secondRead.map((p) => p.name));
@@ -187,5 +189,5 @@ async function executeBackfill(seam, nodeId, plan, tracker) {
187
189
  if (delivered.length > 0) {
188
190
  await seam.reload(nodeId);
189
191
  }
190
- return { delivered, failed, skipped: plan.retired, unresolvable: plan.unresolvable };
192
+ return { delivered, failed, skipped: plan.retired, notOnHub: plan.notOnHub };
191
193
  }
@@ -281,6 +281,32 @@ class AgentRegistryService {
281
281
  return;
282
282
  await this.historyStore?.prunePackages(nodeId, [packageName]);
283
283
  }
284
+ /**
285
+ * The operator removed a whole PACKAGE from a node (`addons.uninstallPackage`
286
+ * with a `nodeId`, or `everywhere`). Same intent as
287
+ * {@link noteAddonUndeployed}, keyed by package — which is what an uninstall
288
+ * names, and the only key that still resolves once the hub itself no longer
289
+ * has the package. Emits one `AddonUninstalled` per addon removed, like the
290
+ * stale-addon reconcile does.
291
+ */
292
+ async notePackageUndeployed(nodeId, packageName, addonIds) {
293
+ if (AGENT_BOOTSTRAP_PACKAGES.has(packageName))
294
+ return;
295
+ await this.historyStore?.prunePackages(nodeId, [packageName]);
296
+ for (const addonId of addonIds) {
297
+ this.eventBus.emit({
298
+ id: (0, node_crypto_1.randomUUID)(),
299
+ timestamp: new Date(),
300
+ source: { type: 'core', id: 'agent-registry' },
301
+ category: types_1.EventCategory.AddonUninstalled,
302
+ data: { addonId, agentId: nodeId, packageName, reason: 'operator uninstall' },
303
+ });
304
+ }
305
+ }
306
+ /** The packages a node last declared (its intended set). `[]` with no store. */
307
+ async getRecordedPackages(nodeId) {
308
+ return this.historyStore ? this.historyStore.getPackages(nodeId) : [];
309
+ }
284
310
  /** Map an addon declaration id to the package that ships it, via the hub registry. */
285
311
  resolvePackageForAddon(addonId) {
286
312
  if (!this.addonRegistry)
@@ -744,9 +770,14 @@ class AgentRegistryService {
744
770
  retired: this.backfillTracker.retiredFor(agentId),
745
771
  hubVersions,
746
772
  });
747
- if (planned.targets.length === 0 &&
748
- planned.retired.length === 0 &&
749
- planned.unresolvable.length === 0) {
773
+ // Never a delivery, and said so: an addon the hub no longer installs stays
774
+ // off the node even though the node once declared it (the stale sweep above
775
+ // removes a copy that is still there).
776
+ if (planned.notOnHub.length > 0) {
777
+ console.log(`[agent-registry] Back-fill ${agentId}: NOT delivering ${planned.notOnHub.join(', ')} — ` +
778
+ `in the node's declared roster but not installed on the hub`);
779
+ }
780
+ if (planned.targets.length === 0 && planned.retired.length === 0) {
750
781
  return;
751
782
  }
752
783
  // GATE 1 — the trigger allowlist. A pass that must not install says so and
@@ -773,7 +804,7 @@ class AgentRegistryService {
773
804
  const plan = await this.confirmBackfillTargets(agentId, planned);
774
805
  if (plan === null)
775
806
  return;
776
- if (plan.targets.length === 0 && plan.retired.length === 0 && plan.unresolvable.length === 0) {
807
+ if (plan.targets.length === 0 && plan.retired.length === 0) {
777
808
  return;
778
809
  }
779
810
  // Hold the delivery window open for the whole pass, so the reload this
@@ -813,9 +844,6 @@ class AgentRegistryService {
813
844
  for (const name of outcome.skipped) {
814
845
  console.error(`[agent-registry] Back-fill ${agentId}: ${name} is still missing and RETIRED — the node is degraded until an operator intervenes`);
815
846
  }
816
- for (const name of outcome.unresolvable) {
817
- console.error(`[agent-registry] Back-fill ${agentId}: ${name} is missing but no version could be resolved (not installed on the hub, no version recorded) — not guessing "latest"`);
818
- }
819
847
  }
820
848
  /**
821
849
  * Re-read the node's roster and drop every target the second read reports
@@ -961,7 +961,6 @@ var require_dist = __commonJS({
961
961
  var import_node_url = require("url");
962
962
  var HOST_EXTERNAL_SPECIFIERS = [
963
963
  "@camstack/system",
964
- "@camstack/shm-ring",
965
964
  "@camstack/types",
966
965
  "@camstack/sdk",
967
966
  "zod",
@@ -189,7 +189,6 @@ exports.CLOSURE_PROVIDED_PACKAGES = [
189
189
  '@camstack/system',
190
190
  '@camstack/types',
191
191
  '@camstack/sdk',
192
- '@camstack/shm-ring',
193
192
  '@camstack/ui-library',
194
193
  ];
195
194
  /**