@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saluzi/saluzi-edu",
3
- "version": "0.2.64",
3
+ "version": "0.2.65",
4
4
  "description": "Saluzi CLI - interactive AI coding assistant in the terminal",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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 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.
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(200)
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 { bridgeAuth, resolveUserIdUuid } from '../../auth/bridge-auth'
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 { bridgeAuth, resolveUserIdUuid } from '../../auth/bridge-auth'
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
- /** Caller identity resolved for attach endpoints. */
37
- type AttachCaller = {
38
- /** Resolved users.id UUID — null for a bare admin API key. */
39
- uuid: string | null
40
- isApiAdmin: boolean
41
- /** True when no identity at all was presented (bare admin API key). */
42
- hasIdentity: boolean
43
- }
44
-
45
- /**
46
- * Resolve the bridge caller identity for attach endpoints.
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: ReturnType<typeof getSession>) {
139
+ function toAttachableEntry(session: SessionRecord) {
154
140
  return {
155
- id: session!.id,
156
- title: session!.title,
157
- status: session!.status,
158
- source: session!.source,
159
- environment_id: session!.environment_id ?? null,
160
- updated_at: session!.updated_at,
161
- busy: isSessionOccupied(session!.id, null),
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.uuid === null && caller.hasIdentity && !caller.isApiAdmin) {
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 db = getDb()
262
- const attachable = storeListSessions()
254
+ const uuid = caller.uuid
255
+ const open = storeListSessions()
263
256
  .filter(s => !isSessionClosedStatus(s.status))
264
257
  .filter(s => s.source !== 'acp')
265
- .filter(
266
- s => caller.uuid === null || canManageSession(s.id, caller.uuid, db),
267
- )
268
- .map(s => toAttachableEntry(getSession(s.id)))
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(attachable, 200)
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 after a network
329
- // blip re-presents the same session+env pair — returning the existing
330
- // binding is not a new grant, so it must not depend on the caller still
331
- // holding manage rights (e.g. a doReconnect re-bind where the bound
332
- // session was assigned by a team admin, or an expired share). The check
333
- // only matches an existing work item on the SAME environment — no state
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
- log(
338
- `[RCS] Attach idempotent re-entry: session=${sessionId} env=${envId} work=${activeItem.id}`,
339
- )
340
- return c.json(
341
- {
342
- status: 'attached',
343
- session_id: sessionId,
344
- work_item_id: activeItem.id,
345
- rebind: false,
346
- idempotent: true,
347
- },
348
- 200,
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
- if (rebind && session.environment_id) {
487
- storeRebindSessionEnvironment(sessionId, session.environment_id)
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() {