@camstack/server 0.1.3

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 (133) hide show
  1. package/.env.example +17 -0
  2. package/package.json +55 -0
  3. package/src/__tests__/addon-install-e2e.test.ts +75 -0
  4. package/src/__tests__/addon-pages-e2e.test.ts +178 -0
  5. package/src/__tests__/addon-route-session.test.ts +17 -0
  6. package/src/__tests__/addon-settings-router.spec.ts +62 -0
  7. package/src/__tests__/addon-upload.spec.ts +355 -0
  8. package/src/__tests__/agent-registry.spec.ts +162 -0
  9. package/src/__tests__/agent-status-page.spec.ts +84 -0
  10. package/src/__tests__/auth-session-cookie.test.ts +21 -0
  11. package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +23 -0
  12. package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +64 -0
  13. package/src/__tests__/cap-routers/_meta.spec.ts +200 -0
  14. package/src/__tests__/cap-routers/addon-settings.router.spec.ts +106 -0
  15. package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +142 -0
  16. package/src/__tests__/cap-routers/harness.ts +159 -0
  17. package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +119 -0
  18. package/src/__tests__/cap-routers/null-provider-guard.spec.ts +66 -0
  19. package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +135 -0
  20. package/src/__tests__/cap-routers/settings-store.router.spec.ts +247 -0
  21. package/src/__tests__/capability-e2e.test.ts +386 -0
  22. package/src/__tests__/cli-e2e.test.ts +129 -0
  23. package/src/__tests__/core-cap-bridge.spec.ts +89 -0
  24. package/src/__tests__/embedded-deps-e2e.test.ts +109 -0
  25. package/src/__tests__/event-bus-proxy-router.spec.ts +72 -0
  26. package/src/__tests__/fixtures/mock-analysis-addon-a.ts +37 -0
  27. package/src/__tests__/fixtures/mock-analysis-addon-b.ts +37 -0
  28. package/src/__tests__/fixtures/mock-log-addon.ts +37 -0
  29. package/src/__tests__/fixtures/mock-storage-addon.ts +40 -0
  30. package/src/__tests__/framework-allowlist.spec.ts +95 -0
  31. package/src/__tests__/https-e2e.test.ts +118 -0
  32. package/src/__tests__/lifecycle-e2e.test.ts +140 -0
  33. package/src/__tests__/live-events-subscription.spec.ts +150 -0
  34. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +229 -0
  35. package/src/__tests__/oauth2-account-linking.spec.ts +736 -0
  36. package/src/__tests__/post-boot-restart.spec.ts +161 -0
  37. package/src/__tests__/singleton-contention.test.ts +487 -0
  38. package/src/__tests__/streaming-diagnostic.test.ts +512 -0
  39. package/src/__tests__/streaming-scale.test.ts +280 -0
  40. package/src/agent-status-page.ts +121 -0
  41. package/src/api/__tests__/addons-custom.spec.ts +134 -0
  42. package/src/api/__tests__/capabilities.router.test.ts +47 -0
  43. package/src/api/addon-upload.ts +472 -0
  44. package/src/api/addons-custom.router.ts +100 -0
  45. package/src/api/auth-whoami.ts +99 -0
  46. package/src/api/bridge-addons.router.ts +120 -0
  47. package/src/api/capabilities.router.ts +226 -0
  48. package/src/api/core/__tests__/auth-router-totp.spec.ts +256 -0
  49. package/src/api/core/addon-settings.router.ts +124 -0
  50. package/src/api/core/agents.router.ts +87 -0
  51. package/src/api/core/auth.router.ts +303 -0
  52. package/src/api/core/cap-providers.ts +993 -0
  53. package/src/api/core/capabilities.router.ts +119 -0
  54. package/src/api/core/collection-preference.ts +40 -0
  55. package/src/api/core/event-bus-proxy.router.ts +45 -0
  56. package/src/api/core/hwaccel.router.ts +81 -0
  57. package/src/api/core/live-events.router.ts +60 -0
  58. package/src/api/core/logs.router.ts +162 -0
  59. package/src/api/core/notifications.router.ts +65 -0
  60. package/src/api/core/repl.router.ts +41 -0
  61. package/src/api/core/settings-backend.router.ts +142 -0
  62. package/src/api/core/stream-probe.router.ts +57 -0
  63. package/src/api/core/system-events.router.ts +116 -0
  64. package/src/api/health/health.routes.ts +123 -0
  65. package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +52 -0
  66. package/src/api/oauth2/consent-page.ts +42 -0
  67. package/src/api/oauth2/oauth2-routes.ts +248 -0
  68. package/src/api/trpc/__tests__/scope-access-device.spec.ts +223 -0
  69. package/src/api/trpc/__tests__/scope-access.spec.ts +107 -0
  70. package/src/api/trpc/cap-mount-helpers.ts +225 -0
  71. package/src/api/trpc/core-cap-bridge.ts +152 -0
  72. package/src/api/trpc/generated-cap-mounts.ts +707 -0
  73. package/src/api/trpc/generated-cap-routers.ts +6340 -0
  74. package/src/api/trpc/scope-access.ts +110 -0
  75. package/src/api/trpc/trpc.context.ts +255 -0
  76. package/src/api/trpc/trpc.middleware.ts +140 -0
  77. package/src/api/trpc/trpc.router.ts +275 -0
  78. package/src/auth/session-cookie.ts +44 -0
  79. package/src/boot/boot-config.ts +278 -0
  80. package/src/boot/post-boot.service.ts +103 -0
  81. package/src/core/addon/__tests__/addon-registry-capability.test.ts +53 -0
  82. package/src/core/addon/addon-package.service.ts +1684 -0
  83. package/src/core/addon/addon-registry.service.ts +2926 -0
  84. package/src/core/addon/addon-search.service.ts +90 -0
  85. package/src/core/addon/addon-settings-provider.ts +276 -0
  86. package/src/core/addon/addon.tokens.ts +2 -0
  87. package/src/core/addon-bridge/addon-bridge.service.ts +125 -0
  88. package/src/core/addon-pages/addon-pages.service.spec.ts +117 -0
  89. package/src/core/addon-pages/addon-pages.service.ts +80 -0
  90. package/src/core/addon-widgets/addon-widgets.service.ts +92 -0
  91. package/src/core/agent/agent-registry.service.ts +507 -0
  92. package/src/core/auth/auth.service.spec.ts +88 -0
  93. package/src/core/auth/auth.service.ts +8 -0
  94. package/src/core/capability/capability.service.ts +57 -0
  95. package/src/core/config/config.schema.ts +3 -0
  96. package/src/core/config/config.service.spec.ts +175 -0
  97. package/src/core/config/config.service.ts +7 -0
  98. package/src/core/events/event-bus.service.spec.ts +212 -0
  99. package/src/core/events/event-bus.service.ts +85 -0
  100. package/src/core/feature/feature.service.spec.ts +96 -0
  101. package/src/core/feature/feature.service.ts +8 -0
  102. package/src/core/lifecycle/lifecycle-state-machine.spec.ts +168 -0
  103. package/src/core/lifecycle/lifecycle-state-machine.ts +3 -0
  104. package/src/core/logging/log-ring-buffer.ts +3 -0
  105. package/src/core/logging/logging.service.spec.ts +247 -0
  106. package/src/core/logging/logging.service.ts +129 -0
  107. package/src/core/logging/scoped-logger.ts +3 -0
  108. package/src/core/moleculer/moleculer.service.ts +612 -0
  109. package/src/core/network/network-quality.service.spec.ts +47 -0
  110. package/src/core/network/network-quality.service.ts +5 -0
  111. package/src/core/notification/notification-wrapper.service.ts +36 -0
  112. package/src/core/notification/toast-wrapper.service.ts +31 -0
  113. package/src/core/provider/provider.tokens.ts +1 -0
  114. package/src/core/repl/repl-engine.service.spec.ts +417 -0
  115. package/src/core/repl/repl-engine.service.ts +156 -0
  116. package/src/core/storage/fs-storage-backend.spec.ts +70 -0
  117. package/src/core/storage/fs-storage-backend.ts +3 -0
  118. package/src/core/storage/settings-store.spec.ts +213 -0
  119. package/src/core/storage/settings-store.ts +2 -0
  120. package/src/core/storage/sql-schema.spec.ts +140 -0
  121. package/src/core/storage/sql-schema.ts +3 -0
  122. package/src/core/storage/storage-location-manager.spec.ts +121 -0
  123. package/src/core/storage/storage-location-manager.ts +3 -0
  124. package/src/core/storage/storage.service.spec.ts +73 -0
  125. package/src/core/storage/storage.service.ts +3 -0
  126. package/src/core/streaming/stream-probe.service.ts +212 -0
  127. package/src/core/topology/topology-emitter.service.ts +101 -0
  128. package/src/launcher.ts +309 -0
  129. package/src/main.ts +1049 -0
  130. package/src/manual-boot.ts +322 -0
  131. package/tsconfig.build.json +8 -0
  132. package/tsconfig.json +21 -0
  133. package/vitest.config.ts +26 -0
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Pure scope-access matcher.
3
+ *
4
+ * Extracted from `trpc.middleware.ts` so the spec can exercise it
5
+ * without spinning up the tRPC initTRPC machinery. The function is
6
+ * stateless aside from an optional device-ancestor lookup callback.
7
+ *
8
+ * Algorithm (v2 — four scope types):
9
+ * 1. Look the tRPC `path` up in `METHOD_ACCESS_MAP`. Unknown =
10
+ * FORBIDDEN (codegen drift; fail closed).
11
+ * 2. For each scope on the caller, check if it matches:
12
+ * - `category` — scope.target matches meta.capScope ('device'|'system')
13
+ * - `capability` — scope.target matches meta.capName exactly
14
+ * - `addon` — scope.target matches meta.addonId (when set)
15
+ * - `device` — input.deviceId (OR any of its ancestor deviceIds
16
+ * via `getDeviceAncestors`) is in scope.targets.
17
+ * Auto-inheritance means granting a Reolink camera
18
+ * implicitly grants its siren / floodlight / PIR
19
+ * child accessories without re-listing them.
20
+ * 3. On a target match, accept iff `scope.access` includes the
21
+ * method's required `access` flavour.
22
+ * 4. No matching scope → FORBIDDEN with a human-readable reason.
23
+ */
24
+ import { METHOD_ACCESS_MAP } from '@camstack/types'
25
+ import type { MethodAccess, TokenScope } from '@camstack/types'
26
+
27
+ export type ScopeAccessResult =
28
+ | { ok: true; access: MethodAccess }
29
+ | { ok: false; reason: string }
30
+
31
+ /**
32
+ * Resolves a deviceId to its ancestor chain (parent, grandparent, …).
33
+ * Empty / omitted for top-level devices. Order is irrelevant — the
34
+ * matcher only checks set membership.
35
+ */
36
+ export type DeviceAncestorLookup = (deviceId: number) => readonly number[]
37
+
38
+ /**
39
+ * Pull `deviceId` off a tRPC request input. Device-scope cap methods
40
+ * uniformly take `{deviceId: number, ...}` per the DeviceProxy contract,
41
+ * so a single extractor covers every device-scope call. Returns null when
42
+ * the input doesn't carry a deviceId (system-scope cap, void input, …).
43
+ */
44
+ function extractDeviceId(input: unknown): number | null {
45
+ if (input === null || typeof input !== 'object') return null
46
+ const candidate = (input as Record<string, unknown>)['deviceId']
47
+ return typeof candidate === 'number' ? candidate : null
48
+ }
49
+
50
+ /**
51
+ * Build the set of deviceIds that count as "this request" for the
52
+ * device-scope match: the deviceId itself plus every ancestor (so a
53
+ * scope on the parent camera covers accessory children).
54
+ */
55
+ function effectiveDeviceIds(
56
+ deviceId: number,
57
+ getAncestors: DeviceAncestorLookup | undefined,
58
+ ): readonly string[] {
59
+ if (!getAncestors) return [String(deviceId)]
60
+ const out = new Set<string>([String(deviceId)])
61
+ for (const ancestor of getAncestors(deviceId)) out.add(String(ancestor))
62
+ return [...out]
63
+ }
64
+
65
+ export function checkScopeAccess(
66
+ scopes: readonly TokenScope[],
67
+ path: string,
68
+ input?: unknown,
69
+ getDeviceAncestors?: DeviceAncestorLookup,
70
+ ): ScopeAccessResult {
71
+ const meta = METHOD_ACCESS_MAP[path]
72
+ if (!meta) {
73
+ return { ok: false, reason: `Unknown method '${path}' — codegen drift` }
74
+ }
75
+ const deviceId = meta.capScope === 'device' ? extractDeviceId(input) : null
76
+ const deviceChain = deviceId !== null ? effectiveDeviceIds(deviceId, getDeviceAncestors) : []
77
+ for (const s of scopes) {
78
+ let targetMatches = false
79
+ switch (s.type) {
80
+ case 'category':
81
+ targetMatches = s.target === meta.capScope
82
+ break
83
+ case 'capability':
84
+ targetMatches = s.target === meta.capName
85
+ break
86
+ case 'addon':
87
+ targetMatches = meta.addonId !== null && s.target === meta.addonId
88
+ break
89
+ case 'device':
90
+ // Match if the request's device — or any of its ancestors — is
91
+ // in the grant's target list. Accessory children inherit the
92
+ // parent's scope without re-enumeration.
93
+ targetMatches = deviceChain.some((id) => s.targets.includes(id))
94
+ break
95
+ }
96
+ if (!targetMatches) continue
97
+ if (s.access.includes(meta.access)) return { ok: true, access: meta.access }
98
+ }
99
+ return {
100
+ ok: false,
101
+ reason: `No scope grants ${meta.access} on '${meta.capName}' (${meta.capScope}-scope cap${
102
+ deviceId !== null ? `, device=${deviceId}` : ''
103
+ }). Have: ${
104
+ scopes.map((s) => {
105
+ const target = s.type === 'device' ? `[${s.targets.join(',')}]` : s.target
106
+ return `${s.type}:${target}[${s.access.join(',')}]`
107
+ }).join(', ') || '(none)'
108
+ }`,
109
+ }
110
+ }
@@ -0,0 +1,255 @@
1
+ import type { FastifyRequest } from 'fastify'
2
+ import type { IncomingMessage } from 'node:http'
3
+ import type { CreateWSSContextFnOptions } from '@trpc/server/adapters/ws'
4
+ import type { AuthenticatedUser, TokenScope } from '@camstack/types'
5
+ import type { AuthService } from '../../core/auth/auth.service'
6
+ import type { AddonRegistryService } from '../../core/addon/addon-registry.service'
7
+
8
+ /** AuthenticatedUser extended with agent identity + scoped-token fields. */
9
+ export interface AuthenticatedAgent extends AuthenticatedUser {
10
+ agentId?: string
11
+ /** True iff this user was resolved from a `cst_*` scoped token. */
12
+ isScoped?: boolean
13
+ /**
14
+ * The scope set that gated this request. Procedures with admin gates
15
+ * reject `isScoped` callers outright; the `protectedProcedure`
16
+ * middleware matches scopes against the request path to decide
17
+ * whether the call is permitted.
18
+ */
19
+ scopes?: readonly TokenScope[]
20
+ }
21
+
22
+ interface ScopedTokenLike {
23
+ readonly id: string
24
+ readonly userId: string
25
+ readonly tokenPrefix: string
26
+ readonly scopes: readonly TokenScope[]
27
+ }
28
+ interface UserManagementLike {
29
+ validateScopedToken(input: { token: string }): Promise<ScopedTokenLike | null>
30
+ }
31
+
32
+ /**
33
+ * Request union accepted by the tRPC context.
34
+ *
35
+ * HTTP tRPC requests arrive as `FastifyRequest`, but the WS adapter provides
36
+ * a raw `IncomingMessage` with no `.query`. Callers must narrow before reading
37
+ * Fastify-specific properties.
38
+ */
39
+ export type TrpcRequest = FastifyRequest | IncomingMessage
40
+
41
+ export interface TrpcContext {
42
+ user: AuthenticatedAgent | null
43
+ /**
44
+ * The originating HTTP/WS request. Absent for mesh-originated calls
45
+ * (the core-cap bridge invokes procedures via `createCaller`, with no
46
+ * request behind them). No procedure reads `req` today; it is kept
47
+ * for diagnostics and future request-scoped logic.
48
+ */
49
+ req?: TrpcRequest
50
+ /**
51
+ * Walks the parent-chain of a deviceId — used by the scope-access
52
+ * matcher so a `device:5` grant implicitly covers every accessory
53
+ * (siren / floodlight / PIR / …) whose `parentDeviceId` is 5.
54
+ * Populated by `createTrpcContext` from the live device registry;
55
+ * omitted on the WS / test paths where the registry isn't wired in.
56
+ */
57
+ getDeviceAncestors?: (deviceId: number) => readonly number[]
58
+ }
59
+
60
+ /** Read `req.query` if present (Fastify-only) without losing type safety. */
61
+ function readQuery(req: TrpcRequest): Record<string, unknown> | null {
62
+ if (!('query' in req)) return null
63
+ const q: unknown = Reflect.get(req, 'query')
64
+ if (q === null || typeof q !== 'object' || Array.isArray(q)) return null
65
+ return { ...q }
66
+ }
67
+
68
+ /**
69
+ * Extract a JWT token from an HTTP request.
70
+ * Priority: Authorization header → Fastify req.query → URL query string
71
+ */
72
+ function extractTokenFromRequest(req: TrpcRequest): string | null {
73
+ const authHeader = req.headers.authorization
74
+ if (authHeader && typeof authHeader === 'string') {
75
+ const [scheme, token] = authHeader.split(' ')
76
+ if (scheme === 'Bearer' && token) return token
77
+ }
78
+
79
+ // Fastify-parsed query object (HTTP tRPC requests)
80
+ const q = readQuery(req)
81
+ if (q && typeof q.token === 'string') {
82
+ return q.token
83
+ }
84
+
85
+ // Raw URL query string fallback (IncomingMessage or Fastify w/o parsed query)
86
+ const url = req.url
87
+ if (url) {
88
+ const qIdx = url.indexOf('?')
89
+ if (qIdx !== -1) {
90
+ const t = new URLSearchParams(url.slice(qIdx + 1)).get('token')
91
+ if (t) return t
92
+ }
93
+ }
94
+
95
+ return null
96
+ }
97
+
98
+ /**
99
+ * Resolve an AuthenticatedAgent from a raw token. Handles both JWT
100
+ * (sync, via authService) and `cst_*` scoped tokens (async, via the
101
+ * `user-management` cap singleton — same path addon-upload uses for
102
+ * its REST auth chain).
103
+ *
104
+ * Returns `null` for: missing token, malformed JWT, unknown scoped
105
+ * token. Caller (protectedProcedure) decides the failure response
106
+ * (typically UNAUTHORIZED).
107
+ */
108
+ async function resolveUser(
109
+ token: string | null | undefined,
110
+ authService: AuthService,
111
+ addonRegistry: AddonRegistryService,
112
+ ): Promise<AuthenticatedAgent | null> {
113
+ if (!token) return null
114
+
115
+ // Scoped-token path: hit the user-management cap. Synthetic user
116
+ // with `isAdmin: false` so admin-gated procedures bounce while
117
+ // protectedProcedure can still gate by scope match.
118
+ if (token.startsWith('cst_')) {
119
+ try {
120
+ const userMgmt = addonRegistry.getCapabilityRegistry().getSingleton('user-management') as UserManagementLike | undefined
121
+ if (!userMgmt) return null
122
+ const record = await userMgmt.validateScopedToken({ token })
123
+ if (!record) return null
124
+ return {
125
+ id: record.userId,
126
+ // Display label — `scoped:<prefix>` makes audit logs read
127
+ // naturally without exposing the token hash.
128
+ username: `scoped:${record.tokenPrefix}`,
129
+ isAdmin: false,
130
+ permissions: {
131
+ isAdmin: false,
132
+ allowedProviders: '*',
133
+ allowedDevices: {},
134
+ },
135
+ isApiKey: true,
136
+ isScoped: true,
137
+ scopes: record.scopes,
138
+ }
139
+ } catch {
140
+ return null
141
+ }
142
+ }
143
+
144
+ // JWT path.
145
+ try {
146
+ const payload = authService.verifyToken(token)
147
+ // Reject pre-v2 JWTs at the boundary. Tokens issued before the
148
+ // role → isAdmin migration don't carry the `isAdmin` field, so
149
+ // letting them through would degrade silently into "non-admin
150
+ // with no scopes" → locked out of every cap. Returning null forces
151
+ // the client to land on 401 → re-login, where it picks up a fresh
152
+ // v2 token. No back-compat shim — the role enum is gone.
153
+ if (typeof payload.isAdmin !== 'boolean') {
154
+ return null
155
+ }
156
+ return {
157
+ id: payload.userId ?? payload.keyId ?? 'unknown',
158
+ username: payload.username ?? 'unknown',
159
+ isAdmin: payload.isAdmin,
160
+ permissions: {
161
+ isAdmin: payload.isAdmin,
162
+ allowedProviders: payload.allowedProviders,
163
+ allowedDevices: payload.allowedDevices,
164
+ },
165
+ isApiKey: payload.type === 'api_key',
166
+ agentId: payload.agentId,
167
+ // Scopes are baked into the JWT at login; the middleware uses
168
+ // them to gate every call until the user re-logs.
169
+ ...(payload.scopes !== undefined ? { scopes: payload.scopes } : {}),
170
+ }
171
+ } catch {
172
+ return null
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Build the parent-chain walker for the scope-access matcher. Returns
178
+ * every ancestor deviceId of `deviceId` (parent, grandparent, …) so a
179
+ * grant on a Reolink camera covers its accessory children without
180
+ * re-enumerating them.
181
+ *
182
+ * Bounded by hop count (defence-in-depth — the device tree should
183
+ * never exceed 2-3 levels but a corrupt registry shouldn't loop forever).
184
+ */
185
+ function makeAncestorLookup(addonRegistry: AddonRegistryService): (deviceId: number) => readonly number[] {
186
+ return (deviceId: number) => {
187
+ const out: number[] = []
188
+ const registry = addonRegistry.getDeviceRegistry()
189
+ let current = registry.getById(deviceId)
190
+ for (let hop = 0; hop < 8 && current?.parentDeviceId != null; hop++) {
191
+ out.push(current.parentDeviceId)
192
+ current = registry.getById(current.parentDeviceId)
193
+ }
194
+ return out
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Context factory for hub-internal calls originating from the trusted
200
+ * Moleculer mesh (the `$core-caps` bridge service in `core-cap-bridge.ts`).
201
+ *
202
+ * Cluster membership is gated by `CAMSTACK_CLUSTER_SECRET`, so a
203
+ * mesh-originated call is treated as a fully-trusted admin: it carries
204
+ * a synthetic `isAdmin` user, which makes `protectedProcedure` /
205
+ * `adminProcedure` pass without a JWT and skips the scope-access
206
+ * matcher entirely. There is no HTTP request behind the call.
207
+ */
208
+ export function createMeshTrpcContext(): TrpcContext {
209
+ const user: AuthenticatedAgent = {
210
+ id: 'mesh',
211
+ username: 'mesh',
212
+ isAdmin: true,
213
+ permissions: { isAdmin: true, allowedProviders: '*', allowedDevices: {} },
214
+ isApiKey: true,
215
+ }
216
+ return { user }
217
+ }
218
+
219
+ /** Context factory for HTTP tRPC requests (Fastify adapter). */
220
+ export async function createTrpcContext(
221
+ req: TrpcRequest,
222
+ authService: AuthService,
223
+ addonRegistry: AddonRegistryService,
224
+ ): Promise<TrpcContext> {
225
+ const token = extractTokenFromRequest(req)
226
+ return {
227
+ user: await resolveUser(token, authService, addonRegistry),
228
+ req,
229
+ getDeviceAncestors: makeAncestorLookup(addonRegistry),
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Context factory for WebSocket tRPC connections (applyWSSHandler).
235
+ * Token is sent via tRPC connectionParams (a JSON message sent right after
236
+ * the WS handshake), which is more reliable than query params through proxies.
237
+ */
238
+ export async function createWsTrpcContext(
239
+ opts: CreateWSSContextFnOptions,
240
+ authService: AuthService,
241
+ addonRegistry: AddonRegistryService,
242
+ ): Promise<TrpcContext> {
243
+ // 1. connectionParams.token (sent by BackendClient's createWSClient)
244
+ const paramToken = opts.info.connectionParams?.['token']
245
+ const token =
246
+ (typeof paramToken === 'string' ? paramToken : null) ??
247
+ extractTokenFromRequest(opts.req)
248
+
249
+ const user = await resolveUser(token, authService, addonRegistry)
250
+ return {
251
+ user,
252
+ req: opts.req,
253
+ getDeviceAncestors: makeAncestorLookup(addonRegistry),
254
+ }
255
+ }
@@ -0,0 +1,140 @@
1
+ import { initTRPC, TRPCError } from '@trpc/server'
2
+ import superjson from 'superjson'
3
+ import { METHOD_ACCESS_MAP } from '@camstack/types'
4
+ import type { TrpcContext } from './trpc.context'
5
+ import { checkScopeAccess } from './scope-access.js'
6
+
7
+ const t = initTRPC.context<TrpcContext>().create({ transformer: superjson })
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Async-generator subscription helpers (tRPC v11 — replaces deprecated observable)
11
+ // ---------------------------------------------------------------------------
12
+
13
+ /**
14
+ * Convert a push-based subscription (callback → unsubscribe) into an async generator
15
+ * suitable for tRPC v11 `.subscription()`.
16
+ *
17
+ * @param subscribe — called once; receives a `push` callback and must return an unsubscribe fn.
18
+ */
19
+ export async function* iterableSubscription<T>(
20
+ subscribe: (push: (value: T) => void) => (() => void),
21
+ ): AsyncGenerator<T> {
22
+ const queue: T[] = []
23
+ let resolve: (() => void) | null = null
24
+
25
+ const unsub = subscribe((value) => {
26
+ queue.push(value)
27
+ resolve?.()
28
+ })
29
+
30
+ try {
31
+ while (true) {
32
+ while (queue.length > 0) {
33
+ yield queue.shift()!
34
+ }
35
+ await new Promise<void>((r) => { resolve = r })
36
+ }
37
+ } finally {
38
+ unsub()
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Create an interval-based async generator that yields a value on each tick.
44
+ * Useful for polling subscriptions.
45
+ */
46
+ export async function* iterableInterval<T>(
47
+ intervalMs: number,
48
+ getValue: () => T,
49
+ ): AsyncGenerator<T> {
50
+ try {
51
+ while (true) {
52
+ yield getValue()
53
+ await new Promise<void>((r) => setTimeout(r, intervalMs))
54
+ }
55
+ } finally {
56
+ // cleanup handled by generator return
57
+ }
58
+ }
59
+
60
+ export const trpcRouter = t.router
61
+ export const publicProcedure = t.procedure
62
+
63
+ /**
64
+ * Server-side caller factory — turns the hub appRouter into a directly
65
+ * invokable record under a supplied `TrpcContext`. The core-cap bridge
66
+ * (`core-cap-bridge.ts`) uses it to expose core routers over the
67
+ * Moleculer mesh without an HTTP round-trip.
68
+ */
69
+ export const createCallerFactory = t.createCallerFactory
70
+
71
+ /**
72
+ * Caps-only authenticated procedure (v2).
73
+ *
74
+ * - `isAdmin: true` → pass-through. Admin's `scopes` field is ignored.
75
+ * - `isAdmin: false` → `METHOD_ACCESS_MAP[path]` lookup + scope match.
76
+ * The caller's scope set must grant the required (capName, access)
77
+ * pair via one of the three forms (`category`/`capability`/`addon`).
78
+ *
79
+ * Hand-written core routers (`auth.*`, `system.info`, etc.) are not
80
+ * codegen'd from cap definitions and therefore not in
81
+ * `METHOD_ACCESS_MAP`. Those routes carry their own gating via
82
+ * `adminProcedure` when destructive; the bare `protectedProcedure`
83
+ * authentication check is the only gate. The middleware skips the
84
+ * scope-check for unknown paths so the SDK boot probe (`auth.me`) and
85
+ * `system.info` reach non-admin / scoped-token callers — without
86
+ * pulling every core route into the codegen map.
87
+ *
88
+ * Single source of authority for caps: `isAdmin`. The legacy role enum
89
+ * collapsed onto this boolean in v2.
90
+ */
91
+ export const protectedProcedure = t.procedure.use(async ({ ctx, next, path, getRawInput }) => {
92
+ if (!ctx.user) {
93
+ throw new TRPCError({ code: 'UNAUTHORIZED' })
94
+ }
95
+ // Spread+reassign of `user` narrows downstream ctx from `User | null`
96
+ // to `User` so `adminProcedure` / `agentProcedure` can read fields
97
+ // without re-checking.
98
+ if (ctx.user.isAdmin) {
99
+ return next({ ctx: { ...ctx, user: ctx.user } })
100
+ }
101
+ // Hand-written core route — no cap entry. Authentication has already
102
+ // passed; defer further gating to any explicit `adminProcedure`
103
+ // chained on top of this one.
104
+ if (!(path in METHOD_ACCESS_MAP)) {
105
+ return next({ ctx: { ...ctx, user: ctx.user } })
106
+ }
107
+ // Device-scope caps may be gated by a `device:N` scope. Resolve the
108
+ // raw input once so the matcher can read `input.deviceId` without
109
+ // re-doing the Zod parse (tRPC caches the parsed input downstream).
110
+ // The `getDeviceAncestors` hook lets the matcher walk parent → child
111
+ // accessory inheritance (grant on Reolink also covers its siren / PIR).
112
+ const rawInput = await getRawInput()
113
+ const result = checkScopeAccess(ctx.user.scopes ?? [], path, rawInput, ctx.getDeviceAncestors)
114
+ if (!result.ok) {
115
+ throw new TRPCError({ code: 'FORBIDDEN', message: result.reason })
116
+ }
117
+ return next({ ctx: { ...ctx, user: ctx.user } })
118
+ })
119
+
120
+ /**
121
+ * Destructive-ops gate. Adds an explicit admin check on top of
122
+ * `protectedProcedure`. Useful on hand-written routes whose admin-only
123
+ * nature should be obvious to a code reader.
124
+ */
125
+ export const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
126
+ if (!ctx.user.isAdmin) {
127
+ throw new TRPCError({ code: 'FORBIDDEN', message: 'Admin required' })
128
+ }
129
+ return next({ ctx })
130
+ })
131
+
132
+ /**
133
+ * Procedure for agent service accounts. After the v2 collapse, agents
134
+ * are admin sessions issued via `createServiceToken` — they all get
135
+ * `isAdmin: true`. This procedure is identical to `adminProcedure`;
136
+ * kept for naming clarity on agent-specific routes.
137
+ */
138
+ export const agentProcedure = adminProcedure.use(({ ctx, next }) => {
139
+ return next({ ctx: { ...ctx, agentId: ctx.user.agentId ?? ctx.user.id } })
140
+ })