@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.
- package/.env.example +17 -0
- package/package.json +55 -0
- package/src/__tests__/addon-install-e2e.test.ts +75 -0
- package/src/__tests__/addon-pages-e2e.test.ts +178 -0
- package/src/__tests__/addon-route-session.test.ts +17 -0
- package/src/__tests__/addon-settings-router.spec.ts +62 -0
- package/src/__tests__/addon-upload.spec.ts +355 -0
- package/src/__tests__/agent-registry.spec.ts +162 -0
- package/src/__tests__/agent-status-page.spec.ts +84 -0
- package/src/__tests__/auth-session-cookie.test.ts +21 -0
- package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +23 -0
- package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +64 -0
- package/src/__tests__/cap-routers/_meta.spec.ts +200 -0
- package/src/__tests__/cap-routers/addon-settings.router.spec.ts +106 -0
- package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +142 -0
- package/src/__tests__/cap-routers/harness.ts +159 -0
- package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +119 -0
- package/src/__tests__/cap-routers/null-provider-guard.spec.ts +66 -0
- package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +135 -0
- package/src/__tests__/cap-routers/settings-store.router.spec.ts +247 -0
- package/src/__tests__/capability-e2e.test.ts +386 -0
- package/src/__tests__/cli-e2e.test.ts +129 -0
- package/src/__tests__/core-cap-bridge.spec.ts +89 -0
- package/src/__tests__/embedded-deps-e2e.test.ts +109 -0
- package/src/__tests__/event-bus-proxy-router.spec.ts +72 -0
- package/src/__tests__/fixtures/mock-analysis-addon-a.ts +37 -0
- package/src/__tests__/fixtures/mock-analysis-addon-b.ts +37 -0
- package/src/__tests__/fixtures/mock-log-addon.ts +37 -0
- package/src/__tests__/fixtures/mock-storage-addon.ts +40 -0
- package/src/__tests__/framework-allowlist.spec.ts +95 -0
- package/src/__tests__/https-e2e.test.ts +118 -0
- package/src/__tests__/lifecycle-e2e.test.ts +140 -0
- package/src/__tests__/live-events-subscription.spec.ts +150 -0
- package/src/__tests__/moleculer-register-node-idempotency.spec.ts +229 -0
- package/src/__tests__/oauth2-account-linking.spec.ts +736 -0
- package/src/__tests__/post-boot-restart.spec.ts +161 -0
- package/src/__tests__/singleton-contention.test.ts +487 -0
- package/src/__tests__/streaming-diagnostic.test.ts +512 -0
- package/src/__tests__/streaming-scale.test.ts +280 -0
- package/src/agent-status-page.ts +121 -0
- package/src/api/__tests__/addons-custom.spec.ts +134 -0
- package/src/api/__tests__/capabilities.router.test.ts +47 -0
- package/src/api/addon-upload.ts +472 -0
- package/src/api/addons-custom.router.ts +100 -0
- package/src/api/auth-whoami.ts +99 -0
- package/src/api/bridge-addons.router.ts +120 -0
- package/src/api/capabilities.router.ts +226 -0
- package/src/api/core/__tests__/auth-router-totp.spec.ts +256 -0
- package/src/api/core/addon-settings.router.ts +124 -0
- package/src/api/core/agents.router.ts +87 -0
- package/src/api/core/auth.router.ts +303 -0
- package/src/api/core/cap-providers.ts +993 -0
- package/src/api/core/capabilities.router.ts +119 -0
- package/src/api/core/collection-preference.ts +40 -0
- package/src/api/core/event-bus-proxy.router.ts +45 -0
- package/src/api/core/hwaccel.router.ts +81 -0
- package/src/api/core/live-events.router.ts +60 -0
- package/src/api/core/logs.router.ts +162 -0
- package/src/api/core/notifications.router.ts +65 -0
- package/src/api/core/repl.router.ts +41 -0
- package/src/api/core/settings-backend.router.ts +142 -0
- package/src/api/core/stream-probe.router.ts +57 -0
- package/src/api/core/system-events.router.ts +116 -0
- package/src/api/health/health.routes.ts +123 -0
- package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +52 -0
- package/src/api/oauth2/consent-page.ts +42 -0
- package/src/api/oauth2/oauth2-routes.ts +248 -0
- package/src/api/trpc/__tests__/scope-access-device.spec.ts +223 -0
- package/src/api/trpc/__tests__/scope-access.spec.ts +107 -0
- package/src/api/trpc/cap-mount-helpers.ts +225 -0
- package/src/api/trpc/core-cap-bridge.ts +152 -0
- package/src/api/trpc/generated-cap-mounts.ts +707 -0
- package/src/api/trpc/generated-cap-routers.ts +6340 -0
- package/src/api/trpc/scope-access.ts +110 -0
- package/src/api/trpc/trpc.context.ts +255 -0
- package/src/api/trpc/trpc.middleware.ts +140 -0
- package/src/api/trpc/trpc.router.ts +275 -0
- package/src/auth/session-cookie.ts +44 -0
- package/src/boot/boot-config.ts +278 -0
- package/src/boot/post-boot.service.ts +103 -0
- package/src/core/addon/__tests__/addon-registry-capability.test.ts +53 -0
- package/src/core/addon/addon-package.service.ts +1684 -0
- package/src/core/addon/addon-registry.service.ts +2926 -0
- package/src/core/addon/addon-search.service.ts +90 -0
- package/src/core/addon/addon-settings-provider.ts +276 -0
- package/src/core/addon/addon.tokens.ts +2 -0
- package/src/core/addon-bridge/addon-bridge.service.ts +125 -0
- package/src/core/addon-pages/addon-pages.service.spec.ts +117 -0
- package/src/core/addon-pages/addon-pages.service.ts +80 -0
- package/src/core/addon-widgets/addon-widgets.service.ts +92 -0
- package/src/core/agent/agent-registry.service.ts +507 -0
- package/src/core/auth/auth.service.spec.ts +88 -0
- package/src/core/auth/auth.service.ts +8 -0
- package/src/core/capability/capability.service.ts +57 -0
- package/src/core/config/config.schema.ts +3 -0
- package/src/core/config/config.service.spec.ts +175 -0
- package/src/core/config/config.service.ts +7 -0
- package/src/core/events/event-bus.service.spec.ts +212 -0
- package/src/core/events/event-bus.service.ts +85 -0
- package/src/core/feature/feature.service.spec.ts +96 -0
- package/src/core/feature/feature.service.ts +8 -0
- package/src/core/lifecycle/lifecycle-state-machine.spec.ts +168 -0
- package/src/core/lifecycle/lifecycle-state-machine.ts +3 -0
- package/src/core/logging/log-ring-buffer.ts +3 -0
- package/src/core/logging/logging.service.spec.ts +247 -0
- package/src/core/logging/logging.service.ts +129 -0
- package/src/core/logging/scoped-logger.ts +3 -0
- package/src/core/moleculer/moleculer.service.ts +612 -0
- package/src/core/network/network-quality.service.spec.ts +47 -0
- package/src/core/network/network-quality.service.ts +5 -0
- package/src/core/notification/notification-wrapper.service.ts +36 -0
- package/src/core/notification/toast-wrapper.service.ts +31 -0
- package/src/core/provider/provider.tokens.ts +1 -0
- package/src/core/repl/repl-engine.service.spec.ts +417 -0
- package/src/core/repl/repl-engine.service.ts +156 -0
- package/src/core/storage/fs-storage-backend.spec.ts +70 -0
- package/src/core/storage/fs-storage-backend.ts +3 -0
- package/src/core/storage/settings-store.spec.ts +213 -0
- package/src/core/storage/settings-store.ts +2 -0
- package/src/core/storage/sql-schema.spec.ts +140 -0
- package/src/core/storage/sql-schema.ts +3 -0
- package/src/core/storage/storage-location-manager.spec.ts +121 -0
- package/src/core/storage/storage-location-manager.ts +3 -0
- package/src/core/storage/storage.service.spec.ts +73 -0
- package/src/core/storage/storage.service.ts +3 -0
- package/src/core/streaming/stream-probe.service.ts +212 -0
- package/src/core/topology/topology-emitter.service.ts +101 -0
- package/src/launcher.ts +309 -0
- package/src/main.ts +1049 -0
- package/src/manual-boot.ts +322 -0
- package/tsconfig.build.json +8 -0
- package/tsconfig.json +21 -0
- 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
|
+
})
|