@saluzi/saluzi-edu 0.2.64 → 0.2.66
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 +46 -46
- package/dist/guide/assets/main-Bx1xb7yl.css +1 -0
- package/dist/guide/assets/main-CdOE18lg.js +277 -0
- package/dist/guide/guide-data.json +17 -7
- package/dist/guide/index.html +2 -2
- package/package.json +1 -1
- package/packages/remote-control-server/src/__tests__/attach-code.test.ts +12 -0
- package/packages/remote-control-server/src/__tests__/attach-wait.test.ts +111 -0
- package/packages/remote-control-server/src/__tests__/attach.test.ts +110 -14
- package/packages/remote-control-server/src/__tests__/low-priority-fixes.test.ts +95 -0
- package/packages/remote-control-server/src/__tests__/routes.test.ts +69 -0
- package/packages/remote-control-server/src/auth/bridge-auth.ts +44 -0
- package/packages/remote-control-server/src/routes/v1/environments.ts +65 -7
- package/packages/remote-control-server/src/routes/v1/environments.work.ts +2 -2
- package/packages/remote-control-server/src/routes/v1/sessions.ts +143 -79
- package/packages/remote-control-server/src/routes/web/sessions.ts +25 -22
- package/packages/remote-control-server/src/services/environment.ts +25 -0
- package/packages/remote-control-server/src/store.ts +17 -1
- package/dist/guide/assets/main-CSpz9pKf.css +0 -1
- package/dist/guide/assets/main-C_ykjDzn.js +0 -167
|
@@ -13,8 +13,13 @@ import { createWorkItem } from '../../services/work-dispatch'
|
|
|
13
13
|
import { publishSessionEvent } from '../../services/transport'
|
|
14
14
|
import { loadPersistedEvents } from '../../services/event-persistence'
|
|
15
15
|
import { getEventBus } from '../../transport/event-bus'
|
|
16
|
+
import { touchEnvironmentActivity } from '../../services/environment'
|
|
16
17
|
import { acceptCliHeaders, apiKeyAuth } from '../../auth/middleware'
|
|
17
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
bridgeAuth,
|
|
20
|
+
resolveAttachCaller,
|
|
21
|
+
type AttachCaller,
|
|
22
|
+
} from '../../auth/bridge-auth'
|
|
18
23
|
import { getDb } from '../../db/sqlite'
|
|
19
24
|
import {
|
|
20
25
|
storeBindSession,
|
|
@@ -25,6 +30,7 @@ import {
|
|
|
25
30
|
storeListSessions,
|
|
26
31
|
storeListSessionsByEnvironment,
|
|
27
32
|
storeRebindSessionEnvironment,
|
|
33
|
+
type SessionRecord,
|
|
28
34
|
} from '../../store'
|
|
29
35
|
|
|
30
36
|
const app = new Hono()
|
|
@@ -33,36 +39,17 @@ const app = new Hono()
|
|
|
33
39
|
// Session Attach — bind an existing session to a CLI-registered environment
|
|
34
40
|
// ---------------------------------------------------------------------------
|
|
35
41
|
|
|
36
|
-
/**
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
*
|
|
48
|
-
* bridgeAuth's userId may be a UUID (rct_* token), a username (in-memory
|
|
49
|
-
* token) or an arbitrary X-Username string (shared admin API key) — only
|
|
50
|
-
* users-table UUIDs can hold session management rights, so everything goes
|
|
51
|
-
* through resolveUserIdUuid (G9: the API layer must self-enforce
|
|
52
|
-
* canManageSession; the ingress WS accepting a global API key is not a
|
|
53
|
-
* boundary we may lean on).
|
|
54
|
-
*/
|
|
55
|
-
function resolveAttachCaller(c: import('hono').Context): AttachCaller {
|
|
56
|
-
const raw = c.get('userId') as string | undefined
|
|
57
|
-
const isApiAdmin = (c.get('isApiAdmin') as boolean | undefined) ?? false
|
|
58
|
-
if (!raw) {
|
|
59
|
-
return { uuid: null, isApiAdmin, hasIdentity: false }
|
|
60
|
-
}
|
|
61
|
-
return {
|
|
62
|
-
uuid: resolveUserIdUuid(getDb(), raw),
|
|
63
|
-
isApiAdmin,
|
|
64
|
-
hasIdentity: true,
|
|
65
|
-
}
|
|
42
|
+
/** 503 body shared by every attach-family route on a DB outage. */
|
|
43
|
+
function dbUnavailable(c: import('hono').Context): Response {
|
|
44
|
+
return c.json(
|
|
45
|
+
{
|
|
46
|
+
error: {
|
|
47
|
+
type: 'database_unavailable',
|
|
48
|
+
message: 'Database unavailable',
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
503,
|
|
52
|
+
)
|
|
66
53
|
}
|
|
67
54
|
|
|
68
55
|
/**
|
|
@@ -150,15 +137,15 @@ function isSessionOccupied(
|
|
|
150
137
|
}
|
|
151
138
|
|
|
152
139
|
/** Shape returned by GET /attachable and consumed by the CLI picker. */
|
|
153
|
-
function toAttachableEntry(session:
|
|
140
|
+
function toAttachableEntry(session: SessionRecord) {
|
|
154
141
|
return {
|
|
155
|
-
id: session
|
|
156
|
-
title: session
|
|
157
|
-
status: session
|
|
158
|
-
source: session
|
|
159
|
-
environment_id: session
|
|
160
|
-
updated_at: session
|
|
161
|
-
busy: isSessionOccupied(session
|
|
142
|
+
id: session.id,
|
|
143
|
+
title: session.title,
|
|
144
|
+
status: session.status,
|
|
145
|
+
source: session.source,
|
|
146
|
+
environment_id: session.environmentId,
|
|
147
|
+
updated_at: session.updatedAt.getTime() / 1000,
|
|
148
|
+
busy: isSessionOccupied(session.id, null),
|
|
162
149
|
}
|
|
163
150
|
}
|
|
164
151
|
|
|
@@ -171,6 +158,19 @@ app.post('/', acceptCliHeaders, apiKeyAuth, async c => {
|
|
|
171
158
|
const body = await c.req.json()
|
|
172
159
|
const username = c.get('userId')
|
|
173
160
|
|
|
161
|
+
// Worker liveness: this route is the claim-wait loop's only traffic (an
|
|
162
|
+
// unowned env retries POST /v1/sessions every 5s until the claim URL is
|
|
163
|
+
// opened, up to 24h). Without touching the env here the disconnect monitor
|
|
164
|
+
// flips it to 'disconnected' after RCS_DISCONNECT_TIMEOUT, and when the
|
|
165
|
+
// claim finally lands the session is created but createWorkItem rejects
|
|
166
|
+
// the non-active env — a session that exists yet never receives work, so
|
|
167
|
+
// the CLI polls 204s forever and never connects. Touch BEFORE the unowned
|
|
168
|
+
// 409 so the retry loop itself keeps the env alive; touchEnvironmentActivity
|
|
169
|
+
// also reactivates an env the monitor already flipped.
|
|
170
|
+
if (typeof body.environment_id === 'string' && body.environment_id) {
|
|
171
|
+
touchEnvironmentActivity(body.environment_id)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
174
|
// Resolve env ownership to bind session_owners.
|
|
175
175
|
let envOwnerUserId: string | null = null
|
|
176
176
|
let envTeamId: string | null = null
|
|
@@ -240,7 +240,14 @@ app.post('/', acceptCliHeaders, apiKeyAuth, async c => {
|
|
|
240
240
|
* worker. */
|
|
241
241
|
app.get('/attachable', bridgeAuth, async c => {
|
|
242
242
|
const caller = resolveAttachCaller(c)
|
|
243
|
-
if (caller.
|
|
243
|
+
if (caller.dbUnavailable) {
|
|
244
|
+
return dbUnavailable(c)
|
|
245
|
+
}
|
|
246
|
+
// An explicit-but-unresolvable identity (e.g. admin API key +
|
|
247
|
+
// X-Username matching no user) is a misconfiguration, not "see
|
|
248
|
+
// everything": 403, mirroring POST /:id/attach's ensureAttachAccess —
|
|
249
|
+
// the listing previously was the laxer of the two.
|
|
250
|
+
if (caller.uuid === null && caller.hasIdentity) {
|
|
244
251
|
return c.json(
|
|
245
252
|
{
|
|
246
253
|
error: {
|
|
@@ -258,16 +265,22 @@ app.get('/attachable', bridgeAuth, async c => {
|
|
|
258
265
|
)
|
|
259
266
|
}
|
|
260
267
|
|
|
261
|
-
const
|
|
262
|
-
const
|
|
268
|
+
const uuid = caller.uuid
|
|
269
|
+
const open = storeListSessions()
|
|
263
270
|
.filter(s => !isSessionClosedStatus(s.status))
|
|
264
271
|
.filter(s => s.source !== 'acp')
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
272
|
+
// Bare admin API key (uuid null) sees all; an identity sees only what it
|
|
273
|
+
// can manage. getDb() is touched only when the filter actually runs.
|
|
274
|
+
let visible = open
|
|
275
|
+
if (uuid !== null) {
|
|
276
|
+
const db = getDb()
|
|
277
|
+
visible = open.filter(s => canManageSession(s.id, uuid, db))
|
|
278
|
+
}
|
|
269
279
|
|
|
270
|
-
return c.json(
|
|
280
|
+
return c.json(
|
|
281
|
+
visible.map(s => toAttachableEntry(s)),
|
|
282
|
+
200,
|
|
283
|
+
)
|
|
271
284
|
})
|
|
272
285
|
|
|
273
286
|
/** POST /v1/sessions/:id/attach — Bind an existing session to a CLI
|
|
@@ -317,6 +330,9 @@ async function performAttach(
|
|
|
317
330
|
},
|
|
318
331
|
): Promise<Response> {
|
|
319
332
|
const { sessionId, envId, caller, skipAccessCheck } = opts
|
|
333
|
+
if (caller.dbUnavailable) {
|
|
334
|
+
return dbUnavailable(c)
|
|
335
|
+
}
|
|
320
336
|
const session = getSession(sessionId)
|
|
321
337
|
if (!session) {
|
|
322
338
|
return c.json(
|
|
@@ -325,28 +341,49 @@ async function performAttach(
|
|
|
325
341
|
)
|
|
326
342
|
}
|
|
327
343
|
|
|
328
|
-
// Idempotent re-entry BEFORE authorization: a CLI retrying
|
|
329
|
-
// blip re-presents the same session+env pair — returning
|
|
330
|
-
// binding is not a new grant, so it must not depend on the
|
|
331
|
-
// holding manage rights (e.g. a doReconnect re-bind where the
|
|
332
|
-
// session was assigned by a team admin, or an expired share). The
|
|
333
|
-
// only matches an existing work item on the SAME environment — no
|
|
334
|
-
// is created.
|
|
344
|
+
// Idempotent re-entry BEFORE session-manage authorization: a CLI retrying
|
|
345
|
+
// after a network blip re-presents the same session+env pair — returning
|
|
346
|
+
// the existing binding is not a new grant, so it must not depend on the
|
|
347
|
+
// caller still holding manage rights (e.g. a doReconnect re-bind where the
|
|
348
|
+
// bound session was assigned by a team admin, or an expired share). The
|
|
349
|
+
// check only matches an existing work item on the SAME environment — no
|
|
350
|
+
// state is created.
|
|
351
|
+
//
|
|
352
|
+
// Still gated on environment ownership (or admin): the CLI re-entering is
|
|
353
|
+
// the env's own worker, so ownership always holds for legitimate retries,
|
|
354
|
+
// while a random authenticated caller who merely knows session+env IDs
|
|
355
|
+
// must not be able to probe the binding (the response echoes the work
|
|
356
|
+
// item id). Non-owners fall through to the full checks below, which
|
|
357
|
+
// reject them.
|
|
335
358
|
const activeItem = storeGetActiveWorkItemBySession(sessionId)
|
|
336
359
|
if (activeItem && activeItem.environmentId === envId) {
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
)
|
|
360
|
+
let idempotentOwner = false
|
|
361
|
+
try {
|
|
362
|
+
const ownership = storeGetEnvironmentOwnership(envId)
|
|
363
|
+
idempotentOwner =
|
|
364
|
+
caller.isApiAdmin ||
|
|
365
|
+
(caller.uuid !== null && ownership?.ownerUserId === caller.uuid)
|
|
366
|
+
} catch (err) {
|
|
367
|
+
logError(
|
|
368
|
+
`[RCS] Ownership lookup failed during attach re-entry (session=${sessionId} env=${envId}): ${err}`,
|
|
369
|
+
)
|
|
370
|
+
return dbUnavailable(c)
|
|
371
|
+
}
|
|
372
|
+
if (idempotentOwner) {
|
|
373
|
+
log(
|
|
374
|
+
`[RCS] Attach idempotent re-entry: session=${sessionId} env=${envId} work=${activeItem.id}`,
|
|
375
|
+
)
|
|
376
|
+
return c.json(
|
|
377
|
+
{
|
|
378
|
+
status: 'attached',
|
|
379
|
+
session_id: sessionId,
|
|
380
|
+
work_item_id: activeItem.id,
|
|
381
|
+
rebind: false,
|
|
382
|
+
idempotent: true,
|
|
383
|
+
},
|
|
384
|
+
200,
|
|
385
|
+
)
|
|
386
|
+
}
|
|
350
387
|
}
|
|
351
388
|
|
|
352
389
|
// Authorization: identity-based attach requires manage rights (G9). The
|
|
@@ -416,18 +453,34 @@ async function performAttach(
|
|
|
416
453
|
// owns (the env it just registered). Admin may attach on behalf.
|
|
417
454
|
const ownership = storeGetEnvironmentOwnership(envId)
|
|
418
455
|
if (!ownership?.ownerUserId) {
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
456
|
+
// The documented self-hosted worker setup authenticates with a bare
|
|
457
|
+
// RCS_API_KEYS admin key (SALUZI_BRIDGE_OAUTH_TOKEN, no
|
|
458
|
+
// SALUZI_BRIDGE_USERNAME) — its env is unowned until claimed, so a hard
|
|
459
|
+
// 409 here made `/rc --attach <id>` fail out of the box with a claim URL
|
|
460
|
+
// the direct-attach path never prints. Bare admin keys are trusted
|
|
461
|
+
// credentials (ensureAttachAccess already audited their session-manage
|
|
462
|
+
// bypass) — allow the bind. Identity callers still get the claim-flow
|
|
463
|
+
// guidance (the wait-mode CLI prints the URL).
|
|
464
|
+
if (!caller.isApiAdmin || caller.hasIdentity) {
|
|
465
|
+
return c.json(
|
|
466
|
+
{
|
|
467
|
+
error: {
|
|
468
|
+
type: 'environment_unowned',
|
|
469
|
+
message:
|
|
470
|
+
'Environment has no owner yet; claim it via the printed URL before attaching sessions',
|
|
471
|
+
},
|
|
425
472
|
},
|
|
426
|
-
|
|
427
|
-
|
|
473
|
+
409,
|
|
474
|
+
)
|
|
475
|
+
}
|
|
476
|
+
log(
|
|
477
|
+
`[audit] Bare admin API key attached session ${sessionId} to unowned env ${envId}`,
|
|
428
478
|
)
|
|
429
479
|
}
|
|
430
|
-
|
|
480
|
+
// Optional chain: the bare-admin fallthrough above leaves ownership
|
|
481
|
+
// possibly-null/unowned here — but isApiAdmin is true on that path, so the
|
|
482
|
+
// short-circuit keeps the semantics identical.
|
|
483
|
+
if (!caller.isApiAdmin && ownership?.ownerUserId !== caller.uuid) {
|
|
431
484
|
return c.json(
|
|
432
485
|
{
|
|
433
486
|
error: {
|
|
@@ -482,9 +535,17 @@ async function performAttach(
|
|
|
482
535
|
workItemId = await createWorkItem(envId, sessionId)
|
|
483
536
|
} catch (err) {
|
|
484
537
|
// Roll the rebind back so a failed dispatch doesn't leave the session
|
|
485
|
-
// pointing at an environment that never picked it up
|
|
486
|
-
|
|
487
|
-
|
|
538
|
+
// pointing at an environment that never picked it up — including back to
|
|
539
|
+
// "no environment" when the session had none. A rollback failure must
|
|
540
|
+
// not mask the original dispatch error.
|
|
541
|
+
if (rebind) {
|
|
542
|
+
try {
|
|
543
|
+
storeRebindSessionEnvironment(sessionId, session.environment_id)
|
|
544
|
+
} catch (rollbackErr) {
|
|
545
|
+
logError(
|
|
546
|
+
`[RCS] Attach rollback failed for session ${sessionId}: ${rollbackErr}`,
|
|
547
|
+
)
|
|
548
|
+
}
|
|
488
549
|
}
|
|
489
550
|
return c.json(
|
|
490
551
|
{
|
|
@@ -570,6 +631,9 @@ app.post('/attach-code/exchange', bridgeAuth, async c => {
|
|
|
570
631
|
* wrong layer (P0-1). */
|
|
571
632
|
app.get('/:id/events', bridgeAuth, async c => {
|
|
572
633
|
const caller = resolveAttachCaller(c)
|
|
634
|
+
if (caller.dbUnavailable) {
|
|
635
|
+
return dbUnavailable(c)
|
|
636
|
+
}
|
|
573
637
|
const sessionId =
|
|
574
638
|
resolveExistingSessionId(c.req.param('id')!) ?? c.req.param('id')!
|
|
575
639
|
const session = getSession(sessionId)
|
|
@@ -96,6 +96,31 @@ app.post('/sessions', sessionAuth, async c => {
|
|
|
96
96
|
const session = getSession(sessionId)
|
|
97
97
|
return c.json(toWebSessionResponse(session!), 200)
|
|
98
98
|
}
|
|
99
|
+
|
|
100
|
+
// In-process (replBridge) workers register with workerType='repl' and
|
|
101
|
+
// maxSessions=1 — they can only serve one session at a time. Reject
|
|
102
|
+
// multi-session creation with a helpful error guiding the user to
|
|
103
|
+
// `slz rc` (spawn mode). MUST run before createSession: the new session
|
|
104
|
+
// is created WITH body.environment_id, so checking after would always
|
|
105
|
+
// see the just-created session and 409 every request (leaving a stray
|
|
106
|
+
// bound-but-never-dispatched session behind on each retry).
|
|
107
|
+
if (env?.workerType === 'repl') {
|
|
108
|
+
const existing = storeListSessionsByEnvironment(env.id).filter(
|
|
109
|
+
s => !isSessionClosedStatus(s.status),
|
|
110
|
+
)
|
|
111
|
+
if (existing.length > 0) {
|
|
112
|
+
return c.json(
|
|
113
|
+
{
|
|
114
|
+
error: {
|
|
115
|
+
type: 'worker_at_capacity',
|
|
116
|
+
message:
|
|
117
|
+
'该 worker 为 in-process 模式(slz 默认),只支持单会话。请用 `slz rc` 启动 spawn 模式 worker 支持多会话。',
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
409,
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
99
124
|
}
|
|
100
125
|
|
|
101
126
|
const teamId = typeof body.team_id === 'string' ? body.team_id : null
|
|
@@ -120,28 +145,6 @@ app.post('/sessions', sessionAuth, async c => {
|
|
|
120
145
|
|
|
121
146
|
// Dispatch work to environment if specified
|
|
122
147
|
if (body.environment_id) {
|
|
123
|
-
// In-process (replBridge) workers register with workerType='repl' and
|
|
124
|
-
// maxSessions=1 — they can only serve one session at a time. Reject
|
|
125
|
-
// multi-session creation with a helpful error guiding the user to
|
|
126
|
-
// `slz rc` (spawn mode).
|
|
127
|
-
const env = storeGetEnvironment(body.environment_id)
|
|
128
|
-
if (env?.workerType === 'repl') {
|
|
129
|
-
const existing = storeListSessionsByEnvironment(env.id).filter(
|
|
130
|
-
s => !isSessionClosedStatus(s.status),
|
|
131
|
-
)
|
|
132
|
-
if (existing.length > 0) {
|
|
133
|
-
return c.json(
|
|
134
|
-
{
|
|
135
|
-
error: {
|
|
136
|
-
type: 'worker_at_capacity',
|
|
137
|
-
message:
|
|
138
|
-
'该 worker 为 in-process 模式(slz 默认),只支持单会话。请用 `slz rc` 启动 spawn 模式 worker 支持多会话。',
|
|
139
|
-
},
|
|
140
|
-
},
|
|
141
|
-
409,
|
|
142
|
-
)
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
148
|
try {
|
|
146
149
|
const spawnMode =
|
|
147
150
|
body.spawn_mode === 'worktree' || body.spawn_mode === 'same-dir'
|
|
@@ -277,6 +277,31 @@ export function updatePollTime(envId: string) {
|
|
|
277
277
|
storeUpdateEnvironment(envId, { lastPollAt: new Date() })
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
+
/**
|
|
281
|
+
* Worker-traffic liveness signal: refresh lastPollAt and, when the disconnect
|
|
282
|
+
* monitor had flipped the env to 'disconnected', reactivate it.
|
|
283
|
+
*
|
|
284
|
+
* The flip is based on a stale heartbeat — a worker actively hitting the env
|
|
285
|
+
* (work poll, assignment poll, session-creation retry during claim wait)
|
|
286
|
+
* proves it is alive. Without reactivation the CLI polls a zombie env
|
|
287
|
+
* forever: the work-poll route never 404s, so the client's env-lost recovery
|
|
288
|
+
* never fires, and any session created on the flipped env gets no work item
|
|
289
|
+
* (createWorkItem rejects non-active envs) — a session that exists but can
|
|
290
|
+
* never connect. 'deregistered' is terminal and stays dead.
|
|
291
|
+
*/
|
|
292
|
+
export function touchEnvironmentActivity(envId: string) {
|
|
293
|
+
const env = storeGetEnvironment(envId)
|
|
294
|
+
if (!env) return
|
|
295
|
+
if (env.status === 'disconnected') {
|
|
296
|
+
storeUpdateEnvironment(envId, { status: 'active', lastPollAt: new Date() })
|
|
297
|
+
log(
|
|
298
|
+
`[RCS] Environment ${envId} reactivated by worker traffic (was disconnected)`,
|
|
299
|
+
)
|
|
300
|
+
} else if (env.status === 'active') {
|
|
301
|
+
storeUpdateEnvironment(envId, { lastPollAt: new Date() })
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
280
305
|
export function listActiveEnvironments() {
|
|
281
306
|
return storeListActiveEnvironments()
|
|
282
307
|
}
|
|
@@ -586,10 +586,13 @@ export function storeListSessionsByEnvironment(envId: string): SessionRecord[] {
|
|
|
586
586
|
* storeUpdateSession's patch type intentionally excludes environment_id
|
|
587
587
|
* because ownership-binding flows must never re-point a session as a side
|
|
588
588
|
* effect. Attach is the one flow with an explicit re-bind contract.
|
|
589
|
+
*
|
|
590
|
+
* environmentId may be null — the rollback path (dispatch failure in
|
|
591
|
+
* performAttach) restores "no environment" when the session had none.
|
|
589
592
|
*/
|
|
590
593
|
export function storeRebindSessionEnvironment(
|
|
591
594
|
sessionId: string,
|
|
592
|
-
environmentId: string,
|
|
595
|
+
environmentId: string | null,
|
|
593
596
|
): boolean {
|
|
594
597
|
const rec = sessions.get(sessionId)
|
|
595
598
|
if (!rec) return false
|
|
@@ -1018,6 +1021,13 @@ export function storeIssueAttachCode(
|
|
|
1018
1021
|
sessionId: string,
|
|
1019
1022
|
ttlMs = 15 * 60 * 1000,
|
|
1020
1023
|
): AttachCodeRecord {
|
|
1024
|
+
// Sweep expired codes: consume() only removes a code when it is actually
|
|
1025
|
+
// presented, so unconsumed expired codes would otherwise accumulate for
|
|
1026
|
+
// the process lifetime. Map deletion during iteration is safe.
|
|
1027
|
+
const now = Date.now()
|
|
1028
|
+
for (const [code, record] of attachCodes) {
|
|
1029
|
+
if (record.expiresAt.getTime() <= now) attachCodes.delete(code)
|
|
1030
|
+
}
|
|
1021
1031
|
const code = `atc_${randomUUID().replace(/-/g, '')}`
|
|
1022
1032
|
const record: AttachCodeRecord = {
|
|
1023
1033
|
code,
|
|
@@ -1045,6 +1055,12 @@ export function storeResetAttachCodes() {
|
|
|
1045
1055
|
attachCodes.clear()
|
|
1046
1056
|
}
|
|
1047
1057
|
|
|
1058
|
+
/** Test helper — number of attach codes currently held (expired codes that
|
|
1059
|
+
* have not been swept yet still count). */
|
|
1060
|
+
export function storeAttachCodesSize(): number {
|
|
1061
|
+
return attachCodes.size
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1048
1064
|
// ---------- Reset (for tests) ----------
|
|
1049
1065
|
|
|
1050
1066
|
export function storeReset() {
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
@import "https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap";@layer components;@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-content:""}}}@layer theme{:root,:host{--font-sans:"Poppins", system-ui, -apple-system, sans-serif;--font-serif:Georgia, "Times New Roman", serif;--font-mono:"JetBrains Mono", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wider:.05em;--leading-tight:1.25;--leading-relaxed:1.625;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--ease-out:cubic-bezier(0, 0, .2, 1);--animate-spin:spin 1s linear infinite;--blur-sm:8px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--radius:.625rem;--font-display:"Poppins", sans-serif;--color-surface-3:#d4bfae;--color-text-primary:#372e37;--color-text-secondary:#737373;--color-background:#f0e0d3;--color-foreground:#372e37;--color-card:#e6d2c2;--color-primary:#7d5a8a;--color-primary-foreground:#f0e0d3;--color-secondary:#e6d2c2;--color-secondary-foreground:#372e37;--color-muted:#e6d2c2;--color-muted-foreground:#737373;--color-accent:#7d5a8a;--color-accent-foreground:#f0e0d3;--color-destructive:#4b1535;--color-border:#7d5a8a;--color-border-light:#e6d2c2;--color-ring:#7d5a8a}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}html,body{background:var(--color-background);color:var(--color-text-primary);font-family:var(--font-sans);-webkit-font-smoothing:antialiased}*,:before,:after{border-color:var(--color-border)}}@layer utilities{.pointer-events-none{pointer-events:none}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:calc(var(--spacing) * 0)}.top-4{top:calc(var(--spacing) * 4)}.top-full{top:100%}.right-0{right:calc(var(--spacing) * 0)}.right-2{right:calc(var(--spacing) * 2)}.right-4{right:calc(var(--spacing) * 4)}.bottom-2{bottom:calc(var(--spacing) * 2)}.bottom-4{bottom:calc(var(--spacing) * 4)}.left-2{left:calc(var(--spacing) * 2)}.left-4{left:calc(var(--spacing) * 4)}.z-10{z-index:10}.z-50{z-index:50}.container{width:100%}@media (width>=40rem){.container{max-width:40rem}}@media (width>=48rem){.container{max-width:48rem}}@media (width>=64rem){.container{max-width:64rem}}@media (width>=80rem){.container{max-width:80rem}}@media (width>=96rem){.container{max-width:96rem}}.mx-1{margin-inline:calc(var(--spacing) * 1)}.mx-auto{margin-inline:auto}.my-1{margin-block:calc(var(--spacing) * 1)}.my-2{margin-block:calc(var(--spacing) * 2)}.my-4{margin-block:calc(var(--spacing) * 4)}.my-6{margin-block:calc(var(--spacing) * 6)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.mb-10{margin-bottom:calc(var(--spacing) * 10)}.mb-12{margin-bottom:calc(var(--spacing) * 12)}.mb-14{margin-bottom:calc(var(--spacing) * 14)}.mb-16{margin-bottom:calc(var(--spacing) * 16)}.mb-20{margin-bottom:calc(var(--spacing) * 20)}.ml-1{margin-left:calc(var(--spacing) * 1)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-3{margin-left:calc(var(--spacing) * 3)}.ml-auto{margin-left:auto}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.list-item{display:list-item}.table{display:table}.table-cell{display:table-cell}.table-row{display:table-row}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-full{width:100%;height:100%}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-4{height:calc(var(--spacing) * 4)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-16{height:calc(var(--spacing) * 16)}.h-\[46px\]{height:46px}.h-\[calc\(100vh-2rem\)\]{height:calc(100vh - 2rem)}.h-auto{height:auto}.h-full{height:100%}.h-screen{height:100vh}.min-h-28{min-height:calc(var(--spacing) * 28)}.min-h-\[200px\]{min-height:200px}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-4{width:calc(var(--spacing) * 4)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-56{width:calc(var(--spacing) * 56)}.w-64{width:calc(var(--spacing) * 64)}.w-full{width:100%}.w-screen{width:100vw}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-full{max-width:100%}.max-w-md{max-width:var(--container-md)}.max-w-none{max-width:none}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-\[120px\]{min-width:120px}.flex-1{flex:1}.shrink-0{flex-shrink:0}.border-collapse{border-collapse:collapse}.origin-center{transform-origin:50%}.rotate-\[0\.3deg\]{rotate:.3deg}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.animate-spin{animation:var(--animate-spin)}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.list-inside{list-style-position:inside}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-stretch{align-items:stretch}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-10{gap:calc(var(--spacing) * 10)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-x-2>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 2) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-border>:not(:last-child)){border-color:var(--color-border)}.self-start{align-self:flex-start}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:var(--radius)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-l-4{border-left-style:var(--tw-border-style);border-left-width:4px}.border-border{border-color:var(--color-border)}.border-border-light{border-color:var(--color-border-light)}.border-current{border-color:currentColor}.border-destructive{border-color:var(--color-destructive)}.border-muted-foreground\/30{border-color:#7373734d}@supports (color:color-mix(in lab, red, red)){.border-muted-foreground\/30{border-color:color-mix(in oklab, var(--color-muted-foreground) 30%, transparent)}}.border-primary{border-color:var(--color-primary)}.border-red-200{border-color:var(--color-red-200)}.border-transparent{border-color:#0000}.border-white\/10{border-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.border-white\/10{border-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.bg-\[\#0b0e14\]{background-color:#0b0e14}.bg-\[\#27c93f\]{background-color:#27c93f}.bg-\[\#ff5f56\]{background-color:#ff5f56}.bg-\[\#ffbd2e\]{background-color:#ffbd2e}.bg-accent{background-color:var(--color-accent)}.bg-background{background-color:var(--color-background)}.bg-background\/80{background-color:#f0e0d3cc}@supports (color:color-mix(in lab, red, red)){.bg-background\/80{background-color:color-mix(in oklab, var(--color-background) 80%, transparent)}}.bg-background\/90{background-color:#f0e0d3e6}@supports (color:color-mix(in lab, red, red)){.bg-background\/90{background-color:color-mix(in oklab, var(--color-background) 90%, transparent)}}.bg-background\/95{background-color:#f0e0d3f2}@supports (color:color-mix(in lab, red, red)){.bg-background\/95{background-color:color-mix(in oklab, var(--color-background) 95%, transparent)}}.bg-black\/10{background-color:#0000001a}@supports (color:color-mix(in lab, red, red)){.bg-black\/10{background-color:color-mix(in oklab, var(--color-black) 10%, transparent)}}.bg-card{background-color:var(--color-card)}.bg-destructive{background-color:var(--color-destructive)}.bg-muted{background-color:var(--color-muted)}.bg-muted\/40{background-color:#e6d2c266}@supports (color:color-mix(in lab, red, red)){.bg-muted\/40{background-color:color-mix(in oklab, var(--color-muted) 40%, transparent)}}.bg-muted\/80{background-color:#e6d2c2cc}@supports (color:color-mix(in lab, red, red)){.bg-muted\/80{background-color:color-mix(in oklab, var(--color-muted) 80%, transparent)}}.bg-primary{background-color:var(--color-primary)}.bg-primary\/10{background-color:#7d5a8a1a}@supports (color:color-mix(in lab, red, red)){.bg-primary\/10{background-color:color-mix(in oklab, var(--color-primary) 10%, transparent)}}.bg-red-50{background-color:var(--color-red-50)}.bg-red-100{background-color:var(--color-red-100)}.bg-secondary{background-color:var(--color-secondary)}.bg-surface-3{background-color:var(--color-surface-3)}.bg-white\/5{background-color:#ffffff0d}@supports (color:color-mix(in lab, red, red)){.bg-white\/5{background-color:color-mix(in oklab, var(--color-white) 5%, transparent)}}.p-1{padding:calc(var(--spacing) * 1)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-8{padding:calc(var(--spacing) * 8)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-8{padding-inline:calc(var(--spacing) * 8)}.px-10{padding-inline:calc(var(--spacing) * 10)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-12{padding-block:calc(var(--spacing) * 12)}.py-16{padding-block:calc(var(--spacing) * 16)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-8{padding-bottom:calc(var(--spacing) * 8)}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-3{padding-left:calc(var(--spacing) * 3)}.pl-4{padding-left:calc(var(--spacing) * 4)}.text-center{text-align:center}.text-left{text-align:left}.font-display{font-family:var(--font-display)}.font-mono{font-family:var(--font-mono)}.font-serif{font-family:var(--font-serif)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[13px\]{font-size:13px}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[0\.2em\]{--tw-tracking:.2em;letter-spacing:.2em}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.wrap-anywhere{overflow-wrap:anywhere}.whitespace-normal{white-space:normal}.whitespace-nowrap{white-space:nowrap}.text-\[\#7dd3fc\]{color:#7dd3fc}.text-\[\#27c93f\]{color:#27c93f}.text-\[\#ffbd2e\]{color:#ffbd2e}.text-accent-foreground{color:var(--color-accent-foreground)}.text-destructive{color:var(--color-destructive)}.text-foreground{color:var(--color-foreground)}.text-muted-foreground{color:var(--color-muted-foreground)}.text-primary{color:var(--color-primary)}.text-primary-foreground{color:var(--color-primary-foreground)}.text-primary\/80{color:#7d5a8acc}@supports (color:color-mix(in lab, red, red)){.text-primary\/80{color:color-mix(in oklab, var(--color-primary) 80%, transparent)}}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-red-800{color:var(--color-red-800)}.text-secondary-foreground{color:var(--color-secondary-foreground)}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.italic{font-style:italic}.underline{text-decoration-line:underline}.opacity-0{opacity:0}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.\[counter-increment\:line_0\]{counter-increment:line 0}.\[counter-reset\:line\]{counter-reset:line}@media (hover:hover){.group-hover\:block:is(:where(.group):hover *){display:block}.group-hover\:translate-x-0\.5:is(:where(.group):hover *){--tw-translate-x:calc(var(--spacing) * .5);translate:var(--tw-translate-x) var(--tw-translate-y)}.group-hover\:translate-x-1:is(:where(.group):hover *){--tw-translate-x:calc(var(--spacing) * 1);translate:var(--tw-translate-x) var(--tw-translate-y)}.group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}}.before\:mr-4:before{content:var(--tw-content);margin-right:calc(var(--spacing) * 4)}.before\:inline-block:before{content:var(--tw-content);display:inline-block}.before\:w-4:before{content:var(--tw-content);width:calc(var(--spacing) * 4)}.before\:w-6:before{content:var(--tw-content);width:calc(var(--spacing) * 6)}.before\:text-right:before{content:var(--tw-content);text-align:right}.before\:font-mono:before{content:var(--tw-content);font-family:var(--font-mono)}.before\:text-\[13px\]:before{content:var(--tw-content);font-size:13px}.before\:text-muted-foreground\/50:before{content:var(--tw-content);color:#73737380}@supports (color:color-mix(in lab, red, red)){.before\:text-muted-foreground\/50:before{color:color-mix(in oklab, var(--color-muted-foreground) 50%, transparent)}}.before\:content-\[counter\(line\)\]:before{--tw-content:counter(line);content:var(--tw-content)}.before\:select-none:before{content:var(--tw-content);-webkit-user-select:none;user-select:none}.before\:\[counter-increment\:line\]:before{content:var(--tw-content);counter-increment:line}:is(.\*\:first\:mt-0>*):first-child{margin-top:calc(var(--spacing) * 0)}:is(.\*\:last\:mb-0>*):last-child{margin-bottom:calc(var(--spacing) * 0)}:is(.\*\:last\:after\:inline>*):last-child:after{content:var(--tw-content);display:inline}:is(.\*\:last\:after\:align-baseline>*):last-child:after{content:var(--tw-content);vertical-align:baseline}:is(.\*\:last\:after\:content-\(--streamdown-caret\)>*):last-child:after{--tw-content:var(--streamdown-caret);content:var(--tw-content)}@media (hover:hover){.hover\:bg-background:hover{background-color:var(--color-background)}.hover\:bg-muted:hover{background-color:var(--color-muted)}.hover\:bg-muted\/40:hover{background-color:#e6d2c266}@supports (color:color-mix(in lab, red, red)){.hover\:bg-muted\/40:hover{background-color:color-mix(in oklab, var(--color-muted) 40%, transparent)}}.hover\:bg-white\/5:hover{background-color:#ffffff0d}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/5:hover{background-color:color-mix(in oklab, var(--color-white) 5%, transparent)}}.hover\:text-foreground:hover{color:var(--color-foreground)}.hover\:opacity-80:hover{opacity:.8}.hover\:shadow-md:hover{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,var(--color-ring));box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-primary:focus-visible{--tw-ring-color:var(--color-primary)}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}@media (width>=40rem){.sm\:px-4{padding-inline:calc(var(--spacing) * 4)}.sm\:px-10{padding-inline:calc(var(--spacing) * 10)}.sm\:px-14{padding-inline:calc(var(--spacing) * 14)}.sm\:px-16{padding-inline:calc(var(--spacing) * 16)}.sm\:py-16{padding-block:calc(var(--spacing) * 16)}.sm\:py-20{padding-block:calc(var(--spacing) * 20)}}@media (width>=48rem){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (width>=64rem){.lg\:block{display:block}}@media (prefers-color-scheme:dark){.dark\:bg-\(--shiki-dark-bg\)\!{background-color:var(--shiki-dark-bg)!important}.dark\:text-\(--shiki-dark\)\!{color:var(--shiki-dark)!important}}.\[\&_svg\]\:h-auto svg{height:auto}.\[\&_svg\]\:w-auto svg{width:auto}.\[\&\>div\]\:h-full>div{height:100%}.\[\&\>div\]\:overflow-hidden>div{overflow:hidden}.\[\&\>p\]\:inline>p{display:inline}li .\[li_\&\]\:pl-6{padding-left:calc(var(--spacing) * 6)}}.dark{--color-surface-0:#1a1917;--color-surface-1:#222120;--color-surface-2:#2c2a28;--color-surface-3:#3a3835;--color-text-primary:#efeee9;--color-text-secondary:#969088;--color-text-muted:#5e5a54;--color-bg-inverted:#f6f5f2;--color-text-inverted:#2c2a28;--color-border:#3a3835;--color-border-light:#2c2a28;--color-background:#1a1917;--color-foreground:#efeee9;--color-card:#222120;--color-card-foreground:#efeee9;--color-popover:#1a1917;--color-popover-foreground:#efeee9;--color-primary:#7d5a8a;--color-primary-foreground:#efeee9;--color-secondary:#2c2a28;--color-secondary-foreground:#efeee9;--color-muted:#2c2a28;--color-muted-foreground:#969088;--color-accent:#7d5a8a;--color-accent-foreground:#efeee9;--color-destructive:#4b1535;--color-input:#3a3835;--color-ring:#7d5a8a}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:0 0}::-webkit-scrollbar-thumb{background:var(--color-surface-3);border-radius:3px}.chapter-content{font-family:var(--font-sans);color:var(--color-text-primary);font-size:1.0625rem;line-height:1.75}.chapter-content p,.chapter-content ul,.chapter-content ol,.chapter-content blockquote,.chapter-content pre,.chapter-content table,.chapter-content hr,.chapter-content img{margin-top:1.25rem;margin-bottom:1.25rem}.chapter-content>:first-child,.chapter-content>div>:first-child{margin-top:0}.chapter-content>:last-child,.chapter-content>div>:last-child{margin-bottom:0}.chapter-content h1,.chapter-content h2,.chapter-content h3,.chapter-content h4,.chapter-content h5,.chapter-content h6{font-family:var(--font-display);letter-spacing:-.015em;color:var(--color-text-primary);margin-top:2.5rem;margin-bottom:1rem;font-weight:600;line-height:1.3}.chapter-content h1{margin-top:0;font-size:1.875rem}.chapter-content h2{border-bottom:1px solid var(--color-border-light);padding-bottom:.5rem;font-size:1.5rem}.chapter-content h3{font-size:1.25rem}.chapter-content h4{font-size:1.125rem}.chapter-content p{line-height:1.75}.chapter-content a{color:var(--color-primary);border-bottom:1px solid #0000;text-decoration:none;transition:border-color .2s}.chapter-content a:hover{border-bottom-color:var(--color-primary)}.chapter-content strong{color:var(--color-text-primary);font-weight:600}.chapter-content em{font-style:italic}.chapter-content ul,.chapter-content ol{padding-left:1.5rem}.chapter-content ul{list-style:outside}.chapter-content ol{list-style:decimal}.chapter-content li{margin-top:.5rem;margin-bottom:.5rem;line-height:1.7}.chapter-content li::marker{color:var(--color-primary)}.chapter-content blockquote{border-left:3px solid var(--color-primary);color:var(--color-text-secondary);padding:.5rem 0 .5rem 1.25rem;font-style:italic}.chapter-content code{font-family:var(--font-mono);background:var(--color-muted);color:var(--color-primary);border-radius:.375rem;padding:.125rem .375rem;font-size:.875em;font-weight:500}.chapter-content pre{background:var(--color-card);border:1px solid var(--color-border-light);border-radius:.75rem;padding:1.25rem 1.5rem;font-size:.875rem;line-height:1.6;overflow-x:auto}.chapter-content pre code{color:inherit;font-weight:inherit;background:0 0;border-radius:0;padding:0}.chapter-content pre code>span:last-child:empty{display:none}.chapter-content table{border-collapse:collapse;width:100%;font-size:.9375rem}.chapter-content th{text-align:left;font-family:var(--font-display);color:var(--color-text-primary);border-bottom:2px solid var(--color-primary);background:var(--color-muted);padding:.75rem 1rem;font-weight:600}.chapter-content td{border-bottom:1px solid var(--color-border-light);color:var(--color-text-primary);vertical-align:top;padding:.75rem 1rem}.chapter-content tr:hover td{background:var(--color-muted)}.chapter-content hr{border:none;border-top:1px solid var(--color-border-light)}.chapter-content img{border-radius:.625rem;max-width:100%;height:auto}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-content{syntax:"*";inherits:false;initial-value:""}@keyframes spin{to{transform:rotate(360deg)}}
|