@saluzi/saluzi-edu 0.2.64 → 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 +42 -42
- package/dist/guide/assets/main-Bx1xb7yl.css +1 -0
- package/dist/guide/assets/main-CdOE18lg.js +277 -0
- package/dist/guide/guide-data.json +17 -7
- package/dist/guide/index.html +2 -2
- package/package.json +1 -1
- package/packages/remote-control-server/src/__tests__/attach-code.test.ts +12 -0
- package/packages/remote-control-server/src/__tests__/attach-wait.test.ts +57 -0
- package/packages/remote-control-server/src/__tests__/attach.test.ts +73 -14
- package/packages/remote-control-server/src/auth/bridge-auth.ts +44 -0
- package/packages/remote-control-server/src/routes/v1/environments.ts +52 -2
- package/packages/remote-control-server/src/routes/v1/sessions.ts +104 -70
- package/packages/remote-control-server/src/store.ts +17 -1
- package/dist/guide/assets/main-CSpz9pKf.css +0 -1
- package/dist/guide/assets/main-C_ykjDzn.js +0 -167
package/package.json
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
storeCreateEnvironment,
|
|
17
17
|
storeIssueAttachCode,
|
|
18
18
|
storeConsumeAttachCode,
|
|
19
|
+
storeAttachCodesSize,
|
|
19
20
|
} from '../store'
|
|
20
21
|
import { initDatabase, resetDbSingleton, getDb } from '../db/sqlite'
|
|
21
22
|
import { issueSessionToken } from '../auth/session'
|
|
@@ -172,6 +173,17 @@ describe('attach codes', () => {
|
|
|
172
173
|
expect(storeConsumeAttachCode(record.code)).toBeNull()
|
|
173
174
|
})
|
|
174
175
|
|
|
176
|
+
test('unconsumed expired codes are swept on the next issue (memory hygiene)', () => {
|
|
177
|
+
expect(storeAttachCodesSize()).toBe(0)
|
|
178
|
+
// Expired immediately and never presented — consume() would never see it.
|
|
179
|
+
storeIssueAttachCode(sessionId, -1000)
|
|
180
|
+
expect(storeAttachCodesSize()).toBe(1)
|
|
181
|
+
// The next issue sweeps it instead of letting it accumulate forever.
|
|
182
|
+
const live = storeIssueAttachCode(sessionId)
|
|
183
|
+
expect(storeAttachCodesSize()).toBe(1)
|
|
184
|
+
expect(storeConsumeAttachCode(live.code)?.sessionId).toBe(sessionId)
|
|
185
|
+
})
|
|
186
|
+
|
|
175
187
|
test('missing code or environment_id → 400', async () => {
|
|
176
188
|
const res = await app.request('/v1/sessions/attach-code/exchange', {
|
|
177
189
|
method: 'POST',
|
|
@@ -30,6 +30,7 @@ import { storeBindSession } from '../store'
|
|
|
30
30
|
* - POST /v1/environments/bridge persists session_mode:'attach'.
|
|
31
31
|
* - GET /v1/environments/:id/assignment returns the first open non-ACP
|
|
32
32
|
* session on the env (assigned:false until the Web UI binds one).
|
|
33
|
+
* Gated on env ownership (admin bypasses; unowned envs stay open).
|
|
33
34
|
* - GET /web/environments surfaces attach_waiting for waiting workers.
|
|
34
35
|
*/
|
|
35
36
|
|
|
@@ -124,6 +125,62 @@ describe('Session Attach wait mode', () => {
|
|
|
124
125
|
expect(after ?? 0).toBeGreaterThan(stale.getTime())
|
|
125
126
|
})
|
|
126
127
|
|
|
128
|
+
test('assignment: owned env + owner rct token → 200 (worker polls as itself)', async () => {
|
|
129
|
+
const env = storeCreateEnvironment({
|
|
130
|
+
secret: 'test-api-key',
|
|
131
|
+
machineName: 'mac1',
|
|
132
|
+
ownerUserId: owner.id,
|
|
133
|
+
sessionMode: 'attach',
|
|
134
|
+
})
|
|
135
|
+
const res = await app.request(`/v1/environments/${env.id}/assignment`, {
|
|
136
|
+
headers: { Authorization: rctAuth(owner.id) },
|
|
137
|
+
})
|
|
138
|
+
expect(res.status).toBe(200)
|
|
139
|
+
expect(await res.json()).toEqual({ assigned: false })
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('assignment: owned env + stranger rct token → 403, heartbeat NOT refreshed', async () => {
|
|
143
|
+
// Assignment discloses the env's bound session AND is its liveness
|
|
144
|
+
// signal — neither may be driven by a third party.
|
|
145
|
+
const env = storeCreateEnvironment({
|
|
146
|
+
secret: 'test-api-key',
|
|
147
|
+
machineName: 'mac1',
|
|
148
|
+
ownerUserId: owner.id,
|
|
149
|
+
sessionMode: 'attach',
|
|
150
|
+
})
|
|
151
|
+
const stale = new Date(Date.now() - 10 * 60 * 1000)
|
|
152
|
+
storeUpdateEnvironment(env.id, { lastPollAt: stale })
|
|
153
|
+
|
|
154
|
+
const stranger = createUser(db)
|
|
155
|
+
const res = await app.request(`/v1/environments/${env.id}/assignment`, {
|
|
156
|
+
headers: { Authorization: rctAuth(stranger.id) },
|
|
157
|
+
})
|
|
158
|
+
expect(res.status).toBe(403)
|
|
159
|
+
const body = (await res.json()) as { error: { type: string } }
|
|
160
|
+
expect(body.error.type).toBe('forbidden')
|
|
161
|
+
// updatePollTime runs only AFTER the gate — a 403 must not keep a dead
|
|
162
|
+
// env looking alive.
|
|
163
|
+
expect(storeGetEnvironment(env.id)?.lastPollAt?.getTime()).toBe(
|
|
164
|
+
stale.getTime(),
|
|
165
|
+
)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
test('assignment: unowned env (claim pending) + any auth → 200 assigned:false', async () => {
|
|
169
|
+
// Two-phase wait (claim → attach): while unowned there is nothing to
|
|
170
|
+
// disclose, so the poll stays open to any authenticated caller.
|
|
171
|
+
const env = storeCreateEnvironment({
|
|
172
|
+
secret: 'test-api-key',
|
|
173
|
+
machineName: 'unclaimed',
|
|
174
|
+
sessionMode: 'attach',
|
|
175
|
+
})
|
|
176
|
+
const stranger = createUser(db)
|
|
177
|
+
const res = await app.request(`/v1/environments/${env.id}/assignment`, {
|
|
178
|
+
headers: { Authorization: rctAuth(stranger.id) },
|
|
179
|
+
})
|
|
180
|
+
expect(res.status).toBe(200)
|
|
181
|
+
expect(await res.json()).toEqual({ assigned: false })
|
|
182
|
+
})
|
|
183
|
+
|
|
127
184
|
test('assignment: session bound to the env (web attach) → assigned with session id', async () => {
|
|
128
185
|
const env = storeCreateEnvironment({
|
|
129
186
|
secret: 'test-api-key',
|
|
@@ -207,33 +207,62 @@ describe('POST /v1/sessions/:id/attach', () => {
|
|
|
207
207
|
expect(body.idempotent).toBeUndefined()
|
|
208
208
|
})
|
|
209
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
|
|
212
|
-
// the
|
|
213
|
-
// still succeed via the same-env idempotent path
|
|
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.
|
|
214
252
|
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
253
|
const first = await app.request(`/v1/sessions/${world.sessionId}/attach`, {
|
|
218
254
|
method: 'POST',
|
|
219
255
|
headers: { ...JSON_HDRS, Authorization: rctAuth(world.owner.id) },
|
|
220
256
|
body: JSON.stringify({ environment_id: world.envId }),
|
|
221
257
|
})
|
|
222
258
|
expect(first.status).toBe(200)
|
|
223
|
-
void admin
|
|
224
259
|
|
|
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
260
|
const second = await app.request(`/v1/sessions/${world.sessionId}/attach`, {
|
|
230
261
|
method: 'POST',
|
|
231
262
|
headers: { ...JSON_HDRS, Authorization: rctAuth(world.stranger.id) },
|
|
232
263
|
body: JSON.stringify({ environment_id: world.envId }),
|
|
233
264
|
})
|
|
234
|
-
expect(second.status).toBe(
|
|
235
|
-
const body = (await second.json()) as { idempotent: boolean }
|
|
236
|
-
expect(body.idempotent).toBe(true)
|
|
265
|
+
expect(second.status).toBe(403)
|
|
237
266
|
})
|
|
238
267
|
|
|
239
268
|
test("stranger's rct token → 403 (canManageSession, G9)", async () => {
|
|
@@ -411,6 +440,19 @@ describe('GET /v1/sessions/attachable', () => {
|
|
|
411
440
|
const list = (await res.json()) as Array<{ id: string }>
|
|
412
441
|
expect(list.some(s => s.id === world.sessionId)).toBe(true)
|
|
413
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
|
+
})
|
|
414
456
|
})
|
|
415
457
|
|
|
416
458
|
describe('GET /v1/sessions/:id/events (bridge auth)', () => {
|
|
@@ -518,4 +560,21 @@ describe('storeRebindSessionEnvironment', () => {
|
|
|
518
560
|
storeReset()
|
|
519
561
|
expect(storeRebindSessionEnvironment('ses_missing', 'env_x')).toBe(false)
|
|
520
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
|
+
})
|
|
521
580
|
})
|
|
@@ -74,3 +74,47 @@ export function resolveUserIdUuid(
|
|
|
74
74
|
.get({ $value: value }) as { id: string } | null
|
|
75
75
|
return row?.id ?? null
|
|
76
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
|
+
}
|
|
@@ -7,7 +7,11 @@ import {
|
|
|
7
7
|
updatePollTime,
|
|
8
8
|
} from '../../services/environment'
|
|
9
9
|
import { acceptCliHeaders, apiKeyAuth } from '../../auth/middleware'
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
bridgeAuth,
|
|
12
|
+
resolveAttachCaller,
|
|
13
|
+
resolveUserIdUuid,
|
|
14
|
+
} from '../../auth/bridge-auth'
|
|
11
15
|
import { getDb } from '../../db/sqlite'
|
|
12
16
|
import { isSessionClosedStatus } from '../../services/session'
|
|
13
17
|
import {
|
|
@@ -282,7 +286,8 @@ app.post('/bridge', acceptCliHeaders, bridgeAuth, async c => {
|
|
|
282
286
|
* waiting for the Web UI to bind a session (POST /v1/sessions/:id/attach).
|
|
283
287
|
* Returns the first open, non-ACP session on the environment, or
|
|
284
288
|
* assigned:false to keep waiting. Long-polling is unnecessary — the worker
|
|
285
|
-
* already runs a poll loop cadence for work items.
|
|
289
|
+
* already runs a poll loop cadence for work items.
|
|
290
|
+
* Access: the env owner (the polling worker itself) or an admin. */
|
|
286
291
|
app.get('/:id/assignment', acceptCliHeaders, bridgeAuth, async c => {
|
|
287
292
|
const envId = c.req.param('id')!
|
|
288
293
|
const env = storeGetEnvironment(envId)
|
|
@@ -292,6 +297,51 @@ app.get('/:id/assignment', acceptCliHeaders, bridgeAuth, async c => {
|
|
|
292
297
|
404,
|
|
293
298
|
)
|
|
294
299
|
}
|
|
300
|
+
|
|
301
|
+
// Ownership gate: assignment discloses the env's bound session AND doubles
|
|
302
|
+
// as its liveness signal (updatePollTime below) — both are restricted to
|
|
303
|
+
// the env owner (the wait-mode worker polls under the same identity it
|
|
304
|
+
// registered with) and admins. Unowned envs (claim pending) stay open:
|
|
305
|
+
// nothing is bound yet, so there is nothing to disclose or keep alive.
|
|
306
|
+
const caller = resolveAttachCaller(c)
|
|
307
|
+
if (caller.dbUnavailable) {
|
|
308
|
+
return c.json(
|
|
309
|
+
{
|
|
310
|
+
error: {
|
|
311
|
+
type: 'database_unavailable',
|
|
312
|
+
message: 'Database unavailable',
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
503,
|
|
316
|
+
)
|
|
317
|
+
}
|
|
318
|
+
let ownerUserId: string | null = null
|
|
319
|
+
try {
|
|
320
|
+
ownerUserId = storeGetEnvironmentOwnership(envId)?.ownerUserId ?? null
|
|
321
|
+
} catch (err) {
|
|
322
|
+
console.error('[bridge] DB error during assignment ownership lookup:', err)
|
|
323
|
+
return c.json(
|
|
324
|
+
{
|
|
325
|
+
error: {
|
|
326
|
+
type: 'database_unavailable',
|
|
327
|
+
message: 'Database unavailable',
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
503,
|
|
331
|
+
)
|
|
332
|
+
}
|
|
333
|
+
if (ownerUserId && !caller.isApiAdmin && caller.uuid !== ownerUserId) {
|
|
334
|
+
return c.json(
|
|
335
|
+
{
|
|
336
|
+
error: {
|
|
337
|
+
type: 'forbidden',
|
|
338
|
+
message: 'Not allowed to poll this environment',
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
403,
|
|
342
|
+
)
|
|
343
|
+
}
|
|
344
|
+
|
|
295
345
|
// The attach-wait worker has no work item in flight, so the work-poll
|
|
296
346
|
// route never refreshes its heartbeat. This poll IS its liveness signal:
|
|
297
347
|
// without touching lastPollAt the disconnect monitor flips the env to
|
|
@@ -14,7 +14,11 @@ import { publishSessionEvent } from '../../services/transport'
|
|
|
14
14
|
import { loadPersistedEvents } from '../../services/event-persistence'
|
|
15
15
|
import { getEventBus } from '../../transport/event-bus'
|
|
16
16
|
import { acceptCliHeaders, apiKeyAuth } from '../../auth/middleware'
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
bridgeAuth,
|
|
19
|
+
resolveAttachCaller,
|
|
20
|
+
type AttachCaller,
|
|
21
|
+
} from '../../auth/bridge-auth'
|
|
18
22
|
import { getDb } from '../../db/sqlite'
|
|
19
23
|
import {
|
|
20
24
|
storeBindSession,
|
|
@@ -25,6 +29,7 @@ import {
|
|
|
25
29
|
storeListSessions,
|
|
26
30
|
storeListSessionsByEnvironment,
|
|
27
31
|
storeRebindSessionEnvironment,
|
|
32
|
+
type SessionRecord,
|
|
28
33
|
} from '../../store'
|
|
29
34
|
|
|
30
35
|
const app = new Hono()
|
|
@@ -33,36 +38,17 @@ const app = new Hono()
|
|
|
33
38
|
// Session Attach — bind an existing session to a CLI-registered environment
|
|
34
39
|
// ---------------------------------------------------------------------------
|
|
35
40
|
|
|
36
|
-
/**
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
*
|
|
48
|
-
* bridgeAuth's userId may be a UUID (rct_* token), a username (in-memory
|
|
49
|
-
* token) or an arbitrary X-Username string (shared admin API key) — only
|
|
50
|
-
* users-table UUIDs can hold session management rights, so everything goes
|
|
51
|
-
* through resolveUserIdUuid (G9: the API layer must self-enforce
|
|
52
|
-
* canManageSession; the ingress WS accepting a global API key is not a
|
|
53
|
-
* boundary we may lean on).
|
|
54
|
-
*/
|
|
55
|
-
function resolveAttachCaller(c: import('hono').Context): AttachCaller {
|
|
56
|
-
const raw = c.get('userId') as string | undefined
|
|
57
|
-
const isApiAdmin = (c.get('isApiAdmin') as boolean | undefined) ?? false
|
|
58
|
-
if (!raw) {
|
|
59
|
-
return { uuid: null, isApiAdmin, hasIdentity: false }
|
|
60
|
-
}
|
|
61
|
-
return {
|
|
62
|
-
uuid: resolveUserIdUuid(getDb(), raw),
|
|
63
|
-
isApiAdmin,
|
|
64
|
-
hasIdentity: true,
|
|
65
|
-
}
|
|
41
|
+
/** 503 body shared by every attach-family route on a DB outage. */
|
|
42
|
+
function dbUnavailable(c: import('hono').Context): Response {
|
|
43
|
+
return c.json(
|
|
44
|
+
{
|
|
45
|
+
error: {
|
|
46
|
+
type: 'database_unavailable',
|
|
47
|
+
message: 'Database unavailable',
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
503,
|
|
51
|
+
)
|
|
66
52
|
}
|
|
67
53
|
|
|
68
54
|
/**
|
|
@@ -150,15 +136,15 @@ function isSessionOccupied(
|
|
|
150
136
|
}
|
|
151
137
|
|
|
152
138
|
/** Shape returned by GET /attachable and consumed by the CLI picker. */
|
|
153
|
-
function toAttachableEntry(session:
|
|
139
|
+
function toAttachableEntry(session: SessionRecord) {
|
|
154
140
|
return {
|
|
155
|
-
id: session
|
|
156
|
-
title: session
|
|
157
|
-
status: session
|
|
158
|
-
source: session
|
|
159
|
-
environment_id: session
|
|
160
|
-
updated_at: session
|
|
161
|
-
busy: isSessionOccupied(session
|
|
141
|
+
id: session.id,
|
|
142
|
+
title: session.title,
|
|
143
|
+
status: session.status,
|
|
144
|
+
source: session.source,
|
|
145
|
+
environment_id: session.environmentId,
|
|
146
|
+
updated_at: session.updatedAt.getTime() / 1000,
|
|
147
|
+
busy: isSessionOccupied(session.id, null),
|
|
162
148
|
}
|
|
163
149
|
}
|
|
164
150
|
|
|
@@ -240,7 +226,14 @@ app.post('/', acceptCliHeaders, apiKeyAuth, async c => {
|
|
|
240
226
|
* worker. */
|
|
241
227
|
app.get('/attachable', bridgeAuth, async c => {
|
|
242
228
|
const caller = resolveAttachCaller(c)
|
|
243
|
-
if (caller.
|
|
229
|
+
if (caller.dbUnavailable) {
|
|
230
|
+
return dbUnavailable(c)
|
|
231
|
+
}
|
|
232
|
+
// An explicit-but-unresolvable identity (e.g. admin API key +
|
|
233
|
+
// X-Username matching no user) is a misconfiguration, not "see
|
|
234
|
+
// everything": 403, mirroring POST /:id/attach's ensureAttachAccess —
|
|
235
|
+
// the listing previously was the laxer of the two.
|
|
236
|
+
if (caller.uuid === null && caller.hasIdentity) {
|
|
244
237
|
return c.json(
|
|
245
238
|
{
|
|
246
239
|
error: {
|
|
@@ -258,16 +251,22 @@ app.get('/attachable', bridgeAuth, async c => {
|
|
|
258
251
|
)
|
|
259
252
|
}
|
|
260
253
|
|
|
261
|
-
const
|
|
262
|
-
const
|
|
254
|
+
const uuid = caller.uuid
|
|
255
|
+
const open = storeListSessions()
|
|
263
256
|
.filter(s => !isSessionClosedStatus(s.status))
|
|
264
257
|
.filter(s => s.source !== 'acp')
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
258
|
+
// Bare admin API key (uuid null) sees all; an identity sees only what it
|
|
259
|
+
// can manage. getDb() is touched only when the filter actually runs.
|
|
260
|
+
let visible = open
|
|
261
|
+
if (uuid !== null) {
|
|
262
|
+
const db = getDb()
|
|
263
|
+
visible = open.filter(s => canManageSession(s.id, uuid, db))
|
|
264
|
+
}
|
|
269
265
|
|
|
270
|
-
return c.json(
|
|
266
|
+
return c.json(
|
|
267
|
+
visible.map(s => toAttachableEntry(s)),
|
|
268
|
+
200,
|
|
269
|
+
)
|
|
271
270
|
})
|
|
272
271
|
|
|
273
272
|
/** POST /v1/sessions/:id/attach — Bind an existing session to a CLI
|
|
@@ -317,6 +316,9 @@ async function performAttach(
|
|
|
317
316
|
},
|
|
318
317
|
): Promise<Response> {
|
|
319
318
|
const { sessionId, envId, caller, skipAccessCheck } = opts
|
|
319
|
+
if (caller.dbUnavailable) {
|
|
320
|
+
return dbUnavailable(c)
|
|
321
|
+
}
|
|
320
322
|
const session = getSession(sessionId)
|
|
321
323
|
if (!session) {
|
|
322
324
|
return c.json(
|
|
@@ -325,28 +327,49 @@ async function performAttach(
|
|
|
325
327
|
)
|
|
326
328
|
}
|
|
327
329
|
|
|
328
|
-
// Idempotent re-entry BEFORE authorization: a CLI retrying
|
|
329
|
-
// blip re-presents the same session+env pair — returning
|
|
330
|
-
// binding is not a new grant, so it must not depend on the
|
|
331
|
-
// holding manage rights (e.g. a doReconnect re-bind where the
|
|
332
|
-
// session was assigned by a team admin, or an expired share). The
|
|
333
|
-
// only matches an existing work item on the SAME environment — no
|
|
334
|
-
// is created.
|
|
330
|
+
// Idempotent re-entry BEFORE session-manage authorization: a CLI retrying
|
|
331
|
+
// after a network blip re-presents the same session+env pair — returning
|
|
332
|
+
// the existing binding is not a new grant, so it must not depend on the
|
|
333
|
+
// caller still holding manage rights (e.g. a doReconnect re-bind where the
|
|
334
|
+
// bound session was assigned by a team admin, or an expired share). The
|
|
335
|
+
// check only matches an existing work item on the SAME environment — no
|
|
336
|
+
// state is created.
|
|
337
|
+
//
|
|
338
|
+
// Still gated on environment ownership (or admin): the CLI re-entering is
|
|
339
|
+
// the env's own worker, so ownership always holds for legitimate retries,
|
|
340
|
+
// while a random authenticated caller who merely knows session+env IDs
|
|
341
|
+
// must not be able to probe the binding (the response echoes the work
|
|
342
|
+
// item id). Non-owners fall through to the full checks below, which
|
|
343
|
+
// reject them.
|
|
335
344
|
const activeItem = storeGetActiveWorkItemBySession(sessionId)
|
|
336
345
|
if (activeItem && activeItem.environmentId === envId) {
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
)
|
|
346
|
+
let idempotentOwner = false
|
|
347
|
+
try {
|
|
348
|
+
const ownership = storeGetEnvironmentOwnership(envId)
|
|
349
|
+
idempotentOwner =
|
|
350
|
+
caller.isApiAdmin ||
|
|
351
|
+
(caller.uuid !== null && ownership?.ownerUserId === caller.uuid)
|
|
352
|
+
} catch (err) {
|
|
353
|
+
logError(
|
|
354
|
+
`[RCS] Ownership lookup failed during attach re-entry (session=${sessionId} env=${envId}): ${err}`,
|
|
355
|
+
)
|
|
356
|
+
return dbUnavailable(c)
|
|
357
|
+
}
|
|
358
|
+
if (idempotentOwner) {
|
|
359
|
+
log(
|
|
360
|
+
`[RCS] Attach idempotent re-entry: session=${sessionId} env=${envId} work=${activeItem.id}`,
|
|
361
|
+
)
|
|
362
|
+
return c.json(
|
|
363
|
+
{
|
|
364
|
+
status: 'attached',
|
|
365
|
+
session_id: sessionId,
|
|
366
|
+
work_item_id: activeItem.id,
|
|
367
|
+
rebind: false,
|
|
368
|
+
idempotent: true,
|
|
369
|
+
},
|
|
370
|
+
200,
|
|
371
|
+
)
|
|
372
|
+
}
|
|
350
373
|
}
|
|
351
374
|
|
|
352
375
|
// Authorization: identity-based attach requires manage rights (G9). The
|
|
@@ -482,9 +505,17 @@ async function performAttach(
|
|
|
482
505
|
workItemId = await createWorkItem(envId, sessionId)
|
|
483
506
|
} catch (err) {
|
|
484
507
|
// Roll the rebind back so a failed dispatch doesn't leave the session
|
|
485
|
-
// pointing at an environment that never picked it up
|
|
486
|
-
|
|
487
|
-
|
|
508
|
+
// pointing at an environment that never picked it up — including back to
|
|
509
|
+
// "no environment" when the session had none. A rollback failure must
|
|
510
|
+
// not mask the original dispatch error.
|
|
511
|
+
if (rebind) {
|
|
512
|
+
try {
|
|
513
|
+
storeRebindSessionEnvironment(sessionId, session.environment_id)
|
|
514
|
+
} catch (rollbackErr) {
|
|
515
|
+
logError(
|
|
516
|
+
`[RCS] Attach rollback failed for session ${sessionId}: ${rollbackErr}`,
|
|
517
|
+
)
|
|
518
|
+
}
|
|
488
519
|
}
|
|
489
520
|
return c.json(
|
|
490
521
|
{
|
|
@@ -570,6 +601,9 @@ app.post('/attach-code/exchange', bridgeAuth, async c => {
|
|
|
570
601
|
* wrong layer (P0-1). */
|
|
571
602
|
app.get('/:id/events', bridgeAuth, async c => {
|
|
572
603
|
const caller = resolveAttachCaller(c)
|
|
604
|
+
if (caller.dbUnavailable) {
|
|
605
|
+
return dbUnavailable(c)
|
|
606
|
+
}
|
|
573
607
|
const sessionId =
|
|
574
608
|
resolveExistingSessionId(c.req.param('id')!) ?? c.req.param('id')!
|
|
575
609
|
const session = getSession(sessionId)
|
|
@@ -586,10 +586,13 @@ export function storeListSessionsByEnvironment(envId: string): SessionRecord[] {
|
|
|
586
586
|
* storeUpdateSession's patch type intentionally excludes environment_id
|
|
587
587
|
* because ownership-binding flows must never re-point a session as a side
|
|
588
588
|
* effect. Attach is the one flow with an explicit re-bind contract.
|
|
589
|
+
*
|
|
590
|
+
* environmentId may be null — the rollback path (dispatch failure in
|
|
591
|
+
* performAttach) restores "no environment" when the session had none.
|
|
589
592
|
*/
|
|
590
593
|
export function storeRebindSessionEnvironment(
|
|
591
594
|
sessionId: string,
|
|
592
|
-
environmentId: string,
|
|
595
|
+
environmentId: string | null,
|
|
593
596
|
): boolean {
|
|
594
597
|
const rec = sessions.get(sessionId)
|
|
595
598
|
if (!rec) return false
|
|
@@ -1018,6 +1021,13 @@ export function storeIssueAttachCode(
|
|
|
1018
1021
|
sessionId: string,
|
|
1019
1022
|
ttlMs = 15 * 60 * 1000,
|
|
1020
1023
|
): AttachCodeRecord {
|
|
1024
|
+
// Sweep expired codes: consume() only removes a code when it is actually
|
|
1025
|
+
// presented, so unconsumed expired codes would otherwise accumulate for
|
|
1026
|
+
// the process lifetime. Map deletion during iteration is safe.
|
|
1027
|
+
const now = Date.now()
|
|
1028
|
+
for (const [code, record] of attachCodes) {
|
|
1029
|
+
if (record.expiresAt.getTime() <= now) attachCodes.delete(code)
|
|
1030
|
+
}
|
|
1021
1031
|
const code = `atc_${randomUUID().replace(/-/g, '')}`
|
|
1022
1032
|
const record: AttachCodeRecord = {
|
|
1023
1033
|
code,
|
|
@@ -1045,6 +1055,12 @@ export function storeResetAttachCodes() {
|
|
|
1045
1055
|
attachCodes.clear()
|
|
1046
1056
|
}
|
|
1047
1057
|
|
|
1058
|
+
/** Test helper — number of attach codes currently held (expired codes that
|
|
1059
|
+
* have not been swept yet still count). */
|
|
1060
|
+
export function storeAttachCodesSize(): number {
|
|
1061
|
+
return attachCodes.size
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1048
1064
|
// ---------- Reset (for tests) ----------
|
|
1049
1065
|
|
|
1050
1066
|
export function storeReset() {
|