@roj-ai/standalone-server 0.1.10 → 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
@@ -1 +1 @@
1
- {"version":3,"file":"user-config-loader.js","sourceRoot":"","sources":["../src/user-config-loader.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"user-config-loader.js","sourceRoot":"","sources":["../src/user-config-loader.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@roj-ai/standalone-server",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "bin": {
@@ -33,10 +33,10 @@
33
33
  "type-check": "tsc --noEmit"
34
34
  },
35
35
  "dependencies": {
36
- "@roj-ai/client": "^0.1.10",
37
- "@roj-ai/sdk": "^0.1.10",
38
- "@roj-ai/shared": "^0.1.10",
39
- "@roj-ai/transport": "^0.1.10",
36
+ "@roj-ai/client": "^0.1.12",
37
+ "@roj-ai/sdk": "^0.1.12",
38
+ "@roj-ai/shared": "^0.1.12",
39
+ "@roj-ai/transport": "^0.1.12",
40
40
  "hono": "^4.10.7"
41
41
  },
42
42
  "devDependencies": {
@@ -0,0 +1,64 @@
1
+ /**
2
+ * POST /api/v1/files/upload — multipart endpoint matching the `@roj-ai/client/platform.files.upload`
3
+ * two-phase contract:
4
+ *
5
+ * 1. Preflight with `contentHash`, `filename`, `mimeType` only. If we already have
6
+ * the bytes (sha-256 match), respond `{ ok, fileId, ..., deduped: true }`.
7
+ * Otherwise respond 409 `{ error: 'file-required' }`.
8
+ * 2. Retry with the same fields plus a `file` blob. We persist the bytes,
9
+ * record the file, and respond `{ ok, fileId, ..., deduped: false }`.
10
+ *
11
+ * No bearer auth (standalone is open) — but we accept and ignore an
12
+ * `Authorization: Bearer …` header so client code copy-pasted from the platform
13
+ * keeps working.
14
+ */
15
+
16
+ import type { Context } from 'hono'
17
+ import type { LocalRegistry } from './local-registry.js'
18
+
19
+ interface Deps {
20
+ registry: LocalRegistry
21
+ }
22
+
23
+ export function createFilesUploadRoute(deps: Deps) {
24
+ return async (c: Context) => {
25
+ let form: FormData
26
+ try {
27
+ form = await c.req.formData()
28
+ } catch {
29
+ return c.json({ ok: false, error: 'invalid_multipart' }, 400)
30
+ }
31
+
32
+ const contentHash = form.get('contentHash')
33
+ const filename = form.get('filename')
34
+ const mimeType = form.get('mimeType')
35
+ if (typeof contentHash !== 'string' || typeof filename !== 'string' || typeof mimeType !== 'string') {
36
+ return c.json(
37
+ { ok: false, error: 'invalid_form' },
38
+ 400,
39
+ )
40
+ }
41
+
42
+ const file = form.get('file')
43
+ const buffer = file && typeof file !== 'string' && 'arrayBuffer' in file
44
+ ? Buffer.from(await file.arrayBuffer())
45
+ : undefined
46
+
47
+ const result = await deps.registry.uploadFile({ buffer, filename, mimeType, contentHash })
48
+
49
+ if ('error' in result) {
50
+ // Preflight miss — client retries with body.
51
+ return c.json({ ok: false, error: result.error }, 409)
52
+ }
53
+
54
+ return c.json({
55
+ ok: true,
56
+ fileId: result.fileId,
57
+ filename: result.filename,
58
+ mimeType: result.mimeType,
59
+ size: result.size,
60
+ r2Key: '',
61
+ deduped: result.deduped,
62
+ })
63
+ }
64
+ }
package/src/instance.ts CHANGED
@@ -4,6 +4,10 @@
4
4
  * A standalone server hosts exactly one "instance" — a workspace containing
5
5
  * sessions. The instance id is either supplied via config or auto-generated
6
6
  * on startup and remains stable for the process lifetime.
7
+ *
8
+ * `metadata` is mutated by `instances.create` calls (last write wins). It is
9
+ * a single-tenant local-dev convenience; consumers that need per-instance
10
+ * isolation must run separate processes.
7
11
  */
8
12
 
9
13
  export interface InstanceState {
@@ -11,13 +15,20 @@ export interface InstanceState {
11
15
  name: string
12
16
  createdAt: string
13
17
  presetIds: string[]
18
+ metadata: Record<string, unknown> | null
14
19
  }
15
20
 
16
- export function createInstance(options: { id?: string; name?: string; presetIds: string[] }): InstanceState {
21
+ export function createInstance(options: {
22
+ id?: string
23
+ name?: string
24
+ presetIds: string[]
25
+ metadata?: Record<string, unknown> | null
26
+ }): InstanceState {
17
27
  return {
18
28
  id: options.id ?? 'default',
19
29
  name: options.name ?? 'standalone',
20
30
  createdAt: new Date().toISOString(),
21
31
  presetIds: options.presetIds,
32
+ metadata: options.metadata ?? null,
22
33
  }
23
34
  }
@@ -0,0 +1,334 @@
1
+ /**
2
+ * On-disk file + resource registry for the standalone server.
3
+ *
4
+ * Stands in for the platform's Contember-backed file/resource service. State
5
+ * lives in `{dataPath}/.roj-platform-state.json` (JSON manifest) plus
6
+ * `{dataPath}/.roj-platform-state/files/{fileId}` (raw bytes). Operations are
7
+ * append-only at the resource level (revisions accumulate), so re-running
8
+ * `roj-standalone` between sessions preserves uploads and revisions added
9
+ * during a previous run.
10
+ *
11
+ * Files are content-addressable by SHA-256 — re-uploading the same bytes
12
+ * returns the existing fileId with `deduped: true`, mirroring the platform's
13
+ * /files/upload contract used by `@roj-ai/client/platform.files.upload`.
14
+ */
15
+
16
+ import { createHash, randomUUID } from 'node:crypto'
17
+ import { existsSync } from 'node:fs'
18
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
19
+ import { basename, extname, join } from 'node:path'
20
+ import type { LocalResource, Logger } from '@roj-ai/sdk'
21
+
22
+ interface FileMeta {
23
+ id: string
24
+ filename: string
25
+ mimeType: string
26
+ size: number
27
+ contentHash: string
28
+ createdAt: string
29
+ }
30
+
31
+ interface ResourceRevision {
32
+ id: string
33
+ fileId: string
34
+ label: string | null
35
+ createdAt: string
36
+ }
37
+
38
+ interface ResourceRecord {
39
+ id: string
40
+ slug: string
41
+ name: string | null
42
+ description: string | null
43
+ revisions: ResourceRevision[]
44
+ createdAt: string
45
+ }
46
+
47
+ interface RegistryState {
48
+ files: Record<string, FileMeta>
49
+ resources: Record<string, ResourceRecord>
50
+ }
51
+
52
+ export interface ResourceOutput {
53
+ id: string
54
+ slug: string
55
+ name: string | null
56
+ description: string | null
57
+ latestRevision: {
58
+ id: string
59
+ label: string | null
60
+ file: { id: string; filename: string; mimeType: string; size: number }
61
+ createdAt: string
62
+ } | null
63
+ createdAt: string
64
+ }
65
+
66
+ export interface UploadResult {
67
+ fileId: string
68
+ filename: string
69
+ mimeType: string
70
+ size: number
71
+ deduped: boolean
72
+ }
73
+
74
+ export class LocalRegistry {
75
+ private state: RegistryState = { files: {}, resources: {} }
76
+ private statePath: string
77
+ private filesDir: string
78
+
79
+ constructor(
80
+ private readonly dataPath: string,
81
+ private readonly logger: Logger,
82
+ ) {
83
+ this.statePath = join(dataPath, '.roj-platform-state.json')
84
+ this.filesDir = join(dataPath, '.roj-platform-state', 'files')
85
+ }
86
+
87
+ async init(): Promise<void> {
88
+ await mkdir(this.filesDir, { recursive: true })
89
+ if (existsSync(this.statePath)) {
90
+ try {
91
+ const raw = await readFile(this.statePath, 'utf8')
92
+ this.state = JSON.parse(raw)
93
+ } catch (err) {
94
+ this.logger.warn('Local registry manifest is corrupt, starting fresh', {
95
+ err: err instanceof Error ? err.message : String(err),
96
+ })
97
+ this.state = { files: {}, resources: {} }
98
+ }
99
+ }
100
+ }
101
+
102
+ async uploadFile(args: {
103
+ buffer?: Buffer
104
+ filename: string
105
+ mimeType: string
106
+ contentHash?: string
107
+ }): Promise<UploadResult | { error: 'file-required'; contentHash: string }> {
108
+ // Phase 1 — preflight by hash. If we already have the bytes, return the
109
+ // existing fileId without needing the body.
110
+ if (args.contentHash) {
111
+ const existing = Object.values(this.state.files).find(f => f.contentHash === args.contentHash)
112
+ if (existing) {
113
+ return {
114
+ fileId: existing.id,
115
+ filename: existing.filename,
116
+ mimeType: existing.mimeType,
117
+ size: existing.size,
118
+ deduped: true,
119
+ }
120
+ }
121
+ if (!args.buffer) {
122
+ return { error: 'file-required', contentHash: args.contentHash }
123
+ }
124
+ }
125
+
126
+ if (!args.buffer) {
127
+ throw new Error('uploadFile requires a buffer when contentHash is unknown')
128
+ }
129
+
130
+ const contentHash = args.contentHash ?? sha256Hex(args.buffer)
131
+
132
+ // Re-check after computing — the client may not have provided a hash but
133
+ // we still want dedupe.
134
+ const matched = Object.values(this.state.files).find(f => f.contentHash === contentHash)
135
+ if (matched) {
136
+ return {
137
+ fileId: matched.id,
138
+ filename: matched.filename,
139
+ mimeType: matched.mimeType,
140
+ size: matched.size,
141
+ deduped: true,
142
+ }
143
+ }
144
+
145
+ const id = randomUUID()
146
+ const meta: FileMeta = {
147
+ id,
148
+ filename: args.filename,
149
+ mimeType: args.mimeType,
150
+ size: args.buffer.length,
151
+ contentHash,
152
+ createdAt: new Date().toISOString(),
153
+ }
154
+ await writeFile(join(this.filesDir, id), args.buffer)
155
+ this.state.files[id] = meta
156
+ await this.persist()
157
+ return { fileId: id, filename: meta.filename, mimeType: meta.mimeType, size: meta.size, deduped: false }
158
+ }
159
+
160
+ async readFileById(fileId: string): Promise<{ meta: FileMeta; buffer: Buffer } | null> {
161
+ const meta = this.state.files[fileId]
162
+ if (!meta) return null
163
+ try {
164
+ const buffer = await readFile(join(this.filesDir, fileId))
165
+ return { meta, buffer }
166
+ } catch {
167
+ return null
168
+ }
169
+ }
170
+
171
+ async createResource(args: {
172
+ slug: string
173
+ name?: string
174
+ description?: string
175
+ fileId: string
176
+ label?: string
177
+ }): Promise<{ resourceId: string; revisionId: string }> {
178
+ if (!this.state.files[args.fileId]) {
179
+ throw new Error(`File not found: ${args.fileId}`)
180
+ }
181
+ const existing = this.findBySlug(args.slug)
182
+ if (existing) {
183
+ throw new Error(`Resource with slug already exists: ${args.slug}`)
184
+ }
185
+ const resourceId = randomUUID()
186
+ const revisionId = randomUUID()
187
+ const now = new Date().toISOString()
188
+ this.state.resources[resourceId] = {
189
+ id: resourceId,
190
+ slug: args.slug,
191
+ name: args.name ?? null,
192
+ description: args.description ?? null,
193
+ revisions: [{ id: revisionId, fileId: args.fileId, label: args.label ?? null, createdAt: now }],
194
+ createdAt: now,
195
+ }
196
+ await this.persist()
197
+ return { resourceId, revisionId }
198
+ }
199
+
200
+ async addRevision(args: {
201
+ resourceId?: string
202
+ resourceSlug?: string
203
+ fileId: string
204
+ label?: string
205
+ }): Promise<{ revisionId: string; noop: boolean }> {
206
+ const record = this.findResource(args)
207
+ if (!record) {
208
+ throw new Error('Resource not found')
209
+ }
210
+ if (!this.state.files[args.fileId]) {
211
+ throw new Error(`File not found: ${args.fileId}`)
212
+ }
213
+ const latest = record.revisions[record.revisions.length - 1]
214
+ const incomingLabel = args.label ?? null
215
+ if (latest && latest.fileId === args.fileId && latest.label === incomingLabel) {
216
+ return { revisionId: latest.id, noop: true }
217
+ }
218
+ const revisionId = randomUUID()
219
+ record.revisions.push({
220
+ id: revisionId,
221
+ fileId: args.fileId,
222
+ label: incomingLabel,
223
+ createdAt: new Date().toISOString(),
224
+ })
225
+ await this.persist()
226
+ return { revisionId, noop: false }
227
+ }
228
+
229
+ getResource(args: { resourceId?: string; resourceSlug?: string }): ResourceOutput | null {
230
+ const record = this.findResource(args)
231
+ return record ? toResourceOutput(record, this.state.files) : null
232
+ }
233
+
234
+ listResources(): ResourceOutput[] {
235
+ return Object.values(this.state.resources).map(r => toResourceOutput(r, this.state.files))
236
+ }
237
+
238
+ async deleteResource(resourceId: string): Promise<{ ok: boolean }> {
239
+ if (!this.state.resources[resourceId]) return { ok: true }
240
+ delete this.state.resources[resourceId]
241
+ await this.persist()
242
+ return { ok: true }
243
+ }
244
+
245
+ /**
246
+ * Idempotent: ensures every config-declared local resource exists in the
247
+ * registry. Skips slugs that already have a record (so previously uploaded
248
+ * revisions survive restarts).
249
+ */
250
+ async bootstrapLocalResources(localResources: LocalResource[]): Promise<void> {
251
+ for (const lr of localResources) {
252
+ if (this.findBySlug(lr.slug)) {
253
+ this.logger.info('Local resource already in registry, skipping bootstrap', { slug: lr.slug })
254
+ continue
255
+ }
256
+ const buffer = await readFile(lr.path)
257
+ const filename = basename(lr.path)
258
+ const mimeType = guessMimeType(filename)
259
+ const upload = await this.uploadFile({ buffer, filename, mimeType })
260
+ if ('error' in upload) {
261
+ throw new Error(`Bootstrap upload failed for ${lr.slug}: ${upload.error}`)
262
+ }
263
+ await this.createResource({
264
+ slug: lr.slug,
265
+ name: lr.name ?? lr.slug,
266
+ fileId: upload.fileId,
267
+ })
268
+ this.logger.info('Bootstrapped local resource', { slug: lr.slug, fileId: upload.fileId, size: upload.size })
269
+ }
270
+ }
271
+
272
+ private async persist(): Promise<void> {
273
+ const tmp = `${this.statePath}.tmp`
274
+ await writeFile(tmp, JSON.stringify(this.state, null, 2), 'utf8')
275
+ await rename(tmp, this.statePath)
276
+ }
277
+
278
+ private findResource(args: { resourceId?: string; resourceSlug?: string }): ResourceRecord | null {
279
+ if (args.resourceId) return this.state.resources[args.resourceId] ?? null
280
+ if (args.resourceSlug) return this.findBySlug(args.resourceSlug)
281
+ return null
282
+ }
283
+
284
+ private findBySlug(slug: string): ResourceRecord | null {
285
+ for (const r of Object.values(this.state.resources)) {
286
+ if (r.slug === slug) return r
287
+ }
288
+ return null
289
+ }
290
+ }
291
+
292
+ function toResourceOutput(record: ResourceRecord, files: Record<string, FileMeta>): ResourceOutput {
293
+ const latest = record.revisions[record.revisions.length - 1]
294
+ const file = latest ? files[latest.fileId] : undefined
295
+ return {
296
+ id: record.id,
297
+ slug: record.slug,
298
+ name: record.name,
299
+ description: record.description,
300
+ latestRevision:
301
+ latest && file
302
+ ? {
303
+ id: latest.id,
304
+ label: latest.label,
305
+ file: { id: file.id, filename: file.filename, mimeType: file.mimeType, size: file.size },
306
+ createdAt: latest.createdAt,
307
+ }
308
+ : null,
309
+ createdAt: record.createdAt,
310
+ }
311
+ }
312
+
313
+ function sha256Hex(buf: Buffer): string {
314
+ return createHash('sha256').update(buf).digest('hex')
315
+ }
316
+
317
+ function guessMimeType(filename: string): string {
318
+ switch (extname(filename).toLowerCase()) {
319
+ case '.zip':
320
+ return 'application/zip'
321
+ case '.json':
322
+ return 'application/json'
323
+ default:
324
+ return 'application/octet-stream'
325
+ }
326
+ }
327
+
328
+ // Exported for tests / smoke scripts that want to nuke local state cleanly.
329
+ export async function clearLocalRegistry(dataPath: string): Promise<void> {
330
+ const statePath = join(dataPath, '.roj-platform-state.json')
331
+ const filesDir = join(dataPath, '.roj-platform-state')
332
+ if (existsSync(statePath)) await rm(statePath)
333
+ if (existsSync(filesDir)) await rm(filesDir, { recursive: true })
334
+ }
package/src/main.ts CHANGED
@@ -20,13 +20,19 @@ async function main() {
20
20
  userConfig = await loadUserConfig(absoluteConfigPath)
21
21
  console.log(`Loaded config from: ${absoluteConfigPath}`)
22
22
  console.log(` Presets: ${userConfig.presets.map(p => p.id).join(', ')}`)
23
+ if (userConfig.localResources?.length) {
24
+ console.log(` Local resources: ${userConfig.localResources.map(r => r.slug).join(', ')}`)
25
+ }
23
26
  } catch (error) {
24
27
  console.error(`Failed to load config from ${absoluteConfigPath}:`)
25
28
  console.error(error instanceof Error ? error.message : String(error))
26
29
  process.exit(1)
27
30
  }
28
31
 
29
- await startStandaloneServer({ presets: userConfig.presets })
32
+ await startStandaloneServer({
33
+ presets: userConfig.presets,
34
+ localResources: userConfig.localResources,
35
+ })
30
36
  }
31
37
 
32
38
  main().catch((error) => {