@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.
@@ -1,19 +1,167 @@
1
1
  import { log, error as logError } from '../../logger'
2
2
  import { Hono } from 'hono'
3
3
  import {
4
+ archiveSession,
5
+ canManageSession,
4
6
  createSession,
5
7
  getSession,
6
- updateSessionTitle,
7
- archiveSession,
8
+ isSessionClosedStatus,
8
9
  resolveExistingSessionId,
10
+ updateSessionTitle,
9
11
  } from '../../services/session'
10
12
  import { createWorkItem } from '../../services/work-dispatch'
11
- import { apiKeyAuth, acceptCliHeaders } from '../../auth/middleware'
12
13
  import { publishSessionEvent } from '../../services/transport'
13
- import { storeBindSession, storeGetEnvironmentOwnership } from '../../store'
14
+ import { loadPersistedEvents } from '../../services/event-persistence'
15
+ import { getEventBus } from '../../transport/event-bus'
16
+ import { acceptCliHeaders, apiKeyAuth } from '../../auth/middleware'
17
+ import { bridgeAuth, resolveUserIdUuid } from '../../auth/bridge-auth'
18
+ import { getDb } from '../../db/sqlite'
19
+ import {
20
+ storeBindSession,
21
+ storeConsumeAttachCode,
22
+ storeGetEnvironment,
23
+ storeGetEnvironmentOwnership,
24
+ storeGetActiveWorkItemBySession,
25
+ storeListSessions,
26
+ storeListSessionsByEnvironment,
27
+ storeRebindSessionEnvironment,
28
+ } from '../../store'
14
29
 
15
30
  const app = new Hono()
16
31
 
32
+ // ---------------------------------------------------------------------------
33
+ // Session Attach — bind an existing session to a CLI-registered environment
34
+ // ---------------------------------------------------------------------------
35
+
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
+ }
66
+ }
67
+
68
+ /**
69
+ * Management check for attach endpoints. Returns an error Response, or null
70
+ * when the caller may manage the session:
71
+ * - rct (SQLite) or in-memory identity → must resolve to a user with manage
72
+ * rights (canManageSession: direct owner, team owner/admin, system admin)
73
+ * - bare admin API key (no identity) → allowed with an audit trail
74
+ * (RCS_API_KEYS are admin credentials — mirrors validateTeamBinding)
75
+ */
76
+ function ensureAttachAccess(
77
+ c: import('hono').Context,
78
+ caller: AttachCaller,
79
+ sessionId: string,
80
+ ): Response | null {
81
+ if (caller.uuid) {
82
+ if (!canManageSession(sessionId, caller.uuid, getDb())) {
83
+ return c.json(
84
+ {
85
+ error: {
86
+ type: 'forbidden',
87
+ message: 'Not allowed to manage this session',
88
+ },
89
+ },
90
+ 403,
91
+ )
92
+ }
93
+ return null
94
+ }
95
+ if (!caller.hasIdentity && caller.isApiAdmin) {
96
+ console.log(`[audit] Bare admin API key attached session ${sessionId}`)
97
+ return null
98
+ }
99
+ return c.json(
100
+ {
101
+ error: {
102
+ type: caller.hasIdentity ? 'forbidden' : 'unauthorized',
103
+ message: caller.hasIdentity
104
+ ? 'Identity does not resolve to a user'
105
+ : 'User identity required to attach sessions',
106
+ },
107
+ },
108
+ caller.hasIdentity ? 403 : 401,
109
+ )
110
+ }
111
+
112
+ /**
113
+ * Occupancy check shared by POST /:id/attach and GET /attachable.
114
+ * A session is occupied when:
115
+ * 1. A non-completed work item exists on a DIFFERENT, STILL-ACTIVE
116
+ * environment (a live worker holds the lease). A work item on a
117
+ * dead/deregistered env does NOT occupy: a crashed attach CLI's acked
118
+ * item would otherwise block re-attach forever (deregisterEnvironment
119
+ * completes that env's open items too, but a kill -9 that skips the
120
+ * deregister leaves the item behind until the server restarts — the
121
+ * env-status check is the recovery path).
122
+ * 2. No work item remains but the session is mid-turn (running /
123
+ * requires_action) on a live, active, non-ACP environment.
124
+ * An active work item on the TARGET environment is not occupied — that is
125
+ * the idempotent re-entry path (CLI retry after a network blip).
126
+ */
127
+ function isSessionOccupied(
128
+ sessionId: string,
129
+ targetEnvId: string | null,
130
+ ): boolean {
131
+ const active = storeGetActiveWorkItemBySession(sessionId)
132
+ if (active) {
133
+ if (active.environmentId === targetEnvId) return false
134
+ const holdingEnv = storeGetEnvironment(active.environmentId)
135
+ // No env record / not active → the lease holder is gone; the session is
136
+ // free to attach.
137
+ if (!holdingEnv || holdingEnv.status !== 'active') return false
138
+ return true
139
+ }
140
+ const session = getSession(sessionId)
141
+ if (!session) return false
142
+ if (session.status !== 'running' && session.status !== 'requires_action') {
143
+ return false
144
+ }
145
+ if (!session.environment_id || session.environment_id === targetEnvId) {
146
+ return false
147
+ }
148
+ const curEnv = storeGetEnvironment(session.environment_id)
149
+ return !!curEnv && curEnv.status === 'active' && curEnv.workerType !== 'acp'
150
+ }
151
+
152
+ /** Shape returned by GET /attachable and consumed by the CLI picker. */
153
+ function toAttachableEntry(session: ReturnType<typeof getSession>) {
154
+ 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),
162
+ }
163
+ }
164
+
17
165
  /** POST /v1/sessions — Create session.
18
166
  * Used by replBridge/ACP workers to auto-create their initial session.
19
167
  * Session ownership: user owner = env.owner_user_id, team owner = env.team_id
@@ -84,6 +232,412 @@ app.post('/', acceptCliHeaders, apiKeyAuth, async c => {
84
232
  return c.json(session, 200)
85
233
  })
86
234
 
235
+ /** GET /v1/sessions/attachable — List sessions the caller can attach to.
236
+ * Must be registered BEFORE GET /:id so "attachable" is not captured as an
237
+ * id. Visibility = canManageSession (direct owner, team owner/admin, system
238
+ * admin); bare admin API key sees all. ACP sessions are excluded — their
239
+ * transport is bound to the acp-link WS and cannot be driven by a bridge
240
+ * worker. */
241
+ app.get('/attachable', bridgeAuth, async c => {
242
+ const caller = resolveAttachCaller(c)
243
+ if (caller.uuid === null && caller.hasIdentity && !caller.isApiAdmin) {
244
+ return c.json(
245
+ {
246
+ error: {
247
+ type: 'forbidden',
248
+ message: 'Identity does not resolve to a user',
249
+ },
250
+ },
251
+ 403,
252
+ )
253
+ }
254
+ if (caller.uuid === null && !caller.hasIdentity && !caller.isApiAdmin) {
255
+ return c.json(
256
+ { error: { type: 'unauthorized', message: 'User identity required' } },
257
+ 401,
258
+ )
259
+ }
260
+
261
+ const db = getDb()
262
+ const attachable = storeListSessions()
263
+ .filter(s => !isSessionClosedStatus(s.status))
264
+ .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)))
269
+
270
+ return c.json(attachable, 200)
271
+ })
272
+
273
+ /** POST /v1/sessions/:id/attach — Bind an existing session to a CLI
274
+ * environment (Session Attach). Re-points session.environment_id (ownership
275
+ * in session_owners is NEVER touched) and dispatches a work item so the
276
+ * polling worker picks the session up through the standard work path.
277
+ * Idempotent: an active work item on the same environment returns 200 with
278
+ * the existing binding instead of 409 (CLI network retries). */
279
+ app.post('/:id/attach', bridgeAuth, async c => {
280
+ const caller = resolveAttachCaller(c)
281
+ const sessionId =
282
+ resolveExistingSessionId(c.req.param('id')!) ?? c.req.param('id')!
283
+ const body = await c.req.json().catch(() => ({}) as Record<string, unknown>)
284
+ const envId =
285
+ typeof body.environment_id === 'string' ? body.environment_id : undefined
286
+ if (!envId) {
287
+ return c.json(
288
+ {
289
+ error: {
290
+ type: 'bad_request',
291
+ message: 'environment_id is required',
292
+ },
293
+ },
294
+ 400,
295
+ )
296
+ }
297
+ return performAttach(c, {
298
+ sessionId,
299
+ envId,
300
+ caller,
301
+ skipAccessCheck: false,
302
+ })
303
+ })
304
+
305
+ /**
306
+ * Core attach logic shared by POST /:id/attach (identity-based) and
307
+ * POST /attach-code/exchange (code-based — the one-time code replaces the
308
+ * caller's own manage rights, everything else is identical).
309
+ */
310
+ async function performAttach(
311
+ c: import('hono').Context,
312
+ opts: {
313
+ sessionId: string
314
+ envId: string
315
+ caller: AttachCaller
316
+ skipAccessCheck: boolean
317
+ },
318
+ ): Promise<Response> {
319
+ const { sessionId, envId, caller, skipAccessCheck } = opts
320
+ const session = getSession(sessionId)
321
+ if (!session) {
322
+ return c.json(
323
+ { error: { type: 'not_found', message: 'Session not found' } },
324
+ 404,
325
+ )
326
+ }
327
+
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.
335
+ const activeItem = storeGetActiveWorkItemBySession(sessionId)
336
+ 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
+ )
350
+ }
351
+
352
+ // Authorization: identity-based attach requires manage rights (G9). The
353
+ // code-based path skips this — presenting a valid unconsumed code IS the
354
+ // authorization (the code was minted by a session manager).
355
+ if (!skipAccessCheck) {
356
+ const accessError = ensureAttachAccess(c, caller, sessionId)
357
+ if (accessError) return accessError
358
+ }
359
+
360
+ if (isSessionClosedStatus(session.status)) {
361
+ return c.json(
362
+ {
363
+ error: {
364
+ type: 'session_closed',
365
+ message: `Session is ${session.status}`,
366
+ },
367
+ },
368
+ 409,
369
+ )
370
+ }
371
+
372
+ // ACP sessions are transport-bound to the acp-link WebSocket.
373
+ if (session.source === 'acp') {
374
+ return c.json(
375
+ {
376
+ error: {
377
+ type: 'acp_session',
378
+ message: 'ACP sessions cannot be attached to a bridge worker',
379
+ },
380
+ },
381
+ 409,
382
+ )
383
+ }
384
+
385
+ const env = storeGetEnvironment(envId)
386
+ if (!env || env.status === 'deregistered') {
387
+ return c.json(
388
+ { error: { type: 'not_found', message: 'Environment not found' } },
389
+ 404,
390
+ )
391
+ }
392
+ if (env.status !== 'active') {
393
+ return c.json(
394
+ {
395
+ error: {
396
+ type: 'environment_inactive',
397
+ message: `Environment is ${env.status}`,
398
+ },
399
+ },
400
+ 409,
401
+ )
402
+ }
403
+ if (env.workerType === 'acp') {
404
+ return c.json(
405
+ {
406
+ error: {
407
+ type: 'acp_environment',
408
+ message: 'ACP environments manage their own sessions',
409
+ },
410
+ },
411
+ 409,
412
+ )
413
+ }
414
+
415
+ // Environment ownership: the attaching worker must be driving an env it
416
+ // owns (the env it just registered). Admin may attach on behalf.
417
+ const ownership = storeGetEnvironmentOwnership(envId)
418
+ if (!ownership?.ownerUserId) {
419
+ return c.json(
420
+ {
421
+ error: {
422
+ type: 'environment_unowned',
423
+ message:
424
+ 'Environment has no owner yet; claim it via the printed URL before attaching sessions',
425
+ },
426
+ },
427
+ 409,
428
+ )
429
+ }
430
+ if (!caller.isApiAdmin && ownership.ownerUserId !== caller.uuid) {
431
+ return c.json(
432
+ {
433
+ error: {
434
+ type: 'forbidden',
435
+ message: 'Cannot attach to an environment owned by another user',
436
+ },
437
+ },
438
+ 403,
439
+ )
440
+ }
441
+
442
+ // In-process (replBridge) workers serve exactly one session at a time.
443
+ if (env.workerType === 'repl') {
444
+ const openOthers = storeListSessionsByEnvironment(envId).filter(
445
+ s => s.id !== sessionId && !isSessionClosedStatus(s.status),
446
+ )
447
+ if (openOthers.length > 0) {
448
+ return c.json(
449
+ {
450
+ error: {
451
+ type: 'worker_at_capacity',
452
+ message:
453
+ '该 worker 为 in-process 模式(slz 默认),只支持单会话。请用 `slz rc` 启动 spawn 模式 worker 支持多会话。',
454
+ },
455
+ },
456
+ 409,
457
+ )
458
+ }
459
+ }
460
+
461
+ // Occupancy: a live worker elsewhere holds the session. Same-environment
462
+ // re-entry was already handled above (before authorization).
463
+ if (isSessionOccupied(sessionId, envId)) {
464
+ return c.json(
465
+ {
466
+ error: {
467
+ type: 'session_busy',
468
+ message: 'Session is being driven by another worker',
469
+ },
470
+ },
471
+ 409,
472
+ )
473
+ }
474
+
475
+ const rebind = session.environment_id !== envId
476
+ if (rebind) {
477
+ storeRebindSessionEnvironment(sessionId, envId)
478
+ }
479
+
480
+ let workItemId: string
481
+ try {
482
+ workItemId = await createWorkItem(envId, sessionId)
483
+ } catch (err) {
484
+ // 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)
488
+ }
489
+ return c.json(
490
+ {
491
+ error: {
492
+ type: 'work_dispatch_failed',
493
+ message: (err as Error).message,
494
+ },
495
+ },
496
+ 409,
497
+ )
498
+ }
499
+
500
+ log(
501
+ `[RCS] Session ${sessionId} attached to env=${envId} (rebind=${rebind} work=${workItemId})`,
502
+ )
503
+ return c.json(
504
+ {
505
+ status: 'attached',
506
+ session_id: sessionId,
507
+ work_item_id: workItemId,
508
+ rebind,
509
+ },
510
+ 200,
511
+ )
512
+ }
513
+
514
+ /** POST /v1/sessions/attach-code/exchange — Redeem a one-time attach code.
515
+ * The code was minted by a session manager (POST /web/sessions/:id/attach-code)
516
+ * and IS the authorization: the CLI holding it needs no manage rights of
517
+ * its own (typical for shared admin API keys that can't enumerate
518
+ * sessions). Single-use — the code is consumed on success AND on any
519
+ * validation failure after redemption to prevent replay probing. */
520
+ app.post('/attach-code/exchange', bridgeAuth, async c => {
521
+ const caller = resolveAttachCaller(c)
522
+ const body = await c.req.json().catch(() => ({}) as Record<string, unknown>)
523
+ const code = typeof body.code === 'string' ? body.code.trim() : ''
524
+ const envId =
525
+ typeof body.environment_id === 'string' ? body.environment_id : undefined
526
+ if (!code || !envId) {
527
+ return c.json(
528
+ {
529
+ error: {
530
+ type: 'bad_request',
531
+ message: 'code and environment_id are required',
532
+ },
533
+ },
534
+ 400,
535
+ )
536
+ }
537
+
538
+ const record = storeConsumeAttachCode(code)
539
+ if (!record) {
540
+ return c.json(
541
+ {
542
+ error: {
543
+ type: 'invalid_attach_code',
544
+ message: 'Attach code is unknown, expired, or already used',
545
+ },
546
+ },
547
+ 403,
548
+ )
549
+ }
550
+
551
+ return performAttach(c, {
552
+ sessionId: record.sessionId,
553
+ envId,
554
+ caller,
555
+ skipAccessCheck: true,
556
+ })
557
+ })
558
+
559
+ /** GET /v1/sessions/:id/events — Historical events for session (bridge auth).
560
+ * CLI-side hydration for Session Attach: merges the durable SQLite log with
561
+ * the in-memory bus window (same merge as the web /history route), deduped
562
+ * by id, chronological.
563
+ *
564
+ * Access: session management rights (canManageSession), a bare admin API
565
+ * key, OR ownership of the environment the session is currently bound to.
566
+ * The env-owner relaxation is what makes wait-mode hydration work: a
567
+ * team-admin binds a session to someone else's CLI env, and that CLI must
568
+ * read the history to hydrate — it already sees the live traffic through
569
+ * the work-item ingress token, so refusing only the history would gate the
570
+ * wrong layer (P0-1). */
571
+ app.get('/:id/events', bridgeAuth, async c => {
572
+ const caller = resolveAttachCaller(c)
573
+ const sessionId =
574
+ resolveExistingSessionId(c.req.param('id')!) ?? c.req.param('id')!
575
+ const session = getSession(sessionId)
576
+ if (!session) {
577
+ return c.json(
578
+ { error: { type: 'not_found', message: 'Session not found' } },
579
+ 404,
580
+ )
581
+ }
582
+
583
+ const db = getDb()
584
+ const manages =
585
+ (caller.uuid !== null && canManageSession(sessionId, caller.uuid, db)) ||
586
+ (!caller.hasIdentity && caller.isApiAdmin)
587
+ let envOwned = false
588
+ if (
589
+ !manages &&
590
+ caller.uuid !== null &&
591
+ session.environment_id !== null &&
592
+ session.environment_id !== undefined
593
+ ) {
594
+ const ownership = storeGetEnvironmentOwnership(session.environment_id)
595
+ envOwned = ownership?.ownerUserId === caller.uuid
596
+ }
597
+ if (!manages && !envOwned) {
598
+ return c.json(
599
+ {
600
+ error: {
601
+ type: caller.hasIdentity ? 'forbidden' : 'unauthorized',
602
+ message: caller.hasIdentity
603
+ ? 'Not allowed to read this session'
604
+ : 'User identity required to read session events',
605
+ },
606
+ },
607
+ caller.hasIdentity ? 403 : 401,
608
+ )
609
+ }
610
+
611
+ const persisted = loadPersistedEvents(sessionId)
612
+ const inMemory = getEventBus(sessionId).getEventsSince(0)
613
+ const byId = new Map<string, (typeof persisted)[number]>()
614
+ for (const event of persisted) byId.set(event.id, event)
615
+ for (const event of inMemory) {
616
+ if (!byId.has(event.id)) byId.set(event.id, event)
617
+ }
618
+ const events = [...byId.values()].sort(
619
+ (a, b) => a.createdAt - b.createdAt || a.seqNum - b.seqNum,
620
+ )
621
+ // models_state fallback — mirrors the web history route so a CLI attaching
622
+ // right after a /clear still receives the model selector snapshot.
623
+ if (!events.some(e => e.type === 'models_state')) {
624
+ const snapshot = getEventBus(sessionId).modelsStateSnapshot
625
+ if (snapshot) {
626
+ const lastCreated = events[events.length - 1]?.createdAt ?? Date.now()
627
+ events.push({
628
+ id: `models_state_snapshot_${sessionId}`,
629
+ sessionId,
630
+ seqNum: 0,
631
+ type: 'models_state',
632
+ payload: { models_state: snapshot },
633
+ direction: 'inbound',
634
+ createdAt: lastCreated + 1,
635
+ })
636
+ }
637
+ }
638
+ return c.json({ events }, 200)
639
+ })
640
+
87
641
  /** GET /v1/sessions/:id — Get session */
88
642
  app.get('/:id', acceptCliHeaders, apiKeyAuth, async c => {
89
643
  const sessionId =
@@ -29,6 +29,7 @@ import {
29
29
  storeGetEnvironment,
30
30
  storeGetSessionWorker,
31
31
  storeGetActiveWorkItemBySession,
32
+ storeIssueAttachCode,
32
33
  storeListSessionsByEnvironment,
33
34
  } from '../../store'
34
35
  import { createWorkItem, stopWork } from '../../services/work-dispatch'
@@ -195,6 +196,72 @@ app.get('/sessions/all', sessionAuth, async c => {
195
196
  return c.json(sessions, 200)
196
197
  })
197
198
 
199
+ /** POST /web/sessions/:id/attach-code — Mint a one-time attach code (P3).
200
+ * The session manager shows/copies `slz rc --attach <code>`; the CLI
201
+ * redeems it via POST /v1/sessions/attach-code/exchange. The code IS the
202
+ * authorization (15-min TTL, single use) — the CLI needs no manage rights
203
+ * of its own. */
204
+ app.post('/sessions/:id/attach-code', sessionAuth, async c => {
205
+ const uuid = c.get('userId')!
206
+ const sessionId = resolveOwnedWebSessionId(c.req.param('id')!, uuid)
207
+ if (!sessionId) {
208
+ return c.json(
209
+ { error: { type: 'forbidden', message: 'Not your session' } },
210
+ 403,
211
+ )
212
+ }
213
+ if (!canManageSession(sessionId, uuid, getDb())) {
214
+ return c.json(
215
+ {
216
+ error: {
217
+ type: 'forbidden',
218
+ message: 'Only session owner or team admin can mint attach codes',
219
+ },
220
+ },
221
+ 403,
222
+ )
223
+ }
224
+ const session = getSession(sessionId)
225
+ if (!session) {
226
+ return c.json(
227
+ { error: { type: 'not_found', message: 'Session not found' } },
228
+ 404,
229
+ )
230
+ }
231
+ if (isSessionClosedStatus(session.status)) {
232
+ return c.json(
233
+ {
234
+ error: {
235
+ type: 'session_closed',
236
+ message: `Session is ${session.status}`,
237
+ },
238
+ },
239
+ 409,
240
+ )
241
+ }
242
+ if (session.source === 'acp') {
243
+ return c.json(
244
+ {
245
+ error: {
246
+ type: 'acp_session',
247
+ message: 'ACP sessions cannot be attached to a bridge worker',
248
+ },
249
+ },
250
+ 409,
251
+ )
252
+ }
253
+
254
+ const record = storeIssueAttachCode(sessionId)
255
+ return c.json(
256
+ {
257
+ code: record.code,
258
+ expires_at: record.expiresAt.getTime(),
259
+ attach_command: `slz rc --attach ${record.code}`,
260
+ },
261
+ 201,
262
+ )
263
+ })
264
+
198
265
  /** GET /web/sessions/:id — Session detail */
199
266
  app.get('/sessions/:id', sessionAuth, async c => {
200
267
  const uuid = c.get('userId')!