@saluzi/saluzi-edu 0.2.63 → 0.2.64
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/dist/cli.js +176 -176
- package/dist/guide/guide-data.json +12 -0
- package/dist/rcs-schema.sql +2 -1
- package/package.json +1 -1
- package/packages/remote-control-server/src/__tests__/attach-code.test.ts +183 -0
- package/packages/remote-control-server/src/__tests__/attach-wait.test.ts +213 -0
- package/packages/remote-control-server/src/__tests__/attach.test.ts +521 -0
- package/packages/remote-control-server/src/auth/bridge-auth.ts +76 -0
- package/packages/remote-control-server/src/db/rcs-schema.sql +2 -1
- package/packages/remote-control-server/src/db/sqlite.ts +1 -0
- package/packages/remote-control-server/src/routes/v1/environments.ts +48 -74
- package/packages/remote-control-server/src/routes/v1/sessions.ts +558 -4
- package/packages/remote-control-server/src/routes/web/sessions.ts +67 -0
- package/packages/remote-control-server/src/services/environment.ts +27 -0
- package/packages/remote-control-server/src/store.ts +107 -2
- package/packages/remote-control-server/src/types/api.ts +2 -0
|
@@ -0,0 +1,521 @@
|
|
|
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 (doReconnect re-bind)', async () => {
|
|
211
|
+
// Wait-mode scenario: a team admin bound the session to the caller's env;
|
|
212
|
+
// the caller itself has no manage rights. The re-bind after env loss must
|
|
213
|
+
// still succeed via the same-env idempotent path.
|
|
214
|
+
const world = seedWorld(db)
|
|
215
|
+
const admin = createUser(db, 'admin')
|
|
216
|
+
// Attach once WITH rights (owner) so a work item exists on world.envId.
|
|
217
|
+
const first = await app.request(`/v1/sessions/${world.sessionId}/attach`, {
|
|
218
|
+
method: 'POST',
|
|
219
|
+
headers: { ...JSON_HDRS, Authorization: rctAuth(world.owner.id) },
|
|
220
|
+
body: JSON.stringify({ environment_id: world.envId }),
|
|
221
|
+
})
|
|
222
|
+
expect(first.status).toBe(200)
|
|
223
|
+
void admin
|
|
224
|
+
|
|
225
|
+
// Re-entry as the STRANGER (no manage rights) but same env → 200.
|
|
226
|
+
// Note: env ownership also matches the stranger? No — the env belongs to
|
|
227
|
+
// owner; but the idempotent check runs BEFORE both the access and env
|
|
228
|
+
// ownership checks, so the re-entry returns the existing binding.
|
|
229
|
+
const second = await app.request(`/v1/sessions/${world.sessionId}/attach`, {
|
|
230
|
+
method: 'POST',
|
|
231
|
+
headers: { ...JSON_HDRS, Authorization: rctAuth(world.stranger.id) },
|
|
232
|
+
body: JSON.stringify({ environment_id: world.envId }),
|
|
233
|
+
})
|
|
234
|
+
expect(second.status).toBe(200)
|
|
235
|
+
const body = (await second.json()) as { idempotent: boolean }
|
|
236
|
+
expect(body.idempotent).toBe(true)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
test("stranger's rct token → 403 (canManageSession, G9)", async () => {
|
|
240
|
+
const world = seedWorld(db)
|
|
241
|
+
const res = await app.request(`/v1/sessions/${world.sessionId}/attach`, {
|
|
242
|
+
method: 'POST',
|
|
243
|
+
headers: { ...JSON_HDRS, Authorization: rctAuth(world.stranger.id) },
|
|
244
|
+
body: JSON.stringify({ environment_id: world.envId }),
|
|
245
|
+
})
|
|
246
|
+
expect(res.status).toBe(403)
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
test('env owned by another user → 403 even for the session owner', async () => {
|
|
250
|
+
const world = seedWorld(db)
|
|
251
|
+
const foreignEnv = storeCreateEnvironment({
|
|
252
|
+
secret: 'test-api-key',
|
|
253
|
+
machineName: 'foreign',
|
|
254
|
+
ownerUserId: world.stranger.id,
|
|
255
|
+
})
|
|
256
|
+
const res = await app.request(`/v1/sessions/${world.sessionId}/attach`, {
|
|
257
|
+
method: 'POST',
|
|
258
|
+
headers: { ...JSON_HDRS, Authorization: rctAuth(world.owner.id) },
|
|
259
|
+
body: JSON.stringify({ environment_id: foreignEnv.id }),
|
|
260
|
+
})
|
|
261
|
+
expect(res.status).toBe(403)
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
test('unowned env → 409 environment_unowned (claim flow pending)', async () => {
|
|
265
|
+
const owner = createUser(db)
|
|
266
|
+
const env = storeCreateEnvironment({
|
|
267
|
+
secret: 'test-api-key',
|
|
268
|
+
machineName: 'unclaimed',
|
|
269
|
+
})
|
|
270
|
+
const session = createSession({
|
|
271
|
+
environment_id: null,
|
|
272
|
+
title: 'Web session',
|
|
273
|
+
source: 'web',
|
|
274
|
+
})
|
|
275
|
+
storeBindSession(session.id, owner.id, 'user')
|
|
276
|
+
|
|
277
|
+
const res = await app.request(`/v1/sessions/${session.id}/attach`, {
|
|
278
|
+
method: 'POST',
|
|
279
|
+
headers: { ...JSON_HDRS, Authorization: rctAuth(owner.id) },
|
|
280
|
+
body: JSON.stringify({ environment_id: env.id }),
|
|
281
|
+
})
|
|
282
|
+
expect(res.status).toBe(409)
|
|
283
|
+
const body = (await res.json()) as { error: { type: string } }
|
|
284
|
+
expect(body.error.type).toBe('environment_unowned')
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
test('archived session → 409 session_closed', async () => {
|
|
288
|
+
const world = seedWorld(db)
|
|
289
|
+
const { archiveSession } = await import('../services/session')
|
|
290
|
+
archiveSession(world.sessionId)
|
|
291
|
+
|
|
292
|
+
const res = await app.request(`/v1/sessions/${world.sessionId}/attach`, {
|
|
293
|
+
method: 'POST',
|
|
294
|
+
headers: { ...JSON_HDRS, Authorization: rctAuth(world.owner.id) },
|
|
295
|
+
body: JSON.stringify({ environment_id: world.envId }),
|
|
296
|
+
})
|
|
297
|
+
expect(res.status).toBe(409)
|
|
298
|
+
const body = (await res.json()) as { error: { type: string } }
|
|
299
|
+
expect(body.error.type).toBe('session_closed')
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
test('ACP session → 409 acp_session', async () => {
|
|
303
|
+
const world = seedWorld(db)
|
|
304
|
+
// ACP env with its own auto-created session.
|
|
305
|
+
const acpEnv = storeCreateEnvironment({
|
|
306
|
+
secret: 'test-api-key',
|
|
307
|
+
machineName: 'acp-agent',
|
|
308
|
+
workerType: 'acp',
|
|
309
|
+
ownerUserId: world.owner.id,
|
|
310
|
+
})
|
|
311
|
+
const acpSession = createSession({
|
|
312
|
+
environment_id: acpEnv.id,
|
|
313
|
+
title: 'ACP Agent',
|
|
314
|
+
source: 'acp',
|
|
315
|
+
})
|
|
316
|
+
storeBindSession(acpSession.id, world.owner.id, 'user')
|
|
317
|
+
|
|
318
|
+
const res = await app.request(`/v1/sessions/${acpSession.id}/attach`, {
|
|
319
|
+
method: 'POST',
|
|
320
|
+
headers: { ...JSON_HDRS, Authorization: rctAuth(world.owner.id) },
|
|
321
|
+
body: JSON.stringify({ environment_id: world.envId }),
|
|
322
|
+
})
|
|
323
|
+
expect(res.status).toBe(409)
|
|
324
|
+
const body = (await res.json()) as { error: { type: string } }
|
|
325
|
+
expect(body.error.type).toBe('acp_session')
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
test('repl env at capacity → 409 worker_at_capacity', async () => {
|
|
329
|
+
const world = seedWorld(db)
|
|
330
|
+
// A second open session already lives on the repl env.
|
|
331
|
+
const other = createSession({
|
|
332
|
+
environment_id: world.envId,
|
|
333
|
+
title: 'Other session',
|
|
334
|
+
source: 'web',
|
|
335
|
+
})
|
|
336
|
+
storeBindSession(other.id, world.owner.id, 'user')
|
|
337
|
+
|
|
338
|
+
const res = await app.request(`/v1/sessions/${world.sessionId}/attach`, {
|
|
339
|
+
method: 'POST',
|
|
340
|
+
headers: { ...JSON_HDRS, Authorization: rctAuth(world.owner.id) },
|
|
341
|
+
body: JSON.stringify({ environment_id: world.envId }),
|
|
342
|
+
})
|
|
343
|
+
expect(res.status).toBe(409)
|
|
344
|
+
const body = (await res.json()) as { error: { type: string } }
|
|
345
|
+
expect(body.error.type).toBe('worker_at_capacity')
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
test('missing environment_id → 400', async () => {
|
|
349
|
+
const world = seedWorld(db)
|
|
350
|
+
const res = await app.request(`/v1/sessions/${world.sessionId}/attach`, {
|
|
351
|
+
method: 'POST',
|
|
352
|
+
headers: { ...JSON_HDRS, Authorization: rctAuth(world.owner.id) },
|
|
353
|
+
body: JSON.stringify({}),
|
|
354
|
+
})
|
|
355
|
+
expect(res.status).toBe(400)
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
test('unknown session id → 404', async () => {
|
|
359
|
+
const world = seedWorld(db)
|
|
360
|
+
const res = await app.request(`/v1/sessions/ses_does_not_exist/attach`, {
|
|
361
|
+
method: 'POST',
|
|
362
|
+
headers: { ...JSON_HDRS, Authorization: rctAuth(world.owner.id) },
|
|
363
|
+
body: JSON.stringify({ environment_id: world.envId }),
|
|
364
|
+
})
|
|
365
|
+
expect(res.status).toBe(404)
|
|
366
|
+
})
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
describe('GET /v1/sessions/attachable', () => {
|
|
370
|
+
let app: Hono
|
|
371
|
+
let db: Database
|
|
372
|
+
|
|
373
|
+
beforeEach(() => {
|
|
374
|
+
db = freshDb()
|
|
375
|
+
storeReset()
|
|
376
|
+
app = createApp()
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
test('returns own manageable sessions, excludes ACP and closed', async () => {
|
|
380
|
+
const world = seedWorld(db)
|
|
381
|
+
// ACP session on an acp env — must be excluded.
|
|
382
|
+
const acpEnv = storeCreateEnvironment({
|
|
383
|
+
secret: 'test-api-key',
|
|
384
|
+
workerType: 'acp',
|
|
385
|
+
ownerUserId: world.owner.id,
|
|
386
|
+
})
|
|
387
|
+
createSession({ environment_id: acpEnv.id, title: 'ACP', source: 'acp' })
|
|
388
|
+
// Stranger's session — must be excluded.
|
|
389
|
+
createSession({
|
|
390
|
+
environment_id: null,
|
|
391
|
+
title: 'Stranger session',
|
|
392
|
+
source: 'web',
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
const res = await app.request('/v1/sessions/attachable', {
|
|
396
|
+
headers: { Authorization: rctAuth(world.owner.id) },
|
|
397
|
+
})
|
|
398
|
+
expect(res.status).toBe(200)
|
|
399
|
+
const list = (await res.json()) as Array<{ id: string; source: string }>
|
|
400
|
+
expect(list).toHaveLength(1)
|
|
401
|
+
expect(list[0]!.id).toBe(world.sessionId)
|
|
402
|
+
expect(list[0]!.source).toBe('web')
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
test('bare admin API key sees all non-ACP sessions', async () => {
|
|
406
|
+
const world = seedWorld(db)
|
|
407
|
+
const res = await app.request('/v1/sessions/attachable', {
|
|
408
|
+
headers: { ...ADMIN_KEY },
|
|
409
|
+
})
|
|
410
|
+
expect(res.status).toBe(200)
|
|
411
|
+
const list = (await res.json()) as Array<{ id: string }>
|
|
412
|
+
expect(list.some(s => s.id === world.sessionId)).toBe(true)
|
|
413
|
+
})
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
describe('GET /v1/sessions/:id/events (bridge auth)', () => {
|
|
417
|
+
let app: Hono
|
|
418
|
+
let db: Database
|
|
419
|
+
|
|
420
|
+
beforeEach(() => {
|
|
421
|
+
db = freshDb()
|
|
422
|
+
storeReset()
|
|
423
|
+
app = createApp()
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
test('merges bus events chronologically for the session manager', async () => {
|
|
427
|
+
const world = seedWorld(db)
|
|
428
|
+
const { publishSessionEvent: publish } = await import(
|
|
429
|
+
'../services/transport'
|
|
430
|
+
)
|
|
431
|
+
publish(
|
|
432
|
+
world.sessionId,
|
|
433
|
+
'user',
|
|
434
|
+
{
|
|
435
|
+
type: 'user',
|
|
436
|
+
uuid: 'u-1',
|
|
437
|
+
message: { role: 'user', content: 'hello' },
|
|
438
|
+
},
|
|
439
|
+
'inbound',
|
|
440
|
+
)
|
|
441
|
+
publish(
|
|
442
|
+
world.sessionId,
|
|
443
|
+
'assistant',
|
|
444
|
+
{
|
|
445
|
+
type: 'assistant',
|
|
446
|
+
uuid: 'a-1',
|
|
447
|
+
message: { role: 'assistant', content: [{ type: 'text', text: 'hi' }] },
|
|
448
|
+
},
|
|
449
|
+
'inbound',
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
const res = await app.request(`/v1/sessions/${world.sessionId}/events`, {
|
|
453
|
+
headers: { Authorization: rctAuth(world.owner.id) },
|
|
454
|
+
})
|
|
455
|
+
expect(res.status).toBe(200)
|
|
456
|
+
const body = (await res.json()) as {
|
|
457
|
+
events: Array<{ type: string; payload: { uuid?: string } }>
|
|
458
|
+
}
|
|
459
|
+
const kinds = body.events.map(e => e.type)
|
|
460
|
+
expect(kinds).toContain('user')
|
|
461
|
+
expect(kinds).toContain('assistant')
|
|
462
|
+
const userEvent = body.events.find(e => e.type === 'user')
|
|
463
|
+
expect(userEvent?.payload.uuid).toBe('u-1')
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
test("stranger's rct token → 403", async () => {
|
|
467
|
+
const world = seedWorld(db)
|
|
468
|
+
const res = await app.request(`/v1/sessions/${world.sessionId}/events`, {
|
|
469
|
+
headers: { Authorization: rctAuth(world.stranger.id) },
|
|
470
|
+
})
|
|
471
|
+
expect(res.status).toBe(403)
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
test('env-owner relaxation: caller owning the bound env can read events (wait-mode hydration)', async () => {
|
|
475
|
+
// P0-1b: a team admin binds a session to someone else's CLI env; that
|
|
476
|
+
// CLI (no manage rights on the session) must still hydrate.
|
|
477
|
+
const world = seedWorld(db)
|
|
478
|
+
const cliOwner = createUser(db)
|
|
479
|
+
// Re-point the session to the CLI owner's env via attach (admin mints).
|
|
480
|
+
const cliEnv = storeCreateEnvironment({
|
|
481
|
+
secret: 'test-api-key',
|
|
482
|
+
machineName: 'cli-worker',
|
|
483
|
+
ownerUserId: cliOwner.id,
|
|
484
|
+
})
|
|
485
|
+
const { storeRebindSessionEnvironment: rebind } = await import('../store')
|
|
486
|
+
rebind(world.sessionId, cliEnv.id)
|
|
487
|
+
|
|
488
|
+
const res = await app.request(`/v1/sessions/${world.sessionId}/events`, {
|
|
489
|
+
headers: { Authorization: rctAuth(cliOwner.id) },
|
|
490
|
+
})
|
|
491
|
+
expect(res.status).toBe(200)
|
|
492
|
+
})
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
describe('storeRebindSessionEnvironment', () => {
|
|
496
|
+
test('re-points the session and persists the change', () => {
|
|
497
|
+
const db = freshDb()
|
|
498
|
+
storeReset()
|
|
499
|
+
const world = seedWorld(db)
|
|
500
|
+
const newEnv = storeCreateEnvironment({
|
|
501
|
+
secret: 'test-api-key',
|
|
502
|
+
machineName: 'mac2',
|
|
503
|
+
ownerUserId: world.owner.id,
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
expect(storeRebindSessionEnvironment(world.sessionId, newEnv.id)).toBe(true)
|
|
507
|
+
|
|
508
|
+
const record = storeGetEnvironment(newEnv.id)
|
|
509
|
+
expect(record).toBeDefined()
|
|
510
|
+
const row = db
|
|
511
|
+
.query('SELECT environment_id FROM sessions WHERE id = $id')
|
|
512
|
+
.get({ $id: world.sessionId }) as { environment_id: string }
|
|
513
|
+
expect(row.environment_id).toBe(newEnv.id)
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
test('returns false for unknown sessions', () => {
|
|
517
|
+
const db = freshDb()
|
|
518
|
+
storeReset()
|
|
519
|
+
expect(storeRebindSessionEnvironment('ses_missing', 'env_x')).toBe(false)
|
|
520
|
+
})
|
|
521
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
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
|
+
}
|