@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.
@@ -0,0 +1,521 @@
1
+ import { describe, test, expect, beforeEach, mock } from 'bun:test'
2
+ import { mockConfigModule } from './helpers/mock-config'
3
+
4
+ // Mock config before any module imports
5
+ mock.module('../config', () => mockConfigModule())
6
+
7
+ import { Hono } from 'hono'
8
+ import type { Database } from 'bun:sqlite'
9
+ import { randomUUID } from 'node:crypto'
10
+ import { tmpdir } from 'node:os'
11
+ import { resolve } from 'node:path'
12
+ import v1Sessions from '../routes/v1/sessions'
13
+ import {
14
+ storeReset,
15
+ storeCreateEnvironment,
16
+ storeGetEnvironment,
17
+ storeRebindSessionEnvironment,
18
+ storeUpdateEnvironment,
19
+ } from '../store'
20
+ import { initDatabase, resetDbSingleton, getDb } from '../db/sqlite'
21
+ import { issueSessionToken } from '../auth/session'
22
+ import { createUserDirectly } from './helpers/fixtures'
23
+ import { createSession } from '../services/session'
24
+ import { storeBindSession } from '../store'
25
+ import { createWorkItem } from '../services/work-dispatch'
26
+
27
+ /**
28
+ * Session Attach endpoints (P1):
29
+ *
30
+ * - POST /v1/sessions/:id/attach — bind an existing session to a CLI env.
31
+ * Ownership via canManageSession (G9), occupancy via work items, env
32
+ * ownership + repl single-session cap enforced, idempotent re-entry.
33
+ * - GET /v1/sessions/attachable — manageable, non-ACP, open sessions.
34
+ * - GET /v1/sessions/:id/events — merged persisted + bus history for CLI
35
+ * hydration (bridge auth, management rights required).
36
+ */
37
+
38
+ function freshDb(): Database {
39
+ try {
40
+ resetDbSingleton()
41
+ } catch {}
42
+ const path = resolve(
43
+ tmpdir(),
44
+ `rcs-attach-${randomUUID().replace(/-/g, '').slice(0, 8)}.db`,
45
+ )
46
+ return initDatabase(path)
47
+ }
48
+
49
+ function createUser(
50
+ db: Database,
51
+ role = 'member',
52
+ ): { id: string; username: string } {
53
+ const username = `user_${randomUUID().slice(0, 6)}`
54
+ const user = createUserDirectly(db, { username, role })
55
+ return { id: user.id, username: user.username }
56
+ }
57
+
58
+ /** Bearer header with a SQLite rct_* token resolving to the users.id. */
59
+ function rctAuth(userId: string): string {
60
+ return `Bearer ${issueSessionToken(userId, getDb()).accessToken}`
61
+ }
62
+
63
+ const ADMIN_KEY = { Authorization: 'Bearer test-api-key' }
64
+ const JSON_HDRS = { 'Content-Type': 'application/json' }
65
+
66
+ function createApp() {
67
+ const app = new Hono()
68
+ app.route('/v1/sessions', v1Sessions)
69
+ return app
70
+ }
71
+
72
+ interface World {
73
+ owner: { id: string; username: string }
74
+ stranger: { id: string; username: string }
75
+ envId: string
76
+ sessionId: string
77
+ }
78
+
79
+ /**
80
+ * Seed: owner user + own env (owner_user_id = owner) + own session bound to
81
+ * the owner and the env.
82
+ */
83
+ function seedWorld(db: Database, envWorkerType = 'repl'): World {
84
+ const owner = createUser(db)
85
+ const stranger = createUser(db)
86
+ const env = storeCreateEnvironment({
87
+ secret: 'test-api-key',
88
+ machineName: 'mac1',
89
+ workerType: envWorkerType,
90
+ ownerUserId: owner.id,
91
+ })
92
+ const session = createSession({
93
+ environment_id: env.id,
94
+ title: 'Existing session',
95
+ source: 'web',
96
+ })
97
+ storeBindSession(session.id, owner.id, 'user')
98
+ return { owner, stranger, envId: env.id, sessionId: session.id }
99
+ }
100
+
101
+ describe('POST /v1/sessions/:id/attach', () => {
102
+ let app: Hono
103
+ let db: Database
104
+
105
+ beforeEach(() => {
106
+ db = freshDb()
107
+ storeReset()
108
+ app = createApp()
109
+ })
110
+
111
+ test('happy path: owner attaches own session to own env → 200 + work item + rebind', async () => {
112
+ const world = seedWorld(db)
113
+ const res = await app.request(`/v1/sessions/${world.sessionId}/attach`, {
114
+ method: 'POST',
115
+ headers: { ...JSON_HDRS, Authorization: rctAuth(world.owner.id) },
116
+ body: JSON.stringify({ environment_id: world.envId }),
117
+ })
118
+ expect(res.status).toBe(200)
119
+ const body = (await res.json()) as {
120
+ status: string
121
+ session_id: string
122
+ work_item_id: string
123
+ rebind: boolean
124
+ }
125
+ expect(body.status).toBe('attached')
126
+ expect(body.session_id).toBe(world.sessionId)
127
+ expect(body.work_item_id).toMatch(/^work_/)
128
+
129
+ // Rebind persisted to SQLite.
130
+ const row = db
131
+ .query('SELECT environment_id FROM sessions WHERE id = $id')
132
+ .get({ $id: world.sessionId }) as { environment_id: string }
133
+ expect(row.environment_id).toBe(world.envId)
134
+ })
135
+
136
+ test('idempotent re-entry: second attach on same env returns the existing work item', async () => {
137
+ const world = seedWorld(db)
138
+ const attach = () =>
139
+ app.request(`/v1/sessions/${world.sessionId}/attach`, {
140
+ method: 'POST',
141
+ headers: { ...JSON_HDRS, Authorization: rctAuth(world.owner.id) },
142
+ body: JSON.stringify({ environment_id: world.envId }),
143
+ })
144
+ const first = await attach()
145
+ expect(first.status).toBe(200)
146
+ const firstBody = (await first.json()) as { work_item_id: string }
147
+
148
+ const second = await attach()
149
+ expect(second.status).toBe(200)
150
+ const secondBody = (await second.json()) as {
151
+ work_item_id: string
152
+ idempotent: boolean
153
+ }
154
+ expect(secondBody.idempotent).toBe(true)
155
+ expect(secondBody.work_item_id).toBe(firstBody.work_item_id)
156
+ })
157
+
158
+ test('session_busy: active work item on a different env → 409', async () => {
159
+ const world = seedWorld(db)
160
+ const otherEnv = storeCreateEnvironment({
161
+ secret: 'test-api-key',
162
+ machineName: 'mac2',
163
+ ownerUserId: world.owner.id,
164
+ })
165
+ // A worker already holds the lease on the other env.
166
+ await createWorkItem(otherEnv.id, world.sessionId)
167
+
168
+ const res = await app.request(`/v1/sessions/${world.sessionId}/attach`, {
169
+ method: 'POST',
170
+ headers: { ...JSON_HDRS, Authorization: rctAuth(world.owner.id) },
171
+ body: JSON.stringify({ environment_id: world.envId }),
172
+ })
173
+ expect(res.status).toBe(409)
174
+ const body = (await res.json()) as { error: { type: string } }
175
+ expect(body.error.type).toBe('session_busy')
176
+ })
177
+
178
+ test('stale lease on a dead env does NOT occupy: crashed attach CLI is re-attachable', async () => {
179
+ const world = seedWorld(db)
180
+ const crashedEnv = storeCreateEnvironment({
181
+ secret: 'test-api-key',
182
+ machineName: 'crashed',
183
+ ownerUserId: world.owner.id,
184
+ })
185
+ // kill -9 before deregister: the acked item stays on the now-dead env.
186
+ // (The session itself is bound to world.envId — seedWorld — so a
187
+ // successful attach here proves the dead-env lease was not honored.)
188
+ await createWorkItem(crashedEnv.id, world.sessionId)
189
+ storeUpdateEnvironment(crashedEnv.id, { status: 'disconnected' })
190
+
191
+ const res = await app.request(`/v1/sessions/${world.sessionId}/attach`, {
192
+ method: 'POST',
193
+ headers: { ...JSON_HDRS, Authorization: rctAuth(world.owner.id) },
194
+ body: JSON.stringify({ environment_id: world.envId }),
195
+ })
196
+ expect(res.status).toBe(200)
197
+ const body = (await res.json()) as {
198
+ status: string
199
+ rebind: boolean
200
+ idempotent: boolean
201
+ }
202
+ expect(body.status).toBe('attached')
203
+ // Session already points at the target env — no rebind, and a FRESH work
204
+ // item (not the same-env idempotent path, which would return the dead
205
+ // env's stale item).
206
+ expect(body.rebind).toBe(false)
207
+ expect(body.idempotent).toBeUndefined()
208
+ })
209
+
210
+ test('idempotent re-entry bypasses manage-rights check (doReconnect re-bind)', async () => {
211
+ // Wait-mode scenario: a team admin bound the session to the caller's env;
212
+ // the caller itself has no manage rights. The re-bind after env loss must
213
+ // still succeed via the same-env idempotent path.
214
+ const world = seedWorld(db)
215
+ const admin = createUser(db, 'admin')
216
+ // Attach once WITH rights (owner) so a work item exists on world.envId.
217
+ const first = await app.request(`/v1/sessions/${world.sessionId}/attach`, {
218
+ method: 'POST',
219
+ headers: { ...JSON_HDRS, Authorization: rctAuth(world.owner.id) },
220
+ body: JSON.stringify({ environment_id: world.envId }),
221
+ })
222
+ expect(first.status).toBe(200)
223
+ void admin
224
+
225
+ // Re-entry as the STRANGER (no manage rights) but same env → 200.
226
+ // Note: env ownership also matches the stranger? No — the env belongs to
227
+ // owner; but the idempotent check runs BEFORE both the access and env
228
+ // ownership checks, so the re-entry returns the existing binding.
229
+ const second = await app.request(`/v1/sessions/${world.sessionId}/attach`, {
230
+ method: 'POST',
231
+ headers: { ...JSON_HDRS, Authorization: rctAuth(world.stranger.id) },
232
+ body: JSON.stringify({ environment_id: world.envId }),
233
+ })
234
+ expect(second.status).toBe(200)
235
+ const body = (await second.json()) as { idempotent: boolean }
236
+ expect(body.idempotent).toBe(true)
237
+ })
238
+
239
+ test("stranger's rct token → 403 (canManageSession, G9)", async () => {
240
+ const world = seedWorld(db)
241
+ const res = await app.request(`/v1/sessions/${world.sessionId}/attach`, {
242
+ method: 'POST',
243
+ headers: { ...JSON_HDRS, Authorization: rctAuth(world.stranger.id) },
244
+ body: JSON.stringify({ environment_id: world.envId }),
245
+ })
246
+ expect(res.status).toBe(403)
247
+ })
248
+
249
+ test('env owned by another user → 403 even for the session owner', async () => {
250
+ const world = seedWorld(db)
251
+ const foreignEnv = storeCreateEnvironment({
252
+ secret: 'test-api-key',
253
+ machineName: 'foreign',
254
+ ownerUserId: world.stranger.id,
255
+ })
256
+ const res = await app.request(`/v1/sessions/${world.sessionId}/attach`, {
257
+ method: 'POST',
258
+ headers: { ...JSON_HDRS, Authorization: rctAuth(world.owner.id) },
259
+ body: JSON.stringify({ environment_id: foreignEnv.id }),
260
+ })
261
+ expect(res.status).toBe(403)
262
+ })
263
+
264
+ test('unowned env → 409 environment_unowned (claim flow pending)', async () => {
265
+ const owner = createUser(db)
266
+ const env = storeCreateEnvironment({
267
+ secret: 'test-api-key',
268
+ machineName: 'unclaimed',
269
+ })
270
+ const session = createSession({
271
+ environment_id: null,
272
+ title: 'Web session',
273
+ source: 'web',
274
+ })
275
+ storeBindSession(session.id, owner.id, 'user')
276
+
277
+ const res = await app.request(`/v1/sessions/${session.id}/attach`, {
278
+ method: 'POST',
279
+ headers: { ...JSON_HDRS, Authorization: rctAuth(owner.id) },
280
+ body: JSON.stringify({ environment_id: env.id }),
281
+ })
282
+ expect(res.status).toBe(409)
283
+ const body = (await res.json()) as { error: { type: string } }
284
+ expect(body.error.type).toBe('environment_unowned')
285
+ })
286
+
287
+ test('archived session → 409 session_closed', async () => {
288
+ const world = seedWorld(db)
289
+ const { archiveSession } = await import('../services/session')
290
+ archiveSession(world.sessionId)
291
+
292
+ const res = await app.request(`/v1/sessions/${world.sessionId}/attach`, {
293
+ method: 'POST',
294
+ headers: { ...JSON_HDRS, Authorization: rctAuth(world.owner.id) },
295
+ body: JSON.stringify({ environment_id: world.envId }),
296
+ })
297
+ expect(res.status).toBe(409)
298
+ const body = (await res.json()) as { error: { type: string } }
299
+ expect(body.error.type).toBe('session_closed')
300
+ })
301
+
302
+ test('ACP session → 409 acp_session', async () => {
303
+ const world = seedWorld(db)
304
+ // ACP env with its own auto-created session.
305
+ const acpEnv = storeCreateEnvironment({
306
+ secret: 'test-api-key',
307
+ machineName: 'acp-agent',
308
+ workerType: 'acp',
309
+ ownerUserId: world.owner.id,
310
+ })
311
+ const acpSession = createSession({
312
+ environment_id: acpEnv.id,
313
+ title: 'ACP Agent',
314
+ source: 'acp',
315
+ })
316
+ storeBindSession(acpSession.id, world.owner.id, 'user')
317
+
318
+ const res = await app.request(`/v1/sessions/${acpSession.id}/attach`, {
319
+ method: 'POST',
320
+ headers: { ...JSON_HDRS, Authorization: rctAuth(world.owner.id) },
321
+ body: JSON.stringify({ environment_id: world.envId }),
322
+ })
323
+ expect(res.status).toBe(409)
324
+ const body = (await res.json()) as { error: { type: string } }
325
+ expect(body.error.type).toBe('acp_session')
326
+ })
327
+
328
+ test('repl env at capacity → 409 worker_at_capacity', async () => {
329
+ const world = seedWorld(db)
330
+ // A second open session already lives on the repl env.
331
+ const other = createSession({
332
+ environment_id: world.envId,
333
+ title: 'Other session',
334
+ source: 'web',
335
+ })
336
+ storeBindSession(other.id, world.owner.id, 'user')
337
+
338
+ const res = await app.request(`/v1/sessions/${world.sessionId}/attach`, {
339
+ method: 'POST',
340
+ headers: { ...JSON_HDRS, Authorization: rctAuth(world.owner.id) },
341
+ body: JSON.stringify({ environment_id: world.envId }),
342
+ })
343
+ expect(res.status).toBe(409)
344
+ const body = (await res.json()) as { error: { type: string } }
345
+ expect(body.error.type).toBe('worker_at_capacity')
346
+ })
347
+
348
+ test('missing environment_id → 400', async () => {
349
+ const world = seedWorld(db)
350
+ const res = await app.request(`/v1/sessions/${world.sessionId}/attach`, {
351
+ method: 'POST',
352
+ headers: { ...JSON_HDRS, Authorization: rctAuth(world.owner.id) },
353
+ body: JSON.stringify({}),
354
+ })
355
+ expect(res.status).toBe(400)
356
+ })
357
+
358
+ test('unknown session id → 404', async () => {
359
+ const world = seedWorld(db)
360
+ const res = await app.request(`/v1/sessions/ses_does_not_exist/attach`, {
361
+ method: 'POST',
362
+ headers: { ...JSON_HDRS, Authorization: rctAuth(world.owner.id) },
363
+ body: JSON.stringify({ environment_id: world.envId }),
364
+ })
365
+ expect(res.status).toBe(404)
366
+ })
367
+ })
368
+
369
+ describe('GET /v1/sessions/attachable', () => {
370
+ let app: Hono
371
+ let db: Database
372
+
373
+ beforeEach(() => {
374
+ db = freshDb()
375
+ storeReset()
376
+ app = createApp()
377
+ })
378
+
379
+ test('returns own manageable sessions, excludes ACP and closed', async () => {
380
+ const world = seedWorld(db)
381
+ // ACP session on an acp env — must be excluded.
382
+ const acpEnv = storeCreateEnvironment({
383
+ secret: 'test-api-key',
384
+ workerType: 'acp',
385
+ ownerUserId: world.owner.id,
386
+ })
387
+ createSession({ environment_id: acpEnv.id, title: 'ACP', source: 'acp' })
388
+ // Stranger's session — must be excluded.
389
+ createSession({
390
+ environment_id: null,
391
+ title: 'Stranger session',
392
+ source: 'web',
393
+ })
394
+
395
+ const res = await app.request('/v1/sessions/attachable', {
396
+ headers: { Authorization: rctAuth(world.owner.id) },
397
+ })
398
+ expect(res.status).toBe(200)
399
+ const list = (await res.json()) as Array<{ id: string; source: string }>
400
+ expect(list).toHaveLength(1)
401
+ expect(list[0]!.id).toBe(world.sessionId)
402
+ expect(list[0]!.source).toBe('web')
403
+ })
404
+
405
+ test('bare admin API key sees all non-ACP sessions', async () => {
406
+ const world = seedWorld(db)
407
+ const res = await app.request('/v1/sessions/attachable', {
408
+ headers: { ...ADMIN_KEY },
409
+ })
410
+ expect(res.status).toBe(200)
411
+ const list = (await res.json()) as Array<{ id: string }>
412
+ expect(list.some(s => s.id === world.sessionId)).toBe(true)
413
+ })
414
+ })
415
+
416
+ describe('GET /v1/sessions/:id/events (bridge auth)', () => {
417
+ let app: Hono
418
+ let db: Database
419
+
420
+ beforeEach(() => {
421
+ db = freshDb()
422
+ storeReset()
423
+ app = createApp()
424
+ })
425
+
426
+ test('merges bus events chronologically for the session manager', async () => {
427
+ const world = seedWorld(db)
428
+ const { publishSessionEvent: publish } = await import(
429
+ '../services/transport'
430
+ )
431
+ publish(
432
+ world.sessionId,
433
+ 'user',
434
+ {
435
+ type: 'user',
436
+ uuid: 'u-1',
437
+ message: { role: 'user', content: 'hello' },
438
+ },
439
+ 'inbound',
440
+ )
441
+ publish(
442
+ world.sessionId,
443
+ 'assistant',
444
+ {
445
+ type: 'assistant',
446
+ uuid: 'a-1',
447
+ message: { role: 'assistant', content: [{ type: 'text', text: 'hi' }] },
448
+ },
449
+ 'inbound',
450
+ )
451
+
452
+ const res = await app.request(`/v1/sessions/${world.sessionId}/events`, {
453
+ headers: { Authorization: rctAuth(world.owner.id) },
454
+ })
455
+ expect(res.status).toBe(200)
456
+ const body = (await res.json()) as {
457
+ events: Array<{ type: string; payload: { uuid?: string } }>
458
+ }
459
+ const kinds = body.events.map(e => e.type)
460
+ expect(kinds).toContain('user')
461
+ expect(kinds).toContain('assistant')
462
+ const userEvent = body.events.find(e => e.type === 'user')
463
+ expect(userEvent?.payload.uuid).toBe('u-1')
464
+ })
465
+
466
+ test("stranger's rct token → 403", async () => {
467
+ const world = seedWorld(db)
468
+ const res = await app.request(`/v1/sessions/${world.sessionId}/events`, {
469
+ headers: { Authorization: rctAuth(world.stranger.id) },
470
+ })
471
+ expect(res.status).toBe(403)
472
+ })
473
+
474
+ test('env-owner relaxation: caller owning the bound env can read events (wait-mode hydration)', async () => {
475
+ // P0-1b: a team admin binds a session to someone else's CLI env; that
476
+ // CLI (no manage rights on the session) must still hydrate.
477
+ const world = seedWorld(db)
478
+ const cliOwner = createUser(db)
479
+ // Re-point the session to the CLI owner's env via attach (admin mints).
480
+ const cliEnv = storeCreateEnvironment({
481
+ secret: 'test-api-key',
482
+ machineName: 'cli-worker',
483
+ ownerUserId: cliOwner.id,
484
+ })
485
+ const { storeRebindSessionEnvironment: rebind } = await import('../store')
486
+ rebind(world.sessionId, cliEnv.id)
487
+
488
+ const res = await app.request(`/v1/sessions/${world.sessionId}/events`, {
489
+ headers: { Authorization: rctAuth(cliOwner.id) },
490
+ })
491
+ expect(res.status).toBe(200)
492
+ })
493
+ })
494
+
495
+ describe('storeRebindSessionEnvironment', () => {
496
+ test('re-points the session and persists the change', () => {
497
+ const db = freshDb()
498
+ storeReset()
499
+ const world = seedWorld(db)
500
+ const newEnv = storeCreateEnvironment({
501
+ secret: 'test-api-key',
502
+ machineName: 'mac2',
503
+ ownerUserId: world.owner.id,
504
+ })
505
+
506
+ expect(storeRebindSessionEnvironment(world.sessionId, newEnv.id)).toBe(true)
507
+
508
+ const record = storeGetEnvironment(newEnv.id)
509
+ expect(record).toBeDefined()
510
+ const row = db
511
+ .query('SELECT environment_id FROM sessions WHERE id = $id')
512
+ .get({ $id: world.sessionId }) as { environment_id: string }
513
+ expect(row.environment_id).toBe(newEnv.id)
514
+ })
515
+
516
+ test('returns false for unknown sessions', () => {
517
+ const db = freshDb()
518
+ storeReset()
519
+ expect(storeRebindSessionEnvironment('ses_missing', 'env_x')).toBe(false)
520
+ })
521
+ })
@@ -0,0 +1,76 @@
1
+ import type { Context, Next } from 'hono'
2
+ import { apiKeyAuth, extractBearerToken } from './middleware'
3
+ import { validateApiKey } from './api-key'
4
+ import { resolveToken } from './token'
5
+ import { resolveSessionToken } from './session'
6
+ import { getDb } from '../db/sqlite'
7
+
8
+ /**
9
+ * Combined auth for bridge endpoints: accepts session tokens (rct_*),
10
+ * in-memory issueToken tokens, or admin API keys. Also falls back to
11
+ * apiKeyAuth's X-Username header support for CLI bridges.
12
+ *
13
+ * Extracted from routes/v1/environments.ts so the v1 sessions attach
14
+ * endpoints (POST /:id/attach, GET /attachable, GET /:id/events) share the
15
+ * exact same identity semantics as environment registration — attach must
16
+ * resolve the caller to the same identity the env was registered under.
17
+ */
18
+ export async function bridgeAuth(c: Context, next: Next) {
19
+ const bearer = extractBearerToken(c)
20
+ if (bearer) {
21
+ // Admin API key
22
+ if (validateApiKey(bearer)) {
23
+ c.set('userId', c.req.header('X-Username') || c.req.query('username'))
24
+ c.set('isApiAdmin', true)
25
+ await next()
26
+ return
27
+ }
28
+ // In-memory token
29
+ const username = resolveToken(bearer)
30
+ if (username) {
31
+ c.set('userId', username)
32
+ await next()
33
+ return
34
+ }
35
+ // SQLite session token
36
+ try {
37
+ const db = getDb()
38
+ const userId = resolveSessionToken(bearer, db)
39
+ if (userId) {
40
+ c.set('userId', userId)
41
+ await next()
42
+ return
43
+ }
44
+ } catch (err) {
45
+ console.error('[bridge] DB error during session token resolution:', err)
46
+ return c.json({ error: 'Database unavailable' }, 503)
47
+ }
48
+ }
49
+ // Fall back to apiKeyAuth (handles X-Username + API key from body/header)
50
+ return apiKeyAuth(c, next)
51
+ }
52
+
53
+ /**
54
+ * Resolve a userId value (UUID or username) to a users.id UUID.
55
+ * Returns null if no matching user exists.
56
+ *
57
+ * bridgeAuth's three token paths return different userId shapes:
58
+ * - resolveSessionToken (rct_* SQLite token) → users.id (UUID)
59
+ * - resolveToken (in-memory token) → username (string)
60
+ * - validateApiKey + X-Username → X-Username header value (arbitrary string)
61
+ *
62
+ * Only UUIDs that exist in the users table can become env owners / session
63
+ * managers, so a client-controlled X-Username value can't create an owner
64
+ * for a non-existent user — resolveUserIdUuid is the safety gate.
65
+ */
66
+ export function resolveUserIdUuid(
67
+ db: ReturnType<typeof getDb>,
68
+ value: string,
69
+ ): string | null {
70
+ const row = db
71
+ .query(
72
+ 'SELECT id FROM users WHERE id = $value OR username = $value LIMIT 1',
73
+ )
74
+ .get({ $value: value }) as { id: string } | null
75
+ return row?.id ?? null
76
+ }
@@ -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
  /**