@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.
- package/dist/cli.js +176 -176
- package/dist/guide/assets/main-Bx1xb7yl.css +1 -0
- package/dist/guide/assets/main-CdOE18lg.js +277 -0
- package/dist/guide/guide-data.json +27 -5
- package/dist/guide/index.html +2 -2
- package/dist/rcs-schema.sql +2 -1
- package/package.json +1 -1
- package/packages/remote-control-server/src/__tests__/attach-code.test.ts +195 -0
- package/packages/remote-control-server/src/__tests__/attach-wait.test.ts +270 -0
- package/packages/remote-control-server/src/__tests__/attach.test.ts +580 -0
- package/packages/remote-control-server/src/auth/bridge-auth.ts +120 -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 +97 -73
- package/packages/remote-control-server/src/routes/v1/sessions.ts +592 -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 +123 -2
- package/packages/remote-control-server/src/types/api.ts +2 -0
- package/dist/guide/assets/main-CSpz9pKf.css +0 -1
- package/dist/guide/assets/main-C_ykjDzn.js +0 -167
|
@@ -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
|
+
}
|