@roj-ai/standalone-server 0.1.11 → 0.1.12

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 (43) hide show
  1. package/dist/files-upload-route.d.ts +36 -0
  2. package/dist/files-upload-route.d.ts.map +1 -0
  3. package/dist/files-upload-route.js +50 -0
  4. package/dist/files-upload-route.js.map +1 -0
  5. package/dist/instance.d.ts +6 -0
  6. package/dist/instance.d.ts.map +1 -1
  7. package/dist/instance.js +5 -0
  8. package/dist/instance.js.map +1 -1
  9. package/dist/local-registry.d.ts +109 -0
  10. package/dist/local-registry.d.ts.map +1 -0
  11. package/dist/local-registry.js +252 -0
  12. package/dist/local-registry.js.map +1 -0
  13. package/dist/main.js +7 -1
  14. package/dist/main.js.map +1 -1
  15. package/dist/platform-api.d.ts +8 -1
  16. package/dist/platform-api.d.ts.map +1 -1
  17. package/dist/platform-api.js +164 -13
  18. package/dist/platform-api.js.map +1 -1
  19. package/dist/server.d.ts +7 -1
  20. package/dist/server.d.ts.map +1 -1
  21. package/dist/server.js +27 -1
  22. package/dist/server.js.map +1 -1
  23. package/dist/session-file-route.d.ts +24 -0
  24. package/dist/session-file-route.d.ts.map +1 -0
  25. package/dist/session-file-route.js +72 -0
  26. package/dist/session-file-route.js.map +1 -0
  27. package/dist/signed-token.d.ts +25 -0
  28. package/dist/signed-token.d.ts.map +1 -0
  29. package/dist/signed-token.js +50 -0
  30. package/dist/signed-token.js.map +1 -0
  31. package/dist/user-config-loader.d.ts.map +1 -1
  32. package/dist/user-config-loader.js +37 -1
  33. package/dist/user-config-loader.js.map +1 -1
  34. package/package.json +5 -5
  35. package/src/files-upload-route.ts +64 -0
  36. package/src/instance.ts +12 -1
  37. package/src/local-registry.ts +334 -0
  38. package/src/main.ts +7 -1
  39. package/src/platform-api.ts +234 -13
  40. package/src/server.ts +42 -2
  41. package/src/session-file-route.ts +99 -0
  42. package/src/signed-token.ts +68 -0
  43. package/src/user-config-loader.ts +41 -2
@@ -14,14 +14,23 @@
14
14
  * - instances.archive — no-op; shutdown the server instead
15
15
  */
16
16
 
17
- import type { Logger, SessionManager } from '@roj-ai/sdk'
17
+ import type { Logger, Preset, SessionManager } from '@roj-ai/sdk'
18
+ import { SessionId } from '@roj-ai/sdk'
18
19
  import { Hono } from 'hono'
19
20
  import type { InstanceState } from './instance.js'
21
+ import type { LocalRegistry } from './local-registry.js'
22
+ import { signFileToken } from './signed-token.js'
20
23
 
21
24
  interface Deps {
22
25
  instance: InstanceState
23
26
  sessionManager: SessionManager
24
27
  logger: Logger
28
+ presets: Preset[]
29
+ registry: LocalRegistry
30
+ /** HMAC secret for signing download tokens (`sessionFiles.createDownloadUrl`). */
31
+ tokenSecret: string
32
+ /** Externally-reachable base URL (e.g. `http://localhost:8765`) used when minting download URLs. */
33
+ publicBaseUrl: string
25
34
  }
26
35
 
27
36
  interface RpcEnvelope {
@@ -77,11 +86,167 @@ async function dispatch(
77
86
 
78
87
  type Handler = (deps: Deps, input: any) => Promise<unknown>
79
88
 
89
+ interface AutoCreateSessionInput {
90
+ presetId: string
91
+ initialPrompt?: string
92
+ resourceIds?: string[]
93
+ fileIds?: string[]
94
+ blocking?: boolean
95
+ }
96
+
97
+ // Shared by `instances.create.autoCreateSession` and `sessions.create`. Creates
98
+ // a session via the SDK session manager, injects any matching local resources,
99
+ // then (if set) pushes `initialPrompt` as a user-chat message — order matters:
100
+ // resources must land in the workspace before the agent's first inference so
101
+ // the agent sees a non-empty workspace. Mirrors roj-platform's project-init
102
+ // (inject-resources → activatePendingSession with initialPrompt).
103
+ async function startSession(
104
+ deps: Deps,
105
+ input: { presetId: string; initialPrompt?: string; resourceIds?: string[] },
106
+ ): Promise<{ sessionId: string }> {
107
+ const created = await deps.sessionManager.callManagerMethod('sessions.create', {
108
+ presetId: input.presetId,
109
+ })
110
+ if (!created.ok) throw new Error(created.error.message)
111
+ const { sessionId } = created.value as { sessionId: string }
112
+
113
+ const resources = resolveSessionResources(deps, input.presetId, input.resourceIds)
114
+ for (const resource of resources) {
115
+ await injectRegistryResource(deps, sessionId, resource)
116
+ }
117
+
118
+ if (input.initialPrompt) {
119
+ const sent = await deps.sessionManager.callPluginMethod(SessionId(sessionId), 'user-chat.sendMessage', {
120
+ content: input.initialPrompt,
121
+ })
122
+ if (!sent.ok) {
123
+ deps.logger.warn('Failed to deliver initialPrompt', {
124
+ sessionId,
125
+ error: sent.error,
126
+ })
127
+ }
128
+ }
129
+
130
+ return { sessionId }
131
+ }
132
+
133
+ interface ResolvedResource {
134
+ resourceId: string
135
+ slug: string
136
+ name: string | null
137
+ fileId: string
138
+ filename: string
139
+ mimeType: string
140
+ }
141
+
142
+ // Resolution mirrors roj-platform's project-init.ts:206:
143
+ // 1. explicit input.resourceIds — matched against the local registry by ID
144
+ // first, then by slug (since local-dev callers often pass slugs as ids).
145
+ // 2. fallback to preset.defaultResourceSlugs (looked up by slug).
146
+ function resolveSessionResources(
147
+ deps: Deps,
148
+ presetId: string,
149
+ inputResourceIds: string[] | undefined,
150
+ ): ResolvedResource[] {
151
+ if (inputResourceIds && inputResourceIds.length > 0) {
152
+ const matched: ResolvedResource[] = []
153
+ const unmatched: string[] = []
154
+ for (const id of inputResourceIds) {
155
+ const resource =
156
+ deps.registry.getResource({ resourceId: id }) ?? deps.registry.getResource({ resourceSlug: id })
157
+ const resolved = toResolvedResource(resource)
158
+ if (resolved) matched.push(resolved)
159
+ else unmatched.push(id)
160
+ }
161
+ if (unmatched.length > 0) {
162
+ deps.logger.warn('Some input resourceIds did not match the local registry; ignoring', {
163
+ unmatched,
164
+ availableSlugs: deps.registry.listResources().map(r => r.slug),
165
+ })
166
+ }
167
+ if (matched.length > 0) return matched
168
+ }
169
+
170
+ const preset = deps.presets.find(p => p.id === presetId)
171
+ const slugs = preset?.defaultResourceSlugs ?? []
172
+ const resolved: ResolvedResource[] = []
173
+ const missing: string[] = []
174
+ for (const slug of slugs) {
175
+ const r = toResolvedResource(deps.registry.getResource({ resourceSlug: slug }))
176
+ if (r) resolved.push(r)
177
+ else missing.push(slug)
178
+ }
179
+ if (missing.length > 0) {
180
+ deps.logger.warn(
181
+ "Preset declares defaultResourceSlugs not present in the local registry; those slots will be skipped",
182
+ { presetId, missing, availableSlugs: deps.registry.listResources().map(r => r.slug) },
183
+ )
184
+ }
185
+ return resolved
186
+ }
187
+
188
+ function toResolvedResource(resource: ReturnType<LocalRegistry['getResource']>): ResolvedResource | null {
189
+ if (!resource || !resource.latestRevision) return null
190
+ const file = resource.latestRevision.file
191
+ return {
192
+ resourceId: resource.id,
193
+ slug: resource.slug,
194
+ name: resource.name,
195
+ fileId: file.id,
196
+ filename: file.filename,
197
+ mimeType: file.mimeType,
198
+ }
199
+ }
200
+
201
+ async function injectRegistryResource(deps: Deps, sessionId: string, resource: ResolvedResource): Promise<void> {
202
+ const file = await deps.registry.readFileById(resource.fileId)
203
+ if (!file) {
204
+ throw new Error(`Registry file not found for resource ${resource.slug} (fileId=${resource.fileId})`)
205
+ }
206
+
207
+ const result = await deps.sessionManager.callPluginMethod(SessionId(sessionId), 'resources.inject', {
208
+ sessionId,
209
+ filename: resource.filename,
210
+ mimeType: resource.mimeType,
211
+ size: file.buffer.length,
212
+ fileBuffer: file.buffer,
213
+ metadata: { slug: resource.slug, name: resource.name ?? resource.slug },
214
+ })
215
+ if (!result.ok) {
216
+ deps.logger.error('Registry resource injection failed', undefined, {
217
+ sessionId,
218
+ slug: resource.slug,
219
+ error: result.error,
220
+ })
221
+ throw new Error(`Failed to inject resource '${resource.slug}': ${result.error.message ?? result.error.type}`)
222
+ }
223
+ }
224
+
80
225
  const handlers: Record<string, Handler> = {
81
- 'instances.create': async ({ instance }) => ({
82
- instanceId: instance.id,
83
- status: 'ready',
84
- }),
226
+ 'instances.create': async (
227
+ deps,
228
+ input: { metadata?: Record<string, unknown>; autoCreateSession?: AutoCreateSessionInput },
229
+ ) => {
230
+ if (input.metadata !== undefined) {
231
+ deps.instance.metadata = input.metadata
232
+ }
233
+
234
+ let sessionId: string | undefined
235
+ if (input.autoCreateSession?.presetId) {
236
+ const result = await startSession(deps, {
237
+ presetId: input.autoCreateSession.presetId,
238
+ initialPrompt: input.autoCreateSession.initialPrompt,
239
+ resourceIds: input.autoCreateSession.resourceIds,
240
+ })
241
+ sessionId = result.sessionId
242
+ }
243
+
244
+ return {
245
+ instanceId: deps.instance.id,
246
+ status: 'ready',
247
+ ...(sessionId ? { sessionId } : {}),
248
+ }
249
+ },
85
250
 
86
251
  'instances.list': async ({ instance }) => ({
87
252
  instances: [instanceSummary(instance)],
@@ -109,13 +274,10 @@ const handlers: Record<string, Handler> = {
109
274
 
110
275
  'instances.archive': async () => ({ ok: true }),
111
276
 
112
- 'sessions.create': async ({ sessionManager }, input: { presetId: string; initialPrompt?: string }) => {
113
- const result = await sessionManager.callManagerMethod('sessions.create', {
114
- presetId: input.presetId,
115
- })
116
- if (!result.ok) throw new Error(result.error.message)
117
- return result.value as { sessionId: string }
118
- },
277
+ 'sessions.create': async (
278
+ deps,
279
+ input: { presetId: string; initialPrompt?: string; resourceIds?: string[] },
280
+ ) => startSession(deps, input),
119
281
 
120
282
  'sessions.list': async ({ sessionManager }) => {
121
283
  const result = await sessionManager.callManagerMethod('sessions.list', {})
@@ -124,6 +286,65 @@ const handlers: Record<string, Handler> = {
124
286
  },
125
287
 
126
288
  'tokens.create': async () => ({ token: '' }),
289
+
290
+ 'resources.create': async (
291
+ deps,
292
+ input: { slug: string; name?: string; description?: string; fileId: string; label?: string },
293
+ ) => deps.registry.createResource(input),
294
+
295
+ 'resources.addRevision': async (
296
+ deps,
297
+ input: { resourceId?: string; resourceSlug?: string; fileId: string; label?: string },
298
+ ) => deps.registry.addRevision(input),
299
+
300
+ 'resources.get': async (deps, input: { resourceId?: string; resourceSlug?: string }) => {
301
+ const resource = deps.registry.getResource(input)
302
+ if (!resource) throw new Error('Resource not found')
303
+ return resource
304
+ },
305
+
306
+ 'resources.list': async (deps, _input: { limit?: number; offset?: number }) => ({
307
+ resources: deps.registry.listResources(),
308
+ }),
309
+
310
+ 'resources.delete': async (deps, input: { resourceId: string }) => deps.registry.deleteResource(input.resourceId),
311
+
312
+ 'sessionFiles.createDownloadUrl': async (
313
+ deps,
314
+ input: {
315
+ instanceId: string
316
+ sessionId: string
317
+ scope: 'workspace' | 'session'
318
+ path: string
319
+ ttlSeconds?: number
320
+ },
321
+ ) => {
322
+ if (input.scope !== 'workspace' && input.scope !== 'session') {
323
+ throw new Error(`Invalid scope: ${input.scope}`)
324
+ }
325
+ if (!input.path || input.path.includes('..')) {
326
+ throw new Error('Path traversal not allowed')
327
+ }
328
+
329
+ const ttl = Math.min(Math.max(input.ttlSeconds ?? 300, 1), 3600)
330
+ const expiresAt = Date.now() + ttl * 1000
331
+
332
+ const token = signFileToken(deps.tokenSecret, {
333
+ instanceId: input.instanceId,
334
+ sessionId: input.sessionId,
335
+ scope: input.scope,
336
+ path: input.path,
337
+ expiresAt,
338
+ })
339
+
340
+ const encodedPath = input.path
341
+ .split('/')
342
+ .map(seg => encodeURIComponent(seg))
343
+ .join('/')
344
+ const url = `${deps.publicBaseUrl}/api/v1/instances/${input.instanceId}/sessions/${input.sessionId}/files/${input.scope}/${encodedPath}?token=${encodeURIComponent(token)}`
345
+
346
+ return { url, expiresAt: new Date(expiresAt).toISOString() }
347
+ },
127
348
  }
128
349
 
129
350
  function instanceSummary(instance: InstanceState) {
@@ -135,7 +356,7 @@ function instanceSummary(instance: InstanceState) {
135
356
  bundleSlug: 'standalone',
136
357
  bundleRevisionId: '',
137
358
  vcsType: 'none',
138
- metadata: null,
359
+ metadata: instance.metadata,
139
360
  createdAt: instance.createdAt,
140
361
  }
141
362
  }
package/src/server.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  * GET /health — health check
13
13
  */
14
14
 
15
- import type { Config, LLMMiddleware, Logger, Preset, SessionId, SessionManager } from '@roj-ai/sdk'
15
+ import type { Config, LLMMiddleware, LocalResource, Logger, Preset, SessionId, SessionManager } from '@roj-ai/sdk'
16
16
  import { bootstrap, createSystemFromServices, loadConfig, validateConfig } from '@roj-ai/sdk'
17
17
  import { createApp } from '@roj-ai/sdk/transport/http/app'
18
18
  import { createAgentTransport, ServerAdapter } from '@roj-ai/sdk/transport/adapter'
@@ -20,9 +20,13 @@ import { createBunPlatform } from '@roj-ai/sdk/bun-platform'
20
20
  import { createBunWebSocketHandlers } from '@roj-ai/transport/bun'
21
21
  import { Hono } from 'hono'
22
22
  import { cors } from 'hono/cors'
23
+ import { createFilesUploadRoute } from './files-upload-route.js'
23
24
  import { createInstance, type InstanceState } from './instance.js'
25
+ import { LocalRegistry } from './local-registry.js'
24
26
  import { createPlatformApi } from './platform-api.js'
25
27
  import { proxyPreview } from './preview-proxy.js'
28
+ import { createSessionFileRoute } from './session-file-route.js'
29
+ import { generateTokenSecret } from './signed-token.js'
26
30
 
27
31
  export interface StartStandaloneOptions {
28
32
  presets: Preset[]
@@ -30,6 +34,12 @@ export interface StartStandaloneOptions {
30
34
  instanceId?: string
31
35
  instanceName?: string
32
36
  llmMiddleware?: LLMMiddleware[]
37
+ /**
38
+ * Local resource registry — files (typically ZIPs) on disk addressable by slug.
39
+ * Injected into new sessions via `resources.inject` whenever the preset's
40
+ * `defaultResourceSlugs` (or input.resourceIds) match a slug here.
41
+ */
42
+ localResources?: LocalResource[]
33
43
  onShutdown?: () => Promise<void> | void
34
44
  onBeforeStart?: (ctx: { config: Config; logger: Logger }) => void | Promise<void>
35
45
  }
@@ -87,7 +97,25 @@ export async function startStandaloneServer(options: StartStandaloneOptions): Pr
87
97
  sessionRuntime: sessionManager,
88
98
  })
89
99
 
90
- const platformApp = createPlatformApi({ instance, sessionManager, logger })
100
+ const tokenSecret = generateTokenSecret()
101
+ const publicHost = config.host === '0.0.0.0' ? 'localhost' : config.host
102
+ const publicBaseUrl = `http://${publicHost}:${config.port}`
103
+
104
+ const registry = new LocalRegistry(config.dataPath, logger)
105
+ await registry.init()
106
+ if (options.localResources?.length) {
107
+ await registry.bootstrapLocalResources(options.localResources)
108
+ }
109
+
110
+ const platformApp = createPlatformApi({
111
+ instance,
112
+ sessionManager,
113
+ logger,
114
+ presets,
115
+ registry,
116
+ tokenSecret,
117
+ publicBaseUrl,
118
+ })
91
119
 
92
120
  const outerApp = new Hono()
93
121
  // Reflect the request origin and allow credentials — the SPA uses
@@ -96,6 +124,11 @@ export async function startStandaloneServer(options: StartStandaloneOptions): Pr
96
124
  outerApp.use('*', cors({ origin: (origin) => origin ?? '*', credentials: true }))
97
125
 
98
126
  outerApp.get('/health', (c) => c.json({ status: 'ok', timestamp: Date.now() }))
127
+
128
+ // Multipart file upload — must be registered BEFORE the /api/v1 RPC mount
129
+ // so it isn't treated as JSON RPC.
130
+ outerApp.post('/api/v1/files/upload', createFilesUploadRoute({ registry }))
131
+
99
132
  outerApp.route('/api/v1', platformApp)
100
133
 
101
134
  // Path-based preview proxy (must come BEFORE the catch-all instance route)
@@ -105,6 +138,13 @@ export async function startStandaloneServer(options: StartStandaloneOptions): Pr
105
138
  return proxyPreview(c.req.raw, prefix, sessionManager, logger)
106
139
  })
107
140
 
141
+ // Token-authenticated download URL minted by `sessionFiles.createDownloadUrl`.
142
+ // Must come BEFORE the agent catch-all so the token gate runs.
143
+ outerApp.get(
144
+ '/api/v1/instances/:id/sessions/:sid/files/:scope{workspace|session}/*',
145
+ createSessionFileRoute({ tokenSecret, agentApp, logger }),
146
+ )
147
+
108
148
  // Noop auth-exchange (platform has real cookies; standalone is open)
109
149
  outerApp.post('/api/v1/instances/:id/exchange', (c) => c.json({ ok: true }))
110
150
 
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Token-authenticated session file download route.
3
+ *
4
+ * GET /api/v1/instances/:id/sessions/:sid/files/:scope/{path}?token=...
5
+ *
6
+ * Validates the HMAC token minted by `sessionFiles.createDownloadUrl` against
7
+ * the URL's (instanceId, sessionId, scope, path), then proxies to the SDK's
8
+ * existing `/sessions/:sid/workspace/*` or `/sessions/:sid/files/*` route on
9
+ * the agent app — same shape as roj-platform's `session-file-route.ts`, just
10
+ * served in-process.
11
+ */
12
+
13
+ import type { Logger } from '@roj-ai/sdk'
14
+ import type { Context } from 'hono'
15
+ import { verifyFileToken } from './signed-token.js'
16
+
17
+ // Decoupled from Hono's exact type so the SDK's hono and standalone-server's
18
+ // hono — which the workspace can resolve to slightly different versions —
19
+ // don't fight over generics. We only need fetch.
20
+ interface AgentAppLike {
21
+ fetch: (req: Request) => Response | Promise<Response>
22
+ }
23
+
24
+ interface Deps {
25
+ tokenSecret: string
26
+ agentApp: AgentAppLike
27
+ logger: Logger
28
+ }
29
+
30
+ const SCOPE_TO_SDK_PREFIX: Record<'workspace' | 'session', string> = {
31
+ workspace: 'workspace',
32
+ session: 'files',
33
+ }
34
+
35
+ export function createSessionFileRoute(deps: Deps) {
36
+ return async (c: Context) => {
37
+ const instanceId = c.req.param('id')
38
+ const sessionId = c.req.param('sid')
39
+ const scope = c.req.param('scope') as 'workspace' | 'session'
40
+ const url = new URL(c.req.url)
41
+
42
+ const tokenStr = url.searchParams.get('token')
43
+ if (!tokenStr) {
44
+ return c.json({ error: { type: 'unauthorized', message: 'Missing token' } }, 401)
45
+ }
46
+
47
+ const verified = verifyFileToken(deps.tokenSecret, tokenStr)
48
+ if (!verified.ok) {
49
+ return c.json({ error: { type: 'unauthorized', message: `Invalid token (${verified.error})` } }, 401)
50
+ }
51
+
52
+ // Recover the file path the token was signed for. Hono `*` capture is
53
+ // available via the URL pathname; decode each segment back to its
54
+ // original form so the comparison against the signed payload is exact.
55
+ const pathPrefix = `/api/v1/instances/${instanceId}/sessions/${sessionId}/files/${scope}/`
56
+ if (!url.pathname.startsWith(pathPrefix)) {
57
+ return c.json({ error: { type: 'invalid_request', message: 'Path mismatch' } }, 400)
58
+ }
59
+ const requestedPath = url.pathname
60
+ .slice(pathPrefix.length)
61
+ .split('/')
62
+ .map(seg => decodeURIComponent(seg))
63
+ .join('/')
64
+
65
+ const p = verified.payload
66
+ if (
67
+ p.instanceId !== instanceId ||
68
+ p.sessionId !== sessionId ||
69
+ p.scope !== scope ||
70
+ p.path !== requestedPath
71
+ ) {
72
+ return c.json(
73
+ { error: { type: 'unauthorized', message: 'Token does not match requested file' } },
74
+ 401,
75
+ )
76
+ }
77
+
78
+ // Proxy to the SDK's underlying file route. Note the path/scope mapping:
79
+ // public `workspace` → SDK `/sessions/:sid/workspace/*`,
80
+ // public `session` → SDK `/sessions/:sid/files/*`.
81
+ const sdkPrefix = SCOPE_TO_SDK_PREFIX[scope]
82
+ const innerUrl = `${url.origin}/sessions/${sessionId}/${sdkPrefix}/${encodePathSegments(requestedPath)}`
83
+ try {
84
+ return await deps.agentApp.fetch(new Request(innerUrl, { method: 'GET' }))
85
+ } catch (err) {
86
+ deps.logger.error('Session file proxy failed', err instanceof Error ? err : new Error(String(err)), {
87
+ instanceId,
88
+ sessionId,
89
+ scope,
90
+ path: requestedPath,
91
+ })
92
+ return c.json({ error: { type: 'proxy_error', message: 'File proxy failed' } }, 502)
93
+ }
94
+ }
95
+ }
96
+
97
+ function encodePathSegments(path: string): string {
98
+ return path.split('/').map(seg => encodeURIComponent(seg)).join('/')
99
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Tiny HMAC-signed download token used by `sessionFiles.createDownloadUrl`.
3
+ *
4
+ * The token binds (instanceId, sessionId, scope, path, expiresAt) so a leaked
5
+ * URL can only fetch the exact file it was minted for, and only briefly. Same
6
+ * shape as roj-platform's `session-file-token.ts`, just pruned for local use.
7
+ */
8
+
9
+ import { randomBytes, createHmac, timingSafeEqual } from 'node:crypto'
10
+
11
+ export interface SignedFilePayload {
12
+ instanceId: string
13
+ sessionId: string
14
+ scope: 'workspace' | 'session'
15
+ path: string
16
+ expiresAt: number
17
+ }
18
+
19
+ export function generateTokenSecret(): string {
20
+ return randomBytes(32).toString('hex')
21
+ }
22
+
23
+ export function signFileToken(secret: string, payload: SignedFilePayload): string {
24
+ const body = base64UrlEncode(Buffer.from(JSON.stringify(payload), 'utf8'))
25
+ const sig = base64UrlEncode(hmac(secret, body))
26
+ return `${body}.${sig}`
27
+ }
28
+
29
+ export type VerifyResult =
30
+ | { ok: true; payload: SignedFilePayload }
31
+ | { ok: false; error: 'malformed' | 'bad_signature' | 'expired' }
32
+
33
+ export function verifyFileToken(secret: string, token: string): VerifyResult {
34
+ const dot = token.indexOf('.')
35
+ if (dot < 0) return { ok: false, error: 'malformed' }
36
+ const body = token.slice(0, dot)
37
+ const sigEncoded = token.slice(dot + 1)
38
+
39
+ const expectedSig = hmac(secret, body)
40
+ const givenSig = base64UrlDecode(sigEncoded)
41
+ if (givenSig.length !== expectedSig.length || !timingSafeEqual(givenSig, expectedSig)) {
42
+ return { ok: false, error: 'bad_signature' }
43
+ }
44
+
45
+ let payload: SignedFilePayload
46
+ try {
47
+ payload = JSON.parse(base64UrlDecode(body).toString('utf8'))
48
+ } catch {
49
+ return { ok: false, error: 'malformed' }
50
+ }
51
+ if (typeof payload.expiresAt !== 'number' || payload.expiresAt < Date.now()) {
52
+ return { ok: false, error: 'expired' }
53
+ }
54
+ return { ok: true, payload }
55
+ }
56
+
57
+ function hmac(secret: string, body: string): Buffer {
58
+ return createHmac('sha256', secret).update(body).digest()
59
+ }
60
+
61
+ function base64UrlEncode(buf: Buffer): string {
62
+ return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
63
+ }
64
+
65
+ function base64UrlDecode(s: string): Buffer {
66
+ const pad = '='.repeat((4 - (s.length % 4)) % 4)
67
+ return Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/') + pad, 'base64')
68
+ }
@@ -5,10 +5,10 @@
5
5
  * runtime-specific I/O) and out of `@roj-ai/sandbox-runtime` (Bun-specific).
6
6
  */
7
7
 
8
- import type { Preset, RojConfig } from '@roj-ai/sdk'
8
+ import type { LocalResource, Preset, RojConfig } from '@roj-ai/sdk'
9
9
  import { validatePreset } from '@roj-ai/sdk'
10
10
  import { existsSync } from 'node:fs'
11
- import { resolve } from 'node:path'
11
+ import { dirname, isAbsolute, resolve } from 'node:path'
12
12
 
13
13
  export async function loadUserConfig(configPath: string): Promise<RojConfig> {
14
14
  const absolutePath = resolve(process.cwd(), configPath)
@@ -63,9 +63,48 @@ export async function loadUserConfig(configPath: string): Promise<RojConfig> {
63
63
  throw new Error(`Duplicate preset IDs: ${[...new Set(duplicates)].join(', ')}`)
64
64
  }
65
65
 
66
+ const configDir = dirname(absolutePath)
67
+ const localResources = parseLocalResources(typedConfig.localResources, configDir, absolutePath)
68
+
66
69
  return {
67
70
  presets,
68
71
  sandboxed: typedConfig.sandboxed as boolean | undefined,
69
72
  snapshotter: typedConfig.snapshotter as RojConfig['snapshotter'],
73
+ localResources,
74
+ }
75
+ }
76
+
77
+ function parseLocalResources(raw: unknown, configDir: string, configPath: string): LocalResource[] | undefined {
78
+ if (raw === undefined) return undefined
79
+ if (!Array.isArray(raw)) {
80
+ throw new Error(`'localResources' must be an array: ${configPath}`)
70
81
  }
82
+
83
+ const seen = new Set<string>()
84
+ return raw.map((entry, i) => {
85
+ if (typeof entry !== 'object' || entry === null) {
86
+ throw new Error(`localResources[${i}] must be an object: ${configPath}`)
87
+ }
88
+ const r = entry as Record<string, unknown>
89
+ if (typeof r.slug !== 'string' || !r.slug) {
90
+ throw new Error(`localResources[${i}] missing required 'slug': ${configPath}`)
91
+ }
92
+ if (typeof r.path !== 'string' || !r.path) {
93
+ throw new Error(`localResources[${i}] missing required 'path': ${configPath}`)
94
+ }
95
+ if (seen.has(r.slug)) {
96
+ throw new Error(`Duplicate localResources slug '${r.slug}': ${configPath}`)
97
+ }
98
+ seen.add(r.slug)
99
+
100
+ const resolvedPath = isAbsolute(r.path) ? r.path : resolve(configDir, r.path)
101
+ if (!existsSync(resolvedPath)) {
102
+ throw new Error(`localResources[${i}] file not found: ${resolvedPath} (config: ${configPath})`)
103
+ }
104
+ return {
105
+ slug: r.slug,
106
+ path: resolvedPath,
107
+ name: typeof r.name === 'string' ? r.name : undefined,
108
+ }
109
+ })
71
110
  }