@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.
- package/dist/cli.js +176 -176
- package/dist/guide/guide-data.json +12 -0
- package/dist/rcs-schema.sql +2 -1
- package/package.json +1 -1
- package/packages/remote-control-server/src/__tests__/attach-code.test.ts +183 -0
- package/packages/remote-control-server/src/__tests__/attach-wait.test.ts +213 -0
- package/packages/remote-control-server/src/__tests__/attach.test.ts +521 -0
- package/packages/remote-control-server/src/auth/bridge-auth.ts +76 -0
- package/packages/remote-control-server/src/db/rcs-schema.sql +2 -1
- package/packages/remote-control-server/src/db/sqlite.ts +1 -0
- package/packages/remote-control-server/src/routes/v1/environments.ts +48 -74
- package/packages/remote-control-server/src/routes/v1/sessions.ts +558 -4
- package/packages/remote-control-server/src/routes/web/sessions.ts +67 -0
- package/packages/remote-control-server/src/services/environment.ts +27 -0
- package/packages/remote-control-server/src/store.ts +107 -2
- package/packages/remote-control-server/src/types/api.ts +2 -0
|
@@ -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
|
-
|
|
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')!
|