@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.
- package/dist/cli.js +176 -176
- package/dist/guide/assets/main-Bx1xb7yl.css +1 -0
- package/dist/guide/assets/main-CdOE18lg.js +277 -0
- package/dist/guide/guide-data.json +27 -5
- package/dist/guide/index.html +2 -2
- package/dist/rcs-schema.sql +2 -1
- package/package.json +1 -1
- package/packages/remote-control-server/src/__tests__/attach-code.test.ts +195 -0
- package/packages/remote-control-server/src/__tests__/attach-wait.test.ts +270 -0
- package/packages/remote-control-server/src/__tests__/attach.test.ts +580 -0
- package/packages/remote-control-server/src/auth/bridge-auth.ts +120 -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 +97 -73
- package/packages/remote-control-server/src/routes/v1/sessions.ts +592 -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 +123 -2
- package/packages/remote-control-server/src/types/api.ts +2 -0
- package/dist/guide/assets/main-CSpz9pKf.css +0 -1
- package/dist/guide/assets/main-C_ykjDzn.js +0 -167
|
@@ -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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
} from '../../auth/
|
|
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')!
|