@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,93 +1,24 @@
1
1
  import { Hono } from 'hono'
2
- import type { Context, Next } from 'hono'
3
2
  import { randomUUID } from 'node:crypto'
4
3
  import {
5
4
  registerEnvironment,
6
5
  deregisterEnvironment,
7
6
  reconnectEnvironment,
7
+ updatePollTime,
8
8
  } from '../../services/environment'
9
- import {
10
- apiKeyAuth,
11
- acceptCliHeaders,
12
- extractBearerToken,
13
- } from '../../auth/middleware'
14
- import { validateApiKey } from '../../auth/api-key'
15
- import { resolveToken } from '../../auth/token'
16
- import { resolveSessionToken } from '../../auth/session'
9
+ import { acceptCliHeaders, apiKeyAuth } from '../../auth/middleware'
10
+ import { bridgeAuth, resolveUserIdUuid } from '../../auth/bridge-auth'
17
11
  import { getDb } from '../../db/sqlite'
12
+ import { isSessionClosedStatus } from '../../services/session'
18
13
  import {
19
14
  storeBindEnvSessionsToTeam,
20
15
  storeGetEnvironment,
21
16
  storeGetEnvironmentOwnership,
17
+ storeListSessionsByEnvironment,
22
18
  } from '../../store'
23
19
 
24
20
  const app = new Hono()
25
21
 
26
- /**
27
- * Combined auth for bridge endpoint: accepts session tokens (rct_*),
28
- * in-memory issueToken tokens, or admin API keys. Also falls back to
29
- * apiKeyAuth's X-Username header support for CLI bridges.
30
- */
31
- async function bridgeAuth(c: Context, next: Next) {
32
- const bearer = extractBearerToken(c)
33
- if (bearer) {
34
- // Admin API key
35
- if (validateApiKey(bearer)) {
36
- c.set('userId', c.req.header('X-Username') || c.req.query('username'))
37
- c.set('isApiAdmin', true)
38
- await next()
39
- return
40
- }
41
- // In-memory token
42
- const username = resolveToken(bearer)
43
- if (username) {
44
- c.set('userId', username)
45
- await next()
46
- return
47
- }
48
- // SQLite session token
49
- try {
50
- const db = getDb()
51
- const userId = resolveSessionToken(bearer, db)
52
- if (userId) {
53
- c.set('userId', userId)
54
- await next()
55
- return
56
- }
57
- } catch (err) {
58
- console.error('[bridge] DB error during session token resolution:', err)
59
- return c.json({ error: 'Database unavailable' }, 503)
60
- }
61
- }
62
- // Fall back to apiKeyAuth (handles X-Username + API key from body/header)
63
- return apiKeyAuth(c, next)
64
- }
65
-
66
- /**
67
- * Resolve a userId value (UUID or username) to a users.id UUID.
68
- * Returns null if no matching user exists.
69
- *
70
- * bridgeAuth's three token paths return different userId shapes:
71
- * - resolveSessionToken (rct_* SQLite token) → users.id (UUID)
72
- * - resolveToken (in-memory token) → username (string)
73
- * - validateApiKey + X-Username → X-Username header value (arbitrary string)
74
- *
75
- * Only UUIDs that exist in the users table can become env owners, so a
76
- * client-controlled X-Username value can't create an owner for a
77
- * non-existent user — resolveUserIdUuid is the safety gate.
78
- */
79
- function resolveUserIdUuid(
80
- db: ReturnType<typeof getDb>,
81
- value: string,
82
- ): string | null {
83
- const row = db
84
- .query(
85
- 'SELECT id FROM users WHERE id = $value OR username = $value LIMIT 1',
86
- )
87
- .get({ $value: value }) as { id: string } | null
88
- return row?.id ?? null
89
- }
90
-
91
22
  /** Result of validateTeamBinding — rendered by the route layer. */
92
23
  type TeamBindingCheck =
93
24
  | { ok: true; audit?: string }
@@ -299,6 +230,10 @@ app.post('/bridge', acceptCliHeaders, bridgeAuth, async c => {
299
230
  teamId: teamId || null,
300
231
  claimToken,
301
232
  claimExpiresAt,
233
+ // Session Attach wait mode: worker registered without creating a session,
234
+ // polling for the Web UI to bind one (surfaces as attach_waiting).
235
+ session_mode:
236
+ typeof body.session_mode === 'string' ? body.session_mode : null,
302
237
  })
303
238
 
304
239
  // Link environment to team if X-Team-Id was provided
@@ -342,6 +277,45 @@ app.post('/bridge', acceptCliHeaders, bridgeAuth, async c => {
342
277
  return c.json(result, 200)
343
278
  })
344
279
 
280
+ /** GET /v1/environments/:id/assignment — Session Attach wait-mode poll.
281
+ * A worker registered with session_mode:'attach' polls this endpoint while
282
+ * waiting for the Web UI to bind a session (POST /v1/sessions/:id/attach).
283
+ * Returns the first open, non-ACP session on the environment, or
284
+ * assigned:false to keep waiting. Long-polling is unnecessary — the worker
285
+ * already runs a poll loop cadence for work items. */
286
+ app.get('/:id/assignment', acceptCliHeaders, bridgeAuth, async c => {
287
+ const envId = c.req.param('id')!
288
+ const env = storeGetEnvironment(envId)
289
+ if (!env || env.status !== 'active') {
290
+ return c.json(
291
+ { error: { type: 'not_found', message: 'Environment not found' } },
292
+ 404,
293
+ )
294
+ }
295
+ // The attach-wait worker has no work item in flight, so the work-poll
296
+ // route never refreshes its heartbeat. This poll IS its liveness signal:
297
+ // without touching lastPollAt the disconnect monitor flips the env to
298
+ // disconnected after the idle timeout, assignment starts 404ing, and the
299
+ // CLI silently spins until its 24h deadline (P0-2).
300
+ updatePollTime(envId)
301
+ const assigned = storeListSessionsByEnvironment(envId)
302
+ .filter(s => !isSessionClosedStatus(s.status))
303
+ .filter(s => s.source !== 'acp')
304
+ .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())[0]
305
+ if (!assigned) {
306
+ return c.json({ assigned: false }, 200)
307
+ }
308
+ return c.json(
309
+ {
310
+ assigned: true,
311
+ session_id: assigned.id,
312
+ title: assigned.title,
313
+ status: assigned.status,
314
+ },
315
+ 200,
316
+ )
317
+ })
318
+
345
319
  /** DELETE /v1/environments/bridge/:id — Deregister */
346
320
  app.delete('/bridge/:id', acceptCliHeaders, apiKeyAuth, async c => {
347
321
  const envId = c.req.param('id')!