@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "private": false,
5
5
  "exports": {
6
6
  "./package.json": "./package.json",
@@ -23,6 +23,7 @@ import type { AddonBridgeService } from '../core/addon-bridge/addon-bridge.servi
23
23
  import type { AuthService } from '../core/auth/auth.service.js'
24
24
  import type { MoleculerService } from '../core/moleculer/moleculer.service.js'
25
25
  import type { AddonRegistryService } from '../core/addon/addon-registry.service.js'
26
+ import type { AddonPackageService } from '../core/addon/addon-package.service.js'
26
27
  import type { IScopedLogger } from '@camstack/types'
27
28
 
28
29
  /**
@@ -129,6 +130,14 @@ function makeAddonRegistry(): AddonRegistryService {
129
130
  } as unknown as AddonRegistryService
130
131
  }
131
132
 
133
+ function makeAddonPackageService(): AddonPackageService {
134
+ // `restartServer` is exercised only by the framework-package branch; the
135
+ // default happy-path tests deploy a regular addon and never call it.
136
+ return {
137
+ restartServer: vi.fn((_requestedBy?: string) => {}),
138
+ } as unknown as AddonPackageService
139
+ }
140
+
132
141
  function makeLogger(): IScopedLogger {
133
142
  return {
134
143
  debug: vi.fn(),
@@ -145,6 +154,7 @@ async function makeServer(opts: {
145
154
  bridge: AddonBridgeService
146
155
  moleculer: MoleculerService
147
156
  addonRegistry?: AddonRegistryService
157
+ addonPackageService?: AddonPackageService
148
158
  logger?: IScopedLogger
149
159
  }): Promise<FastifyInstance> {
150
160
  const fastify = Fastify({ logger: false })
@@ -154,6 +164,7 @@ async function makeServer(opts: {
154
164
  opts.auth,
155
165
  opts.moleculer,
156
166
  opts.addonRegistry ?? makeAddonRegistry(),
167
+ opts.addonPackageService ?? makeAddonPackageService(),
157
168
  opts.logger ?? makeLogger(),
158
169
  )
159
170
  await fastify.ready()
@@ -258,6 +269,53 @@ describe('POST /api/addons/upload', () => {
258
269
  }
259
270
  })
260
271
 
272
+ it('restarts the server (not an in-process reload) when a framework package is deployed', async () => {
273
+ // @camstack/core ships hub builtins that can't hot-reload in place — the
274
+ // upload path must call restartServer() and SKIP the per-addon restartAddon.
275
+ const tmpRoot = await import('node:fs/promises').then(fs => fs.mkdtemp('/tmp/camstack-upload-fw-'))
276
+ const previous = process.env['CAMSTACK_DATA']
277
+ process.env['CAMSTACK_DATA'] = tmpRoot
278
+ const fsSync = await import('node:fs')
279
+ fsSync.mkdirSync(`${tmpRoot}/addons/@camstack/core`, { recursive: true })
280
+
281
+ try {
282
+ const restartServer = vi.fn((_requestedBy?: string) => {})
283
+ const restartAddon = vi.fn(async (_id: string) => ({ success: true }))
284
+ const registry = {
285
+ listAddons: vi.fn(() => [{ manifest: { id: 'sqlite-settings', packageName: '@camstack/core' } }]),
286
+ loadNewAddons: vi.fn(async () => ({ loaded: [] as string[], failed: [] as string[] })),
287
+ restartAddon,
288
+ getCapabilityRegistry: vi.fn(() => ({ getSingleton: vi.fn((_n: string) => null) })),
289
+ } as unknown as AddonRegistryService
290
+ const pkgSvc = { restartServer } as unknown as AddonPackageService
291
+
292
+ fastify = await makeServer({
293
+ auth: makeAuth('admin'),
294
+ bridge: makeAddonBridge({ name: '@camstack/core', version: '9.9.9' }),
295
+ moleculer: makeMoleculer(vi.fn()),
296
+ addonRegistry: registry,
297
+ addonPackageService: pkgSvc,
298
+ })
299
+ const res = await fastify.inject({
300
+ method: 'POST',
301
+ url: '/api/addons/upload',
302
+ headers: { 'content-type': `multipart/form-data; boundary=${TGZ_BOUNDARY}`, authorization: 'Bearer t' },
303
+ payload: buildMultipart(TGZ_BOUNDARY, {
304
+ file: { filename: 'camstack-core-9.9.9.tgz', content: buildValidTarball({ name: '@camstack/core', version: '9.9.9' }) },
305
+ }),
306
+ })
307
+ expect(res.statusCode).toBe(200)
308
+ expect(res.json()).toMatchObject({ success: true, requiresRestart: true, restarting: true })
309
+ expect(restartServer).toHaveBeenCalledOnce()
310
+ // Critical: the in-process restart that breaks builtins must NOT run.
311
+ expect(restartAddon).not.toHaveBeenCalled()
312
+ } finally {
313
+ if (previous === undefined) delete process.env['CAMSTACK_DATA']
314
+ else process.env['CAMSTACK_DATA'] = previous
315
+ fsSync.rmSync(tmpRoot, { recursive: true, force: true })
316
+ }
317
+ })
318
+
261
319
  it('routes to $agent.deploy when nodeId is provided', async () => {
262
320
  const call = vi.fn(async () => ({ success: true, addonId: 'addon-x', path: '/agent/addons/addon-x' }))
263
321
  fastify = await makeServer({
@@ -0,0 +1,286 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import {
3
+ BulkUpdateCoordinator,
4
+ type BulkUpdateCoordinatorDeps,
5
+ } from '../api/core/bulk-update-coordinator.js'
6
+ import { EventCategory, type BulkUpdateState } from '@camstack/types'
7
+
8
+ interface TestRig {
9
+ readonly coordinator: BulkUpdateCoordinator
10
+ readonly events: BulkUpdateState[]
11
+ readonly updateAddon: ReturnType<typeof vi.fn>
12
+ readonly updateFrameworkPackage: ReturnType<typeof vi.fn>
13
+ readonly restartServer: ReturnType<typeof vi.fn>
14
+ readonly now: () => number
15
+ }
16
+
17
+ function makeRig(opts?: { failItems?: ReadonlySet<string> }): TestRig {
18
+ const events: BulkUpdateState[] = []
19
+ const fail = opts?.failItems ?? new Set<string>()
20
+ let clock = 1_000_000
21
+
22
+ const updateAddon = vi.fn(async (input: { name: string; version: string }) => {
23
+ if (fail.has(input.name)) throw new Error(`mock fail ${input.name}`)
24
+ })
25
+ const updateFrameworkPackage = vi.fn(async (input: { packageName: string; version: string; deferRestart: boolean }) => {
26
+ if (fail.has(input.packageName)) throw new Error(`mock fail fw ${input.packageName}`)
27
+ })
28
+ const restartServer = vi.fn(async () => {
29
+ // simulates the real restartServer: in a real run the process dies
30
+ })
31
+
32
+ const deps: BulkUpdateCoordinatorDeps = {
33
+ eventBus: {
34
+ emit: (category, payload) => {
35
+ if (category === EventCategory.AddonsBulkUpdateProgress) {
36
+ events.push(structuredClone(payload as BulkUpdateState))
37
+ }
38
+ },
39
+ } as unknown as BulkUpdateCoordinatorDeps['eventBus'],
40
+ updateAddon,
41
+ updateFrameworkPackage,
42
+ restartServer,
43
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as unknown as BulkUpdateCoordinatorDeps['logger'],
44
+ clock: () => clock++,
45
+ }
46
+
47
+ return {
48
+ coordinator: new BulkUpdateCoordinator(deps),
49
+ events,
50
+ updateAddon,
51
+ updateFrameworkPackage,
52
+ restartServer,
53
+ now: () => clock,
54
+ }
55
+ }
56
+
57
+ describe('BulkUpdateCoordinator', () => {
58
+ beforeEach(() => { vi.useFakeTimers() })
59
+ afterEach(() => { vi.useRealTimers() })
60
+
61
+ it('regular-only happy path: processes 3 addons in order, no restart', async () => {
62
+ const rig = makeRig()
63
+ const { id } = rig.coordinator.start({
64
+ nodeId: 'hub',
65
+ items: [
66
+ { name: '@camstack/addon-a', version: '1.0.1', isSystem: false },
67
+ { name: '@camstack/addon-b', version: '2.0.1', isSystem: false },
68
+ { name: '@camstack/addon-c', version: '3.0.1', isSystem: false },
69
+ ],
70
+ })
71
+
72
+ await vi.runAllTimersAsync()
73
+
74
+ const final = rig.coordinator.get(id)!
75
+ expect(final.completed).toBe(3)
76
+ expect(final.failed).toBe(0)
77
+ expect(final.phase).toBe('finalizing')
78
+ expect(final.items.every(i => i.status === 'done')).toBe(true)
79
+ expect(rig.restartServer).not.toHaveBeenCalled()
80
+ expect(rig.updateAddon).toHaveBeenCalledTimes(3)
81
+ })
82
+
83
+ it('mixed happy path: regular first, system after, single restart at end', async () => {
84
+ const rig = makeRig()
85
+ const { id } = rig.coordinator.start({
86
+ nodeId: 'hub',
87
+ items: [
88
+ { name: '@camstack/addon-a', version: '1.0.1', isSystem: false },
89
+ { name: '@camstack/types', version: '0.1.40', isSystem: true },
90
+ { name: '@camstack/addon-b', version: '2.0.1', isSystem: false },
91
+ { name: '@camstack/kernel', version: '0.1.30', isSystem: true },
92
+ ],
93
+ })
94
+
95
+ await vi.runAllTimersAsync()
96
+
97
+ const final = rig.coordinator.get(id)!
98
+ expect(final.completed).toBe(4)
99
+ expect(rig.updateAddon).toHaveBeenCalledTimes(2)
100
+ expect(rig.updateFrameworkPackage).toHaveBeenCalledTimes(2)
101
+ expect(rig.updateFrameworkPackage).toHaveBeenCalledWith(expect.objectContaining({ deferRestart: true }))
102
+ expect(rig.restartServer).toHaveBeenCalledOnce()
103
+
104
+ // Verify ordering via event sequence: regular items reach 'updating' before any system item
105
+ const updatingNames = rig.events
106
+ .map(s => s.current)
107
+ .filter((n): n is string => n !== null)
108
+ const firstSystemIdx = updatingNames.findIndex(n => n === '@camstack/types' || n === '@camstack/kernel')
109
+ const lastRegularIdx = updatingNames.findLastIndex(n => n === '@camstack/addon-a' || n === '@camstack/addon-b')
110
+ expect(firstSystemIdx).toBeGreaterThan(lastRegularIdx)
111
+ })
112
+
113
+ it('failure isolation: failed item does not block others', async () => {
114
+ const rig = makeRig({ failItems: new Set(['@camstack/addon-b']) })
115
+ const { id } = rig.coordinator.start({
116
+ nodeId: 'hub',
117
+ items: [
118
+ { name: '@camstack/addon-a', version: '1.0.1', isSystem: false },
119
+ { name: '@camstack/addon-b', version: '2.0.1', isSystem: false },
120
+ { name: '@camstack/addon-c', version: '3.0.1', isSystem: false },
121
+ ],
122
+ })
123
+
124
+ await vi.runAllTimersAsync()
125
+
126
+ const final = rig.coordinator.get(id)!
127
+ expect(final.failed).toBe(1)
128
+ expect(final.items.find(i => i.name === '@camstack/addon-b')?.status).toBe('failed')
129
+ expect(final.items.find(i => i.name === '@camstack/addon-b')?.error).toContain('mock fail')
130
+ expect(final.items.find(i => i.name === '@camstack/addon-a')?.status).toBe('done')
131
+ expect(final.items.find(i => i.name === '@camstack/addon-c')?.status).toBe('done')
132
+ })
133
+
134
+ it('cancel pre-restart: loop exits, no restart, queued items remain queued', async () => {
135
+ const rig = makeRig()
136
+ rig.updateAddon.mockImplementation(async () => {
137
+ // Slow enough that cancel fires before item 2
138
+ await new Promise<void>(resolve => { setTimeout(resolve, 50) })
139
+ })
140
+
141
+ const { id } = rig.coordinator.start({
142
+ nodeId: 'hub',
143
+ items: [
144
+ { name: '@camstack/addon-a', version: '1.0.1', isSystem: false },
145
+ { name: '@camstack/addon-b', version: '2.0.1', isSystem: false },
146
+ { name: '@camstack/addon-c', version: '3.0.1', isSystem: false },
147
+ ],
148
+ })
149
+
150
+ // Advance time so item 1 starts updating
151
+ await vi.advanceTimersByTimeAsync(10)
152
+ const cancel = rig.coordinator.cancel(id)
153
+ expect(cancel.cancelled).toBe(true)
154
+ await vi.runAllTimersAsync()
155
+
156
+ const final = rig.coordinator.get(id)!
157
+ expect(final.cancelled).toBe(true)
158
+ expect(rig.restartServer).not.toHaveBeenCalled()
159
+ // At least one item should still be queued (we cancelled before item 2 ran)
160
+ expect(final.items.filter(i => i.status === 'queued').length).toBeGreaterThanOrEqual(1)
161
+ })
162
+
163
+ it('cancel ignored during restarting phase', async () => {
164
+ const rig = makeRig()
165
+ rig.restartServer.mockImplementation(async () => {
166
+ // Hang briefly so we can attempt cancel during restart
167
+ await new Promise<void>(resolve => { setTimeout(resolve, 50) })
168
+ })
169
+
170
+ const { id } = rig.coordinator.start({
171
+ nodeId: 'hub',
172
+ items: [
173
+ { name: '@camstack/types', version: '0.1.40', isSystem: true },
174
+ ],
175
+ })
176
+
177
+ // Let it reach restarting
178
+ await vi.advanceTimersByTimeAsync(10)
179
+ const state = rig.coordinator.get(id)
180
+ if (state?.phase === 'restarting') {
181
+ const cancel = rig.coordinator.cancel(id)
182
+ expect(cancel.cancelled).toBe(false)
183
+ }
184
+
185
+ await vi.runAllTimersAsync()
186
+ })
187
+
188
+ it('restart failure: pending-restart items get promoted to done with caveat error', async () => {
189
+ const rig = makeRig()
190
+ rig.restartServer.mockRejectedValueOnce(new Error('restart crashed'))
191
+
192
+ const { id } = rig.coordinator.start({
193
+ nodeId: 'hub',
194
+ items: [
195
+ { name: '@camstack/types', version: '0.1.40', isSystem: true },
196
+ ],
197
+ })
198
+
199
+ await vi.runAllTimersAsync()
200
+
201
+ const final = rig.coordinator.get(id)!
202
+ const typesItem = final.items.find(i => i.name === '@camstack/types')!
203
+ expect(typesItem.status).toBe('done')
204
+ expect(typesItem.error).toContain('Restart failed')
205
+ })
206
+
207
+ it('concurrent start for same nodeId is rejected', () => {
208
+ const rig = makeRig()
209
+ rig.coordinator.start({
210
+ nodeId: 'hub',
211
+ items: [{ name: '@camstack/addon-a', version: '1.0.1', isSystem: false }],
212
+ })
213
+
214
+ expect(() => rig.coordinator.start({
215
+ nodeId: 'hub',
216
+ items: [{ name: '@camstack/addon-b', version: '2.0.1', isSystem: false }],
217
+ })).toThrow(/already in progress/i)
218
+ })
219
+
220
+ it('auto-cleanup: state purged 5 min after completedAt', async () => {
221
+ const rig = makeRig()
222
+ const { id } = rig.coordinator.start({
223
+ nodeId: 'hub',
224
+ items: [{ name: '@camstack/addon-a', version: '1.0.1', isSystem: false }],
225
+ })
226
+
227
+ await vi.runAllTimersAsync()
228
+ expect(rig.coordinator.get(id)).not.toBeNull()
229
+
230
+ await vi.advanceTimersByTimeAsync(5 * 60 * 1000 + 100)
231
+ expect(rig.coordinator.get(id)).toBeNull()
232
+ })
233
+
234
+ it('list excludes stale states after cleanup window (lazy cleanup symmetry)', async () => {
235
+ const rig = makeRig()
236
+ const { id } = rig.coordinator.start({
237
+ nodeId: 'hub',
238
+ items: [{ name: '@camstack/addon-a', version: '1.0.1', isSystem: false }],
239
+ })
240
+
241
+ await vi.runAllTimersAsync()
242
+
243
+ // Right after completion: both get() and list() see the state
244
+ expect(rig.coordinator.get(id)).not.toBeNull()
245
+ expect(rig.coordinator.list()).toHaveLength(1)
246
+
247
+ // After cleanup window: both should agree that the state is gone
248
+ await vi.advanceTimersByTimeAsync(5 * 60 * 1000 + 100)
249
+ expect(rig.coordinator.get(id)).toBeNull()
250
+ expect(rig.coordinator.list()).toHaveLength(0)
251
+ expect(rig.coordinator.list('hub')).toHaveLength(0)
252
+ })
253
+
254
+ it('list filters by nodeId', () => {
255
+ const rig = makeRig()
256
+ rig.coordinator.start({
257
+ nodeId: 'hub',
258
+ items: [{ name: '@camstack/addon-a', version: '1.0.1', isSystem: false }],
259
+ })
260
+ expect(rig.coordinator.list('hub')).toHaveLength(1)
261
+ expect(rig.coordinator.list('agent-1')).toHaveLength(0)
262
+ expect(rig.coordinator.list()).toHaveLength(1)
263
+ })
264
+
265
+ it('emits AddonsBulkUpdateProgress event on every status transition', async () => {
266
+ const rig = makeRig()
267
+ rig.coordinator.start({
268
+ nodeId: 'hub',
269
+ items: [
270
+ { name: '@camstack/addon-a', version: '1.0.1', isSystem: false },
271
+ { name: '@camstack/addon-b', version: '2.0.1', isSystem: false },
272
+ ],
273
+ })
274
+
275
+ await vi.runAllTimersAsync()
276
+
277
+ // At minimum: phase transition + 2 items × 2 transitions (updating → done)
278
+ expect(rig.events.length).toBeGreaterThanOrEqual(5)
279
+ // Every payload must be a complete BulkUpdateState (not a partial diff)
280
+ for (const evt of rig.events) {
281
+ expect(evt.id).toBeDefined()
282
+ expect(evt.nodeId).toBe('hub')
283
+ expect(Array.isArray(evt.items)).toBe(true)
284
+ }
285
+ })
286
+ })