@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.
@@ -90,7 +90,8 @@ CREATE TABLE IF NOT EXISTS environments (
90
90
  created_at TEXT NOT NULL,
91
91
  owner_user_id TEXT,
92
92
  claim_token TEXT,
93
- claim_expires_at TEXT
93
+ claim_expires_at TEXT,
94
+ session_mode TEXT
94
95
  );
95
96
 
96
97
  CREATE TABLE IF NOT EXISTS sessions (
@@ -208,6 +208,7 @@ function migrateEnvironmentsColumns(db: Database): void {
208
208
  addIfMissing('owner_user_id', 'TEXT')
209
209
  addIfMissing('claim_token', 'TEXT')
210
210
  addIfMissing('claim_expires_at', 'TEXT')
211
+ addIfMissing('session_mode', 'TEXT')
211
212
  }
212
213
 
213
214
  /**
@@ -1,93 +1,28 @@
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 { acceptCliHeaders, apiKeyAuth } from '../../auth/middleware'
9
10
  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'
11
+ bridgeAuth,
12
+ resolveAttachCaller,
13
+ resolveUserIdUuid,
14
+ } from '../../auth/bridge-auth'
17
15
  import { getDb } from '../../db/sqlite'
16
+ import { isSessionClosedStatus } from '../../services/session'
18
17
  import {
19
18
  storeBindEnvSessionsToTeam,
20
19
  storeGetEnvironment,
21
20
  storeGetEnvironmentOwnership,
21
+ storeListSessionsByEnvironment,
22
22
  } from '../../store'
23
23
 
24
24
  const app = new Hono()
25
25
 
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
26
  /** Result of validateTeamBinding — rendered by the route layer. */
92
27
  type TeamBindingCheck =
93
28
  | { ok: true; audit?: string }
@@ -299,6 +234,10 @@ app.post('/bridge', acceptCliHeaders, bridgeAuth, async c => {
299
234
  teamId: teamId || null,
300
235
  claimToken,
301
236
  claimExpiresAt,
237
+ // Session Attach wait mode: worker registered without creating a session,
238
+ // polling for the Web UI to bind one (surfaces as attach_waiting).
239
+ session_mode:
240
+ typeof body.session_mode === 'string' ? body.session_mode : null,
302
241
  })
303
242
 
304
243
  // Link environment to team if X-Team-Id was provided
@@ -342,6 +281,91 @@ app.post('/bridge', acceptCliHeaders, bridgeAuth, async c => {
342
281
  return c.json(result, 200)
343
282
  })
344
283
 
284
+ /** GET /v1/environments/:id/assignment — Session Attach wait-mode poll.
285
+ * A worker registered with session_mode:'attach' polls this endpoint while
286
+ * waiting for the Web UI to bind a session (POST /v1/sessions/:id/attach).
287
+ * Returns the first open, non-ACP session on the environment, or
288
+ * assigned:false to keep waiting. Long-polling is unnecessary — the worker
289
+ * already runs a poll loop cadence for work items.
290
+ * Access: the env owner (the polling worker itself) or an admin. */
291
+ app.get('/:id/assignment', acceptCliHeaders, bridgeAuth, async c => {
292
+ const envId = c.req.param('id')!
293
+ const env = storeGetEnvironment(envId)
294
+ if (!env || env.status !== 'active') {
295
+ return c.json(
296
+ { error: { type: 'not_found', message: 'Environment not found' } },
297
+ 404,
298
+ )
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
+
345
+ // The attach-wait worker has no work item in flight, so the work-poll
346
+ // route never refreshes its heartbeat. This poll IS its liveness signal:
347
+ // without touching lastPollAt the disconnect monitor flips the env to
348
+ // disconnected after the idle timeout, assignment starts 404ing, and the
349
+ // CLI silently spins until its 24h deadline (P0-2).
350
+ updatePollTime(envId)
351
+ const assigned = storeListSessionsByEnvironment(envId)
352
+ .filter(s => !isSessionClosedStatus(s.status))
353
+ .filter(s => s.source !== 'acp')
354
+ .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())[0]
355
+ if (!assigned) {
356
+ return c.json({ assigned: false }, 200)
357
+ }
358
+ return c.json(
359
+ {
360
+ assigned: true,
361
+ session_id: assigned.id,
362
+ title: assigned.title,
363
+ status: assigned.status,
364
+ },
365
+ 200,
366
+ )
367
+ })
368
+
345
369
  /** DELETE /v1/environments/bridge/:id — Deregister */
346
370
  app.delete('/bridge/:id', acceptCliHeaders, apiKeyAuth, async c => {
347
371
  const envId = c.req.param('id')!