@camstack/server 0.1.6 → 0.1.7

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.
Files changed (42) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/addon-upload.spec.ts +58 -0
  3. package/src/__tests__/bulk-update-coordinator.spec.ts +286 -0
  4. package/src/__tests__/cap-ownership-authority.spec.ts +400 -0
  5. package/src/__tests__/cap-providers-bulk-update.spec.ts +388 -0
  6. package/src/__tests__/cap-route-adapter.spec.ts +289 -0
  7. package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +123 -0
  8. package/src/__tests__/cap-routers/capabilities-node.spec.ts +55 -0
  9. package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +30 -0
  10. package/src/__tests__/device-settings-contribution-dispatch.spec.ts +249 -0
  11. package/src/__tests__/framework-installer-defer-restart.spec.ts +165 -0
  12. package/src/__tests__/moleculer/uds-readiness.spec.ts +143 -0
  13. package/src/__tests__/moleculer/uds-topology.spec.ts +390 -0
  14. package/src/__tests__/moleculer/uds-unowned-call.spec.ts +123 -0
  15. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +39 -4
  16. package/src/__tests__/native-cap-route.spec.ts +404 -0
  17. package/src/__tests__/oauth2-account-linking.spec.ts +85 -0
  18. package/src/__tests__/uds-addon-call-wiring.spec.ts +237 -0
  19. package/src/__tests__/uds-log-ingest.spec.ts +183 -0
  20. package/src/api/addon-upload.ts +27 -1
  21. package/src/api/capabilities.router.ts +1 -1
  22. package/src/api/core/bulk-update-coordinator.ts +302 -0
  23. package/src/api/core/cap-providers.ts +59 -6
  24. package/src/api/core/capabilities.router.ts +26 -3
  25. package/src/api/oauth2/oauth2-routes.ts +5 -1
  26. package/src/api/trpc/__tests__/client-ip.spec.ts +120 -0
  27. package/src/api/trpc/cap-route-error-formatter.ts +163 -0
  28. package/src/api/trpc/client-ip.ts +130 -0
  29. package/src/api/trpc/generated-cap-mounts.ts +19 -1
  30. package/src/api/trpc/generated-cap-routers.ts +180 -1
  31. package/src/api/trpc/trpc.middleware.ts +5 -1
  32. package/src/api/trpc/trpc.router.ts +45 -0
  33. package/src/core/addon/addon-call-gateway.ts +157 -0
  34. package/src/core/addon/addon-package.service.ts +9 -0
  35. package/src/core/addon/addon-registry.service.ts +364 -105
  36. package/src/core/addon/addon-settings-provider.ts +40 -116
  37. package/src/core/capability/capability.service.ts +9 -0
  38. package/src/core/moleculer/cap-call-fn.spec.ts +166 -0
  39. package/src/core/moleculer/cap-call-fn.ts +103 -0
  40. package/src/core/moleculer/cap-route-authority.ts +182 -0
  41. package/src/core/moleculer/moleculer.service.ts +380 -36
  42. package/src/main.ts +45 -12
@@ -0,0 +1,237 @@
1
+ /**
2
+ * F3 backend wiring — forked-addon routes + custom actions over UDS.
3
+ *
4
+ * Verifies the production wiring `AddonRegistryService` builds, end-to-end over
5
+ * a REAL LocalChildRegistry + LocalChildClient pair, with the child running the
6
+ * REAL addon-runner-side dispatcher (`createChildAddonCallDispatch`):
7
+ *
8
+ * - Custom actions: a `CustomActionRegistry` whose dispatcher is the F3
9
+ * `callAddonOnChild({target:'custom', action, args})` reaches the child's
10
+ * real custom-action handler and returns its result (the path
11
+ * `registerForkedAddonCustomActions` builds via `dispatchForkedCustomAction`).
12
+ * - Routes: `callAddonOnChild({target:'routes'})` returns handler-stripped
13
+ * descriptors; the per-route bridge handler dispatches through the
14
+ * `addon-routes` `invoke()` cap method (the path `mountForkedAddonRoutes`
15
+ * builds), and the captured envelope round-trips.
16
+ *
17
+ * The child-side `onAddonCall` handler is a faithful inline of the addon-runner
18
+ * dispatcher (resolve route provider / custom registry; strip route handlers).
19
+ */
20
+
21
+ import { describe, it, expect, afterEach } from 'vitest'
22
+ import { z } from 'zod'
23
+ import {
24
+ LocalChildRegistry,
25
+ LocalChildClient,
26
+ CustomActionRegistry,
27
+ createLocalTransport,
28
+ } from '@camstack/kernel'
29
+ import type { AddonCallInput } from '@camstack/kernel'
30
+ import { buildAddonRouteProvider, customAction, defineCustomActions } from '@camstack/types'
31
+ import type { IAddonHttpRoute, CustomActionsSpec } from '@camstack/types'
32
+
33
+ const nid = (): string => `f3-wiring-${process.pid}-${Math.random().toString(36).slice(2)}`
34
+
35
+ describe('F3 — forked-addon routes + custom actions over UDS (backend wiring)', () => {
36
+ let registry: LocalChildRegistry | null = null
37
+ const clients: LocalChildClient[] = []
38
+
39
+ afterEach(async () => {
40
+ for (const c of clients) await c.close().catch(() => {})
41
+ clients.length = 0
42
+ await registry?.close().catch(() => {})
43
+ registry = null
44
+ })
45
+
46
+ /** Stand up a hub-side registry + a child running the real addon-call dispatcher. */
47
+ async function standUp(opts: {
48
+ addonId: string
49
+ routes?: readonly IAddonHttpRoute[]
50
+ catalog?: CustomActionsSpec
51
+ handlers?: Record<string, (input: unknown) => Promise<unknown>>
52
+ }): Promise<{ childCustomActions: CustomActionRegistry }> {
53
+ const nodeId = nid()
54
+ registry = new LocalChildRegistry(createLocalTransport().createServer(nodeId))
55
+ await registry.start()
56
+
57
+ // The runner-side custom-action registry — populated as the addon-runner does.
58
+ const childCustomActions = new CustomActionRegistry()
59
+ if (opts.catalog && opts.handlers) {
60
+ const handlers = opts.handlers
61
+ childCustomActions.registerAddon(opts.addonId, opts.catalog, (action, input) => {
62
+ const fn = handlers[action]
63
+ if (!fn) throw new Error(`no handler for ${action}`)
64
+ return fn(input)
65
+ })
66
+ }
67
+
68
+ const routeProvider = opts.routes ? buildAddonRouteProvider(opts.addonId, opts.routes) : null
69
+
70
+ const child = new LocalChildClient({
71
+ nodeId,
72
+ childId: opts.addonId,
73
+ caps: [{ capName: 'addon-routes', mode: 'collection' }],
74
+ // Cap dispatch — resolve the `addon-routes` cap's `invoke`/`getRoutes`
75
+ // method on the live route provider (mirrors the runner's
76
+ // createChildCapDispatch resolving allSingletonProviders['addon-routes']).
77
+ dispatch: async (call) => {
78
+ if (call.capName === 'addon-routes' && routeProvider !== null) {
79
+ const fn = Reflect.get(routeProvider, call.method)
80
+ if (typeof fn === 'function') return fn.call(routeProvider, call.args)
81
+ }
82
+ return null
83
+ },
84
+ })
85
+ // Inline addon-runner dispatcher: routes → handler-stripped descriptors,
86
+ // custom → the child custom-action registry. Mirrors
87
+ // `createChildAddonCallDispatch` in the kernel.
88
+ child.onAddonCall(async (call: AddonCallInput) => {
89
+ if (call.target === 'routes') {
90
+ if (routeProvider === null) throw new Error(`addon "${call.addonId}" has no routes`)
91
+ const live = routeProvider.getRoutes()
92
+ return live.map((r) => ({
93
+ method: r.method,
94
+ path: r.path,
95
+ ...(r.access !== undefined ? { access: r.access } : {}),
96
+ ...(r.description !== undefined ? { description: r.description } : {}),
97
+ }))
98
+ }
99
+ const action = call.action
100
+ if (typeof action !== 'string') throw new Error('missing action')
101
+ const entry = childCustomActions.resolve(call.addonId, action)
102
+ if (entry === null) throw new Error(`no custom action "${action}"`)
103
+ return entry.handler(call.args)
104
+ })
105
+ clients.push(child)
106
+ await child.start()
107
+
108
+ return { childCustomActions }
109
+ }
110
+
111
+ it('custom action: the registerForkedAddonCustomActions dispatch path reaches the child handler', async () => {
112
+ const catalog = defineCustomActions({
113
+ runBenchmark: customAction(
114
+ z.object({ iterations: z.number() }),
115
+ z.object({ ran: z.number() }),
116
+ { kind: 'mutation' },
117
+ ),
118
+ })
119
+ await standUp({
120
+ addonId: 'benchmark',
121
+ catalog,
122
+ handlers: {
123
+ runBenchmark: async (input) => {
124
+ const args = input as { iterations: number }
125
+ return { ran: args.iterations }
126
+ },
127
+ },
128
+ })
129
+
130
+ // Hub-side registry — the dispatcher mirrors `dispatchForkedCustomAction`.
131
+ const hubRegistry = new CustomActionRegistry()
132
+ hubRegistry.registerAddon('benchmark', catalog, (action, input) =>
133
+ registry!.callAddonOnChild('benchmark', {
134
+ addonId: 'benchmark',
135
+ target: 'custom',
136
+ action,
137
+ args: input,
138
+ }),
139
+ )
140
+
141
+ const entry = hubRegistry.resolve('benchmark', 'runBenchmark')
142
+ expect(entry).not.toBeNull()
143
+ const result = await entry!.handler({ iterations: 9 })
144
+ expect(result).toEqual({ ran: 9 })
145
+ })
146
+
147
+ it('routes: callAddonOnChild(routes) returns stripped descriptors and invoke() round-trips', async () => {
148
+ const routes: IAddonHttpRoute[] = [
149
+ {
150
+ method: 'GET',
151
+ path: '/hello/:name',
152
+ access: 'public',
153
+ handler: async (req, reply) => {
154
+ reply.code(200)
155
+ reply.send({ greeting: `hi ${req.params.name}` })
156
+ },
157
+ },
158
+ ]
159
+ await standUp({ addonId: 'export-alexa', routes })
160
+
161
+ // (a) routes come back handler-stripped (the `mountForkedAddonRoutes` fetch).
162
+ const rawRoutes = await registry!.callAddonOnChild('export-alexa', {
163
+ addonId: 'export-alexa',
164
+ target: 'routes',
165
+ })
166
+ expect(rawRoutes).toEqual([{ method: 'GET', path: '/hello/:name', access: 'public' }])
167
+
168
+ // (b) dispatch through the addon-routes cap proxy's `invoke` cap method —
169
+ // the bridge handler `mountForkedAddonRoutes` synthesizes calls this.
170
+ const routesCapProxy = {
171
+ invoke: (input: unknown) =>
172
+ registry!.callCapOnChild('export-alexa', {
173
+ capName: 'addon-routes',
174
+ method: 'invoke',
175
+ args: input,
176
+ }),
177
+ }
178
+ const envelope = await routesCapProxy.invoke({
179
+ method: 'GET',
180
+ path: '/hello/world',
181
+ params: { name: 'world' },
182
+ query: {},
183
+ body: undefined,
184
+ headers: {},
185
+ })
186
+ expect(envelope).toMatchObject({ status: 200, body: { greeting: 'hi world' } })
187
+ })
188
+
189
+ it('auth-oidc shape: a forked addon with a SYNC array getRoutes + async handlers mounts via the strip path without serializing a function', async () => {
190
+ // Reproduces the regression: `auth-oidc` declares a manual route provider
191
+ // `{ id, getRoutes: () => routes }` (NO `buildAddonRouteProvider`, NO
192
+ // `invoke`). It forks like every non-core addon, so the hub resolves an
193
+ // async UDS cap proxy whose `getRoutes()` returns a Promise. The mount path
194
+ // MUST NOT register that proxy directly (→ `getRoutes(...).map is not a
195
+ // function`) nor dispatch its `getRoutes` cap method over UDS (the child's
196
+ // `getRoutes()` returns the live array WITH async `handler` functions →
197
+ // MsgPack "Unrecognized object: [object AsyncFunction]"). It uses the
198
+ // handler-stripped `callAddonOnChild(target:'routes')` bridge instead.
199
+ const routes: IAddonHttpRoute[] = [
200
+ {
201
+ method: 'GET',
202
+ path: '/:providerId/start',
203
+ access: 'public',
204
+ description: 'Begin OIDC redirect login flow',
205
+ handler: async (req, reply) => {
206
+ reply.code(302)
207
+ reply.send('')
208
+ },
209
+ },
210
+ {
211
+ method: 'GET',
212
+ path: '/:providerId/callback',
213
+ access: 'public',
214
+ handler: async (req, reply) => {
215
+ reply.code(200)
216
+ reply.send('ok')
217
+ },
218
+ },
219
+ ]
220
+ await standUp({ addonId: 'auth-oidc', routes })
221
+
222
+ // The handler-stripped descriptors cross the real UDS pair. If the strip
223
+ // path leaked a `handler` function, MsgPack `encodeFrame` would throw
224
+ // "Unrecognized object: [object AsyncFunction]" before this resolves.
225
+ const rawRoutes = await registry!.callAddonOnChild('auth-oidc', {
226
+ addonId: 'auth-oidc',
227
+ target: 'routes',
228
+ })
229
+ expect(rawRoutes).toEqual([
230
+ { method: 'GET', path: '/:providerId/start', access: 'public', description: 'Begin OIDC redirect login flow' },
231
+ { method: 'GET', path: '/:providerId/callback', access: 'public' },
232
+ ])
233
+ // Coarse reachability gate the route-mount fallback uses: the child is
234
+ // connected even though we only checked the addon-call surface.
235
+ expect(registry!.isChildKnown('auth-oidc')).toBe(true)
236
+ })
237
+ })
@@ -0,0 +1,183 @@
1
+ /**
2
+ * UDS child log ingest — unit test for Task B2.
3
+ *
4
+ * Verifies that wiring `LocalChildRegistry.onChildLog` to
5
+ * `LoggingService.writeFromWorker` correctly maps a `ChildLogMessage`
6
+ * produced over a UDS channel to the `writeFromWorker` entry shape.
7
+ *
8
+ * We test the helper `udsChildLogToWorkerEntry` (extracted from the
9
+ * hub/agent wiring) directly, plus the full registry+handler pairing so
10
+ * the glue code exercises the real `onChildLog` callback path.
11
+ */
12
+ import { describe, it, expect, vi } from 'vitest'
13
+ import type { ChildLogMessage } from '@camstack/kernel'
14
+ import { udsChildLogToWorkerEntry } from '@camstack/kernel'
15
+
16
+ // --------------------------------------------------------------------------
17
+ // Minimal `WriteFromWorkerEntry` shape mirrored from LoggingService signature
18
+ // --------------------------------------------------------------------------
19
+ interface WriteFromWorkerEntry {
20
+ addonId: string
21
+ nodeId?: string
22
+ level: string
23
+ message: string
24
+ scope?: string
25
+ tags?: Record<string, string | number | undefined>
26
+ meta?: Record<string, unknown>
27
+ }
28
+
29
+ // --------------------------------------------------------------------------
30
+ // Helper: build a minimal ChildLogMessage
31
+ // --------------------------------------------------------------------------
32
+ function makeLogMessage(overrides?: Partial<ChildLogMessage>): ChildLogMessage {
33
+ return {
34
+ kind: 'log',
35
+ level: 'info',
36
+ message: 'hello from child',
37
+ addonId: 'stream-broker',
38
+ ...overrides,
39
+ }
40
+ }
41
+
42
+ // --------------------------------------------------------------------------
43
+ // Tests for udsChildLogToWorkerEntry
44
+ // --------------------------------------------------------------------------
45
+
46
+ describe('udsChildLogToWorkerEntry', () => {
47
+ it('maps the required fields — addonId, level, message', () => {
48
+ const entry = makeLogMessage()
49
+ const result = udsChildLogToWorkerEntry('stream-broker', entry)
50
+
51
+ expect(result.addonId).toBe('stream-broker')
52
+ expect(result.level).toBe('info')
53
+ expect(result.message).toBe('hello from child')
54
+ })
55
+
56
+ it('uses entry.nodeId when present', () => {
57
+ const entry = makeLogMessage({ nodeId: 'hub/stream-broker' })
58
+ const result = udsChildLogToWorkerEntry('stream-broker', entry)
59
+
60
+ expect(result.nodeId).toBe('hub/stream-broker')
61
+ })
62
+
63
+ it('falls back to childId when nodeId is absent', () => {
64
+ const entry = makeLogMessage()
65
+ const result = udsChildLogToWorkerEntry('stream-broker', entry)
66
+
67
+ expect(result.nodeId).toBe('stream-broker')
68
+ })
69
+
70
+ it('omits scope when not present in entry', () => {
71
+ const entry = makeLogMessage()
72
+ const result = udsChildLogToWorkerEntry('stream-broker', entry)
73
+
74
+ expect(Object.prototype.hasOwnProperty.call(result, 'scope')).toBe(false)
75
+ })
76
+
77
+ it('includes scope when present in entry', () => {
78
+ const entry = makeLogMessage({ scope: 'rtsp' })
79
+ const result = udsChildLogToWorkerEntry('stream-broker', entry)
80
+
81
+ expect(result.scope).toBe('rtsp')
82
+ })
83
+
84
+ it('omits tags when not present in entry', () => {
85
+ const entry = makeLogMessage()
86
+ const result = udsChildLogToWorkerEntry('stream-broker', entry)
87
+
88
+ expect(Object.prototype.hasOwnProperty.call(result, 'tags')).toBe(false)
89
+ })
90
+
91
+ it('includes tags when present in entry', () => {
92
+ const entry = makeLogMessage({ tags: { deviceId: 5, streamId: '5/main' } })
93
+ const result = udsChildLogToWorkerEntry('stream-broker', entry)
94
+
95
+ expect(result.tags).toEqual({ deviceId: 5, streamId: '5/main' })
96
+ })
97
+
98
+ it('omits meta when not present in entry', () => {
99
+ const entry = makeLogMessage()
100
+ const result = udsChildLogToWorkerEntry('stream-broker', entry)
101
+
102
+ expect(Object.prototype.hasOwnProperty.call(result, 'meta')).toBe(false)
103
+ })
104
+
105
+ it('includes meta when present in entry', () => {
106
+ const entry = makeLogMessage({ meta: { rtt: 42 } })
107
+ const result = udsChildLogToWorkerEntry('stream-broker', entry)
108
+
109
+ expect(result.meta).toEqual({ rtt: 42 })
110
+ })
111
+
112
+ it('produces the same shape that $hub.log onLog produces', () => {
113
+ // The existing onLog wiring in moleculer.service.ts maps:
114
+ // { addonId, nodeId, level, message, scope?, tags?, meta? }
115
+ // Verify we produce an identical structure for a fully-populated entry.
116
+ const entry = makeLogMessage({
117
+ addonId: 'ptz-provider',
118
+ nodeId: 'hub/ptz-provider',
119
+ level: 'warn',
120
+ message: 'PTZ timeout',
121
+ scope: 'ptz',
122
+ tags: { deviceId: 3 },
123
+ meta: { timeout: 5000 },
124
+ })
125
+ const result = udsChildLogToWorkerEntry('hub/ptz-provider', entry)
126
+
127
+ const expected: WriteFromWorkerEntry = {
128
+ addonId: 'ptz-provider',
129
+ nodeId: 'hub/ptz-provider',
130
+ level: 'warn',
131
+ message: 'PTZ timeout',
132
+ scope: 'ptz',
133
+ tags: { deviceId: 3 },
134
+ meta: { timeout: 5000 },
135
+ }
136
+ expect(result).toEqual(expected)
137
+ })
138
+ })
139
+
140
+ // --------------------------------------------------------------------------
141
+ // Tests for the registry+handler wiring (the onChildLog callback path)
142
+ // --------------------------------------------------------------------------
143
+
144
+ describe('onChildLog handler wiring', () => {
145
+ it('calls writeFromWorker when a ChildLogMessage arrives', () => {
146
+ // Fake logging service — typed minimal interface
147
+ const fakeLogging = {
148
+ writeFromWorker: vi.fn<[WriteFromWorkerEntry], void>(),
149
+ }
150
+
151
+ // The actual wiring closure (mirrors hub & agent wiring)
152
+ const handler = (childId: string, entry: ChildLogMessage): void => {
153
+ fakeLogging.writeFromWorker(udsChildLogToWorkerEntry(childId, entry))
154
+ }
155
+
156
+ const entry = makeLogMessage({ addonId: 'motion-detector', nodeId: 'hub/motion-detector' })
157
+ handler('hub/motion-detector', entry)
158
+
159
+ expect(fakeLogging.writeFromWorker).toHaveBeenCalledOnce()
160
+ const called = fakeLogging.writeFromWorker.mock.calls[0]![0]
161
+ expect(called.addonId).toBe('motion-detector')
162
+ expect(called.nodeId).toBe('hub/motion-detector')
163
+ expect(called.level).toBe('info')
164
+ expect(called.message).toBe('hello from child')
165
+ })
166
+
167
+ it('invokes writeFromWorker once per log entry', () => {
168
+ const fakeLogging = {
169
+ writeFromWorker: vi.fn<[WriteFromWorkerEntry], void>(),
170
+ }
171
+
172
+ const handler = (childId: string, entry: ChildLogMessage): void => {
173
+ fakeLogging.writeFromWorker(udsChildLogToWorkerEntry(childId, entry))
174
+ }
175
+
176
+ handler('child-a', makeLogMessage({ addonId: 'child-a', level: 'debug', message: 'first' }))
177
+ handler('child-b', makeLogMessage({ addonId: 'child-b', level: 'error', message: 'second' }))
178
+
179
+ expect(fakeLogging.writeFromWorker).toHaveBeenCalledTimes(2)
180
+ expect(fakeLogging.writeFromWorker.mock.calls[0]![0].addonId).toBe('child-a')
181
+ expect(fakeLogging.writeFromWorker.mock.calls[1]![0].addonId).toBe('child-b')
182
+ })
183
+ })
@@ -7,6 +7,8 @@ import type { IScopedLogger, TokenScope } from '@camstack/types'
7
7
  import { checkScopeAccess } from './trpc/scope-access.js'
8
8
  import type { AddonBridgeService } from '../core/addon-bridge/addon-bridge.service'
9
9
  import type { AddonRegistryService } from '../core/addon/addon-registry.service'
10
+ import type { AddonPackageService } from '../core/addon/addon-package.service'
11
+ import { isFrameworkPackage } from '../core/addon/addon-package.service.js'
10
12
  import type { AuthService } from '../core/auth/auth.service'
11
13
  import type { MoleculerService } from '../core/moleculer/moleculer.service'
12
14
 
@@ -112,6 +114,7 @@ export async function registerAddonUploadRoute(
112
114
  authService: AuthService,
113
115
  moleculer: MoleculerService,
114
116
  addonRegistry: AddonRegistryService,
117
+ addonPackageService: AddonPackageService,
115
118
  logger: IScopedLogger,
116
119
  ): Promise<void> {
117
120
  await fastify.register(import('@fastify/multipart'), {
@@ -197,7 +200,7 @@ export async function registerAddonUploadRoute(
197
200
  // the package's addons (i.e. anything not marked hub-only). Any other
198
201
  // explicit `nodeId` value routes only to that agent via `$agent.deploy`.
199
202
  if (!nodeId || nodeId === 'hub') {
200
- return installToHub(reply, addonBridge, addonRegistry, moleculer, logger, data.filename, buffer)
203
+ return installToHub(reply, addonBridge, addonRegistry, addonPackageService, moleculer, logger, data.filename, buffer)
201
204
  }
202
205
  const agentAddonId = addonIdHint ?? manifest.name
203
206
  return deployToAgent(reply, moleculer, nodeId, agentAddonId, buffer)
@@ -330,6 +333,7 @@ async function installToHub(
330
333
  reply: FastifyReply,
331
334
  addonBridge: AddonBridgeService,
332
335
  addonRegistry: AddonRegistryService,
336
+ addonPackageService: AddonPackageService,
333
337
  moleculer: MoleculerService,
334
338
  logger: IScopedLogger,
335
339
  filename: string,
@@ -360,6 +364,28 @@ async function installToHub(
360
364
  const addonsDir = path.resolve(process.env['CAMSTACK_DATA'] ?? 'camstack-data', 'addons')
361
365
  fs.writeFileSync(path.join(addonsDir, result.name, '.install-source'), 'upload')
362
366
 
367
+ // Framework / system packages (`camstack.system: true`, e.g. @camstack/core)
368
+ // ship hub builtins (sqlite-settings, filesystem-storage, …) that CANNOT be
369
+ // hot-reloaded in place — an in-process `restartAddon` leaves them
370
+ // uninitialized ("SqliteSettingsBackend not initialized"), breaking auth +
371
+ // settings. The new code is now on disk; a clean server restart reloads it
372
+ // and re-runs boot initialization. The 10s restart grace lets this response
373
+ // flush before the process exits, so the CLI sees a clean confirmation.
374
+ if (isFrameworkPackage(result.name)) {
375
+ logger.info('framework package deployed — scheduling server restart', {
376
+ meta: { packageName: result.name, packageVersion: result.version },
377
+ })
378
+ addonPackageService.restartServer(`addon-upload: ${result.name}@${result.version}`)
379
+ return reply.send({
380
+ success: true,
381
+ name: result.name,
382
+ version: result.version,
383
+ requiresRestart: true,
384
+ restarting: true,
385
+ message: 'Framework package installed — server is restarting to load it',
386
+ })
387
+ }
388
+
363
389
  // `addonRegistry.loadNewAddons()` already runs its own fresh filesystem
364
390
  // scan, diff'ing against existing `addonEntries` — it updates metadata
365
391
  // for known addons without touching their live instances + initializes
@@ -113,7 +113,7 @@ export function createCapabilitiesRouter(
113
113
 
114
114
  if (!requiresRestart) {
115
115
  // Hot-swap at runtime
116
- await registry.setActiveSingleton(capability, addonId, true)
116
+ await registry.setActiveSingleton(capability, addonId)
117
117
  }
118
118
 
119
119
  // Persist preference