1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { EnvironmentId } from "@t3tools/contracts";
4import { expect, it } from "@effect/vitest";
5import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";
7import * as Fiber from "effect/Fiber";
8import * as Deferred from "effect/Deferred";
9import * as FileSystem from "effect/FileSystem";
10import * as Layer from "effect/Layer";
11import * as Option from "effect/Option";
12import * as Redacted from "effect/Redacted";
13import * as TestClock from "effect/testing/TestClock";
14import * as SqlClient from "effect/sql/SqlClient";
15
16import * as ServerConfig from "../config.ts";
17import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
18
19import * as SqlitePersistence from "../persistence/Sqlite.ts";
20import * as SessionStore from "./SessionStore.ts";
21import * as ServerSecretStore from "./ServerSecretStore.ts";
22import { base64UrlDecodeUtf8, base64UrlEncode } from "./utils.ts";
23import * as PayloadSigner from "./PayloadSigner.ts";
24
25const layerServerConfig = (overrides?: Partial<ServerConfig.ServerConfig["Service"]>) =>
26 Layer.effect(
27 ServerConfig.ServerConfig,
28 Effect.gen(function* () {
29 const config = yield* ServerConfig.ServerConfig;
30 return {
31 ...config,
32 ...overrides,
33 } satisfies ServerConfig.ServerConfig["Service"];
34 }),
35 ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-session-test-" })));
36
37const layerServerEnvironment = (environmentId: EnvironmentId) =>
38 Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, {
39 getEnvironmentId: Effect.succeed(environmentId),
40 });
41
42const layerSessionStore = (
43 overrides?: Partial<ServerConfig.ServerConfig["Service"]>,
44 environmentId = EnvironmentId.make("test-environment"),
45) =>
46 SessionStore.layer.pipe(
47 Layer.provide(SqlitePersistence.layerMemory),
48 Layer.provide(ServerSecretStore.layer),
49 Layer.provide(layerServerEnvironment(environmentId)),
50 Layer.provide(layerServerConfig(overrides)),
51 );
52
53const relaySessionInput = {
54 subject: "managed-relay-bootstrap",
55 method: "dpop-access-token",
56 proofKeyThumbprint: "relay-proof-key",
57 ttl: Duration.hours(1),
58 client: { label: "Relay desktop", deviceType: "desktop" },
59} as const;
60
61const makeDiskSessionStoreLayer = Effect.fn("makeDiskSessionStoreLayer")(function* (
62 baseDir: string,
63 token?: string,
64) {
65 const devUrl = new URL("http://127.0.0.1:5173");
66 const paths = yield* ServerConfig.deriveServerPaths(baseDir, devUrl, {
67 baseDirIsExplicit: true,
68 });
69 yield* ServerConfig.ensureServerDirectories(paths);
70 const layerPersistence = SqlitePersistence.layerFromPath(paths.dbPath);
71 return SessionStore.layer.pipe(
72 Layer.provide(layerPersistence),
73 Layer.provide(ServerSecretStore.layer),
74 Layer.provide(layerServerEnvironment(EnvironmentId.make(baseDir))),
75 Layer.provide(
76 layerServerConfig({
77 ...paths,
78 baseDir,
79 mode: "web",
80 devUrl,
81 ...(token === undefined ? {} : { devAuthToken: Redacted.make(token) }),
82 }),
83 ),
84 );
85});
86
87it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("SessionStore.layer", (it) => {
88 it.effect.each(["revoke", "revokeAllExcept", "replace"] as const)(
89 "invalidates every connection for a session on %s and leaves other sessions active",
90 (operation) =>
91 Effect.gen(function* () {
92 const sessions = yield* SessionStore.SessionStore;
93 const owner = yield* sessions.issue({ subject: "owner" });
94 const client = yield* sessions.issue(relaySessionInput);
95 const ownerInvalidated = yield* Deferred.make<void>();
96 yield* sessions
97 .awaitInvalidation(owner.sessionId)
98 .pipe(
99 Effect.andThen(Deferred.succeed(ownerInvalidated, undefined)),
100 Effect.forkScoped({ startImmediately: true }),
101 );
102 const first = yield* sessions
103 .awaitInvalidation(client.sessionId, relaySessionInput.proofKeyThumbprint)
104 .pipe(Effect.forkScoped({ startImmediately: true }));
105 const second = yield* sessions
106 .awaitInvalidation(client.sessionId, relaySessionInput.proofKeyThumbprint)
107 .pipe(Effect.forkScoped({ startImmediately: true }));
108 if (operation === "revoke") yield* sessions.revoke(client.sessionId);
109 else if (operation === "revokeAllExcept") yield* sessions.revokeAllExcept(owner.sessionId);
110 else yield* sessions.issue({ ...relaySessionInput, replaceSessionId: client.sessionId });
111 yield* Fiber.join(first);
112 yield* Fiber.join(second);
113 expect(yield* Deferred.isDone(ownerInvalidated)).toBe(false);
114 yield* sessions.awaitInvalidation(client.sessionId);
115 }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerSessionStore(), TestClock.layer()))),
116 );
117
118 it.effect("expires a connected session at its credential deadline", () =>
119 Effect.gen(function* () {
120 const sessions = yield* SessionStore.SessionStore;
121 const client = yield* sessions.issue(relaySessionInput);
122 yield* sessions.markConnected(client.sessionId);
123 const invalidated = yield* Deferred.make<void>();
124 const watcher = yield* sessions
125 .awaitInvalidation(client.sessionId, relaySessionInput.proofKeyThumbprint)
126 .pipe(
127 Effect.andThen(Deferred.succeed(invalidated, undefined)),
128 Effect.forkScoped({ startImmediately: true }),
129 );
130 yield* TestClock.adjust(Duration.minutes(59));
131 expect(yield* Deferred.isDone(invalidated)).toBe(false);
132 yield* TestClock.adjust(Duration.minutes(1));
133 yield* Fiber.join(watcher);
134 yield* sessions.awaitInvalidation(client.sessionId);
135 }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerSessionStore(), TestClock.layer()))),
136 );
137
138 it.effect.each([
139 { name: "a renewal for the same key", renewal: {}, closesAtMinute: 119 },
140 {
141 name: "a renewal for another key",
142 renewal: { proofKeyThumbprint: "other-proof-key" },
143 closesAtMinute: 60,
144 },
145 {
146 name: "a renewal with fewer scopes",
147 renewal: { scopes: ["orchestration:read"] as const },
148 closesAtMinute: 60,
149 },
150 {
151 name: "a renewal, until the original is revoked",
152 renewal: {},
153 closesAtMinute: 90,
154 revoke: "original",
155 },
156 {
157 name: "a renewal, until the renewal is revoked",
158 renewal: {},
159 closesAtMinute: 90,
160 revoke: "renewal",
161 },
162 ] as const)("closes a DPoP socket at minute $closesAtMinute after $name", (testCase) =>
163 Effect.gen(function* () {
164 const sessions = yield* SessionStore.SessionStore;
165 const client = yield* sessions.issue(relaySessionInput);
166 const invalidated = yield* Deferred.make<void>();
167 const watcher = yield* sessions
168 .awaitInvalidation(client.sessionId, relaySessionInput.proofKeyThumbprint)
169 .pipe(
170 Effect.andThen(Deferred.succeed(invalidated, undefined)),
171 Effect.forkScoped({ startImmediately: true }),
172 );
173 yield* TestClock.adjust(Duration.minutes(59));
174 const renewal = yield* sessions.issue({ ...relaySessionInput, ...testCase.renewal });
175 yield* TestClock.adjust(Duration.millis((testCase.closesAtMinute - 59) * 60_000 - 1));
176 expect(yield* Deferred.isDone(invalidated)).toBe(false);
177 yield* TestClock.adjust(Duration.millis(1));
178 if ("revoke" in testCase) {
179 yield* sessions.revoke(
180 testCase.revoke === "original" ? client.sessionId : renewal.sessionId,
181 );
182 }
183 yield* Fiber.join(watcher);
184 }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerSessionStore(), TestClock.layer()))),
185 );
186
187 it.effect("closes a DPoP socket when an earlier renewal in its chain is revoked", () =>
188 Effect.gen(function* () {
189 const sessions = yield* SessionStore.SessionStore;
190 const client = yield* sessions.issue(relaySessionInput);
191 const invalidated = yield* Deferred.make<void>();
192 const watcher = yield* sessions
193 .awaitInvalidation(client.sessionId, relaySessionInput.proofKeyThumbprint)
194 .pipe(
195 Effect.andThen(Deferred.succeed(invalidated, undefined)),
196 Effect.forkScoped({ startImmediately: true }),
197 );
198 yield* TestClock.adjust(Duration.minutes(59));
199 const first = yield* sessions.issue(relaySessionInput);
200 yield* TestClock.adjust(Duration.minutes(59));
201 yield* sessions.issue(relaySessionInput);
202 // Past the first renewal's expiry, so the socket now follows the second.
203 yield* TestClock.adjust(Duration.minutes(2));
204 expect(yield* Deferred.isDone(invalidated)).toBe(false);
205 yield* sessions.revoke(first.sessionId);
206 yield* Fiber.join(watcher);
207 }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerSessionStore(), TestClock.layer()))),
208 );
209
210 it.effect("keeps a renewed DPoP socket when another socket on its expired session closes", () =>
211 Effect.gen(function* () {
212 const sessions = yield* SessionStore.SessionStore;
213 const client = yield* sessions.issue(relaySessionInput);
214 yield* sessions.markConnected(client.sessionId);
215 const invalidated = yield* Deferred.make<void>();
216 const watcher = yield* sessions
217 .awaitInvalidation(client.sessionId, relaySessionInput.proofKeyThumbprint)
218 .pipe(
219 Effect.andThen(Deferred.succeed(invalidated, undefined)),
220 Effect.forkScoped({ startImmediately: true }),
221 );
222 yield* TestClock.adjust(Duration.minutes(59));
223 yield* sessions.issue(relaySessionInput);
224 yield* TestClock.adjust(Duration.minutes(2));
225 // An expired session that loses its last socket is reported as removed, not revoked.
226 yield* sessions.markDisconnected(client.sessionId);
227 yield* TestClock.adjust(Duration.millis(58 * 60_000 - 1));
228 expect(yield* Deferred.isDone(invalidated)).toBe(false);
229 yield* TestClock.adjust(Duration.millis(1));
230 yield* Fiber.join(watcher);
231 }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerSessionStore(), TestClock.layer()))),
232 );
233
234 it.effect("keys remote cookies by environment identity instead of state directory", () =>
235 Effect.gen(function* () {
236 const cookieName = (stateDir: string, environmentId: EnvironmentId) =>
237 Effect.gen(function* () {
238 const sessions = yield* SessionStore.SessionStore;
239 return sessions.cookieName;
240 }).pipe(
241 Effect.provide(
242 layerSessionStore({ mode: "web", host: "192.168.1.50", stateDir }, environmentId),
243 ),
244 );
245
246 const original = yield* cookieName("/srv/t3-one", EnvironmentId.make("environment-one"));
247 const moved = yield* cookieName("/srv/t3-moved", EnvironmentId.make("environment-one"));
248 const other = yield* cookieName("/srv/t3-one", EnvironmentId.make("environment-two"));
249
250 expect(moved).toBe(original);
251 expect(other).not.toBe(original);
252 }),
253 );
254
255 it.effect("keeps reusable dev auth local across disk-backed stores and restarts", () =>
256 Effect.gen(function* () {
257 const fs = yield* FileSystem.FileSystem;
258 const token = "reusable-dev-auth-token-that-is-long-enough";
259 const baseA = yield* fs.makeTempDirectoryScoped({ prefix: "t3-dev-auth-a-" });
260 const baseB = yield* fs.makeTempDirectoryScoped({ prefix: "t3-dev-auth-b-" });
261 const layerA = yield* makeDiskSessionStoreLayer(baseA, token);
262 const fromA = yield* Effect.gen(function* () {
263 const sessions = yield* SessionStore.SessionStore;
264 const dev = yield* sessions.verify(token);
265 const local = yield* sessions.issue({ subject: "environment-a" });
266 const ticket = yield* sessions.issueWebSocketToken(local.sessionId);
267 yield* sessions.revoke(dev.sessionId);
268 return { dev, local, ticket };
269 }).pipe(Effect.provide(layerA), Effect.scoped);
270
271 const layerB = yield* makeDiskSessionStoreLayer(baseB, token);
272 yield* Effect.gen(function* () {
273 const sessions = yield* SessionStore.SessionStore;
274 const dev = yield* sessions.verify(token);
275 expect(dev.sessionId).toBe(fromA.dev.sessionId);
276 expect((yield* Effect.flip(sessions.verify(fromA.local.token)))._tag).toBe(
277 "InvalidSessionTokenSignatureError",
278 );
279 expect((yield* Effect.flip(sessions.verifyWebSocketToken(fromA.ticket.token)))._tag).toBe(
280 "InvalidWebSocketTokenSignatureError",
281 );
282 }).pipe(Effect.provide(layerB), Effect.scoped);
283
284 const layerReopenedB = yield* makeDiskSessionStoreLayer(baseB, token);
285 yield* Effect.gen(function* () {
286 const sessions = yield* SessionStore.SessionStore;
287 const dev = yield* sessions.verify(token);
288 expect(dev.sessionId).toBe(fromA.dev.sessionId);
289 const ticket = yield* sessions.issueWebSocketToken(dev.sessionId);
290 expect((yield* sessions.verifyWebSocketToken(ticket.token)).sessionId).toBe(dev.sessionId);
291 }).pipe(Effect.provide(layerReopenedB), Effect.scoped);
292
293 const layerReopenedA = yield* makeDiskSessionStoreLayer(baseA, token);
294 yield* Effect.gen(function* () {
295 const sessions = yield* SessionStore.SessionStore;
296 expect((yield* Effect.flip(sessions.verify(token)))._tag).toBe("SessionTokenRevokedError");
297 }).pipe(Effect.provide(layerReopenedA), Effect.scoped);
298 }),
299 );
300
301 it.effect("invalidates old dev credentials and tickets after rotation or removal", () =>
302 Effect.gen(function* () {
303 const fs = yield* FileSystem.FileSystem;
304 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-dev-auth-rotation-" });
305 const oldToken = "old-reusable-dev-auth-token-that-is-long-enough";
306 const newToken = "new-reusable-dev-auth-token-that-is-long-enough";
307 const layerInitial = yield* makeDiskSessionStoreLayer(baseDir, oldToken);
308 const old = yield* Effect.gen(function* () {
309 const sessions = yield* SessionStore.SessionStore;
310 const dev = yield* sessions.verify(oldToken);
311 const ticket = yield* sessions.issueWebSocketToken(dev.sessionId);
312 return { dev, ticket };
313 }).pipe(Effect.provide(layerInitial), Effect.scoped);
314
315 const layerRotated = yield* makeDiskSessionStoreLayer(baseDir, newToken);
316 const rotated = yield* Effect.gen(function* () {
317 const sessions = yield* SessionStore.SessionStore;
318 expect((yield* Effect.flip(sessions.verify(oldToken)))._tag).toBe(
319 "MalformedSessionTokenError",
320 );
321 expect((yield* Effect.flip(sessions.verifyWebSocketToken(old.ticket.token)))._tag).toBe(
322 "UnknownWebSocketSessionError",
323 );
324 expect(
325 (yield* sessions.listActive()).some((row) => row.sessionId === old.dev.sessionId),
326 ).toBe(true);
327 const dev = yield* sessions.verify(newToken);
328 const ticket = yield* sessions.issueWebSocketToken(dev.sessionId);
329 return { dev, ticket };
330 }).pipe(Effect.provide(layerRotated), Effect.scoped);
331
332 const layerRemoved = yield* makeDiskSessionStoreLayer(baseDir);
333 yield* Effect.gen(function* () {
334 const sessions = yield* SessionStore.SessionStore;
335 expect((yield* Effect.flip(sessions.verify(newToken)))._tag).toBe(
336 "MalformedSessionTokenError",
337 );
338 expect((yield* Effect.flip(sessions.verifyWebSocketToken(rotated.ticket.token)))._tag).toBe(
339 "UnknownWebSocketSessionError",
340 );
341 expect(
342 (yield* sessions.listActive()).some((row) => row.sessionId === rotated.dev.sessionId),
343 ).toBe(true);
344 }).pipe(Effect.provide(layerRemoved), Effect.scoped);
345 }),
346 );
347
348 it.effect("carries a runtime-mode ceiling only on sessions issued with one", () =>
349 Effect.gen(function* () {
350 const sessions = yield* SessionStore.SessionStore;
351 const capped = yield* sessions.issue({
352 subject: "mcp-client",
353 method: "bearer-access-token",
354 runtimeModeCeiling: "auto",
355 });
356 const uncapped = yield* sessions.issue({ method: "bearer-access-token" });
357
358 expect((yield* sessions.verify(capped.token)).runtimeModeCeiling).toBe("auto");
359 expect((yield* sessions.verify(uncapped.token)).runtimeModeCeiling).toBeUndefined();
360 }).pipe(Effect.provide(layerSessionStore())),
361 );
362 it.effect("takes scopes from the session record", () =>
363 Effect.gen(function* () {
364 const sessions = yield* SessionStore.SessionStore;
365 const secrets = yield* ServerSecretStore.ServerSecretStore;
366 const legacyScopes = ["orchestration:read", "terminal:operate", "review:write"] as const;
367 const issued = yield* sessions.issue({ subject: "one-time-token", scopes: legacyScopes });
368 const [encodedPayload] = issued.token.split(".");
369 const currentClaims = base64UrlDecodeUtf8(encodedPayload!);
370 expect(currentClaims).toContain('"v":1');
371 const signer = yield* PayloadSigner.fromSecret(
372 yield* secrets.getOrCreateRandom("server-signing-key", 32),
373 );
374 const resign = (claims: string) => {
375 const payload = base64UrlEncode(claims);
376 return signer.sign(payload).pipe(Effect.map((signature) => `${payload}.${signature}`));
377 };
378 const legacyToken = yield* resign(currentClaims.replace('"v":1', '"v":2'));
379 const editedToken = yield* resign(
380 currentClaims.replace(/"scopes":\[[^\]]*\]/, '"scopes":["access:write"]'),
381 );
382
383 expect((yield* sessions.verify(issued.token)).scopes).toEqual(legacyScopes);
384 expect((yield* sessions.verify(legacyToken)).scopes).toEqual(legacyScopes);
385 expect((yield* sessions.verify(editedToken)).scopes).toEqual(legacyScopes);
386 }).pipe(
387 Effect.provide(
388 SessionStore.layer.pipe(
389 Layer.provideMerge(ServerSecretStore.layer),
390 Layer.provide(SqlitePersistence.layerMemory),
391 Layer.provide(layerServerEnvironment(EnvironmentId.make("test-environment"))),
392 Layer.provide(layerServerConfig()),
393 ),
394 ),
395 ),
396 );
397
398 it.effect("atomically replaces active sessions with the same subject and method", () =>
399 Effect.gen(function* () {
400 const sessions = yield* SessionStore.SessionStore;
401 const browser = yield* sessions.issue({
402 subject: "desktop-bootstrap",
403 method: "browser-session-cookie",
404 });
405 const [firstBearer, secondBearer] = yield* Effect.all(
406 [
407 sessions.issue({
408 subject: "desktop-bootstrap",
409 method: "bearer-access-token",
410 replaceActiveForSubjectAndMethod: true,
411 }),
412 sessions.issue({
413 subject: "desktop-bootstrap",
414 method: "bearer-access-token",
415 replaceActiveForSubjectAndMethod: true,
416 }),
417 ],
418 { concurrency: "unbounded" },
419 );
420
421 const active = yield* sessions.listActive();
422 const bearerVerification = yield* Effect.all([
423 sessions.verify(firstBearer.token).pipe(Effect.option),
424 sessions.verify(secondBearer.token).pipe(Effect.option),
425 ]);
426
427 expect(active).toHaveLength(2);
428 expect(active.find((entry) => entry.sessionId === browser.sessionId)).toBeDefined();
429 expect(
430 active.filter(
431 (entry) =>
432 entry.subject === "desktop-bootstrap" && entry.method === "bearer-access-token",
433 ),
434 ).toHaveLength(1);
435 expect(bearerVerification.filter(Option.isSome)).toHaveLength(1);
436 }).pipe(Effect.provide(layerSessionStore())),
437 );
438
439 it.effect("keeps the previous desktop session valid when replacement fails", () =>
440 Effect.gen(function* () {
441 const sessions = yield* SessionStore.SessionStore;
442 const sql = yield* SqlClient.SqlClient;
443 const previous = yield* sessions.issue({
444 subject: "desktop-bootstrap",
445 method: "bearer-access-token",
446 });
447 yield* sql`
448 CREATE TRIGGER reject_auth_session_insert BEFORE INSERT ON auth_sessions
449 BEGIN
450 SELECT RAISE(ABORT, 'simulated insert failure');
451 END
452 `;
453
454 const error = yield* sessions
455 .issue({
456 subject: "desktop-bootstrap",
457 method: "bearer-access-token",
458 replaceActiveForSubjectAndMethod: true,
459 })
460 .pipe(Effect.flip);
461
462 expect(error._tag).toBe("SessionCredentialIssueError");
463 expect((yield* sessions.verify(previous.token)).sessionId).toBe(previous.sessionId);
464 expect((yield* sessions.listActive()).map((session) => session.sessionId)).toEqual([
465 previous.sessionId,
466 ]);
467 }).pipe(Effect.provide(Layer.mergeAll(layerSessionStore(), SqlitePersistence.layerMemory))),
468 );
469
470 it.effect.each(["insert", "revoke"] as const)(
471 "keeps existing browser sessions valid when replacement cannot %s",
472 (operation) =>
473 Effect.gen(function* () {
474 const sessions = yield* SessionStore.SessionStore;
475 const sql = yield* SqlClient.SqlClient;
476 const previous = yield* sessions.issue({ subject: "one-time-token" });
477 const unrelated = yield* sessions.issue({ subject: "one-time-token" });
478 if (operation === "insert") {
479 yield* sql`
480 CREATE TRIGGER reject_auth_session_insert BEFORE INSERT ON auth_sessions
481 BEGIN
482 SELECT RAISE(ABORT, 'simulated insert failure');
483 END
484 `;
485 } else {
486 yield* sql`
487 CREATE TRIGGER reject_auth_session_revocation BEFORE UPDATE OF revoked_at ON auth_sessions
488 BEGIN
489 SELECT RAISE(ABORT, 'simulated revocation failure');
490 END
491 `;
492 }
493
494 const error = yield* sessions
495 .issue({
496 subject: "replacement-pairing",
497 scopes: ["orchestration:read"],
498 replaceSessionId: previous.sessionId,
499 })
500 .pipe(Effect.flip);
501
502 expect(error._tag).toBe("SessionCredentialIssueError");
503 expect((yield* sessions.verify(previous.token)).sessionId).toBe(previous.sessionId);
504 expect((yield* sessions.verify(unrelated.token)).sessionId).toBe(unrelated.sessionId);
505 expect((yield* sessions.listActive()).map((session) => session.sessionId).sort()).toEqual(
506 [previous.sessionId, unrelated.sessionId].sort(),
507 );
508 }).pipe(Effect.provide(Layer.mergeAll(layerSessionStore(), SqlitePersistence.layerMemory))),
509 );
510
511 it.effect("rejects websocket tokens once the parent session has expired", () =>
512 Effect.gen(function* () {
513 const sessions = yield* SessionStore.SessionStore;
514 const issued = yield* sessions.issue({
515 method: "bearer-access-token",
516 subject: "short-lived",
517 ttl: Duration.seconds(1),
518 });
519 const websocket = yield* sessions.issueWebSocketToken(issued.sessionId);
520
521 yield* TestClock.adjust(Duration.seconds(2));
522
523 const error = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token));
524 expect(error._tag).toBe("WebSocketSessionExpiredError");
525 if (error._tag === "WebSocketSessionExpiredError") {
526 expect(error.sessionId).toBe(issued.sessionId);
527 expect(error.expiresAt.epochMilliseconds).toBe(issued.expiresAt.epochMilliseconds);
528 expect(error.observedAt.epochMilliseconds).toBeGreaterThan(
529 error.expiresAt.epochMilliseconds,
530 );
531 }
532 }).pipe(Effect.provide(Layer.merge(layerSessionStore(), TestClock.layer()))),
533 );
534
535 it.effect("lists active sessions, tracks connectivity, and revokes other sessions", () =>
536 Effect.gen(function* () {
537 const sessions = yield* SessionStore.SessionStore;
538 const administrative = yield* sessions.issue({
539 subject: "desktop-bootstrap",
540 scopes: ["orchestration:read", "access:write"],
541 client: {
542 label: "Desktop app",
543 deviceType: "desktop",
544 os: "macOS",
545 browser: "Electron",
546 },
547 });
548 const client = yield* sessions.issue({
549 subject: "one-time-token",
550 scopes: ["orchestration:read"],
551 client: {
552 label: "Julius iPhone",
553 deviceType: "mobile",
554 os: "iOS",
555 browser: "Safari",
556 ipAddress: "192.168.1.88",
557 },
558 });
559 const clientWebSocket = yield* sessions.issueWebSocketToken(client.sessionId);
560
561 yield* sessions.markConnected(client.sessionId);
562 const beforeRevoke = yield* sessions.listActive();
563 const revokedCount = yield* sessions.revokeAllExcept(administrative.sessionId);
564 const afterRevoke = yield* sessions.listActive();
565 const revokedClient = yield* Effect.flip(sessions.verify(client.token));
566 const revokedClientWebSocket = yield* Effect.flip(
567 sessions.verifyWebSocketToken(clientWebSocket.token),
568 );
569
570 expect(beforeRevoke).toHaveLength(2);
571 expect(beforeRevoke.find((entry) => entry.sessionId === client.sessionId)?.connected).toBe(
572 true,
573 );
574 expect(beforeRevoke.find((entry) => entry.sessionId === client.sessionId)?.client.label).toBe(
575 "Julius iPhone",
576 );
577 expect(
578 beforeRevoke.find((entry) => entry.sessionId === administrative.sessionId)?.client
579 .deviceType,
580 ).toBe("desktop");
581 expect(revokedCount).toBe(1);
582 expect(afterRevoke).toHaveLength(1);
583 expect(afterRevoke[0]?.sessionId).toBe(administrative.sessionId);
584 expect(revokedClient._tag).toBe("SessionTokenRevokedError");
585 if (revokedClient._tag === "SessionTokenRevokedError") {
586 expect(revokedClient.sessionId).toBe(client.sessionId);
587 expect(revokedClient.revokedAt.epochMilliseconds).toBeGreaterThanOrEqual(0);
588 }
589 expect(revokedClientWebSocket._tag).toBe("WebSocketSessionRevokedError");
590 if (revokedClientWebSocket._tag === "WebSocketSessionRevokedError") {
591 expect(revokedClientWebSocket.sessionId).toBe(client.sessionId);
592 expect(revokedClientWebSocket.revokedAt.epochMilliseconds).toBeGreaterThanOrEqual(0);
593 }
594 }).pipe(Effect.provide(layerSessionStore())),
595 );
596});