@saluzi/saluzi-edu 0.2.63 → 0.2.65

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.
@@ -0,0 +1,580 @@
1
+ import { describe, test, expect, beforeEach, mock } from 'bun:test'
2
+ import { mockConfigModule } from './helpers/mock-config'
3
+
4
+ // Mock config before any module imports
5
+ mock.module('../config', () => mockConfigModule())
6
+
7
+ import { Hono } from 'hono'
8
+ import type { Database } from 'bun:sqlite'
9
+ import { randomUUID } from 'node:crypto'
10
+ import { tmpdir } from 'node:os'
11
+ import { resolve } from 'node:path'
12
+ import v1Sessions from '../routes/v1/sessions'
13
+ import {
14
+ storeReset,
15
+ storeCreateEnvironment,
16
+ storeGetEnvironment,
17
+ storeRebindSessionEnvironment,
18
+ storeUpdateEnvironment,
19
+ } from '../store'
20
+ import { initDatabase, resetDbSingleton, getDb } from '../db/sqlite'
21
+ import { issueSessionToken } from '../auth/session'
22
+ import { createUserDirectly } from './helpers/fixtures'
23
+ import { createSession } from '../services/session'
24
+ import { storeBindSession } from '../store'
25
+ import { createWorkItem } from '../services/work-dispatch'
26
+
27
+ /**
28
+ * Session Attach endpoints (P1):
29
+ *
30
+ * - POST /v1/sessions/:id/attach — bind an existing session to a CLI env.
31
+ * Ownership via canManageSession (G9), occupancy via work items, env
32
+ * ownership + repl single-session cap enforced, idempotent re-entry.
33
+ * - GET /v1/sessions/attachable — manageable, non-ACP, open sessions.
34
+ * - GET /v1/sessions/:id/events — merged persisted + bus history for CLI
35
+ * hydration (bridge auth, management rights required).
36
+ */
37
+
38
+ function freshDb(): Database {
39
+ try {
40
+ resetDbSingleton()
41
+ } catch {}
42
+ const path = resolve(
43
+ tmpdir(),
44
+ `rcs-attach-${randomUUID().replace(/-/g, '').slice(0, 8)}.db`,
45
+ )
46
+ return initDatabase(path)
47
+ }
48
+
49
+ function createUser(
50
+ db: Database,
51
+ role = 'member',
52
+ ): { id: string; username: string } {
53
+ const username = `user_${randomUUID().slice(0, 6)}`
54
+ const user = createUserDirectly(db, { username, role })
55
+ return { id: user.id, username: user.username }
56
+ }
57
+
58
+ /** Bearer header with a SQLite rct_* token resolving to the users.id. */
59
+ function rctAuth(userId: string): string {
60
+ return `Bearer ${issueSessionToken(userId, getDb()).accessToken}`
61
+ }
62
+
63
+ const ADMIN_KEY = { Authorization: 'Bearer test-api-key' }
64
+ const JSON_HDRS = { 'Content-Type': 'application/json' }
65
+
66
+ function createApp() {
67
+ const app = new Hono()
68
+ app.route('/v1/sessions', v1Sessions)
69
+ return app
70
+ }
71
+
72
+ interface World {
73
+ owner: { id: string; username: string }
74
+ stranger: { id: string; username: string }
75
+ envId: string
76
+ sessionId: string
77
+ }
78
+
79
+ /**
80
+ * Seed: owner user + own env (owner_user_id = owner) + own session bound to
81
+ * the owner and the env.
82
+ */
83
+ function seedWorld(db: Database, envWorkerType = 'repl'): World {
84
+ const owner = createUser(db)
85
+ const stranger = createUser(db)
86
+ const env = storeCreateEnvironment({
87
+ secret: 'test-api-key',
88
+ machineName: 'mac1',
89
+ workerType: envWorkerType,
90
+ ownerUserId: owner.id,
91
+ })
92
+ const session = createSession({
93
+ environment_id: env.id,
94
+ title: 'Existing session',
95
+ source: 'web',
96
+ })
97
+ storeBindSession(session.id, owner.id, 'user')
98
+ return { owner, stranger, envId: env.id, sessionId: session.id }
99
+ }
100
+
101
+ describe('POST /v1/sessions/:id/attach', () => {
102
+ let app: Hono
103
+ let db: Database
104
+
105
+ beforeEach(() => {
106
+ db = freshDb()
107
+ storeReset()
108
+ app = createApp()
109
+ })
110
+
111
+ test('happy path: owner attaches own session to own env → 200 + work item + rebind', async () => {
112
+ const world = seedWorld(db)
113
+ const res = await app.request(`/v1/sessions/${world.sessionId}/attach`, {
114
+ method: 'POST',
115
+ headers: { ...JSON_HDRS, Authorization: rctAuth(world.owner.id) },
116
+ body: JSON.stringify({ environment_id: world.envId }),
117
+ })
118
+ expect(res.status).toBe(200)
119
+ const body = (await res.json()) as {
120
+ status: string
121
+ session_id: string
122
+ work_item_id: string
123
+ rebind: boolean
124
+ }
125
+ expect(body.status).toBe('attached')
126
+ expect(body.session_id).toBe(world.sessionId)
127
+ expect(body.work_item_id).toMatch(/^work_/)
128
+
129
+ // Rebind persisted to SQLite.
130
+ const row = db
131
+ .query('SELECT environment_id FROM sessions WHERE id = $id')
132
+ .get({ $id: world.sessionId }) as { environment_id: string }
133
+ expect(row.environment_id).toBe(world.envId)
134
+ })
135
+
136
+ test('idempotent re-entry: second attach on same env returns the existing work item', async () => {
137
+ const world = seedWorld(db)
138
+ const attach = () =>
139
+ app.request(`/v1/sessions/${world.sessionId}/attach`, {
140
+ method: 'POST',
141
+ headers: { ...JSON_HDRS, Authorization: rctAuth(world.owner.id) },
142
+ body: JSON.stringify({ environment_id: world.envId }),
143
+ })
144
+ const first = await attach()
145
+ expect(first.status).toBe(200)
146
+ const firstBody = (await first.json()) as { work_item_id: string }
147
+
148
+ const second = await attach()
149
+ expect(second.status).toBe(200)
150
+ const secondBody = (await second.json()) as {
151
+ work_item_id: string
152
+ idempotent: boolean
153
+ }
154
+ expect(secondBody.idempotent).toBe(true)
155
+ expect(secondBody.work_item_id).toBe(firstBody.work_item_id)
156
+ })
157
+
158
+ test('session_busy: active work item on a different env → 409', async () => {
159
+ const world = seedWorld(db)
160
+ const otherEnv = storeCreateEnvironment({
161
+ secret: 'test-api-key',
162
+ machineName: 'mac2',
163
+ ownerUserId: world.owner.id,
164
+ })
165
+ // A worker already holds the lease on the other env.
166
+ await createWorkItem(otherEnv.id, world.sessionId)
167
+
168
+ const res = await app.request(`/v1/sessions/${world.sessionId}/attach`, {
169
+ method: 'POST',
170
+ headers: { ...JSON_HDRS, Authorization: rctAuth(world.owner.id) },
171
+ body: JSON.stringify({ environment_id: world.envId }),
172
+ })
173
+ expect(res.status).toBe(409)
174
+ const body = (await res.json()) as { error: { type: string } }
175
+ expect(body.error.type).toBe('session_busy')
176
+ })
177
+
178
+ test('stale lease on a dead env does NOT occupy: crashed attach CLI is re-attachable', async () => {
179
+ const world = seedWorld(db)
180
+ const crashedEnv = storeCreateEnvironment({
181
+ secret: 'test-api-key',
182
+ machineName: 'crashed',
183
+ ownerUserId: world.owner.id,
184
+ })
185
+ // kill -9 before deregister: the acked item stays on the now-dead env.
186
+ // (The session itself is bound to world.envId — seedWorld — so a
187
+ // successful attach here proves the dead-env lease was not honored.)
188
+ await createWorkItem(crashedEnv.id, world.sessionId)
189
+ storeUpdateEnvironment(crashedEnv.id, { status: 'disconnected' })
190
+
191
+ const res = await app.request(`/v1/sessions/${world.sessionId}/attach`, {
192
+ method: 'POST',
193
+ headers: { ...JSON_HDRS, Authorization: rctAuth(world.owner.id) },
194
+ body: JSON.stringify({ environment_id: world.envId }),
195
+ })
196
+ expect(res.status).toBe(200)
197
+ const body = (await res.json()) as {
198
+ status: string
199
+ rebind: boolean
200
+ idempotent: boolean
201
+ }
202
+ expect(body.status).toBe('attached')
203
+ // Session already points at the target env — no rebind, and a FRESH work
204
+ // item (not the same-env idempotent path, which would return the dead
205
+ // env's stale item).
206
+ expect(body.rebind).toBe(false)
207
+ expect(body.idempotent).toBeUndefined()
208
+ })
209
+
210
+ test('idempotent re-entry bypasses manage-rights check for the env owner (doReconnect re-bind)', async () => {
211
+ // Wait-mode scenario: a team admin bound the session to the CLI worker's
212
+ // env; the worker itself has no manage rights on the session. The re-bind
213
+ // after env loss must still succeed via the same-env idempotent path —
214
+ // gated on env ownership (the worker owns its env), not session rights.
215
+ const world = seedWorld(db)
216
+ const cliOwner = createUser(db)
217
+ const cliEnv = storeCreateEnvironment({
218
+ secret: 'test-api-key',
219
+ machineName: 'cli-worker',
220
+ ownerUserId: cliOwner.id,
221
+ })
222
+ // The team-admin bind: the session is re-pointed to the CLI env with a
223
+ // live work item (bare admin API key performs the attach).
224
+ const first = await app.request(`/v1/sessions/${world.sessionId}/attach`, {
225
+ method: 'POST',
226
+ headers: { ...JSON_HDRS, ...ADMIN_KEY },
227
+ body: JSON.stringify({ environment_id: cliEnv.id }),
228
+ })
229
+ expect(first.status).toBe(200)
230
+ const { work_item_id } = (await first.json()) as { work_item_id: string }
231
+
232
+ // Re-entry as the CLI owner (no manage rights, owns the env) → 200 with
233
+ // the SAME work item.
234
+ const second = await app.request(`/v1/sessions/${world.sessionId}/attach`, {
235
+ method: 'POST',
236
+ headers: { ...JSON_HDRS, Authorization: rctAuth(cliOwner.id) },
237
+ body: JSON.stringify({ environment_id: cliEnv.id }),
238
+ })
239
+ expect(second.status).toBe(200)
240
+ const body = (await second.json()) as {
241
+ idempotent: boolean
242
+ work_item_id: string
243
+ }
244
+ expect(body.idempotent).toBe(true)
245
+ expect(body.work_item_id).toBe(work_item_id)
246
+ })
247
+
248
+ test('idempotent re-entry by a non-owner who knows session+env IDs → 403', async () => {
249
+ // The idempotent path echoes the work-item id; a random authenticated
250
+ // caller must not be able to probe bindings. Falls through to the full
251
+ // checks → manage-rights 403.
252
+ const world = seedWorld(db)
253
+ const first = await app.request(`/v1/sessions/${world.sessionId}/attach`, {
254
+ method: 'POST',
255
+ headers: { ...JSON_HDRS, Authorization: rctAuth(world.owner.id) },
256
+ body: JSON.stringify({ environment_id: world.envId }),
257
+ })
258
+ expect(first.status).toBe(200)
259
+
260
+ const second = await app.request(`/v1/sessions/${world.sessionId}/attach`, {
261
+ method: 'POST',
262
+ headers: { ...JSON_HDRS, Authorization: rctAuth(world.stranger.id) },
263
+ body: JSON.stringify({ environment_id: world.envId }),
264
+ })
265
+ expect(second.status).toBe(403)
266
+ })
267
+
268
+ test("stranger's rct token → 403 (canManageSession, G9)", async () => {
269
+ const world = seedWorld(db)
270
+ const res = await app.request(`/v1/sessions/${world.sessionId}/attach`, {
271
+ method: 'POST',
272
+ headers: { ...JSON_HDRS, Authorization: rctAuth(world.stranger.id) },
273
+ body: JSON.stringify({ environment_id: world.envId }),
274
+ })
275
+ expect(res.status).toBe(403)
276
+ })
277
+
278
+ test('env owned by another user → 403 even for the session owner', async () => {
279
+ const world = seedWorld(db)
280
+ const foreignEnv = storeCreateEnvironment({
281
+ secret: 'test-api-key',
282
+ machineName: 'foreign',
283
+ ownerUserId: world.stranger.id,
284
+ })
285
+ const res = await app.request(`/v1/sessions/${world.sessionId}/attach`, {
286
+ method: 'POST',
287
+ headers: { ...JSON_HDRS, Authorization: rctAuth(world.owner.id) },
288
+ body: JSON.stringify({ environment_id: foreignEnv.id }),
289
+ })
290
+ expect(res.status).toBe(403)
291
+ })
292
+
293
+ test('unowned env → 409 environment_unowned (claim flow pending)', async () => {
294
+ const owner = createUser(db)
295
+ const env = storeCreateEnvironment({
296
+ secret: 'test-api-key',
297
+ machineName: 'unclaimed',
298
+ })
299
+ const session = createSession({
300
+ environment_id: null,
301
+ title: 'Web session',
302
+ source: 'web',
303
+ })
304
+ storeBindSession(session.id, owner.id, 'user')
305
+
306
+ const res = await app.request(`/v1/sessions/${session.id}/attach`, {
307
+ method: 'POST',
308
+ headers: { ...JSON_HDRS, Authorization: rctAuth(owner.id) },
309
+ body: JSON.stringify({ environment_id: env.id }),
310
+ })
311
+ expect(res.status).toBe(409)
312
+ const body = (await res.json()) as { error: { type: string } }
313
+ expect(body.error.type).toBe('environment_unowned')
314
+ })
315
+
316
+ test('archived session → 409 session_closed', async () => {
317
+ const world = seedWorld(db)
318
+ const { archiveSession } = await import('../services/session')
319
+ archiveSession(world.sessionId)
320
+
321
+ const res = await app.request(`/v1/sessions/${world.sessionId}/attach`, {
322
+ method: 'POST',
323
+ headers: { ...JSON_HDRS, Authorization: rctAuth(world.owner.id) },
324
+ body: JSON.stringify({ environment_id: world.envId }),
325
+ })
326
+ expect(res.status).toBe(409)
327
+ const body = (await res.json()) as { error: { type: string } }
328
+ expect(body.error.type).toBe('session_closed')
329
+ })
330
+
331
+ test('ACP session → 409 acp_session', async () => {
332
+ const world = seedWorld(db)
333
+ // ACP env with its own auto-created session.
334
+ const acpEnv = storeCreateEnvironment({
335
+ secret: 'test-api-key',
336
+ machineName: 'acp-agent',
337
+ workerType: 'acp',
338
+ ownerUserId: world.owner.id,
339
+ })
340
+ const acpSession = createSession({
341
+ environment_id: acpEnv.id,
342
+ title: 'ACP Agent',
343
+ source: 'acp',
344
+ })
345
+ storeBindSession(acpSession.id, world.owner.id, 'user')
346
+
347
+ const res = await app.request(`/v1/sessions/${acpSession.id}/attach`, {
348
+ method: 'POST',
349
+ headers: { ...JSON_HDRS, Authorization: rctAuth(world.owner.id) },
350
+ body: JSON.stringify({ environment_id: world.envId }),
351
+ })
352
+ expect(res.status).toBe(409)
353
+ const body = (await res.json()) as { error: { type: string } }
354
+ expect(body.error.type).toBe('acp_session')
355
+ })
356
+
357
+ test('repl env at capacity → 409 worker_at_capacity', async () => {
358
+ const world = seedWorld(db)
359
+ // A second open session already lives on the repl env.
360
+ const other = createSession({
361
+ environment_id: world.envId,
362
+ title: 'Other session',
363
+ source: 'web',
364
+ })
365
+ storeBindSession(other.id, world.owner.id, 'user')
366
+
367
+ const res = await app.request(`/v1/sessions/${world.sessionId}/attach`, {
368
+ method: 'POST',
369
+ headers: { ...JSON_HDRS, Authorization: rctAuth(world.owner.id) },
370
+ body: JSON.stringify({ environment_id: world.envId }),
371
+ })
372
+ expect(res.status).toBe(409)
373
+ const body = (await res.json()) as { error: { type: string } }
374
+ expect(body.error.type).toBe('worker_at_capacity')
375
+ })
376
+
377
+ test('missing environment_id → 400', async () => {
378
+ const world = seedWorld(db)
379
+ const res = await app.request(`/v1/sessions/${world.sessionId}/attach`, {
380
+ method: 'POST',
381
+ headers: { ...JSON_HDRS, Authorization: rctAuth(world.owner.id) },
382
+ body: JSON.stringify({}),
383
+ })
384
+ expect(res.status).toBe(400)
385
+ })
386
+
387
+ test('unknown session id → 404', async () => {
388
+ const world = seedWorld(db)
389
+ const res = await app.request(`/v1/sessions/ses_does_not_exist/attach`, {
390
+ method: 'POST',
391
+ headers: { ...JSON_HDRS, Authorization: rctAuth(world.owner.id) },
392
+ body: JSON.stringify({ environment_id: world.envId }),
393
+ })
394
+ expect(res.status).toBe(404)
395
+ })
396
+ })
397
+
398
+ describe('GET /v1/sessions/attachable', () => {
399
+ let app: Hono
400
+ let db: Database
401
+
402
+ beforeEach(() => {
403
+ db = freshDb()
404
+ storeReset()
405
+ app = createApp()
406
+ })
407
+
408
+ test('returns own manageable sessions, excludes ACP and closed', async () => {
409
+ const world = seedWorld(db)
410
+ // ACP session on an acp env — must be excluded.
411
+ const acpEnv = storeCreateEnvironment({
412
+ secret: 'test-api-key',
413
+ workerType: 'acp',
414
+ ownerUserId: world.owner.id,
415
+ })
416
+ createSession({ environment_id: acpEnv.id, title: 'ACP', source: 'acp' })
417
+ // Stranger's session — must be excluded.
418
+ createSession({
419
+ environment_id: null,
420
+ title: 'Stranger session',
421
+ source: 'web',
422
+ })
423
+
424
+ const res = await app.request('/v1/sessions/attachable', {
425
+ headers: { Authorization: rctAuth(world.owner.id) },
426
+ })
427
+ expect(res.status).toBe(200)
428
+ const list = (await res.json()) as Array<{ id: string; source: string }>
429
+ expect(list).toHaveLength(1)
430
+ expect(list[0]!.id).toBe(world.sessionId)
431
+ expect(list[0]!.source).toBe('web')
432
+ })
433
+
434
+ test('bare admin API key sees all non-ACP sessions', async () => {
435
+ const world = seedWorld(db)
436
+ const res = await app.request('/v1/sessions/attachable', {
437
+ headers: { ...ADMIN_KEY },
438
+ })
439
+ expect(res.status).toBe(200)
440
+ const list = (await res.json()) as Array<{ id: string }>
441
+ expect(list.some(s => s.id === world.sessionId)).toBe(true)
442
+ })
443
+
444
+ test('admin API key + unresolvable X-Username → 403 (consistent with attach)', async () => {
445
+ // An explicit-but-unknown identity is a misconfiguration, not "see
446
+ // everything" — the listing must be exactly as strict as
447
+ // POST /:id/attach's ensureAttachAccess.
448
+ await seedWorld(db)
449
+ const res = await app.request('/v1/sessions/attachable', {
450
+ headers: { ...ADMIN_KEY, 'X-Username': 'no_such_user_xyz' },
451
+ })
452
+ expect(res.status).toBe(403)
453
+ const body = (await res.json()) as { error: { type: string } }
454
+ expect(body.error.type).toBe('forbidden')
455
+ })
456
+ })
457
+
458
+ describe('GET /v1/sessions/:id/events (bridge auth)', () => {
459
+ let app: Hono
460
+ let db: Database
461
+
462
+ beforeEach(() => {
463
+ db = freshDb()
464
+ storeReset()
465
+ app = createApp()
466
+ })
467
+
468
+ test('merges bus events chronologically for the session manager', async () => {
469
+ const world = seedWorld(db)
470
+ const { publishSessionEvent: publish } = await import(
471
+ '../services/transport'
472
+ )
473
+ publish(
474
+ world.sessionId,
475
+ 'user',
476
+ {
477
+ type: 'user',
478
+ uuid: 'u-1',
479
+ message: { role: 'user', content: 'hello' },
480
+ },
481
+ 'inbound',
482
+ )
483
+ publish(
484
+ world.sessionId,
485
+ 'assistant',
486
+ {
487
+ type: 'assistant',
488
+ uuid: 'a-1',
489
+ message: { role: 'assistant', content: [{ type: 'text', text: 'hi' }] },
490
+ },
491
+ 'inbound',
492
+ )
493
+
494
+ const res = await app.request(`/v1/sessions/${world.sessionId}/events`, {
495
+ headers: { Authorization: rctAuth(world.owner.id) },
496
+ })
497
+ expect(res.status).toBe(200)
498
+ const body = (await res.json()) as {
499
+ events: Array<{ type: string; payload: { uuid?: string } }>
500
+ }
501
+ const kinds = body.events.map(e => e.type)
502
+ expect(kinds).toContain('user')
503
+ expect(kinds).toContain('assistant')
504
+ const userEvent = body.events.find(e => e.type === 'user')
505
+ expect(userEvent?.payload.uuid).toBe('u-1')
506
+ })
507
+
508
+ test("stranger's rct token → 403", async () => {
509
+ const world = seedWorld(db)
510
+ const res = await app.request(`/v1/sessions/${world.sessionId}/events`, {
511
+ headers: { Authorization: rctAuth(world.stranger.id) },
512
+ })
513
+ expect(res.status).toBe(403)
514
+ })
515
+
516
+ test('env-owner relaxation: caller owning the bound env can read events (wait-mode hydration)', async () => {
517
+ // P0-1b: a team admin binds a session to someone else's CLI env; that
518
+ // CLI (no manage rights on the session) must still hydrate.
519
+ const world = seedWorld(db)
520
+ const cliOwner = createUser(db)
521
+ // Re-point the session to the CLI owner's env via attach (admin mints).
522
+ const cliEnv = storeCreateEnvironment({
523
+ secret: 'test-api-key',
524
+ machineName: 'cli-worker',
525
+ ownerUserId: cliOwner.id,
526
+ })
527
+ const { storeRebindSessionEnvironment: rebind } = await import('../store')
528
+ rebind(world.sessionId, cliEnv.id)
529
+
530
+ const res = await app.request(`/v1/sessions/${world.sessionId}/events`, {
531
+ headers: { Authorization: rctAuth(cliOwner.id) },
532
+ })
533
+ expect(res.status).toBe(200)
534
+ })
535
+ })
536
+
537
+ describe('storeRebindSessionEnvironment', () => {
538
+ test('re-points the session and persists the change', () => {
539
+ const db = freshDb()
540
+ storeReset()
541
+ const world = seedWorld(db)
542
+ const newEnv = storeCreateEnvironment({
543
+ secret: 'test-api-key',
544
+ machineName: 'mac2',
545
+ ownerUserId: world.owner.id,
546
+ })
547
+
548
+ expect(storeRebindSessionEnvironment(world.sessionId, newEnv.id)).toBe(true)
549
+
550
+ const record = storeGetEnvironment(newEnv.id)
551
+ expect(record).toBeDefined()
552
+ const row = db
553
+ .query('SELECT environment_id FROM sessions WHERE id = $id')
554
+ .get({ $id: world.sessionId }) as { environment_id: string }
555
+ expect(row.environment_id).toBe(newEnv.id)
556
+ })
557
+
558
+ test('returns false for unknown sessions', () => {
559
+ const db = freshDb()
560
+ storeReset()
561
+ expect(storeRebindSessionEnvironment('ses_missing', 'env_x')).toBe(false)
562
+ })
563
+
564
+ test('rebinding to null clears the binding (dispatch-failure rollback)', () => {
565
+ const db = freshDb()
566
+ storeReset()
567
+ const world = seedWorld(db)
568
+ const web = createSession({
569
+ environment_id: null,
570
+ title: 'Web session',
571
+ source: 'web',
572
+ })
573
+ expect(storeRebindSessionEnvironment(web.id, world.envId)).toBe(true)
574
+ expect(storeRebindSessionEnvironment(web.id, null)).toBe(true)
575
+ const row = db
576
+ .query('SELECT environment_id FROM sessions WHERE id = $id')
577
+ .get({ $id: web.id }) as { environment_id: string | null }
578
+ expect(row.environment_id).toBeNull()
579
+ })
580
+ })
@@ -0,0 +1,120 @@
1
+ import type { Context, Next } from 'hono'
2
+ import { apiKeyAuth, extractBearerToken } from './middleware'
3
+ import { validateApiKey } from './api-key'
4
+ import { resolveToken } from './token'
5
+ import { resolveSessionToken } from './session'
6
+ import { getDb } from '../db/sqlite'
7
+
8
+ /**
9
+ * Combined auth for bridge endpoints: accepts session tokens (rct_*),
10
+ * in-memory issueToken tokens, or admin API keys. Also falls back to
11
+ * apiKeyAuth's X-Username header support for CLI bridges.
12
+ *
13
+ * Extracted from routes/v1/environments.ts so the v1 sessions attach
14
+ * endpoints (POST /:id/attach, GET /attachable, GET /:id/events) share the
15
+ * exact same identity semantics as environment registration — attach must
16
+ * resolve the caller to the same identity the env was registered under.
17
+ */
18
+ export async function bridgeAuth(c: Context, next: Next) {
19
+ const bearer = extractBearerToken(c)
20
+ if (bearer) {
21
+ // Admin API key
22
+ if (validateApiKey(bearer)) {
23
+ c.set('userId', c.req.header('X-Username') || c.req.query('username'))
24
+ c.set('isApiAdmin', true)
25
+ await next()
26
+ return
27
+ }
28
+ // In-memory token
29
+ const username = resolveToken(bearer)
30
+ if (username) {
31
+ c.set('userId', username)
32
+ await next()
33
+ return
34
+ }
35
+ // SQLite session token
36
+ try {
37
+ const db = getDb()
38
+ const userId = resolveSessionToken(bearer, db)
39
+ if (userId) {
40
+ c.set('userId', userId)
41
+ await next()
42
+ return
43
+ }
44
+ } catch (err) {
45
+ console.error('[bridge] DB error during session token resolution:', err)
46
+ return c.json({ error: 'Database unavailable' }, 503)
47
+ }
48
+ }
49
+ // Fall back to apiKeyAuth (handles X-Username + API key from body/header)
50
+ return apiKeyAuth(c, next)
51
+ }
52
+
53
+ /**
54
+ * Resolve a userId value (UUID or username) to a users.id UUID.
55
+ * Returns null if no matching user exists.
56
+ *
57
+ * bridgeAuth's three token paths return different userId shapes:
58
+ * - resolveSessionToken (rct_* SQLite token) → users.id (UUID)
59
+ * - resolveToken (in-memory token) → username (string)
60
+ * - validateApiKey + X-Username → X-Username header value (arbitrary string)
61
+ *
62
+ * Only UUIDs that exist in the users table can become env owners / session
63
+ * managers, so a client-controlled X-Username value can't create an owner
64
+ * for a non-existent user — resolveUserIdUuid is the safety gate.
65
+ */
66
+ export function resolveUserIdUuid(
67
+ db: ReturnType<typeof getDb>,
68
+ value: string,
69
+ ): string | null {
70
+ const row = db
71
+ .query(
72
+ 'SELECT id FROM users WHERE id = $value OR username = $value LIMIT 1',
73
+ )
74
+ .get({ $value: value }) as { id: string } | null
75
+ return row?.id ?? null
76
+ }
77
+
78
+ /** Caller identity resolved for attach-family endpoints. */
79
+ export type AttachCaller = {
80
+ /** Resolved users.id UUID — null for a bare admin API key. */
81
+ uuid: string | null
82
+ isApiAdmin: boolean
83
+ /** True when no identity at all was presented (bare admin API key). */
84
+ hasIdentity: boolean
85
+ /**
86
+ * True when the users-table lookup itself failed (DB unavailable). Routes
87
+ * must return 503 BEFORE treating uuid:null as an unresolvable identity —
88
+ * a DB outage must not masquerade as a 403.
89
+ */
90
+ dbUnavailable: boolean
91
+ }
92
+
93
+ /**
94
+ * Resolve the bridge caller identity for attach endpoints.
95
+ *
96
+ * bridgeAuth's userId may be a UUID (rct_* token), a username (in-memory
97
+ * token) or an arbitrary X-Username string (shared admin API key) — only
98
+ * users-table UUIDs can hold session management rights, so everything goes
99
+ * through resolveUserIdUuid (G9: the API layer must self-enforce
100
+ * canManageSession; the ingress WS accepting a global API key is not a
101
+ * boundary we may lean on).
102
+ */
103
+ export function resolveAttachCaller(c: Context): AttachCaller {
104
+ const raw = c.get('userId') as string | undefined
105
+ const isApiAdmin = (c.get('isApiAdmin') as boolean | undefined) ?? false
106
+ if (!raw) {
107
+ return { uuid: null, isApiAdmin, hasIdentity: false, dbUnavailable: false }
108
+ }
109
+ try {
110
+ return {
111
+ uuid: resolveUserIdUuid(getDb(), raw),
112
+ isApiAdmin,
113
+ hasIdentity: true,
114
+ dbUnavailable: false,
115
+ }
116
+ } catch (err) {
117
+ console.error('[bridge] DB error resolving attach caller identity:', err)
118
+ return { uuid: null, isApiAdmin, hasIdentity: true, dbUnavailable: true }
119
+ }
120
+ }