History

[codex] align server auth Effect services (#3180)

Julius Marminge committed 4 months agov0.0.28-nightly.20260620.608

Commit 97e5cd31 parent b19fc1b22 files · +1434 −764Browse files

Unified · Split

22 files
  1. apps/desktop/src/main.ts +2 −2
  2. apps/server/src/assets/AssetAccess.ts +6 −6
  3. apps/server/src/auth/EnvironmentAuth.test.ts +10 −17
  4. apps/server/src/auth/EnvironmentAuth.ts +492 −222
  5. apps/server/src/auth/EnvironmentAuthAdmin.test.ts +5 −1
  6. apps/server/src/auth/EnvironmentAuthPolicy.ts +9 −11
  7. apps/server/src/auth/PairingGrantStore.test.ts +5 −5
  8. apps/server/src/auth/PairingGrantStore.ts +150 −82
  9. apps/server/src/auth/ServerSecretStore.test.ts +4 −4
  10. apps/server/src/auth/ServerSecretStore.ts +157 −48
  11. apps/server/src/auth/SessionStore.test.ts +4 −4
  12. apps/server/src/auth/SessionStore.ts +343 −150
  13. apps/server/src/auth/dpop.test.ts +10 −6
  14. apps/server/src/auth/dpop.ts +17 −13
  15. apps/server/src/auth/http.ts +42 −36
  16. apps/server/src/cloud/environmentKeys.test.ts +4 −4
  17. apps/server/src/cloud/environmentKeys.ts +17 −18
  18. apps/server/src/cloud/http.test.ts +26 −2
  19. apps/server/src/cloud/http.ts +113 −119
  20. apps/server/src/http.ts +6 −4
  21. apps/server/src/relay/AgentAwarenessRelay.test.ts +6 −6
  22. apps/server/src/ws.ts +6 −4
apps/desktop/src/main.ts+2−2
@@ -1,7 +1,7 @@
11 import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient";
22 import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
33 import * as NodeServices from "@effect/platform-node/NodeServices";
4−import { homedir } from "node:os";
4+import * as NodeOS from "node:os";
55 import * as Effect from "effect/Effect";
66 import * as Layer from "effect/Layer";
77 import * as Option from "effect/Option";
@@ -60,7 +60,7 @@ const desktopEnvironmentLayer = Layer.unwrap( · 52 unmodified lines
6060 const processArch = yield* HostProcessArchitecture;
6161 return DesktopEnvironment.layer({
6262 dirname: __dirname,
63− homeDirectory: homedir(),
63+ homeDirectory: NodeOS.homedir(),
6464 platform,
6565 processArch,
6666 ...metadata,
apps/server/src/assets/AssetAccess.ts+6−6
@@ -19,9 +19,9 @@ import { · 18 unmodified lines
1919 signPayload,
2020 timingSafeEqualBase64Url,
2121 } from "../auth/utils.ts";
22−import { ServerSecretStore } from "../auth/ServerSecretStore.ts";
22+import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
2323 import { resolveAttachmentPathById } from "../attachmentStore.ts";
24−import { ServerConfig } from "../config.ts";
24+import * as ServerConfig from "../config.ts";
2525 import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
2626 import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
2727
@@ -181,7 +181,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i · 153 unmodified lines
181181 break;
182182 }
183183 case "attachment": {
184− const config = yield* ServerConfig;
184+ const config = yield* ServerConfig.ServerConfig;
185185 const attachmentPath = resolveAttachmentPathById({
186186 attachmentsDir: config.attachmentsDir,
187187 attachmentId: input.resource.attachmentId,
@@ -225,7 +225,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i · 37 unmodified lines
225225 }
226226 }
227227
228− const secretStore = yield* ServerSecretStore;
228+ const secretStore = yield* ServerSecretStore.ServerSecretStore;
229229 const signingSecret = yield* secretStore
230230 .getOrCreateRandom(SIGNING_SECRET_NAME, 32)
231231 .pipe(Effect.mapError((cause) => failAccess(cause.message, cause)));
@@ -244,7 +244,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( · 12 unmodified lines
244244 const [encodedPayload, signature] = token.split(".");
245245 if (!encodedPayload || !signature) return null;
246246
247− const secretStore = yield* ServerSecretStore;
247+ const secretStore = yield* ServerSecretStore.ServerSecretStore;
248248 const signingSecret = yield* secretStore
249249 .getOrCreateRandom(SIGNING_SECRET_NAME, 32)
250250 .pipe(Effect.orElseSucceed(() => null));
@@ -255,7 +255,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( · 4 unmodified lines
255255 if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) return null;
256256
257257 if (claims.kind === "attachment") {
258− const config = yield* ServerConfig;
258+ const config = yield* ServerConfig.ServerConfig;
259259 const attachmentPath = resolveAttachmentPathById({
260260 attachmentsDir: config.attachmentsDir,
261261 attachmentId: claims.attachmentId,
apps/server/src/auth/EnvironmentAuth.test.ts+10−17
@@ -53,29 +53,25 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { · 52 unmodified lines
5353 it.effect("classifies invalid bootstrap credential failures for the HTTP boundary", () =>
5454 Effect.sync(() => {
5555 const error = EnvironmentAuth.toBootstrapExchangeError(
56− new PairingGrantStore.BootstrapCredentialInvalidError({
57− message: "Unknown bootstrap credential.",
58− }),
56+ new PairingGrantStore.UnknownBootstrapCredentialError({}),
5957 );
6058
6159 expect(error._tag).toBe("ServerAuthInvalidCredentialError");
62− if (error._tag === "ServerAuthInvalidCredentialError") {
63− expect(error.reason).toBe("invalid_credential");
64− }
6560 }),
6661 );
6762
6863 it.effect("maps unexpected bootstrap failures to 500", () =>
6964 Effect.sync(() => {
70− const error = EnvironmentAuth.toBootstrapExchangeError(
71− new PairingGrantStore.BootstrapCredentialInternalError({
72− message: "Failed to consume bootstrap credential.",
73− cause: new Error("sqlite is unavailable"),
74− }),
75− );
65+ const cause = new PairingGrantStore.BootstrapCredentialConsumeError({
66+ cause: new Error("sqlite is unavailable"),
67+ });
68+ const error = EnvironmentAuth.toBootstrapExchangeError(cause);
7669
77− expect(error._tag).toBe("ServerAuthInternalError");
70+ expect(error._tag).toBe("ServerAuthBootstrapCredentialValidationError");
7871 expect(error.message).toBe("Failed to validate bootstrap credential.");
72+ if (error._tag === "ServerAuthBootstrapCredentialValidationError") {
73+ expect(error.cause).toBe(cause);
74+ }
7975 }),
8076 );
8177
@@ -117,10 +113,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { · 35 unmodified lines
117113 )
118114 .pipe(Effect.flip);
119115
120− expect(error._tag).toBe("ServerAuthInvalidRequestError");
121− if (error._tag === "ServerAuthInvalidRequestError") {
122− expect(error.reason).toBe("scope_not_granted");
123− }
116+ expect(error._tag).toBe("ServerAuthScopeNotGrantedError");
124117 }).pipe(Effect.provide(makeEnvironmentAuthLayer())),
125118 );
126119
apps/server/src/auth/EnvironmentAuth.ts+492−222
@@ -20,12 +20,12 @@ import { · 19 unmodified lines
2020 import { encodeOAuthScope } from "@t3tools/shared/oauthScope";
2121 import * as Context from "effect/Context";
2222 import * as Crypto from "effect/Crypto";
23−import * as Data from "effect/Data";
2423 import * as DateTime from "effect/DateTime";
2524 import * as Duration from "effect/Duration";
2625 import * as Effect from "effect/Effect";
2726 import * as Layer from "effect/Layer";
2827 import * as Option from "effect/Option";
28+import * as Schema from "effect/Schema";
2929 import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
3030
3131 import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts";
@@ -67,123 +67,429 @@ export interface AuthenticatedSession { · 35 unmodified lines
6767 readonly expiresAt?: DateTime.DateTime;
6868 }
6969
70−export class ServerAuthInternalError extends Data.TaggedError("ServerAuthInternalError")<{
71− readonly message: string;
72− readonly cause?: unknown;
73−}> {}
70+const serverAuthInternalErrorContext = {
71+ cause: Schema.Defect(),
72+};
73+
74+export class ServerAuthBootstrapCredentialValidationError extends Schema.TaggedErrorClass<ServerAuthBootstrapCredentialValidationError>()(
75+ "ServerAuthBootstrapCredentialValidationError",
76+ {
77+ ...serverAuthInternalErrorContext,
78+ },
79+) {
80+ override get message(): string {
81+ return "Failed to validate bootstrap credential.";
82+ }
83+}
84+
85+export class ServerAuthSessionCredentialValidationError extends Schema.TaggedErrorClass<ServerAuthSessionCredentialValidationError>()(
86+ "ServerAuthSessionCredentialValidationError",
87+ {
88+ ...serverAuthInternalErrorContext,
89+ },
90+) {
91+ override get message(): string {
92+ return "Failed to validate session credential.";
93+ }
94+}
95+
96+export class ServerAuthAuthenticatedSessionIssueError extends Schema.TaggedErrorClass<ServerAuthAuthenticatedSessionIssueError>()(
97+ "ServerAuthAuthenticatedSessionIssueError",
98+ {
99+ ...serverAuthInternalErrorContext,
100+ },
101+) {
102+ override get message(): string {
103+ return "Failed to issue authenticated session.";
104+ }
105+}
106+
107+export class ServerAuthAuthenticatedAccessTokenIssueError extends Schema.TaggedErrorClass<ServerAuthAuthenticatedAccessTokenIssueError>()(
108+ "ServerAuthAuthenticatedAccessTokenIssueError",
109+ {
110+ ...serverAuthInternalErrorContext,
111+ },
112+) {
113+ override get message(): string {
114+ return "Failed to issue authenticated access token.";
115+ }
116+}
117+
118+export class ServerAuthPairingLinkCreationError extends Schema.TaggedErrorClass<ServerAuthPairingLinkCreationError>()(
119+ "ServerAuthPairingLinkCreationError",
120+ {
121+ ...serverAuthInternalErrorContext,
122+ },
123+) {
124+ override get message(): string {
125+ return "Failed to create pairing link.";
126+ }
127+}
128+
129+export class ServerAuthPairingLinksListError extends Schema.TaggedErrorClass<ServerAuthPairingLinksListError>()(
130+ "ServerAuthPairingLinksListError",
131+ {
132+ ...serverAuthInternalErrorContext,
133+ },
134+) {
135+ override get message(): string {
136+ return "Failed to list pairing links.";
137+ }
138+}
139+
140+export class ServerAuthPairingLinkRevocationError extends Schema.TaggedErrorClass<ServerAuthPairingLinkRevocationError>()(
141+ "ServerAuthPairingLinkRevocationError",
142+ {
143+ ...serverAuthInternalErrorContext,
144+ },
145+) {
146+ override get message(): string {
147+ return "Failed to revoke pairing link.";
148+ }
149+}
150+
151+export class ServerAuthSessionTokenIssueError extends Schema.TaggedErrorClass<ServerAuthSessionTokenIssueError>()(
152+ "ServerAuthSessionTokenIssueError",
153+ {
154+ ...serverAuthInternalErrorContext,
155+ },
156+) {
157+ override get message(): string {
158+ return "Failed to issue session token.";
159+ }
160+}
161+
162+export class ServerAuthSessionsListError extends Schema.TaggedErrorClass<ServerAuthSessionsListError>()(
163+ "ServerAuthSessionsListError",
164+ {
165+ ...serverAuthInternalErrorContext,
166+ },
167+) {
168+ override get message(): string {
169+ return "Failed to list sessions.";
170+ }
171+}
172+
173+export class ServerAuthSessionRevocationError extends Schema.TaggedErrorClass<ServerAuthSessionRevocationError>()(
174+ "ServerAuthSessionRevocationError",
175+ {
176+ ...serverAuthInternalErrorContext,
177+ },
178+) {
179+ override get message(): string {
180+ return "Failed to revoke session.";
181+ }
182+}
183+
184+export class ServerAuthOtherSessionsRevocationError extends Schema.TaggedErrorClass<ServerAuthOtherSessionsRevocationError>()(
185+ "ServerAuthOtherSessionsRevocationError",
186+ {
187+ ...serverAuthInternalErrorContext,
188+ },
189+) {
190+ override get message(): string {
191+ return "Failed to revoke other sessions.";
192+ }
193+}
194+
195+export class ServerAuthWebSocketTokenIssueError extends Schema.TaggedErrorClass<ServerAuthWebSocketTokenIssueError>()(
196+ "ServerAuthWebSocketTokenIssueError",
197+ {
198+ ...serverAuthInternalErrorContext,
199+ },
200+) {
201+ override get message(): string {
202+ return "Failed to issue websocket token.";
203+ }
204+}
205+
206+export class ServerAuthDpopReplayStateRecordError extends Schema.TaggedErrorClass<ServerAuthDpopReplayStateRecordError>()(
207+ "ServerAuthDpopReplayStateRecordError",
208+ {
209+ ...serverAuthInternalErrorContext,
210+ },
211+) {
212+ override get message(): string {
213+ return "Failed to record DPoP proof replay state.";
214+ }
215+}
216+
217+export class ServerAuthDpopReplayKeyCalculationError extends Schema.TaggedErrorClass<ServerAuthDpopReplayKeyCalculationError>()(
218+ "ServerAuthDpopReplayKeyCalculationError",
219+ {
220+ ...serverAuthInternalErrorContext,
221+ },
222+) {
223+ override get message(): string {
224+ return "Failed to calculate DPoP replay key.";
225+ }
226+}
227+
228+export class ServerAuthLinkedCloudAccountVerificationError extends Schema.TaggedErrorClass<ServerAuthLinkedCloudAccountVerificationError>()(
229+ "ServerAuthLinkedCloudAccountVerificationError",
230+ {
231+ ...serverAuthInternalErrorContext,
232+ },
233+) {
234+ override get message(): string {
235+ return "Could not verify the linked cloud account.";
236+ }
237+}
238+
239+export class ServerAuthLinkedCloudAccountReadError extends Schema.TaggedErrorClass<ServerAuthLinkedCloudAccountReadError>()(
240+ "ServerAuthLinkedCloudAccountReadError",
241+ {
242+ ...serverAuthInternalErrorContext,
243+ },
244+) {
245+ override get message(): string {
246+ return "Could not read the linked cloud account.";
247+ }
248+}
249+
250+export class ServerAuthLinkedCloudAccountMissingError extends Schema.TaggedErrorClass<ServerAuthLinkedCloudAccountMissingError>()(
251+ "ServerAuthLinkedCloudAccountMissingError",
252+ {},
253+) {
254+ override get message(): string {
255+ return "Cloud linked user is not installed for this environment.";
256+ }
257+}
258+
259+export class ServerAuthCloudLinkJwtSigningError extends Schema.TaggedErrorClass<ServerAuthCloudLinkJwtSigningError>()(
260+ "ServerAuthCloudLinkJwtSigningError",
261+ {
262+ ...serverAuthInternalErrorContext,
263+ },
264+) {
265+ override get message(): string {
266+ return "Failed to sign cloud link JWT.";
267+ }
268+}
269+
270+export class ServerAuthCloudMintPublicKeyMissingError extends Schema.TaggedErrorClass<ServerAuthCloudMintPublicKeyMissingError>()(
271+ "ServerAuthCloudMintPublicKeyMissingError",
272+ {},
273+) {
274+ override get message(): string {
275+ return "Cloud mint public key is not installed for this environment.";
276+ }
277+}
278+
279+export class ServerAuthCloudRelayIssuerMissingError extends Schema.TaggedErrorClass<ServerAuthCloudRelayIssuerMissingError>()(
280+ "ServerAuthCloudRelayIssuerMissingError",
281+ {},
282+) {
283+ override get message(): string {
284+ return "Cloud relay issuer is not installed for this environment.";
285+ }
286+}
74287
75−export class ServerAuthInvalidCredentialError extends Data.TaggedError(
288+export class ServerAuthCloudHealthJwtSigningError extends Schema.TaggedErrorClass<ServerAuthCloudHealthJwtSigningError>()(
289+ "ServerAuthCloudHealthJwtSigningError",
290+ {
291+ ...serverAuthInternalErrorContext,
292+ },
293+) {
294+ override get message(): string {
295+ return "Failed to sign cloud health JWT.";
296+ }
297+}
298+
299+export class ServerAuthCloudMintJwtSigningError extends Schema.TaggedErrorClass<ServerAuthCloudMintJwtSigningError>()(
300+ "ServerAuthCloudMintJwtSigningError",
301+ {
302+ ...serverAuthInternalErrorContext,
303+ },
304+) {
305+ override get message(): string {
306+ return "Failed to sign cloud mint JWT.";
307+ }
308+}
309+
310+export const ServerAuthInternalError = Schema.Union([
311+ ServerAuthBootstrapCredentialValidationError,
312+ ServerAuthSessionCredentialValidationError,
313+ ServerAuthAuthenticatedSessionIssueError,
314+ ServerAuthAuthenticatedAccessTokenIssueError,
315+ ServerAuthPairingLinkCreationError,
316+ ServerAuthPairingLinksListError,
317+ ServerAuthPairingLinkRevocationError,
318+ ServerAuthSessionTokenIssueError,
319+ ServerAuthSessionsListError,
320+ ServerAuthSessionRevocationError,
321+ ServerAuthOtherSessionsRevocationError,
322+ ServerAuthWebSocketTokenIssueError,
323+ ServerAuthDpopReplayStateRecordError,
324+ ServerAuthDpopReplayKeyCalculationError,
325+ ServerAuthLinkedCloudAccountVerificationError,
326+ ServerAuthLinkedCloudAccountReadError,
327+ ServerAuthLinkedCloudAccountMissingError,
328+ ServerAuthCloudLinkJwtSigningError,
329+ ServerAuthCloudMintPublicKeyMissingError,
330+ ServerAuthCloudRelayIssuerMissingError,
331+ ServerAuthCloudHealthJwtSigningError,
332+ ServerAuthCloudMintJwtSigningError,
333+]);
334+export type ServerAuthInternalError = typeof ServerAuthInternalError.Type;
335+export const isServerAuthInternalError = Schema.is(ServerAuthInternalError);
336+
337+export class ServerAuthMissingCredentialError extends Schema.TaggedErrorClass<ServerAuthMissingCredentialError>()(
338+ "ServerAuthMissingCredentialError",
339+ {},
340+) {
341+ override get message(): string {
342+ return "Server authentication credential is missing.";
343+ }
344+}
345+
346+export class ServerAuthInvalidCredentialError extends Schema.TaggedErrorClass<ServerAuthInvalidCredentialError>()(
76347 "ServerAuthInvalidCredentialError",
77−)<{
78− readonly reason: "missing_credential" | "invalid_credential";
79− readonly cause?: unknown;
80−}> {}
81−
82−export class ServerAuthInvalidRequestError extends Data.TaggedError(
83− "ServerAuthInvalidRequestError",
84−)<{
85− readonly reason: "invalid_scope" | "scope_not_granted";
86−}> {}
87−
88−export class ServerAuthForbiddenOperationError extends Data.TaggedError(
89− "ServerAuthForbiddenOperationError",
90−)<{
91− readonly reason: "current_session_revoke_not_allowed";
92−}> {}
348+ {
349+ diagnostic: Schema.optional(Schema.String),
350+ cause: Schema.optional(Schema.Defect()),
351+ },
352+) {
353+ override get message(): string {
354+ return "Server authentication credential is invalid.";
355+ }
356+}
93357
94−export interface EnvironmentAuthShape {
95− readonly getDescriptor: () => Effect.Effect<ServerAuthDescriptor>;
96− readonly getSessionState: (
97− request: HttpServerRequest.HttpServerRequest,
98− ) => Effect.Effect<AuthSessionState, ServerAuthInternalError>;
99− readonly createBrowserSession: (
100− credential: string,
101− requestMetadata: AuthClientMetadata,
102− ) => Effect.Effect<
103− {
104− readonly response: AuthBrowserSessionResult;
105− readonly sessionToken: string;
106− },
107− ServerAuthInvalidCredentialError | ServerAuthInternalError
108− >;
109− readonly exchangeBootstrapCredentialForAccessToken: (
110− credential: string,
111− requestedScopes: ReadonlyArray<AuthEnvironmentScope> | undefined,
112− requestMetadata: AuthClientMetadata,
113− input?: {
114− readonly proofKeyThumbprint?: string;
115− },
116− ) => Effect.Effect<
117− AuthAccessTokenResult,
118− ServerAuthInvalidCredentialError | ServerAuthInvalidRequestError | ServerAuthInternalError
119− >;
120− readonly createPairingLink: (input?: {
121− readonly ttl?: Duration.Duration;
122− readonly label?: string;
123− readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
124− readonly subject?: string;
125− readonly proofKeyThumbprint?: string;
126− }) => Effect.Effect<IssuedPairingLink, ServerAuthInternalError>;
127− readonly issuePairingCredential: (
128− input?: AuthCreatePairingCredentialInput,
129− ) => Effect.Effect<AuthPairingCredentialResult, ServerAuthInternalError>;
130− readonly issueStartupPairingCredential: () => Effect.Effect<
131− AuthPairingCredentialResult,
132− ServerAuthInternalError
133− >;
134− readonly listPairingLinks: (input?: {
135− readonly excludeSubjects?: ReadonlyArray<string>;
136− }) => Effect.Effect<ReadonlyArray<AuthPairingLink>, ServerAuthInternalError>;
137− readonly revokePairingLink: (id: string) => Effect.Effect<boolean, ServerAuthInternalError>;
138− readonly issueSession: (input?: {
139− readonly ttl?: Duration.Duration;
140− readonly subject?: string;
141− readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
142− readonly label?: string;
143− }) => Effect.Effect<IssuedBearerSession, ServerAuthInternalError>;
144− readonly listSessions: () => Effect.Effect<
145− ReadonlyArray<AuthClientSession>,
146− ServerAuthInternalError
147− >;
148− readonly revokeSession: (
149− sessionId: AuthSessionId,
150− ) => Effect.Effect<boolean, ServerAuthInternalError>;
151− readonly revokeOtherSessionsExcept: (
152− sessionId: AuthSessionId,
153− ) => Effect.Effect<number, ServerAuthInternalError>;
154− readonly listClientSessions: (
155− currentSessionId: AuthSessionId,
156− ) => Effect.Effect<ReadonlyArray<AuthClientSession>, ServerAuthInternalError>;
157− readonly revokeClientSession: (
158− currentSessionId: AuthSessionId,
159− targetSessionId: AuthSessionId,
160− ) => Effect.Effect<boolean, ServerAuthForbiddenOperationError | ServerAuthInternalError>;
161− readonly revokeOtherClientSessions: (
162− currentSessionId: AuthSessionId,
163− ) => Effect.Effect<number, ServerAuthInternalError>;
164− readonly authenticateHttpRequest: (
165− request: HttpServerRequest.HttpServerRequest,
166− ) => Effect.Effect<
167− AuthenticatedSession,
168− ServerAuthInvalidCredentialError | ServerAuthInternalError
169− >;
170− readonly authenticateWebSocketUpgrade: (
171− request: HttpServerRequest.HttpServerRequest,
172− ) => Effect.Effect<
173− AuthenticatedSession,
174− ServerAuthInvalidCredentialError | ServerAuthInternalError
175− >;
176− readonly issueWebSocketTicket: (
177− session: Pick<AuthenticatedSession, "sessionId">,
178− ) => Effect.Effect<AuthWebSocketTicketResult, ServerAuthInternalError>;
179− readonly issueStartupPairingUrl: (
180− baseUrl: string,
181− ) => Effect.Effect<string, ServerAuthInternalError>;
358+export const ServerAuthCredentialError = Schema.Union([
359+ ServerAuthMissingCredentialError,
360+ ServerAuthInvalidCredentialError,
361+]);
362+export type ServerAuthCredentialError = typeof ServerAuthCredentialError.Type;
363+export const isServerAuthCredentialError = Schema.is(ServerAuthCredentialError);
364+export const serverAuthCredentialReason = (
365+ error: ServerAuthCredentialError,
366+): "missing_credential" | "invalid_credential" =>
367+ error._tag === "ServerAuthMissingCredentialError" ? "missing_credential" : "invalid_credential";
368+
369+export class ServerAuthInvalidScopeError extends Schema.TaggedErrorClass<ServerAuthInvalidScopeError>()(
370+ "ServerAuthInvalidScopeError",
371+ {},
372+) {
373+ override get message(): string {
374+ return "The requested authentication scope is invalid.";
375+ }
182376 }
183377
184−export class EnvironmentAuth extends Context.Service<EnvironmentAuth, EnvironmentAuthShape>()(
185− "t3/auth/EnvironmentAuth",
186−) {}
378+export class ServerAuthScopeNotGrantedError extends Schema.TaggedErrorClass<ServerAuthScopeNotGrantedError>()(
379+ "ServerAuthScopeNotGrantedError",
380+ {},
381+) {
382+ override get message(): string {
383+ return "The requested authentication scope was not granted.";
384+ }
385+}
386+
387+export const ServerAuthInvalidRequestError = Schema.Union([
388+ ServerAuthInvalidScopeError,
389+ ServerAuthScopeNotGrantedError,
390+]);
391+export type ServerAuthInvalidRequestError = typeof ServerAuthInvalidRequestError.Type;
392+export const isServerAuthInvalidRequestError = Schema.is(ServerAuthInvalidRequestError);
393+export const serverAuthInvalidRequestReason = (
394+ error: ServerAuthInvalidRequestError,
395+): "invalid_scope" | "scope_not_granted" =>
396+ error._tag === "ServerAuthInvalidScopeError" ? "invalid_scope" : "scope_not_granted";
397+
398+export class ServerAuthForbiddenOperationError extends Schema.TaggedErrorClass<ServerAuthForbiddenOperationError>()(
399+ "ServerAuthForbiddenOperationError",
400+ {},
401+) {
402+ override get message(): string {
403+ return "The current authentication session cannot revoke itself.";
404+ }
405+}
406+
407+export class EnvironmentAuth extends Context.Service<
408+ EnvironmentAuth,
409+ {
410+ readonly getDescriptor: () => Effect.Effect<ServerAuthDescriptor>;
411+ readonly getSessionState: (
412+ request: HttpServerRequest.HttpServerRequest,
413+ ) => Effect.Effect<AuthSessionState, ServerAuthInternalError>;
414+ readonly createBrowserSession: (
415+ credential: string,
416+ requestMetadata: AuthClientMetadata,
417+ ) => Effect.Effect<
418+ {
419+ readonly response: AuthBrowserSessionResult;
420+ readonly sessionToken: string;
421+ },
422+ ServerAuthInvalidCredentialError | ServerAuthInternalError
423+ >;
424+ readonly exchangeBootstrapCredentialForAccessToken: (
425+ credential: string,
426+ requestedScopes: ReadonlyArray<AuthEnvironmentScope> | undefined,
427+ requestMetadata: AuthClientMetadata,
428+ input?: {
429+ readonly proofKeyThumbprint?: string;
430+ },
431+ ) => Effect.Effect<
432+ AuthAccessTokenResult,
433+ ServerAuthInvalidCredentialError | ServerAuthInvalidRequestError | ServerAuthInternalError
434+ >;
435+ readonly createPairingLink: (input?: {
436+ readonly ttl?: Duration.Duration;
437+ readonly label?: string;
438+ readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
439+ readonly subject?: string;
440+ readonly proofKeyThumbprint?: string;
441+ }) => Effect.Effect<IssuedPairingLink, ServerAuthInternalError>;
442+ readonly issuePairingCredential: (
443+ input?: AuthCreatePairingCredentialInput,
444+ ) => Effect.Effect<AuthPairingCredentialResult, ServerAuthInternalError>;
445+ readonly issueStartupPairingCredential: () => Effect.Effect<
446+ AuthPairingCredentialResult,
447+ ServerAuthInternalError
448+ >;
449+ readonly listPairingLinks: (input?: {
450+ readonly excludeSubjects?: ReadonlyArray<string>;
451+ }) => Effect.Effect<ReadonlyArray<AuthPairingLink>, ServerAuthInternalError>;
452+ readonly revokePairingLink: (id: string) => Effect.Effect<boolean, ServerAuthInternalError>;
453+ readonly issueSession: (input?: {
454+ readonly ttl?: Duration.Duration;
455+ readonly subject?: string;
456+ readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
457+ readonly label?: string;
458+ }) => Effect.Effect<IssuedBearerSession, ServerAuthInternalError>;
459+ readonly listSessions: () => Effect.Effect<
460+ ReadonlyArray<AuthClientSession>,
461+ ServerAuthInternalError
462+ >;
463+ readonly revokeSession: (
464+ sessionId: AuthSessionId,
465+ ) => Effect.Effect<boolean, ServerAuthInternalError>;
466+ readonly revokeOtherSessionsExcept: (
467+ sessionId: AuthSessionId,
468+ ) => Effect.Effect<number, ServerAuthInternalError>;
469+ readonly listClientSessions: (
470+ currentSessionId: AuthSessionId,
471+ ) => Effect.Effect<ReadonlyArray<AuthClientSession>, ServerAuthInternalError>;
472+ readonly revokeClientSession: (
473+ currentSessionId: AuthSessionId,
474+ targetSessionId: AuthSessionId,
475+ ) => Effect.Effect<boolean, ServerAuthForbiddenOperationError | ServerAuthInternalError>;
476+ readonly revokeOtherClientSessions: (
477+ currentSessionId: AuthSessionId,
478+ ) => Effect.Effect<number, ServerAuthInternalError>;
479+ readonly authenticateHttpRequest: (
480+ request: HttpServerRequest.HttpServerRequest,
481+ ) => Effect.Effect<AuthenticatedSession, ServerAuthCredentialError | ServerAuthInternalError>;
482+ readonly authenticateWebSocketUpgrade: (
483+ request: HttpServerRequest.HttpServerRequest,
484+ ) => Effect.Effect<AuthenticatedSession, ServerAuthCredentialError | ServerAuthInternalError>;
485+ readonly issueWebSocketTicket: (
486+ session: Pick<AuthenticatedSession, "sessionId">,
487+ ) => Effect.Effect<AuthWebSocketTicketResult, ServerAuthInternalError>;
488+ readonly issueStartupPairingUrl: (
489+ baseUrl: string,
490+ ) => Effect.Effect<string, ServerAuthInternalError>;
491+ }
492+>()("t3/auth/EnvironmentAuth") {}
187493
188494 type BootstrapExchangeResult = {
189495 readonly response: AuthBrowserSessionResult;
@@ -206,23 +512,14 @@ const bySessionPriority = (left: AuthClientSession, right: AuthClientSession) => · 16 unmodified lines
206512 return right.issuedAt.epochMilliseconds - left.issuedAt.epochMilliseconds;
207513 };
208514
209−const toInternalError =
210− (message: string) =>
211− (cause: unknown): ServerAuthInternalError =>
212− new ServerAuthInternalError({ message, cause });
213−
214515 export function toBootstrapExchangeError(
215516 cause: PairingGrantStore.BootstrapCredentialError,
216517 ): ServerAuthInvalidCredentialError | ServerAuthInternalError {
217− if (cause._tag === "BootstrapCredentialInternalError") {
218− return new ServerAuthInternalError({
219− message: "Failed to validate bootstrap credential.",
220− cause,
221− });
518+ if (PairingGrantStore.isBootstrapCredentialInternalError(cause)) {
519+ return new ServerAuthBootstrapCredentialValidationError({ cause });
222520 }
223521
224522 return new ServerAuthInvalidCredentialError({
225− reason: "invalid_credential",
226523 cause,
227524 });
228525 }
@@ -231,17 +528,11 @@ const mapSessionVerificationErrors = <A, R>( · 2 unmodified lines
231528 effect: Effect.Effect<A, SessionStore.SessionCredentialError, R>,
232529 ): Effect.Effect<A, ServerAuthInvalidCredentialError | ServerAuthInternalError, R> =>
233530 effect.pipe(
234− Effect.catchTags({
235− SessionCredentialInvalidError: (cause) =>
236− Effect.fail(new ServerAuthInvalidCredentialError({ reason: "invalid_credential", cause })),
237− SessionCredentialInternalError: (cause) =>
238− Effect.fail(
239− new ServerAuthInternalError({
240− message: "Failed to validate session credential.",
241− cause,
242− }),
243− ),
244− }),
531+ Effect.mapError((cause) =>
532+ SessionStore.isSessionCredentialInvalidError(cause)
533+ ? new ServerAuthInvalidCredentialError({ cause })
534+ : new ServerAuthSessionCredentialValidationError({ cause }),
535+ ),
245536 );
246537
247538 function parseBearerToken(request: HttpServerRequest.HttpServerRequest): string | null {
@@ -262,7 +553,7 @@ function parseDpopToken(request: HttpServerRequest.HttpServerRequest): string | · 14 unmodified lines
262553 return token.length > 0 ? token : null;
263554 }
264555
265−export const make = Effect.fn("makeEnvironmentAuth")(function* () {
556+export const make = Effect.gen(function* () {
266557 const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy;
267558 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
268559 const sessions = yield* SessionStore.SessionStore;
@@ -277,12 +568,14 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { · 8 unmodified lines
277568 ServerAuthInvalidCredentialError | ServerAuthInternalError
278569 > =>
279570 sessions.verify(token).pipe(
280− Effect.tapErrorTag("SessionCredentialInvalidError", (cause) =>
281− Effect.logWarning("Rejected authenticated session credential.").pipe(
282− Effect.annotateLogs({
283− reason: cause.message,
284− }),
285− ),
571+ Effect.tapError((cause) =>
572+ SessionStore.isSessionCredentialInvalidError(cause)
573+ ? Effect.logWarning("Rejected authenticated session credential.").pipe(
574+ Effect.annotateLogs({
575+ reason: cause.message,
576+ }),
577+ )
578+ : Effect.void,
286579 ),
287580 Effect.map((session) => ({
288581 sessionId: session.sessionId,
@@ -295,13 +588,15 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { · 6 unmodified lines
295588 mapSessionVerificationErrors,
296589 );
297590
298− const authenticateRequest = (request: HttpServerRequest.HttpServerRequest) => {
591+ const authenticateRequest = (
592+ request: HttpServerRequest.HttpServerRequest,
593+ ): Effect.Effect<AuthenticatedSession, ServerAuthCredentialError | ServerAuthInternalError> => {
299594 const cookieToken = request.cookies[sessions.cookieName];
300595 const bearerToken = parseBearerToken(request);
301596 const dpopToken = parseDpopToken(request);
302597 const credential = cookieToken ?? bearerToken ?? dpopToken;
303598 if (!credential) {
304− return Effect.fail(new ServerAuthInvalidCredentialError({ reason: "missing_credential" }));
599+ return Effect.fail(new ServerAuthMissingCredentialError({}));
305600 }
306601 return authenticateToken(credential).pipe(
307602 Effect.flatMap((session) => {
@@ -309,8 +604,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { · 1 unmodified line
309604 if (!dpopToken || dpopToken !== credential) {
310605 return Effect.fail(
311606 new ServerAuthInvalidCredentialError({
312− reason: "invalid_credential",
313− cause: "DPoP-bound access token requires DPoP authorization.",
607+ diagnostic: "DPoP-bound access token requires DPoP authorization.",
314608 }),
315609 );
316610 }
@@ -327,8 +621,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { · 10 unmodified lines
327621 if (dpopToken) {
328622 return Effect.fail(
329623 new ServerAuthInvalidCredentialError({
330− reason: "invalid_credential",
331− cause: "DPoP authorization requires a proof-bound access token.",
624+ diagnostic: "DPoP authorization requires a proof-bound access token.",
332625 }),
333626 );
334627 }
@@ -337,7 +630,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { · 2 unmodified lines
337630 );
338631 };
339632
340− const getSessionState: EnvironmentAuthShape["getSessionState"] = (request) =>
633+ const getSessionState: EnvironmentAuth["Service"]["getSessionState"] = (request) =>
341634 authenticateRequest(request).pipe(
342635 Effect.map(
343636 (session) =>
@@ -349,7 +642,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { · 5 unmodified lines
349642 ...(session.expiresAt ? { expiresAt: DateTime.toUtc(session.expiresAt) } : {}),
350643 }) satisfies AuthSessionState,
351644 ),
352− Effect.catchTag("ServerAuthInvalidCredentialError", () =>
645+ Effect.catchIf(isServerAuthCredentialError, () =>
353646 Effect.succeed({
354647 authenticated: false,
355648 auth: descriptor,
@@ -358,7 +651,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { · 2 unmodified lines
358651 Effect.withSpan("EnvironmentAuth.getSessionState"),
359652 );
360653
361− const createBrowserSession: EnvironmentAuthShape["createBrowserSession"] = (
654+ const createBrowserSession: EnvironmentAuth["Service"]["createBrowserSession"] = (
362655 credential,
363656 requestMetadata,
364657 ) =>
@@ -376,13 +669,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { · 11 unmodified lines
376669 },
377670 })
378671 .pipe(
379− Effect.mapError(
380− (cause) =>
381− new ServerAuthInternalError({
382− message: "Failed to issue authenticated session.",
383− cause,
384− }),
385− ),
672+ Effect.mapError((cause) => new ServerAuthAuthenticatedSessionIssueError({ cause })),
386673 ),
387674 ),
388675 Effect.map(
@@ -400,7 +687,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { · 11 unmodified lines
400687 Effect.withSpan("EnvironmentAuth.createBrowserSession"),
401688 );
402689
403− const exchangeBootstrapCredentialForAccessToken: EnvironmentAuthShape["exchangeBootstrapCredentialForAccessToken"] =
690+ const exchangeBootstrapCredentialForAccessToken: EnvironmentAuth["Service"]["exchangeBootstrapCredentialForAccessToken"] =
404691 (credential, requestedScopes, requestMetadata, input) =>
405692 bootstrapCredentials.consume(credential, input).pipe(
406693 Effect.mapError(toBootstrapExchangeError),
@@ -408,9 +695,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { · 1 unmodified line
408695 Effect.gen(function* () {
409696 const grantedScopes = requestedScopes ?? grant.scopes;
410697 if (!grantedScopes.every((scope) => grant.scopes.includes(scope))) {
411− return yield* new ServerAuthInvalidRequestError({
412− reason: "scope_not_granted",
413− });
698+ return yield* new ServerAuthScopeNotGrantedError({});
414699 }
415700 return yield* sessions
416701 .issue({
@@ -430,11 +715,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { · 13 unmodified lines
430715 })
431716 .pipe(
432717 Effect.mapError(
433− (cause) =>
434− new ServerAuthInternalError({
435− message: "Failed to issue authenticated access token.",
436− cause,
437− }),
718+ (cause) => new ServerAuthAuthenticatedAccessTokenIssueError({ cause }),
438719 ),
439720 );
440721 }),
@@ -482,7 +763,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { · 41 unmodified lines
482763 ),
483764 );
484765
485− const createPairingLink: EnvironmentAuthShape["createPairingLink"] = Effect.fn(
766+ const createPairingLink: EnvironmentAuth["Service"]["createPairingLink"] = Effect.fn(
486767 "EnvironmentAuth.createPairingLink",
487768 )(
488769 function* (input) {
@@ -504,10 +785,10 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { · 15 unmodified lines
504785 expiresAt: DateTime.toUtc(issued.expiresAt),
505786 } satisfies IssuedPairingLink;
506787 },
507− Effect.mapError(toInternalError("Failed to create pairing link.")),
788+ Effect.mapError((cause) => new ServerAuthPairingLinkCreationError({ cause })),
508789 );
509790
510− const listPairingLinks: EnvironmentAuthShape["listPairingLinks"] = (input) =>
791+ const listPairingLinks: EnvironmentAuth["Service"]["listPairingLinks"] = (input) =>
511792 bootstrapCredentials.listActive().pipe(
512793 Effect.map((pairingLinks) => {
513794 const excludedSubjects = input?.excludeSubjects ?? [
@@ -519,19 +800,17 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { · 5 unmodified lines
519800 (left, right) => right.createdAt.epochMilliseconds - left.createdAt.epochMilliseconds,
520801 );
521802 }),
522− Effect.mapError(toInternalError("Failed to list pairing links.")),
803+ Effect.mapError((cause) => new ServerAuthPairingLinksListError({ cause })),
523804 Effect.withSpan("EnvironmentAuth.listPairingLinks"),
524805 );
525806
526− const revokePairingLink: EnvironmentAuthShape["revokePairingLink"] = (id) =>
527− bootstrapCredentials
528− .revoke(id)
529− .pipe(
530− Effect.mapError(toInternalError("Failed to revoke pairing link.")),
531− Effect.withSpan("EnvironmentAuth.revokePairingLink"),
532− );
807+ const revokePairingLink: EnvironmentAuth["Service"]["revokePairingLink"] = (id) =>
808+ bootstrapCredentials.revoke(id).pipe(
809+ Effect.mapError((cause) => new ServerAuthPairingLinkRevocationError({ cause })),
810+ Effect.withSpan("EnvironmentAuth.revokePairingLink"),
811+ );
533812
534− const issueSession: EnvironmentAuthShape["issueSession"] = (input) =>
813+ const issueSession: EnvironmentAuth["Service"]["issueSession"] = (input) =>
535814 sessions
536815 .issue({
537816 subject: input?.subject ?? DEFAULT_SESSION_SUBJECT,
@@ -556,49 +835,46 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { · 18 unmodified lines
556835 expiresAt: DateTime.toUtc(issued.expiresAt),
557836 }) satisfies IssuedBearerSession,
558837 ),
559− Effect.mapError(toInternalError("Failed to issue session token.")),
838+ Effect.mapError((cause) => new ServerAuthSessionTokenIssueError({ cause })),
560839 Effect.withSpan("EnvironmentAuth.issueSession"),
561840 );
562841
563− const listSessions: EnvironmentAuthShape["listSessions"] = () =>
842+ const listSessions: EnvironmentAuth["Service"]["listSessions"] = () =>
564843 sessions.listActive().pipe(
565844 Effect.map((activeSessions) => activeSessions.toSorted(bySessionPriority)),
566− Effect.mapError(toInternalError("Failed to list sessions.")),
845+ Effect.mapError((cause) => new ServerAuthSessionsListError({ cause })),
567846 Effect.withSpan("EnvironmentAuth.listSessions"),
568847 );
569848
570− const revokeSession: EnvironmentAuthShape["revokeSession"] = (sessionId) =>
571− sessions
572− .revoke(sessionId)
573− .pipe(
574− Effect.mapError(toInternalError("Failed to revoke session.")),
575− Effect.withSpan("EnvironmentAuth.revokeSession"),
576− );
849+ const revokeSession: EnvironmentAuth["Service"]["revokeSession"] = (sessionId) =>
850+ sessions.revoke(sessionId).pipe(
851+ Effect.mapError((cause) => new ServerAuthSessionRevocationError({ cause })),
852+ Effect.withSpan("EnvironmentAuth.revokeSession"),
853+ );
577854
578− const revokeOtherSessionsExcept: EnvironmentAuthShape["revokeOtherSessionsExcept"] = (
855+ const revokeOtherSessionsExcept: EnvironmentAuth["Service"]["revokeOtherSessionsExcept"] = (
579856 sessionId,
580857 ) =>
581− sessions
582− .revokeAllExcept(sessionId)
583− .pipe(
584− Effect.mapError(toInternalError("Failed to revoke other sessions.")),
585− Effect.withSpan("EnvironmentAuth.revokeOtherSessionsExcept"),
586− );
858+ sessions.revokeAllExcept(sessionId).pipe(
859+ Effect.mapError((cause) => new ServerAuthOtherSessionsRevocationError({ cause })),
860+ Effect.withSpan("EnvironmentAuth.revokeOtherSessionsExcept"),
861+ );
587862
588− const issuePairingCredential: EnvironmentAuthShape["issuePairingCredential"] = (input) =>
863+ const issuePairingCredential: EnvironmentAuth["Service"]["issuePairingCredential"] = (input) =>
589864 issuePairingCredentialForSubject({
590865 scopes: input?.scopes ?? AuthStandardClientScopes,
591866 subject: "one-time-token",
592867 ...(input?.label ? { label: input.label } : {}),
593868 }).pipe(Effect.withSpan("EnvironmentAuth.issuePairingCredential"));
594869
595− const issueStartupPairingCredential: EnvironmentAuthShape["issueStartupPairingCredential"] = () =>
596− issuePairingCredentialForSubject({
597− scopes: AuthAdministrativeScopes,
598− subject: INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT,
599− }).pipe(Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential"));
870+ const issueStartupPairingCredential: EnvironmentAuth["Service"]["issueStartupPairingCredential"] =
871+ () =>
872+ issuePairingCredentialForSubject({
873+ scopes: AuthAdministrativeScopes,
874+ subject: INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT,
875+ }).pipe(Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential"));
600876
601− const listClientSessions: EnvironmentAuthShape["listClientSessions"] = (currentSessionId) =>
877+ const listClientSessions: EnvironmentAuth["Service"]["listClientSessions"] = (currentSessionId) =>
602878 listSessions().pipe(
603879 Effect.map((clientSessions) =>
604880 clientSessions.map(
@@ -611,25 +887,23 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { · 6 unmodified lines
611887 Effect.withSpan("EnvironmentAuth.listClientSessions"),
612888 );
613889
614− const revokeClientSession: EnvironmentAuthShape["revokeClientSession"] = Effect.fn(
890+ const revokeClientSession: EnvironmentAuth["Service"]["revokeClientSession"] = Effect.fn(
615891 "EnvironmentAuth.revokeClientSession",
616892 )(function* (currentSessionId, targetSessionId) {
617893 if (currentSessionId === targetSessionId) {
618− return yield* new ServerAuthForbiddenOperationError({
619− reason: "current_session_revoke_not_allowed",
620− });
894+ return yield* new ServerAuthForbiddenOperationError({});
621895 }
622896 return yield* revokeSession(targetSessionId);
623897 });
624898
625− const revokeOtherClientSessions: EnvironmentAuthShape["revokeOtherClientSessions"] = (
899+ const revokeOtherClientSessions: EnvironmentAuth["Service"]["revokeOtherClientSessions"] = (
626900 currentSessionId,
627901 ) =>
628902 revokeOtherSessionsExcept(currentSessionId).pipe(
629903 Effect.withSpan("EnvironmentAuth.revokeOtherClientSessions"),
630904 );
631905
632− const issueStartupPairingUrl: EnvironmentAuthShape["issueStartupPairingUrl"] = (baseUrl) =>
906+ const issueStartupPairingUrl: EnvironmentAuth["Service"]["issueStartupPairingUrl"] = (baseUrl) =>
633907 issueStartupPairingCredential().pipe(
634908 Effect.map((issued) => {
635909 const url = new URL(baseUrl);
@@ -641,15 +915,9 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { · 5 unmodified lines
641915 Effect.withSpan("EnvironmentAuth.issueStartupPairingUrl"),
642916 );
643917
644− const issueWebSocketTicket: EnvironmentAuthShape["issueWebSocketTicket"] = (session) =>
918+ const issueWebSocketTicket: EnvironmentAuth["Service"]["issueWebSocketTicket"] = (session) =>
645919 sessions.issueWebSocketToken(session.sessionId).pipe(
646− Effect.mapError(
647− (cause) =>
648− new ServerAuthInternalError({
649− message: "Failed to issue websocket token.",
650− cause,
651− }),
652− ),
920+ Effect.mapError((cause) => new ServerAuthWebSocketTokenIssueError({ cause })),
653921 Effect.map(
654922 (issued) =>
655923 ({
@@ -660,10 +928,12 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { · 4 unmodified lines
660928 Effect.withSpan("EnvironmentAuth.issueWebSocketTicket"),
661929 );
662930
663− const authenticateHttpRequest: EnvironmentAuthShape["authenticateHttpRequest"] = (request) =>
931+ const authenticateHttpRequest: EnvironmentAuth["Service"]["authenticateHttpRequest"] = (
932+ request,
933+ ) =>
664934 authenticateRequest(request).pipe(Effect.withSpan("EnvironmentAuth.authenticateHttpRequest"));
665935
666− const authenticateWebSocketUpgrade: EnvironmentAuthShape["authenticateWebSocketUpgrade"] =
936+ const authenticateWebSocketUpgrade: EnvironmentAuth["Service"]["authenticateWebSocketUpgrade"] =
667937 Effect.fn("EnvironmentAuth.authenticateWebSocketUpgrade")(function* (request) {
668938 const requestUrl = HttpServerRequest.toURL(request);
669939 if (Option.isSome(requestUrl)) {
@@ -685,7 +955,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { · 15 unmodified lines
685955 return yield* authenticateRequest(request);
686956 });
687957
688− return {
958+ return EnvironmentAuth.of({
689959 getDescriptor: () =>
690960 Effect.succeed(descriptor).pipe(Effect.withSpan("EnvironmentAuth.getDescriptor")),
691961 getSessionState,
@@ -707,10 +977,10 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { · 15 unmodified lines
707977 authenticateWebSocketUpgrade,
708978 issueWebSocketTicket,
709979 issueStartupPairingUrl,
710− } satisfies EnvironmentAuthShape;
980+ });
711981 });
712982
713−export const layer = Layer.effect(EnvironmentAuth, make()).pipe(
983+export const layer = Layer.effect(EnvironmentAuth, make).pipe(
714984 Layer.provideMerge(PairingGrantStore.layer),
715985 Layer.provideMerge(SessionStore.layer),
716986 Layer.provideMerge(EnvironmentAuthPolicy.layer),
apps/server/src/auth/EnvironmentAuthAdmin.test.ts+5−1
@@ -22,7 +22,11 @@ const makeServerConfigLayer = ( · 21 unmodified lines
2222 } satisfies ServerConfig.ServerConfig["Service"];
2323 }),
2424 ).pipe(
25− Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-control-plane-test-" })),
25+ Layer.provide(
26+ ServerConfig.layerTest(process.cwd(), {
27+ prefix: "t3-auth-control-plane-test-",
28+ }),
29+ ),
2630 );
2731
2832 const makeEnvironmentAuthLayer = (
apps/server/src/auth/EnvironmentAuthPolicy.ts+9−11
@@ -3,21 +3,19 @@ import * as Context from "effect/Context"; · 2 unmodified lines
33 import * as Effect from "effect/Effect";
44 import * as Layer from "effect/Layer";
55
6−import { ServerConfig } from "../config.ts";
6+import * as ServerConfig from "../config.ts";
77 import { resolveSessionCookieName } from "./utils.ts";
88 import { isLoopbackHost, isWildcardHost } from "../startupAccess.ts";
99
10−export interface EnvironmentAuthPolicyShape {
11− readonly getDescriptor: () => Effect.Effect<ServerAuthDescriptor>;
12−}
13−
1410 export class EnvironmentAuthPolicy extends Context.Service<
1511 EnvironmentAuthPolicy,
16− EnvironmentAuthPolicyShape
12+ {
13+ readonly getDescriptor: () => Effect.Effect<ServerAuthDescriptor>;
14+ }
1715 >()("t3/auth/EnvironmentAuthPolicy") {}
1816
19−export const make = Effect.fn("makeEnvironmentAuthPolicy")(function* () {
20− const config = yield* ServerConfig;
17+export const make = Effect.gen(function* () {
18+ const config = yield* ServerConfig.ServerConfig;
2119 const isRemoteReachable = isWildcardHost(config.host) || !isLoopbackHost(config.host);
2220
2321 const policy =
@@ -46,10 +44,10 @@ export const make = Effect.fn("makeEnvironmentAuthPolicy")(function* () { · 22 unmodified lines
4644 }),
4745 };
4846
49− return {
47+ return EnvironmentAuthPolicy.of({
5048 getDescriptor: () =>
5149 Effect.succeed(descriptor).pipe(Effect.withSpan("EnvironmentAuthPolicy.getDescriptor")),
52− } satisfies EnvironmentAuthPolicyShape;
50+ });
5351 });
5452
55−export const layer = Layer.effect(EnvironmentAuthPolicy, make());
53+export const layer = Layer.effect(EnvironmentAuthPolicy, make);
apps/server/src/auth/PairingGrantStore.test.ts+5−5
@@ -61,7 +61,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { · 60 unmodified lines
6161 expect(first.subject).toBe("one-time-token");
6262 expect(first.label).toBe("Julius iPhone");
6363 expect(issued.label).toBe("Julius iPhone");
64− expect(second._tag).toBe("BootstrapCredentialInvalidError");
64+ expect(second._tag).toBe("UnknownBootstrapCredentialError");
6565 expect(second.message).toContain("Unknown bootstrap credential");
6666 }).pipe(Effect.provide(makePairingGrantStoreLayer())),
6767 );
@@ -85,7 +85,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { · 17 unmodified lines
8585 expect(successes).toHaveLength(1);
8686 expect(failures).toHaveLength(7);
8787 for (const failure of failures) {
88− expect(failure.failure._tag).toBe("BootstrapCredentialInvalidError");
88+ expect(failure.failure._tag).toBe("UnknownBootstrapCredentialError");
8989 expect(failure.failure.message).toContain("Unknown bootstrap credential");
9090 }
9191 }).pipe(Effect.provide(makePairingGrantStoreLayer())),
@@ -132,7 +132,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { · 40 unmodified lines
132132 "relay:write",
133133 ]);
134134 expect(first.subject).toBe("desktop-bootstrap");
135− expect(second._tag).toBe("BootstrapCredentialInvalidError");
135+ expect(second._tag).toBe("UnknownBootstrapCredentialError");
136136 }).pipe(
137137 Effect.provide(
138138 makePairingGrantStoreLayer({
@@ -149,7 +149,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { · 10 unmodified lines
149149 yield* TestClock.adjust(Duration.minutes(6));
150150 const expired = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token"));
151151
152− expect(expired._tag).toBe("BootstrapCredentialInvalidError");
152+ expect(expired._tag).toBe("ExpiredBootstrapCredentialError");
153153 expect(expired.message).toContain("Bootstrap credential expired");
154154 }).pipe(
155155 Effect.provide(
@@ -183,7 +183,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { · 27 unmodified lines
183183 expect(activeAfterRevoke.map((entry) => entry.id)).not.toContain(first.id);
184184 expect(activeAfterRevoke.map((entry) => entry.id)).toContain(second.id);
185185 expect(revokedConsume.message).toContain("no longer available");
186− expect(revokedConsume._tag).toBe("BootstrapCredentialInvalidError");
186+ expect(revokedConsume._tag).toBe("UnavailableBootstrapCredentialError");
187187 }).pipe(Effect.provide(makePairingGrantStoreLayer())),
188188 );
189189 });
apps/server/src/auth/PairingGrantStore.ts+150−82
@@ -7,17 +7,17 @@ import { · 6 unmodified lines
77 } from "@t3tools/contracts";
88 import * as Context from "effect/Context";
99 import * as Crypto from "effect/Crypto";
10−import * as Data from "effect/Data";
1110 import * as DateTime from "effect/DateTime";
1211 import * as Duration from "effect/Duration";
1312 import * as Effect from "effect/Effect";
1413 import * as Layer from "effect/Layer";
14+import * as Option from "effect/Option";
1515 import * as PubSub from "effect/PubSub";
1616 import * as Ref from "effect/Ref";
17+import * as Schema from "effect/Schema";
1718 import * as Stream from "effect/Stream";
18−import * as Option from "effect/Option";
1919
20−import { ServerConfig } from "../config.ts";
20+import * as ServerConfig from "../config.ts";
2121 import * as AuthPairingLinks from "../persistence/AuthPairingLinks.ts";
2222
2323 export interface BootstrapGrant {
@@ -29,22 +29,110 @@ export interface BootstrapGrant { · 5 unmodified lines
2929 readonly expiresAt: DateTime.DateTime;
3030 }
3131
32−export class BootstrapCredentialInvalidError extends Data.TaggedError(
33− "BootstrapCredentialInvalidError",
34−)<{
35− readonly message: string;
36−}> {}
32+export class UnknownBootstrapCredentialError extends Schema.TaggedErrorClass<UnknownBootstrapCredentialError>()(
33+ "UnknownBootstrapCredentialError",
34+ {},
35+) {
36+ override get message(): string {
37+ return "Unknown bootstrap credential.";
38+ }
39+}
40+
41+export class ExpiredBootstrapCredentialError extends Schema.TaggedErrorClass<ExpiredBootstrapCredentialError>()(
42+ "ExpiredBootstrapCredentialError",
43+ {},
44+) {
45+ override get message(): string {
46+ return "Bootstrap credential expired.";
47+ }
48+}
49+
50+export class BootstrapCredentialProofKeyMismatchError extends Schema.TaggedErrorClass<BootstrapCredentialProofKeyMismatchError>()(
51+ "BootstrapCredentialProofKeyMismatchError",
52+ {},
53+) {
54+ override get message(): string {
55+ return "Bootstrap credential proof key mismatch.";
56+ }
57+}
58+
59+export class UnavailableBootstrapCredentialError extends Schema.TaggedErrorClass<UnavailableBootstrapCredentialError>()(
60+ "UnavailableBootstrapCredentialError",
61+ {},
62+) {
63+ override get message(): string {
64+ return "Bootstrap credential is no longer available.";
65+ }
66+}
67+
68+export const BootstrapCredentialInvalidError = Schema.Union([
69+ UnknownBootstrapCredentialError,
70+ ExpiredBootstrapCredentialError,
71+ BootstrapCredentialProofKeyMismatchError,
72+ UnavailableBootstrapCredentialError,
73+]);
74+export type BootstrapCredentialInvalidError = typeof BootstrapCredentialInvalidError.Type;
75+export const isBootstrapCredentialInvalidError = Schema.is(BootstrapCredentialInvalidError);
76+
77+export class ActivePairingLinksLoadError extends Schema.TaggedErrorClass<ActivePairingLinksLoadError>()(
78+ "ActivePairingLinksLoadError",
79+ {
80+ cause: Schema.Defect(),
81+ },
82+) {
83+ override get message(): string {
84+ return "Failed to load active pairing links.";
85+ }
86+}
87+
88+export class PairingLinkRevokeError extends Schema.TaggedErrorClass<PairingLinkRevokeError>()(
89+ "PairingLinkRevokeError",
90+ {
91+ cause: Schema.Defect(),
92+ },
93+) {
94+ override get message(): string {
95+ return "Failed to revoke pairing link.";
96+ }
97+}
3798
38−export class BootstrapCredentialInternalError extends Data.TaggedError(
39− "BootstrapCredentialInternalError",
40−)<{
41− readonly message: string;
42− readonly cause?: unknown;
43−}> {}
99+export class PairingCredentialIssueError extends Schema.TaggedErrorClass<PairingCredentialIssueError>()(
100+ "PairingCredentialIssueError",
101+ {
102+ cause: Schema.Defect(),
103+ },
104+) {
105+ override get message(): string {
106+ return "Failed to issue pairing credential.";
107+ }
108+}
44109
45−export type BootstrapCredentialError =
46− | BootstrapCredentialInvalidError
47− | BootstrapCredentialInternalError;
110+export class BootstrapCredentialConsumeError extends Schema.TaggedErrorClass<BootstrapCredentialConsumeError>()(
111+ "BootstrapCredentialConsumeError",
112+ {
113+ cause: Schema.Defect(),
114+ },
115+) {
116+ override get message(): string {
117+ return "Failed to consume bootstrap credential.";
118+ }
119+}
120+
121+export const BootstrapCredentialInternalError = Schema.Union([
122+ ActivePairingLinksLoadError,
123+ PairingLinkRevokeError,
124+ PairingCredentialIssueError,
125+ BootstrapCredentialConsumeError,
126+]);
127+export type BootstrapCredentialInternalError = typeof BootstrapCredentialInternalError.Type;
128+export const isBootstrapCredentialInternalError = Schema.is(BootstrapCredentialInternalError);
129+
130+export const BootstrapCredentialError = Schema.Union([
131+ BootstrapCredentialInvalidError,
132+ BootstrapCredentialInternalError,
133+]);
134+export type BootstrapCredentialError = typeof BootstrapCredentialError.Type;
135+export const isBootstrapCredentialError = Schema.is(BootstrapCredentialError);
48136
49137 export interface IssuedBootstrapCredential {
50138 readonly id: string;
@@ -64,31 +152,30 @@ export type BootstrapCredentialChange = · 13 unmodified lines
64152 readonly id: string;
65153 };
66154
67−export interface PairingGrantStoreShape {
68− readonly issueOneTimeToken: (input?: {
69− readonly ttl?: Duration.Duration;
70− readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
71− readonly subject?: string;
72− readonly label?: string;
73− readonly proofKeyThumbprint?: string;
74− }) => Effect.Effect<IssuedBootstrapCredential, BootstrapCredentialInternalError>;
75− readonly listActive: () => Effect.Effect<
76− ReadonlyArray<AuthPairingLink>,
77− BootstrapCredentialInternalError
78− >;
79− readonly streamChanges: Stream.Stream<BootstrapCredentialChange>;
80− readonly revoke: (id: string) => Effect.Effect<boolean, BootstrapCredentialInternalError>;
81− readonly consume: (
82− credential: string,
83− input?: {
155+export class PairingGrantStore extends Context.Service<
156+ PairingGrantStore,
157+ {
158+ readonly issueOneTimeToken: (input?: {
159+ readonly ttl?: Duration.Duration;
160+ readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
161+ readonly subject?: string;
162+ readonly label?: string;
84163 readonly proofKeyThumbprint?: string;
85− },
86− ) => Effect.Effect<BootstrapGrant, BootstrapCredentialError>;
87−}
88−
89−export class PairingGrantStore extends Context.Service<PairingGrantStore, PairingGrantStoreShape>()(
90− "t3/auth/PairingGrantStore",
91−) {}
164+ }) => Effect.Effect<IssuedBootstrapCredential, BootstrapCredentialInternalError>;
165+ readonly listActive: () => Effect.Effect<
166+ ReadonlyArray<AuthPairingLink>,
167+ BootstrapCredentialInternalError
168+ >;
169+ readonly streamChanges: Stream.Stream<BootstrapCredentialChange>;
170+ readonly revoke: (id: string) => Effect.Effect<boolean, BootstrapCredentialInternalError>;
171+ readonly consume: (
172+ credential: string,
173+ input?: {
174+ readonly proofKeyThumbprint?: string;
175+ },
176+ ) => Effect.Effect<BootstrapGrant, BootstrapCredentialError>;
177+ }
178+>()("t3/auth/PairingGrantStore") {}
92179
93180 interface StoredBootstrapGrant extends BootstrapGrant {
94181 readonly remainingUses: number | "unbounded";
@@ -111,20 +198,9 @@ const PAIRING_TOKEN_LENGTH = 12; · 16 unmodified lines
111198 const PAIRING_TOKEN_REJECTION_LIMIT =
112199 Math.floor(256 / PAIRING_TOKEN_ALPHABET.length) * PAIRING_TOKEN_ALPHABET.length;
113200
114−const invalidBootstrapCredentialError = (message: string) =>
115− new BootstrapCredentialInvalidError({
116− message,
117− });
118−
119−const internalBootstrapCredentialError = (message: string, cause: unknown) =>
120− new BootstrapCredentialInternalError({
121− message,
122− cause,
123− });
124−
125−export const make = Effect.fn("makePairingGrantStore")(function* () {
201+export const make = Effect.gen(function* () {
126202 const crypto = yield* Crypto.Crypto;
127− const config = yield* ServerConfig;
203+ const config = yield* ServerConfig.ServerConfig;
128204 const pairingLinks = yield* AuthPairingLinks.AuthPairingLinkRepository;
129205 const seededGrantsRef = yield* Ref.make(new Map<string, StoredBootstrapGrant>());
130206 const changesPubSub = yield* PubSub.unbounded<BootstrapCredentialChange>();
@@ -177,10 +253,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { · 46 unmodified lines
177253 });
178254 }
179255
180− const toBootstrapCredentialError = (message: string) => (cause: unknown) =>
181− internalBootstrapCredentialError(message, cause);
182−
183− const listActive: PairingGrantStoreShape["listActive"] = Effect.fn(
256+ const listActive: PairingGrantStore["Service"]["listActive"] = Effect.fn(
184257 "PairingGrantStore.listActive",
185258 )(
186259 function* () {
@@ -208,10 +281,10 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { · 21 unmodified lines
208281 } satisfies AuthPairingLink),
209282 );
210283 },
211− Effect.mapError(toBootstrapCredentialError("Failed to load active pairing links.")),
284+ Effect.mapError((cause) => new ActivePairingLinksLoadError({ cause })),
212285 );
213286
214− const revoke: PairingGrantStoreShape["revoke"] = Effect.fn("PairingGrantStore.revoke")(
287+ const revoke: PairingGrantStore["Service"]["revoke"] = Effect.fn("PairingGrantStore.revoke")(
215288 function* (id) {
216289 const revokedAt = yield* DateTime.now;
217290 const revoked = yield* pairingLinks.revoke({
@@ -223,10 +296,10 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { · 5 unmodified lines
223296 }
224297 return revoked;
225298 },
226− Effect.mapError(toBootstrapCredentialError("Failed to revoke pairing link.")),
299+ Effect.mapError((cause) => new PairingLinkRevokeError({ cause })),
227300 );
228301
229− const issueOneTimeToken: PairingGrantStoreShape["issueOneTimeToken"] = Effect.fn(
302+ const issueOneTimeToken: PairingGrantStore["Service"]["issueOneTimeToken"] = Effect.fn(
230303 "PairingGrantStore.issueOneTimeToken",
231304 )(
232305 function* (input) {
@@ -264,10 +337,10 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { · 31 unmodified lines
264337 });
265338 return issued;
266339 },
267− Effect.mapError(toBootstrapCredentialError("Failed to issue pairing credential.")),
340+ Effect.mapError((cause) => new PairingCredentialIssueError({ cause })),
268341 );
269342
270− const consume: PairingGrantStoreShape["consume"] = Effect.fn("PairingGrantStore.consume")(
343+ const consume: PairingGrantStore["Service"]["consume"] = Effect.fn("PairingGrantStore.consume")(
271344 function* (credential, input) {
272345 const now = yield* DateTime.now;
273346 const seededResult: ConsumeResult = yield* Ref.modify(
@@ -279,7 +352,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { · 5 unmodified lines
279352 {
280353 _tag: "error",
281354 reason: "not-found",
282− error: invalidBootstrapCredentialError("Unknown bootstrap credential."),
355+ error: new UnknownBootstrapCredentialError({}),
283356 },
284357 current,
285358 ];
@@ -292,7 +365,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { · 6 unmodified lines
292365 {
293366 _tag: "error",
294367 reason: "expired",
295− error: invalidBootstrapCredentialError("Bootstrap credential expired."),
368+ error: new ExpiredBootstrapCredentialError({}),
296369 },
297370 next,
298371 ];
@@ -303,7 +376,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { · 4 unmodified lines
303376 {
304377 _tag: "error",
305378 reason: "not-found",
306− error: invalidBootstrapCredentialError("Bootstrap credential proof key mismatch."),
379+ error: new BootstrapCredentialProofKeyMismatchError({}),
307380 },
308381 next,
309382 ];
@@ -370,41 +443,36 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { · 60 unmodified lines
370443
371444 const matching = yield* pairingLinks.getByCredential({ credential });
372445 if (Option.isNone(matching)) {
373− return yield* invalidBootstrapCredentialError("Unknown bootstrap credential.");
446+ return yield* new UnknownBootstrapCredentialError({});
374447 }
375448
376449 if (matching.value.revokedAt !== null) {
377− return yield* invalidBootstrapCredentialError(
378− "Bootstrap credential is no longer available.",
379− );
450+ return yield* new UnavailableBootstrapCredentialError({});
380451 }
381452
382453 if (matching.value.consumedAt !== null) {
383− return yield* invalidBootstrapCredentialError("Unknown bootstrap credential.");
454+ return yield* new UnknownBootstrapCredentialError({});
384455 }
385456
386457 if (DateTime.isGreaterThanOrEqualTo(now, matching.value.expiresAt)) {
387− return yield* invalidBootstrapCredentialError("Bootstrap credential expired.");
458+ return yield* new ExpiredBootstrapCredentialError({});
388459 }
389460
390461 if (
391462 matching.value.proofKeyThumbprint !== null &&
392463 matching.value.proofKeyThumbprint !== input?.proofKeyThumbprint
393464 ) {
394− return yield* invalidBootstrapCredentialError("Bootstrap credential proof key mismatch.");
465+ return yield* new BootstrapCredentialProofKeyMismatchError({});
395466 }
396467
397− return yield* invalidBootstrapCredentialError("Bootstrap credential is no longer available.");
468+ return yield* new UnavailableBootstrapCredentialError({});
398469 },
399470 Effect.mapError((cause) =>
400− cause._tag === "BootstrapCredentialInvalidError" ||
401− cause._tag === "BootstrapCredentialInternalError"
402− ? cause
403− : internalBootstrapCredentialError("Failed to consume bootstrap credential.", cause),
471+ isBootstrapCredentialError(cause) ? cause : new BootstrapCredentialConsumeError({ cause }),
404472 ),
405473 );
406474
407− return {
475+ return PairingGrantStore.of({
408476 issueOneTimeToken,
409477 listActive,
410478 get streamChanges() {
@@ -412,9 +480,9 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { · 1 unmodified line
412480 },
413481 revoke,
414482 consume,
415− } satisfies PairingGrantStoreShape;
483+ });
416484 });
417485
418−export const layer = Layer.effect(PairingGrantStore, make()).pipe(
486+export const layer = Layer.effect(PairingGrantStore, make).pipe(
419487 Layer.provideMerge(AuthPairingLinks.layer),
420488 );
apps/server/src/auth/ServerSecretStore.test.ts+4−4
@@ -9,7 +9,7 @@ import * as Option from "effect/Option"; · 8 unmodified lines
99 import * as Ref from "effect/Ref";
1010 import * as PlatformError from "effect/PlatformError";
1111
12−import { ServerConfig } from "../config.ts";
12+import * as ServerConfig from "../config.ts";
1313 import * as ServerSecretStore from "./ServerSecretStore.ts";
1414
1515 const makeServerConfigLayer = () =>
@@ -231,7 +231,7 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { · 215 unmodified lines
231231
232232 const error = yield* Effect.flip(secretStore.getOrCreateRandom("session-signing-key", 32));
233233
234− assert.instanceOf(error, ServerSecretStore.SecretStoreError);
234+ assert.instanceOf(error, ServerSecretStore.SecretStoreReadError);
235235 assert.include(error.message, "Failed to read secret session-signing-key.");
236236 assert.instanceOf(error.cause, PlatformError.PlatformError);
237237 assert.equal((error.cause as PlatformError.PlatformError).reason._tag, "PermissionDenied");
@@ -246,7 +246,7 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { · 8 unmodified lines
246246 secretStore.set("session-signing-key", Uint8Array.from([1, 2, 3])),
247247 );
248248
249− assert.instanceOf(error, ServerSecretStore.SecretStoreError);
249+ assert.instanceOf(error, ServerSecretStore.SecretStorePersistError);
250250 assert.include(error.message, "Failed to persist secret session-signing-key.");
251251 assert.instanceOf(error.cause, PlatformError.PlatformError);
252252 assert.equal((error.cause as PlatformError.PlatformError).reason._tag, "PermissionDenied");
@@ -259,7 +259,7 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { · 6 unmodified lines
259259
260260 const error = yield* Effect.flip(secretStore.remove("session-signing-key"));
261261
262− assert.instanceOf(error, ServerSecretStore.SecretStoreError);
262+ assert.instanceOf(error, ServerSecretStore.SecretStoreRemoveError);
263263 assert.include(error.message, "Failed to remove secret session-signing-key.");
264264 assert.instanceOf(error.cause, PlatformError.PlatformError);
265265 assert.equal((error.cause as PlatformError.PlatformError).reason._tag, "PermissionDenied");
apps/server/src/auth/ServerSecretStore.ts+157−48
@@ -9,49 +9,158 @@ import * as Predicate from "effect/Predicate"; · 8 unmodified lines
99 import * as PlatformError from "effect/PlatformError";
1010 import * as Schema from "effect/Schema";
1111
12−import { ServerConfig } from "../config.ts";
12+import * as ServerConfig from "../config.ts";
1313
14−export class SecretStoreError extends Schema.TaggedErrorClass<SecretStoreError>()(
15− "SecretStoreError",
14+const secretStoreErrorContext = {
15+ resource: Schema.String,
16+ cause: Schema.Defect(),
17+};
18+
19+export class SecretStoreSecureError extends Schema.TaggedErrorClass<SecretStoreSecureError>()(
20+ "SecretStoreSecureError",
21+ {
22+ ...secretStoreErrorContext,
23+ },
24+) {
25+ override get message(): string {
26+ return `Failed to secure ${this.resource}.`;
27+ }
28+}
29+
30+export class SecretStoreReadError extends Schema.TaggedErrorClass<SecretStoreReadError>()(
31+ "SecretStoreReadError",
1632 {
17− message: Schema.String,
18− cause: Schema.optional(Schema.Defect()),
33+ ...secretStoreErrorContext,
1934 },
20−) {}
35+) {
36+ override get message(): string {
37+ return `Failed to read ${this.resource}.`;
38+ }
39+}
40+
41+export class SecretStoreTemporaryPathError extends Schema.TaggedErrorClass<SecretStoreTemporaryPathError>()(
42+ "SecretStoreTemporaryPathError",
43+ {
44+ ...secretStoreErrorContext,
45+ },
46+) {
47+ override get message(): string {
48+ return `Failed to create temporary path for ${this.resource}.`;
49+ }
50+}
51+
52+export class SecretStorePersistError extends Schema.TaggedErrorClass<SecretStorePersistError>()(
53+ "SecretStorePersistError",
54+ {
55+ ...secretStoreErrorContext,
56+ },
57+) {
58+ override get message(): string {
59+ return `Failed to persist ${this.resource}.`;
60+ }
61+}
62+
63+export class SecretStoreRandomGenerationError extends Schema.TaggedErrorClass<SecretStoreRandomGenerationError>()(
64+ "SecretStoreRandomGenerationError",
65+ {
66+ ...secretStoreErrorContext,
67+ },
68+) {
69+ override get message(): string {
70+ return `Failed to generate random bytes for ${this.resource}.`;
71+ }
72+}
73+
74+export class SecretStoreConcurrentReadError extends Schema.TaggedErrorClass<SecretStoreConcurrentReadError>()(
75+ "SecretStoreConcurrentReadError",
76+ {
77+ resource: Schema.String,
78+ },
79+) {
80+ override get message(): string {
81+ return `Failed to read ${this.resource} after concurrent creation.`;
82+ }
83+}
84+
85+export class SecretStoreRemoveError extends Schema.TaggedErrorClass<SecretStoreRemoveError>()(
86+ "SecretStoreRemoveError",
87+ {
88+ ...secretStoreErrorContext,
89+ },
90+) {
91+ override get message(): string {
92+ return `Failed to remove ${this.resource}.`;
93+ }
94+}
95+
96+export class SecretStoreDecodeError extends Schema.TaggedErrorClass<SecretStoreDecodeError>()(
97+ "SecretStoreDecodeError",
98+ {
99+ ...secretStoreErrorContext,
100+ },
101+) {
102+ override get message(): string {
103+ return `Failed to decode ${this.resource}.`;
104+ }
105+}
106+
107+export class SecretStoreEncodeError extends Schema.TaggedErrorClass<SecretStoreEncodeError>()(
108+ "SecretStoreEncodeError",
109+ {
110+ ...secretStoreErrorContext,
111+ },
112+) {
113+ override get message(): string {
114+ return `Failed to encode ${this.resource}.`;
115+ }
116+}
117+
118+export const SecretStoreError = Schema.Union([
119+ SecretStoreSecureError,
120+ SecretStoreReadError,
121+ SecretStoreTemporaryPathError,
122+ SecretStorePersistError,
123+ SecretStoreRandomGenerationError,
124+ SecretStoreConcurrentReadError,
125+ SecretStoreRemoveError,
126+ SecretStoreDecodeError,
127+ SecretStoreEncodeError,
128+]);
129+export type SecretStoreError = typeof SecretStoreError.Type;
130+export const isSecretStoreError = Schema.is(SecretStoreError);
21131
22132 const isPlatformError = (value: unknown): value is PlatformError.PlatformError =>
23133 Predicate.isTagged(value, "PlatformError");
24134
25135 export const isSecretAlreadyExistsError = (error: SecretStoreError): boolean =>
26− isPlatformError(error.cause) && error.cause.reason._tag === "AlreadyExists";
27−
28−export interface ServerSecretStoreShape {
29− readonly get: (name: string) => Effect.Effect<Option.Option<Uint8Array>, SecretStoreError>;
30− readonly set: (name: string, value: Uint8Array) => Effect.Effect<void, SecretStoreError>;
31− readonly create: (name: string, value: Uint8Array) => Effect.Effect<void, SecretStoreError>;
32− readonly getOrCreateRandom: (
33− name: string,
34− bytes: number,
35− ) => Effect.Effect<Uint8Array, SecretStoreError>;
36− readonly remove: (name: string) => Effect.Effect<void, SecretStoreError>;
37−}
136+ "cause" in error && isPlatformError(error.cause) && error.cause.reason._tag === "AlreadyExists";
38137
39−export class ServerSecretStore extends Context.Service<ServerSecretStore, ServerSecretStoreShape>()(
40− "t3/auth/ServerSecretStore",
41−) {}
138+export class ServerSecretStore extends Context.Service<
139+ ServerSecretStore,
140+ {
141+ readonly get: (name: string) => Effect.Effect<Option.Option<Uint8Array>, SecretStoreError>;
142+ readonly set: (name: string, value: Uint8Array) => Effect.Effect<void, SecretStoreError>;
143+ readonly create: (name: string, value: Uint8Array) => Effect.Effect<void, SecretStoreError>;
144+ readonly getOrCreateRandom: (
145+ name: string,
146+ bytes: number,
147+ ) => Effect.Effect<Uint8Array, SecretStoreError>;
148+ readonly remove: (name: string) => Effect.Effect<void, SecretStoreError>;
149+ }
150+>()("t3/auth/ServerSecretStore") {}
42151
43−export const make = Effect.fn("makeServerSecretStore")(function* () {
152+export const make = Effect.gen(function* () {
44153 const crypto = yield* Crypto.Crypto;
45154 const fileSystem = yield* FileSystem.FileSystem;
46155 const path = yield* Path.Path;
47− const serverConfig = yield* ServerConfig;
156+ const serverConfig = yield* ServerConfig.ServerConfig;
48157
49158 yield* fileSystem.makeDirectory(serverConfig.secretsDir, { recursive: true });
50159 yield* fileSystem.chmod(serverConfig.secretsDir, 0o700).pipe(
51160 Effect.mapError(
52161 (cause) =>
53− new SecretStoreError({
54− message: `Failed to secure secrets directory ${serverConfig.secretsDir}.`,
162+ new SecretStoreSecureError({
163+ resource: `secrets directory ${serverConfig.secretsDir}`,
55164 cause,
56165 }),
57166 ),
@@ -59,15 +168,15 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { · 1 unmodified line
59168
60169 const resolveSecretPath = (name: string) => path.join(serverConfig.secretsDir, `${name}.bin`);
61170
62− const get: ServerSecretStoreShape["get"] = (name) =>
171+ const get: ServerSecretStore["Service"]["get"] = (name) =>
63172 fileSystem.readFile(resolveSecretPath(name)).pipe(
64173 Effect.map((bytes) => Option.some(Uint8Array.from(bytes))),
65174 Effect.catch((cause) =>
66175 cause.reason._tag === "NotFound"
67176 ? Effect.succeed(Option.none())
68177 : Effect.fail(
69− new SecretStoreError({
70− message: `Failed to read secret ${name}.`,
178+ new SecretStoreReadError({
179+ resource: `secret ${name}`,
71180 cause,
72181 }),
73182 ),
@@ -75,13 +184,13 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { · 1 unmodified line
75184 Effect.withSpan("ServerSecretStore.get"),
76185 );
77186
78− const set: ServerSecretStoreShape["set"] = (name, value) => {
187+ const set: ServerSecretStore["Service"]["set"] = (name, value) => {
79188 const secretPath = resolveSecretPath(name);
80189 return crypto.randomUUIDv4.pipe(
81190 Effect.mapError(
82191 (cause) =>
83− new SecretStoreError({
84− message: `Failed to create temporary path for secret ${name}.`,
192+ new SecretStoreTemporaryPathError({
193+ resource: `secret ${name}`,
85194 cause,
86195 }),
87196 ),
@@ -98,8 +207,8 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { · 10 unmodified lines
98207 Effect.ignore,
99208 Effect.flatMap(() =>
100209 Effect.fail(
101− new SecretStoreError({
102− message: `Failed to persist secret ${name}.`,
210+ new SecretStorePersistError({
211+ resource: `secret ${name}`,
103212 cause,
104213 }),
105214 ),
@@ -112,7 +221,7 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { · 6 unmodified lines
112221 );
113222 };
114223
115− const create: ServerSecretStoreShape["create"] = (name, value) => {
224+ const create: ServerSecretStore["Service"]["create"] = (name, value) => {
116225 const secretPath = resolveSecretPath(name);
117226 return Effect.scoped(
118227 Effect.gen(function* () {
@@ -127,15 +236,15 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { · 8 unmodified lines
127236 ).pipe(
128237 Effect.mapError(
129238 (cause) =>
130− new SecretStoreError({
131− message: `Failed to persist secret ${name}.`,
239+ new SecretStorePersistError({
240+ resource: `secret ${name}`,
132241 cause,
133242 }),
134243 ),
135244 );
136245 };
137246
138− const getOrCreateRandom: ServerSecretStoreShape["getOrCreateRandom"] = (name, bytes) =>
247+ const getOrCreateRandom: ServerSecretStore["Service"]["getOrCreateRandom"] = (name, bytes) =>
139248 get(name).pipe(
140249 Effect.flatMap(
141250 Option.match({
@@ -144,15 +253,15 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { · 2 unmodified lines
144253 crypto.randomBytes(bytes).pipe(
145254 Effect.mapError(
146255 (cause) =>
147− new SecretStoreError({
148− message: `Failed to generate random bytes for secret ${name}.`,
256+ new SecretStoreRandomGenerationError({
257+ resource: `secret ${name}`,
149258 cause,
150259 }),
151260 ),
152261 Effect.flatMap((generated) =>
153262 create(name, generated).pipe(
154263 Effect.as(Uint8Array.from(generated)),
155− Effect.catchTag("SecretStoreError", (error) =>
264+ Effect.catchIf(isSecretStoreError, (error) =>
156265 isSecretAlreadyExistsError(error)
157266 ? get(name).pipe(
158267 Effect.flatMap(
@@ -160,8 +269,8 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { · 1 unmodified line
160269 onSome: Effect.succeed,
161270 onNone: () =>
162271 Effect.fail(
163− new SecretStoreError({
164− message: `Failed to read secret ${name} after concurrent creation.`,
272+ new SecretStoreConcurrentReadError({
273+ resource: `secret ${name}`,
165274 }),
166275 ),
167276 }),
@@ -177,14 +286,14 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { · 9 unmodified lines
177286 Effect.withSpan("ServerSecretStore.getOrCreateRandom"),
178287 );
179288
180− const remove: ServerSecretStoreShape["remove"] = (name) =>
289+ const remove: ServerSecretStore["Service"]["remove"] = (name) =>
181290 fileSystem.remove(resolveSecretPath(name)).pipe(
182291 Effect.catch((cause) =>
183292 cause.reason._tag === "NotFound"
184293 ? Effect.void
185294 : Effect.fail(
186− new SecretStoreError({
187− message: `Failed to remove secret ${name}.`,
295+ new SecretStoreRemoveError({
296+ resource: `secret ${name}`,
188297 cause,
189298 }),
190299 ),
@@ -192,13 +301,13 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { · 1 unmodified line
192301 Effect.withSpan("ServerSecretStore.remove"),
193302 );
194303
195− return {
304+ return ServerSecretStore.of({
196305 get,
197306 set,
198307 create,
199308 getOrCreateRandom,
200309 remove,
201− } satisfies ServerSecretStoreShape;
310+ });
202311 });
203312
204−export const layer = Layer.effect(ServerSecretStore, make());
313+export const layer = Layer.effect(ServerSecretStore, make);
apps/server/src/auth/SessionStore.test.ts+4−4
@@ -51,7 +51,7 @@ const failingSessionLookupRepositoryLayer = Layer.succeed(AuthSessions.AuthSessi · 50 unmodified lines
5151
5252 const failingSessionLookupCredentialLayer = Layer.effect(
5353 SessionStore.SessionStore,
54− SessionStore.make(),
54+ SessionStore.make,
5555 ).pipe(
5656 Layer.provide(failingSessionLookupRepositoryLayer),
5757 Layer.provide(ServerSecretStore.layer),
@@ -89,7 +89,7 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { · 31 unmodified lines
8989 const sessions = yield* SessionStore.SessionStore;
9090 const error = yield* Effect.flip(sessions.verify("not-a-session-token"));
9191
92− expect(error._tag).toBe("SessionCredentialInvalidError");
92+ expect(error._tag).toBe("MalformedSessionTokenError");
9393 expect(error.message).toContain("Malformed session token");
9494 }).pipe(Effect.provide(makeSessionStoreLayer())),
9595 );
@@ -105,8 +105,8 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { · 9 unmodified lines
105105 const sessionError = yield* Effect.flip(sessions.verify(issued.token));
106106 const websocketError = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token));
107107
108− expect(sessionError._tag).toBe("SessionCredentialInternalError");
109− expect(websocketError._tag).toBe("SessionCredentialInternalError");
108+ expect(sessionError._tag).toBe("SessionCredentialVerificationError");
109+ expect(websocketError._tag).toBe("WebSocketTokenVerificationError");
110110 expect(sessionError.cause).toBe(repositoryFailure);
111111 expect(websocketError.cause).toBe(repositoryFailure);
112112 }).pipe(Effect.provide(failingSessionLookupCredentialLayer)),
apps/server/src/auth/SessionStore.ts+343−150
@@ -10,7 +10,6 @@ import { · 9 unmodified lines
1010 import * as Clock from "effect/Clock";
1111 import * as Context from "effect/Context";
1212 import * as Crypto from "effect/Crypto";
13−import * as Data from "effect/Data";
1413 import * as DateTime from "effect/DateTime";
1514 import * as Duration from "effect/Duration";
1615 import * as Effect from "effect/Effect";
@@ -21,7 +20,7 @@ import * as Schema from "effect/Schema"; · 4 unmodified lines
2120 import * as Stream from "effect/Stream";
2221 import * as Option from "effect/Option";
2322
24−import { ServerConfig } from "../config.ts";
23+import * as ServerConfig from "../config.ts";
2524 import * as AuthSessions from "../persistence/AuthSessions.ts";
2625 import * as ServerSecretStore from "./ServerSecretStore.ts";
2726 import {
@@ -63,66 +62,311 @@ export type SessionCredentialChange = · 35 unmodified lines
6362 readonly sessionId: AuthSessionId;
6463 };
6564
66−export class SessionCredentialInvalidError extends Data.TaggedError(
67− "SessionCredentialInvalidError",
68−)<{
69− readonly message: string;
70− readonly cause?: unknown;
71−}> {}
72−
73−export class SessionCredentialInternalError extends Data.TaggedError(
74− "SessionCredentialInternalError",
75−)<{
76− readonly message: string;
77− readonly cause?: unknown;
78−}> {}
79−
80−export type SessionCredentialError = SessionCredentialInvalidError | SessionCredentialInternalError;
81−
82−export interface SessionStoreShape {
83− readonly cookieName: string;
84− readonly issue: (input?: {
85− readonly ttl?: Duration.Duration;
86− readonly subject?: string;
87− readonly method?: ServerAuthSessionMethod;
88− readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
89− readonly client?: AuthClientMetadata;
90− readonly proofKeyThumbprint?: string;
91− }) => Effect.Effect<IssuedSession, SessionCredentialInternalError>;
92− readonly verify: (token: string) => Effect.Effect<VerifiedSession, SessionCredentialError>;
93− readonly issueWebSocketToken: (
94− sessionId: AuthSessionId,
95− input?: {
96− readonly ttl?: Duration.Duration;
97− },
98− ) => Effect.Effect<
99− {
100− readonly token: string;
101− readonly expiresAt: DateTime.DateTime;
102− },
103− SessionCredentialInternalError
104− >;
105− readonly verifyWebSocketToken: (
106− token: string,
107− ) => Effect.Effect<VerifiedSession, SessionCredentialError>;
108− readonly listActive: () => Effect.Effect<
109− ReadonlyArray<AuthClientSession>,
110− SessionCredentialInternalError
111− >;
112− readonly streamChanges: Stream.Stream<SessionCredentialChange>;
113− readonly revoke: (
114− sessionId: AuthSessionId,
115− ) => Effect.Effect<boolean, SessionCredentialInternalError>;
116− readonly revokeAllExcept: (
117− sessionId: AuthSessionId,
118− ) => Effect.Effect<number, SessionCredentialInternalError>;
119− readonly markConnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>;
120− readonly markDisconnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>;
65+export class MalformedSessionTokenError extends Schema.TaggedErrorClass<MalformedSessionTokenError>()(
66+ "MalformedSessionTokenError",
67+ {},
68+) {
69+ override get message(): string {
70+ return "Malformed session token.";
71+ }
72+}
73+
74+export class InvalidSessionTokenSignatureError extends Schema.TaggedErrorClass<InvalidSessionTokenSignatureError>()(
75+ "InvalidSessionTokenSignatureError",
76+ {},
77+) {
78+ override get message(): string {
79+ return "Invalid session token signature.";
80+ }
12181 }
12282
123−export class SessionStore extends Context.Service<SessionStore, SessionStoreShape>()(
124− "t3/auth/SessionStore",
125−) {}
83+export class InvalidSessionTokenPayloadError extends Schema.TaggedErrorClass<InvalidSessionTokenPayloadError>()(
84+ "InvalidSessionTokenPayloadError",
85+ {
86+ cause: Schema.Defect(),
87+ },
88+) {
89+ override get message(): string {
90+ return "Invalid session token payload.";
91+ }
92+}
93+
94+export class SessionTokenExpiredError extends Schema.TaggedErrorClass<SessionTokenExpiredError>()(
95+ "SessionTokenExpiredError",
96+ {},
97+) {
98+ override get message(): string {
99+ return "Session token expired.";
100+ }
101+}
102+
103+export class UnknownSessionTokenError extends Schema.TaggedErrorClass<UnknownSessionTokenError>()(
104+ "UnknownSessionTokenError",
105+ {},
106+) {
107+ override get message(): string {
108+ return "Unknown session token.";
109+ }
110+}
111+
112+export class SessionTokenRevokedError extends Schema.TaggedErrorClass<SessionTokenRevokedError>()(
113+ "SessionTokenRevokedError",
114+ {},
115+) {
116+ override get message(): string {
117+ return "Session token revoked.";
118+ }
119+}
120+
121+export class InvalidSessionExpirationClaimError extends Schema.TaggedErrorClass<InvalidSessionExpirationClaimError>()(
122+ "InvalidSessionExpirationClaimError",
123+ {},
124+) {
125+ override get message(): string {
126+ return "Invalid `exp` claim";
127+ }
128+}
129+
130+export class MalformedWebSocketTokenError extends Schema.TaggedErrorClass<MalformedWebSocketTokenError>()(
131+ "MalformedWebSocketTokenError",
132+ {},
133+) {
134+ override get message(): string {
135+ return "Malformed websocket token.";
136+ }
137+}
138+
139+export class InvalidWebSocketTokenSignatureError extends Schema.TaggedErrorClass<InvalidWebSocketTokenSignatureError>()(
140+ "InvalidWebSocketTokenSignatureError",
141+ {},
142+) {
143+ override get message(): string {
144+ return "Invalid websocket token signature.";
145+ }
146+}
147+
148+export class InvalidWebSocketTokenPayloadError extends Schema.TaggedErrorClass<InvalidWebSocketTokenPayloadError>()(
149+ "InvalidWebSocketTokenPayloadError",
150+ {
151+ cause: Schema.Defect(),
152+ },
153+) {
154+ override get message(): string {
155+ return "Invalid websocket token payload.";
156+ }
157+}
158+
159+export class WebSocketTokenExpiredError extends Schema.TaggedErrorClass<WebSocketTokenExpiredError>()(
160+ "WebSocketTokenExpiredError",
161+ {},
162+) {
163+ override get message(): string {
164+ return "Websocket token expired.";
165+ }
166+}
167+
168+export class UnknownWebSocketSessionError extends Schema.TaggedErrorClass<UnknownWebSocketSessionError>()(
169+ "UnknownWebSocketSessionError",
170+ {},
171+) {
172+ override get message(): string {
173+ return "Unknown websocket session.";
174+ }
175+}
176+
177+export class WebSocketSessionExpiredError extends Schema.TaggedErrorClass<WebSocketSessionExpiredError>()(
178+ "WebSocketSessionExpiredError",
179+ {},
180+) {
181+ override get message(): string {
182+ return "Websocket session expired.";
183+ }
184+}
185+
186+export class WebSocketSessionRevokedError extends Schema.TaggedErrorClass<WebSocketSessionRevokedError>()(
187+ "WebSocketSessionRevokedError",
188+ {},
189+) {
190+ override get message(): string {
191+ return "Websocket session revoked.";
192+ }
193+}
194+
195+export const SessionCredentialInvalidError = Schema.Union([
196+ MalformedSessionTokenError,
197+ InvalidSessionTokenSignatureError,
198+ InvalidSessionTokenPayloadError,
199+ SessionTokenExpiredError,
200+ UnknownSessionTokenError,
201+ SessionTokenRevokedError,
202+ InvalidSessionExpirationClaimError,
203+ MalformedWebSocketTokenError,
204+ InvalidWebSocketTokenSignatureError,
205+ InvalidWebSocketTokenPayloadError,
206+ WebSocketTokenExpiredError,
207+ UnknownWebSocketSessionError,
208+ WebSocketSessionExpiredError,
209+ WebSocketSessionRevokedError,
210+]);
211+export type SessionCredentialInvalidError = typeof SessionCredentialInvalidError.Type;
212+export const isSessionCredentialInvalidError = Schema.is(SessionCredentialInvalidError);
213+
214+const sessionCredentialInternalErrorContext = {
215+ cause: Schema.Defect(),
216+};
217+
218+export class SessionClaimsEncodingError extends Schema.TaggedErrorClass<SessionClaimsEncodingError>()(
219+ "SessionClaimsEncodingError",
220+ {
221+ operation: Schema.Literals(["encode_session_claims", "encode_websocket_claims"]),
222+ ...sessionCredentialInternalErrorContext,
223+ },
224+) {
225+ override get message(): string {
226+ return "Failed to encode claims";
227+ }
228+}
229+
230+export class SessionCredentialIssueError extends Schema.TaggedErrorClass<SessionCredentialIssueError>()(
231+ "SessionCredentialIssueError",
232+ {
233+ ...sessionCredentialInternalErrorContext,
234+ },
235+) {
236+ override get message(): string {
237+ return "Failed to issue session credential.";
238+ }
239+}
240+
241+export class SessionCredentialVerificationError extends Schema.TaggedErrorClass<SessionCredentialVerificationError>()(
242+ "SessionCredentialVerificationError",
243+ {
244+ ...sessionCredentialInternalErrorContext,
245+ },
246+) {
247+ override get message(): string {
248+ return "Failed to verify session credential.";
249+ }
250+}
251+
252+export class WebSocketTokenIssueError extends Schema.TaggedErrorClass<WebSocketTokenIssueError>()(
253+ "WebSocketTokenIssueError",
254+ {
255+ ...sessionCredentialInternalErrorContext,
256+ },
257+) {
258+ override get message(): string {
259+ return "Failed to issue websocket token.";
260+ }
261+}
262+
263+export class WebSocketTokenVerificationError extends Schema.TaggedErrorClass<WebSocketTokenVerificationError>()(
264+ "WebSocketTokenVerificationError",
265+ {
266+ ...sessionCredentialInternalErrorContext,
267+ },
268+) {
269+ override get message(): string {
270+ return "Failed to verify websocket token.";
271+ }
272+}
273+
274+export class ActiveSessionsListError extends Schema.TaggedErrorClass<ActiveSessionsListError>()(
275+ "ActiveSessionsListError",
276+ {
277+ ...sessionCredentialInternalErrorContext,
278+ },
279+) {
280+ override get message(): string {
281+ return "Failed to list active sessions.";
282+ }
283+}
284+
285+export class SessionRevocationError extends Schema.TaggedErrorClass<SessionRevocationError>()(
286+ "SessionRevocationError",
287+ {
288+ ...sessionCredentialInternalErrorContext,
289+ },
290+) {
291+ override get message(): string {
292+ return "Failed to revoke session.";
293+ }
294+}
295+
296+export class OtherSessionsRevocationError extends Schema.TaggedErrorClass<OtherSessionsRevocationError>()(
297+ "OtherSessionsRevocationError",
298+ {
299+ ...sessionCredentialInternalErrorContext,
300+ },
301+) {
302+ override get message(): string {
303+ return "Failed to revoke other sessions.";
304+ }
305+}
306+
307+export const SessionCredentialInternalError = Schema.Union([
308+ SessionClaimsEncodingError,
309+ SessionCredentialIssueError,
310+ SessionCredentialVerificationError,
311+ WebSocketTokenIssueError,
312+ WebSocketTokenVerificationError,
313+ ActiveSessionsListError,
314+ SessionRevocationError,
315+ OtherSessionsRevocationError,
316+]);
317+export type SessionCredentialInternalError = typeof SessionCredentialInternalError.Type;
318+export const isSessionCredentialInternalError = Schema.is(SessionCredentialInternalError);
319+
320+export const SessionCredentialError = Schema.Union([
321+ SessionCredentialInvalidError,
322+ SessionCredentialInternalError,
323+]);
324+export type SessionCredentialError = typeof SessionCredentialError.Type;
325+export const isSessionCredentialError = Schema.is(SessionCredentialError);
326+
327+export class SessionStore extends Context.Service<
328+ SessionStore,
329+ {
330+ readonly cookieName: string;
331+ readonly issue: (input?: {
332+ readonly ttl?: Duration.Duration;
333+ readonly subject?: string;
334+ readonly method?: ServerAuthSessionMethod;
335+ readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
336+ readonly client?: AuthClientMetadata;
337+ readonly proofKeyThumbprint?: string;
338+ }) => Effect.Effect<IssuedSession, SessionCredentialInternalError>;
339+ readonly verify: (token: string) => Effect.Effect<VerifiedSession, SessionCredentialError>;
340+ readonly issueWebSocketToken: (
341+ sessionId: AuthSessionId,
342+ input?: {
343+ readonly ttl?: Duration.Duration;
344+ },
345+ ) => Effect.Effect<
346+ {
347+ readonly token: string;
348+ readonly expiresAt: DateTime.DateTime;
349+ },
350+ SessionCredentialInternalError
351+ >;
352+ readonly verifyWebSocketToken: (
353+ token: string,
354+ ) => Effect.Effect<VerifiedSession, SessionCredentialError>;
355+ readonly listActive: () => Effect.Effect<
356+ ReadonlyArray<AuthClientSession>,
357+ SessionCredentialInternalError
358+ >;
359+ readonly streamChanges: Stream.Stream<SessionCredentialChange>;
360+ readonly revoke: (
361+ sessionId: AuthSessionId,
362+ ) => Effect.Effect<boolean, SessionCredentialInternalError>;
363+ readonly revokeAllExcept: (
364+ sessionId: AuthSessionId,
365+ ) => Effect.Effect<number, SessionCredentialInternalError>;
366+ readonly markConnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>;
367+ readonly markDisconnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>;
368+ }
369+>()("t3/auth/SessionStore") {}
126370
127371 const SIGNING_SECRET_NAME = "server-signing-key";
128372 const DEFAULT_SESSION_TTL = Duration.days(30);
@@ -184,15 +428,9 @@ function toAuthClientSession(input: Omit<AuthClientSession, "current">): AuthCli · 55 unmodified lines
184428 };
185429 }
186430
187−const toSessionCredentialInternalError = (message: string) => (cause: unknown) =>
188− new SessionCredentialInternalError({
189− message,
190− cause,
191− });
192−
193−export const make = Effect.fn("makeSessionStore")(function* () {
431+export const make = Effect.gen(function* () {
194432 const crypto = yield* Crypto.Crypto;
195− const serverConfig = yield* ServerConfig;
433+ const serverConfig = yield* ServerConfig.ServerConfig;
196434 const secretStore = yield* ServerSecretStore.ServerSecretStore;
197435 const authSessions = yield* AuthSessions.AuthSessionRepository;
198436 const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32);
@@ -238,7 +476,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { · 39 unmodified lines
238476 );
239477 });
240478
241− const markConnected: SessionStoreShape["markConnected"] = (sessionId) =>
479+ const markConnected: SessionStore["Service"]["markConnected"] = (sessionId) =>
242480 Ref.modify(connectedSessionsRef, (current) => {
243481 const next = new Map(current);
244482 const wasDisconnected = !next.has(sessionId);
@@ -272,7 +510,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { · 27 unmodified lines
272510 Effect.withSpan("SessionStore.markConnected"),
273511 );
274512
275− const markDisconnected: SessionStoreShape["markDisconnected"] = (sessionId) =>
513+ const markDisconnected: SessionStore["Service"]["markDisconnected"] = (sessionId) =>
276514 Ref.update(connectedSessionsRef, (current) => {
277515 const next = new Map(current);
278516 const remaining = (next.get(sessionId) ?? 0) - 1;
@@ -299,7 +537,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { · 20 unmodified lines
299537 );
300538
301539 const encodeClaims = Schema.encodeEffect(Schema.fromJsonString(SessionClaims));
302− const issue: SessionStoreShape["issue"] = Effect.fn("SessionStore.issue")(
540+ const issue: SessionStore["Service"]["issue"] = Effect.fn("SessionStore.issue")(
303541 function* (input) {
304542 const sessionId = AuthSessionId.make(yield* crypto.randomUUIDv4);
305543 const issuedAt = yield* DateTime.now;
@@ -321,8 +559,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { · 15 unmodified lines
321559 const encodedPayload = yield* encodeClaims(claims).pipe(
322560 Effect.map(base64UrlEncode),
323561 Effect.mapError(
324− (cause) =>
325− new SessionCredentialInternalError({ message: "Failed to encode claims", cause }),
562+ (cause) => new SessionClaimsEncodingError({ operation: "encode_session_claims", cause }),
326563 ),
327564 );
328565 const signature = signPayload(encodedPayload, signingSecret);
@@ -367,59 +604,41 @@ export const make = Effect.fn("makeSessionStore")(function* () { · 38 unmodified lines
367604 ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}),
368605 } satisfies IssuedSession;
369606 },
370− Effect.mapError(toSessionCredentialInternalError("Failed to issue session credential.")),
607+ Effect.mapError((cause) => new SessionCredentialIssueError({ cause })),
371608 );
372609
373− const verify: SessionStoreShape["verify"] = Effect.fn("SessionStore.verify")(
610+ const verify: SessionStore["Service"]["verify"] = Effect.fn("SessionStore.verify")(
374611 function* (token) {
375612 const [encodedPayload, signature] = token.split(".");
376613 if (!encodedPayload || !signature) {
377− return yield* new SessionCredentialInvalidError({
378− message: "Malformed session token.",
379− });
614+ return yield* new MalformedSessionTokenError({});
380615 }
381616
382617 const expectedSignature = signPayload(encodedPayload, signingSecret);
383618 if (!timingSafeEqualBase64Url(signature, expectedSignature)) {
384− return yield* new SessionCredentialInvalidError({
385− message: "Invalid session token signature.",
386− });
619+ return yield* new InvalidSessionTokenSignatureError({});
387620 }
388621
389622 const claims = yield* decodeSessionClaims(base64UrlDecodeUtf8(encodedPayload)).pipe(
390− Effect.mapError(
391− (cause) =>
392− new SessionCredentialInvalidError({
393− message: "Invalid session token payload.",
394− cause,
395− }),
396− ),
623+ Effect.mapError((cause) => new InvalidSessionTokenPayloadError({ cause })),
397624 );
398625
399626 const now = yield* Clock.currentTimeMillis;
400627 if (claims.exp <= now) {
401− return yield* new SessionCredentialInvalidError({
402− message: "Session token expired.",
403− });
628+ return yield* new SessionTokenExpiredError({});
404629 }
405630
406631 const row = yield* authSessions.getById({ sessionId: claims.sid });
407632 if (Option.isNone(row)) {
408− return yield* new SessionCredentialInvalidError({
409− message: "Unknown session token.",
410− });
633+ return yield* new UnknownSessionTokenError({});
411634 }
412635 if (row.value.revokedAt !== null) {
413− return yield* new SessionCredentialInvalidError({
414− message: "Session token revoked.",
415− });
636+ return yield* new SessionTokenRevokedError({});
416637 }
417638
418639 const expiresAt = DateTime.make(claims.exp);
419640 if (Option.isNone(expiresAt)) {
420− return yield* new SessionCredentialInvalidError({
421− message: "Invalid `exp` claim",
422− });
641+ return yield* new InvalidSessionExpirationClaimError({});
423642 }
424643
425644 return {
@@ -434,17 +653,14 @@ export const make = Effect.fn("makeSessionStore")(function* () { · 8 unmodified lines
434653 } satisfies VerifiedSession;
435654 },
436655 Effect.mapError((cause) =>
437− cause._tag === "SessionCredentialInvalidError"
656+ isSessionCredentialInvalidError(cause)
438657 ? cause
439− : new SessionCredentialInternalError({
440− message: "Failed to verify session credential.",
441− cause,
442− }),
658+ : new SessionCredentialVerificationError({ cause }),
443659 ),
444660 );
445661
446662 const encodeWsClaims = Schema.encodeEffect(Schema.fromJsonString(WebSocketClaims));
447− const issueWebSocketToken: SessionStoreShape["issueWebSocketToken"] = Effect.fn(
663+ const issueWebSocketToken: SessionStore["Service"]["issueWebSocketToken"] = Effect.fn(
448664 "SessionStore.issueWebSocketToken",
449665 )(
450666 function* (sessionId, input) {
@@ -463,7 +679,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { · 12 unmodified lines
463679 Effect.map(base64UrlEncode),
464680 Effect.mapError(
465681 (cause) =>
466− new SessionCredentialInternalError({ message: "Failed to encode claims", cause }),
682+ new SessionClaimsEncodingError({ operation: "encode_websocket_claims", cause }),
467683 ),
468684 );
469685 const signature = signPayload(encodedPayload, signingSecret);
@@ -472,59 +688,41 @@ export const make = Effect.fn("makeSessionStore")(function* () { · 2 unmodified lines
472688 expiresAt,
473689 };
474690 },
475− Effect.mapError(toSessionCredentialInternalError("Failed to issue websocket token.")),
691+ Effect.mapError((cause) => new WebSocketTokenIssueError({ cause })),
476692 );
477693
478− const verifyWebSocketToken: SessionStoreShape["verifyWebSocketToken"] = Effect.fn(
694+ const verifyWebSocketToken: SessionStore["Service"]["verifyWebSocketToken"] = Effect.fn(
479695 "SessionStore.verifyWebSocketToken",
480696 )(
481697 function* (token) {
482698 const [encodedPayload, signature] = token.split(".");
483699 if (!encodedPayload || !signature) {
484− return yield* new SessionCredentialInvalidError({
485− message: "Malformed websocket token.",
486− });
700+ return yield* new MalformedWebSocketTokenError({});
487701 }
488702
489703 const expectedSignature = signPayload(encodedPayload, signingSecret);
490704 if (!timingSafeEqualBase64Url(signature, expectedSignature)) {
491− return yield* new SessionCredentialInvalidError({
492− message: "Invalid websocket token signature.",
493− });
705+ return yield* new InvalidWebSocketTokenSignatureError({});
494706 }
495707
496708 const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe(
497− Effect.mapError(
498− (cause) =>
499− new SessionCredentialInvalidError({
500− message: "Invalid websocket token payload.",
501− cause,
502− }),
503− ),
709+ Effect.mapError((cause) => new InvalidWebSocketTokenPayloadError({ cause })),
504710 );
505711
506712 const now = yield* Clock.currentTimeMillis;
507713 if (claims.exp <= now) {
508− return yield* new SessionCredentialInvalidError({
509− message: "Websocket token expired.",
510− });
714+ return yield* new WebSocketTokenExpiredError({});
511715 }
512716
513717 const row = yield* authSessions.getById({ sessionId: claims.sid });
514718 if (Option.isNone(row)) {
515− return yield* new SessionCredentialInvalidError({
516− message: "Unknown websocket session.",
517− });
719+ return yield* new UnknownWebSocketSessionError({});
518720 }
519721 if (row.value.expiresAt.epochMilliseconds <= now) {
520− return yield* new SessionCredentialInvalidError({
521− message: "Websocket session expired.",
522− });
722+ return yield* new WebSocketSessionExpiredError({});
523723 }
524724 if (row.value.revokedAt !== null) {
525− return yield* new SessionCredentialInvalidError({
526− message: "Websocket session revoked.",
527− });
725+ return yield* new WebSocketSessionRevokedError({});
528726 }
529727
530728 return {
@@ -538,16 +736,13 @@ export const make = Effect.fn("makeSessionStore")(function* () { · 7 unmodified lines
538736 } satisfies VerifiedSession;
539737 },
540738 Effect.mapError((cause) =>
541− cause._tag === "SessionCredentialInvalidError"
739+ isSessionCredentialInvalidError(cause)
542740 ? cause
543− : new SessionCredentialInternalError({
544− message: "Failed to verify websocket token.",
545− cause,
546− }),
741+ : new WebSocketTokenVerificationError({ cause }),
547742 ),
548743 );
549744
550− const listActive: SessionStoreShape["listActive"] = Effect.fn("SessionStore.listActive")(
745+ const listActive: SessionStore["Service"]["listActive"] = Effect.fn("SessionStore.listActive")(
551746 function* () {
552747 const now = yield* DateTime.now;
553748 const connectedSessions = yield* Ref.get(connectedSessionsRef);
@@ -567,10 +762,10 @@ export const make = Effect.fn("makeSessionStore")(function* () { · 13 unmodified lines
567762 }),
568763 );
569764 },
570− Effect.mapError(toSessionCredentialInternalError("Failed to list active sessions.")),
765+ Effect.mapError((cause) => new ActiveSessionsListError({ cause })),
571766 );
572767
573− const revoke: SessionStoreShape["revoke"] = Effect.fn("SessionStore.revoke")(
768+ const revoke: SessionStore["Service"]["revoke"] = Effect.fn("SessionStore.revoke")(
574769 function* (sessionId) {
575770 const revokedAt = yield* DateTime.now;
576771 const revoked = yield* authSessions.revoke({
@@ -587,10 +782,10 @@ export const make = Effect.fn("makeSessionStore")(function* () { · 10 unmodified lines
587782 }
588783 return revoked;
589784 },
590− Effect.mapError(toSessionCredentialInternalError("Failed to revoke session.")),
785+ Effect.mapError((cause) => new SessionRevocationError({ cause })),
591786 );
592787
593− const revokeAllExcept: SessionStoreShape["revokeAllExcept"] = Effect.fn(
788+ const revokeAllExcept: SessionStore["Service"]["revokeAllExcept"] = Effect.fn(
594789 "SessionStore.revokeAllExcept",
595790 )(
596791 function* (sessionId) {
@@ -618,10 +813,10 @@ export const make = Effect.fn("makeSessionStore")(function* () { · 21 unmodified lines
618813 }
619814 return revokedSessionIds.length;
620815 },
621− Effect.mapError(toSessionCredentialInternalError("Failed to revoke other sessions.")),
816+ Effect.mapError((cause) => new OtherSessionsRevocationError({ cause })),
622817 );
623818
624− return {
819+ return SessionStore.of({
625820 cookieName,
626821 issue,
627822 verify,
@@ -635,9 +830,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { · 7 unmodified lines
635830 revokeAllExcept,
636831 markConnected,
637832 markDisconnected,
638− } satisfies SessionStoreShape;
833+ });
639834 });
640835
641−export const layer = Layer.effect(SessionStore, make()).pipe(
642− Layer.provideMerge(AuthSessions.layer),
643−);
836+export const layer = Layer.effect(SessionStore, make).pipe(Layer.provideMerge(AuthSessions.layer));
apps/server/src/auth/dpop.test.ts+10−6
@@ -1,12 +1,12 @@
11 import { describe, expect, it } from "vite-plus/test";
22 import * as PlatformError from "effect/PlatformError";
33
4−import * as ServerSecretStore from "./ServerSecretStore.ts";
4+import { SecretStorePersistError } from "./ServerSecretStore.ts";
55 import { mapDpopReplayStoreError } from "./dpop.ts";
66
77 const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") =>
8− new ServerSecretStore.SecretStoreError({
9− message: "Failed to persist DPoP proof.",
8+ new SecretStorePersistError({
9+ resource: "DPoP proof",
1010 cause: PlatformError.systemError({
1111 _tag: tag,
1212 module: "FileSystem",
@@ -17,16 +17,20 @@ const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => · 4 unmodified lines
1717
1818 describe("mapDpopReplayStoreError", () => {
1919 it("reports replay conflicts as invalid credentials", () => {
20− const error = mapDpopReplayStoreError(storeFailure("AlreadyExists"));
20+ const cause = storeFailure("AlreadyExists");
21+ const error = mapDpopReplayStoreError(cause);
2122
2223 expect(error._tag).toBe("ServerAuthInvalidCredentialError");
24+ if (error._tag === "ServerAuthInvalidCredentialError") {
25+ expect(error.cause).toBe(cause);
26+ }
2327 });
2428
2529 it("reports replay-store availability failures as internal errors", () => {
2630 const error = mapDpopReplayStoreError(storeFailure("PermissionDenied"));
2731
28− expect(error._tag).toBe("ServerAuthInternalError");
29− if (error._tag === "ServerAuthInternalError") {
32+ expect(error._tag).toBe("ServerAuthDpopReplayStateRecordError");
33+ if (error._tag === "ServerAuthDpopReplayStateRecordError") {
3034 expect(error.message).toBe("Failed to record DPoP proof replay state.");
3135 }
3236 });
apps/server/src/auth/dpop.ts+17−13
@@ -5,7 +5,12 @@ import * as Effect from "effect/Effect"; · 4 unmodified lines
55 import * as Encoding from "effect/Encoding";
66 import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
77
8−import * as EnvironmentAuth from "./EnvironmentAuth.ts";
8+import {
9+ ServerAuthDpopReplayKeyCalculationError,
10+ ServerAuthDpopReplayStateRecordError,
11+ ServerAuthInvalidCredentialError,
12+ type ServerAuthInternalError,
13+} from "./EnvironmentAuth.ts";
914 import * as ServerSecretStore from "./ServerSecretStore.ts";
1015
1116 function firstHeaderValue(value: string | undefined): string | undefined {
@@ -26,14 +31,13 @@ export function requestAbsoluteUrl(request: HttpServerRequest.HttpServerRequest) · 14 unmodified lines
2631
2732 export const mapDpopReplayStoreError = (
2833 error: ServerSecretStore.SecretStoreError,
29−): EnvironmentAuth.ServerAuthInvalidCredentialError | EnvironmentAuth.ServerAuthInternalError =>
34+): ServerAuthInvalidCredentialError | ServerAuthInternalError =>
3035 ServerSecretStore.isSecretAlreadyExistsError(error)
31− ? new EnvironmentAuth.ServerAuthInvalidCredentialError({
32− reason: "invalid_credential",
33− cause: "DPoP proof replayed.",
36+ ? new ServerAuthInvalidCredentialError({
37+ diagnostic: "DPoP proof replayed.",
38+ cause: error,
3439 })
35− : new EnvironmentAuth.ServerAuthInternalError({
36− message: "Failed to record DPoP proof replay state.",
40+ : new ServerAuthDpopReplayStateRecordError({
3741 cause: error,
3842 });
3943
@@ -54,9 +58,8 @@ export const verifyRequestDpopProof = (input: { · 14 unmodified lines
5458 ...(input.expectedAccessToken ? { expectedAccessToken: input.expectedAccessToken } : {}),
5559 });
5660 if (!result.ok) {
57− return yield* new EnvironmentAuth.ServerAuthInvalidCredentialError({
58− reason: "invalid_credential",
59− cause: result.reason,
61+ return yield* new ServerAuthInvalidCredentialError({
62+ diagnostic: result.reason,
6063 });
6164 }
6265 const secretStore = yield* ServerSecretStore.ServerSecretStore;
@@ -67,8 +70,7 @@ export const verifyRequestDpopProof = (input: { · 4 unmodified lines
6770 Effect.map(Encoding.encodeBase64Url),
6871 Effect.mapError(
6972 (cause) =>
70− new EnvironmentAuth.ServerAuthInternalError({
71− message: "Failed to calculate DPoP replay key.",
73+ new ServerAuthDpopReplayKeyCalculationError({
7274 cause,
7375 }),
7476 ),
@@ -86,7 +88,9 @@ export const verifyRequestDpopProof = (input: { · 11 unmodified lines
8688 ),
8789 )
8890 .pipe(
89− Effect.catchTag("SecretStoreError", (error) => Effect.fail(mapDpopReplayStoreError(error))),
91+ Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) =>
92+ Effect.fail(mapDpopReplayStoreError(error)),
93+ ),
9094 );
9195 return result.thumbprint;
9296 });
apps/server/src/auth/http.ts+42−36
@@ -169,10 +169,12 @@ export const environmentAuthenticatedAuthLayer = Layer.effect( · 168 unmodified lines
169169 Effect.gen(function* () {
170170 const request = yield* HttpServerRequest.HttpServerRequest;
171171 const session = yield* serverAuth.authenticateHttpRequest(request).pipe(
172− Effect.catchTags({
173− ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason),
174− ServerAuthInternalError: (error) => failEnvironmentInternal("internal_error", error),
175− }),
172+ Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>
173+ failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)),
174+ ),
175+ Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
176+ failEnvironmentInternal("internal_error", error),
177+ ),
176178 );
177179 return yield* httpEffect.pipe(
178180 Effect.provideService(EnvironmentAuthenticatedPrincipal, {
@@ -201,7 +203,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( · 22 unmodified lines
201203 const request = yield* HttpServerRequest.HttpServerRequest;
202204 return yield* serverAuth.getSessionState(request);
203205 },
204− Effect.catchTag("ServerAuthInternalError", (error) =>
206+ Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
205207 failEnvironmentInternal("internal_error", error),
206208 ),
207209 ),
@@ -231,11 +233,12 @@ export const authHttpApiLayer = HttpApiBuilder.group( · 23 unmodified lines
231233 yield* appendCredentialResponseHeaders;
232234 return result.response;
233235 },
234− Effect.catchTags({
235− ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason),
236− ServerAuthInternalError: (error) =>
237− failEnvironmentInternal("browser_session_issuance_failed", error),
238− }),
236+ Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>
237+ failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)),
238+ ),
239+ Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
240+ failEnvironmentInternal("browser_session_issuance_failed", error),
241+ ),
239242 ),
240243 )
241244 .handle(
@@ -265,14 +268,14 @@ export const authHttpApiLayer = HttpApiBuilder.group( · 23 unmodified lines
265268 }
266269 const proofKeyThumbprint = args.headers.dpop
267270 ? yield* verifyRequestDpopProof({ request }).pipe(
268− Effect.catchTags({
269− ServerAuthInvalidCredentialError: () =>
270− appendDpopChallengeHeader.pipe(
271− Effect.andThen(failEnvironmentAuthInvalid("invalid_credential")),
272− ),
273− ServerAuthInternalError: (error) =>
274− failEnvironmentInternal("access_token_issuance_failed", error),
275− }),
271+ Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, () =>
272+ appendDpopChallengeHeader.pipe(
273+ Effect.andThen(failEnvironmentAuthInvalid("invalid_credential")),
274+ ),
275+ ),
276+ Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
277+ failEnvironmentInternal("access_token_issuance_failed", error),
278+ ),
276279 )
277280 : undefined;
278281 yield* appendCredentialResponseHeaders;
@@ -293,12 +296,15 @@ export const authHttpApiLayer = HttpApiBuilder.group( · 14 unmodified lines
293296 );
294297 },
295298 traceRelayRequest,
296− Effect.catchTags({
297− ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason),
298− ServerAuthInvalidRequestError: (error) => failEnvironmentInvalidRequest(error.reason),
299− ServerAuthInternalError: (error) =>
300− failEnvironmentInternal("access_token_issuance_failed", error),
301− }),
299+ Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>
300+ failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)),
301+ ),
302+ Effect.catchIf(EnvironmentAuth.isServerAuthInvalidRequestError, (error) =>
303+ failEnvironmentInvalidRequest(EnvironmentAuth.serverAuthInvalidRequestReason(error)),
304+ ),
305+ Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
306+ failEnvironmentInternal("access_token_issuance_failed", error),
307+ ),
302308 ),
303309 )
304310 .handle(
@@ -310,7 +316,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( · 5 unmodified lines
310316 yield* appendCredentialResponseHeaders;
311317 return yield* serverAuth.issueWebSocketTicket(session);
312318 },
313− Effect.catchTag("ServerAuthInternalError", (error) =>
319+ Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
314320 failEnvironmentInternal("websocket_ticket_issuance_failed", error),
315321 ),
316322 ),
@@ -335,7 +341,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( · 18 unmodified lines
335341 }
336342 return yield* serverAuth.issuePairingCredential(args.payload);
337343 },
338− Effect.catchTag("ServerAuthInternalError", (error) =>
344+ Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
339345 failEnvironmentInternal("pairing_credential_issuance_failed", error),
340346 ),
341347 ),
@@ -348,7 +354,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( · 6 unmodified lines
348354 yield* requireEnvironmentScope(AuthAccessReadScope);
349355 return yield* serverAuth.listPairingLinks();
350356 },
351− Effect.catchTag("ServerAuthInternalError", (error) =>
357+ Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
352358 failEnvironmentInternal("pairing_links_load_failed", error),
353359 ),
354360 ),
@@ -362,7 +368,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( · 7 unmodified lines
362368 const revoked = yield* serverAuth.revokePairingLink(args.payload.id);
363369 return { revoked };
364370 },
365− Effect.catchTag("ServerAuthInternalError", (error) =>
371+ Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
366372 failEnvironmentInternal("pairing_link_revoke_failed", error),
367373 ),
368374 ),
@@ -375,7 +381,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( · 6 unmodified lines
375381 const session = yield* requireEnvironmentScope(AuthAccessReadScope);
376382 return yield* serverAuth.listClientSessions(session.sessionId);
377383 },
378− Effect.catchTag("ServerAuthInternalError", (error) =>
384+ Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
379385 failEnvironmentInternal("client_sessions_load_failed", error),
380386 ),
381387 ),
@@ -392,12 +398,12 @@ export const authHttpApiLayer = HttpApiBuilder.group( · 10 unmodified lines
392398 );
393399 return { revoked };
394400 },
395− Effect.catchTags({
396− ServerAuthForbiddenOperationError: (error) =>
397− failEnvironmentOperationForbidden(error.reason),
398− ServerAuthInternalError: (error) =>
399− failEnvironmentInternal("client_session_revoke_failed", error),
400− }),
401+ Effect.catchTag("ServerAuthForbiddenOperationError", () =>
402+ failEnvironmentOperationForbidden("current_session_revoke_not_allowed"),
403+ ),
404+ Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
405+ failEnvironmentInternal("client_session_revoke_failed", error),
406+ ),
401407 ),
402408 )
403409 .handle(
@@ -409,7 +415,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( · 5 unmodified lines
409415 const revokedCount = yield* serverAuth.revokeOtherClientSessions(session.sessionId);
410416 return { revokedCount };
411417 },
412− Effect.catchTag("ServerAuthInternalError", (error) =>
418+ Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
413419 failEnvironmentInternal("client_session_revoke_failed", error),
414420 ),
415421 ),
apps/server/src/cloud/environmentKeys.test.ts+4−4
@@ -6,7 +6,7 @@ import * as Option from "effect/Option"; · 5 unmodified lines
66 import * as PlatformError from "effect/PlatformError";
77
88 import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
9−import { ServerConfig } from "../config.ts";
9+import * as ServerConfig from "../config.ts";
1010 import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts";
1111
1212 const makeServerSecretStoreLayer = () =>
@@ -65,8 +65,8 @@ it.layer(NodeServices.layer)("getOrCreateEnvironmentKeyPairFromSecretStore", (it · 52 unmodified lines
6565 }).pipe(
6666 Effect.flatMap(() =>
6767 Effect.fail(
68− new ServerSecretStore.SecretStoreError({
69− message: "Concurrent keypair creation won.",
68+ new ServerSecretStore.SecretStorePersistError({
69+ resource: "environment signing key pair",
7070 cause: PlatformError.systemError({
7171 _tag: "AlreadyExists",
7272 module: "FileSystem",
@@ -79,7 +79,7 @@ it.layer(NodeServices.layer)("getOrCreateEnvironmentKeyPairFromSecretStore", (it · 6 unmodified lines
7979 ),
8080 getOrCreateRandom: unusedSecretStoreOperation,
8181 remove: unusedSecretStoreOperation,
82− } satisfies ServerSecretStore.ServerSecretStoreShape;
82+ } satisfies ServerSecretStore.ServerSecretStore["Service"];
8383
8484 assert.deepEqual(yield* getOrCreateEnvironmentKeyPairFromSecretStore(secretStore), {
8585 privateKey: "winner-private",
apps/server/src/cloud/environmentKeys.ts+17−18
@@ -27,47 +27,46 @@ function stringToBytes(value: string): Uint8Array { · 26 unmodified lines
2727 return new TextEncoder().encode(value);
2828 }
2929
30−const keyPairPersistenceError = (message: string, cause?: unknown) =>
31− new ServerSecretStore.SecretStoreError({ message, cause });
30+const KEY_PAIR_RESOURCE = "environment signing key pair";
31+
32+const keyPairDecodeError = (cause: unknown): ServerSecretStore.SecretStoreDecodeError =>
33+ new ServerSecretStore.SecretStoreDecodeError({ resource: KEY_PAIR_RESOURCE, cause });
34+
35+const keyPairEncodeError = (cause: unknown): ServerSecretStore.SecretStoreEncodeError =>
36+ new ServerSecretStore.SecretStoreEncodeError({ resource: KEY_PAIR_RESOURCE, cause });
37+
38+const keyPairConcurrentReadError = (): ServerSecretStore.SecretStoreConcurrentReadError =>
39+ new ServerSecretStore.SecretStoreConcurrentReadError({ resource: KEY_PAIR_RESOURCE });
3240
3341 const readEnvironmentKeyPair = Effect.fn("readEnvironmentKeyPair")(function* (
34− secrets: ServerSecretStore.ServerSecretStoreShape,
42+ secrets: ServerSecretStore.ServerSecretStore["Service"],
3543 ) {
3644 const encoded = yield* secrets.get(CLOUD_LINK_KEY_PAIR);
3745 if (Option.isNone(encoded)) {
3846 return Option.none<EnvironmentKeyPair>();
3947 }
4048 const decoded = yield* decodeEnvironmentKeyPair(bytesToString(encoded.value)).pipe(
41− Effect.mapError((cause) =>
42− keyPairPersistenceError("Failed to decode environment signing key pair.", cause),
43− ),
49+ Effect.mapError(keyPairDecodeError),
4450 );
4551 return Option.some(decoded);
4652 });
4753
4854 const persistEnvironmentKeyPair = Effect.fn("persistEnvironmentKeyPair")(function* (
49− secrets: ServerSecretStore.ServerSecretStoreShape,
55+ secrets: ServerSecretStore.ServerSecretStore["Service"],
5056 keyPair: EnvironmentKeyPair,
5157 ) {
5258 const encoded = yield* encodeEnvironmentKeyPair(keyPair).pipe(
53− Effect.mapError((cause) =>
54− keyPairPersistenceError("Failed to encode environment signing key pair.", cause),
55− ),
59+ Effect.mapError(keyPairEncodeError),
5660 );
5761 return yield* secrets.create(CLOUD_LINK_KEY_PAIR, stringToBytes(encoded)).pipe(
5862 Effect.as(keyPair),
59− Effect.catchTag("SecretStoreError", (error) =>
63+ Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) =>
6064 ServerSecretStore.isSecretAlreadyExistsError(error)
6165 ? readEnvironmentKeyPair(secrets).pipe(
6266 Effect.flatMap(
6367 Option.match({
6468 onSome: Effect.succeed,
65− onNone: () =>
66− Effect.fail(
67− keyPairPersistenceError(
68− "Failed to read environment signing key pair after concurrent creation.",
69− ),
70− ),
69+ onNone: () => Effect.fail(keyPairConcurrentReadError()),
7170 }),
7271 ),
7372 )
@@ -77,7 +76,7 @@ const persistEnvironmentKeyPair = Effect.fn("persistEnvironmentKeyPair")(functio · 3 unmodified lines
7776 });
7877
7978 export const getOrCreateEnvironmentKeyPairFromSecretStore = Effect.fn(function* (
80− secrets: ServerSecretStore.ServerSecretStoreShape,
79+ secrets: ServerSecretStore.ServerSecretStore["Service"],
8180 ) {
8281 const existing = yield* readEnvironmentKeyPair(secrets);
8382 if (Option.isSome(existing)) {
apps/server/src/cloud/http.test.ts+26−2
@@ -16,8 +16,8 @@ import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; · 15 unmodified lines
1616 import { traceAuthenticatedRelayRequest, traceRelayRequest } from "./traceRelayRequest.ts";
1717
1818 const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") =>
19− new ServerSecretStore.SecretStoreError({
20− message: "Failed to persist cloud replay guard.",
19+ new ServerSecretStore.SecretStorePersistError({
20+ resource: "cloud replay guard",
2121 cause: PlatformError.systemError({
2222 _tag: tag,
2323 module: "FileSystem",
@@ -40,6 +40,30 @@ function makeSecretStore( · 16 unmodified lines
4040 };
4141 }
4242
43+it("preserves messages surfaced by cloud 500 responses", () => {
44+ const cause = new Error("cloud operation failed");
45+
46+ expect([
47+ new EnvironmentAuth.ServerAuthLinkedCloudAccountVerificationError({ cause }).message,
48+ new EnvironmentAuth.ServerAuthLinkedCloudAccountReadError({ cause }).message,
49+ new EnvironmentAuth.ServerAuthLinkedCloudAccountMissingError({}).message,
50+ new EnvironmentAuth.ServerAuthCloudLinkJwtSigningError({ cause }).message,
51+ new EnvironmentAuth.ServerAuthCloudMintPublicKeyMissingError({}).message,
52+ new EnvironmentAuth.ServerAuthCloudRelayIssuerMissingError({}).message,
53+ new EnvironmentAuth.ServerAuthCloudHealthJwtSigningError({ cause }).message,
54+ new EnvironmentAuth.ServerAuthCloudMintJwtSigningError({ cause }).message,
55+ ]).toEqual([
56+ "Could not verify the linked cloud account.",
57+ "Could not read the linked cloud account.",
58+ "Cloud linked user is not installed for this environment.",
59+ "Failed to sign cloud link JWT.",
60+ "Cloud mint public key is not installed for this environment.",
61+ "Cloud relay issuer is not installed for this environment.",
62+ "Failed to sign cloud health JWT.",
63+ "Failed to sign cloud mint JWT.",
64+ ]);
65+});
66+
4367 describe("consumeCloudReplayGuards", () => {
4468 it.effect("reports already-created guards as replay conflicts", () =>
4569 Effect.gen(function* () {
apps/server/src/cloud/http.ts+113−119
@@ -68,7 +68,7 @@ import { · 67 unmodified lines
6868 RELAY_URL_SECRET,
6969 } from "./config.ts";
7070 import { relayUrlConfig } from "./publicConfig.ts";
71−import * as CliState from "./CliState.ts";
71+import { setCliDesiredCloudLink } from "./CliState.ts";
7272 import * as CliTokenManager from "./CliTokenManager.ts";
7373 import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts";
7474 import { traceRelayRequest } from "./traceRelayRequest.ts";
@@ -98,7 +98,7 @@ const failEnvironmentCloudInternalError = · 23 unmodified lines
9898 );
9999
100100 const failCloudCliTokenManagerError = (error: CliTokenManager.CloudCliTokenManagerError) =>
101− failEnvironmentCloudInternalError(error.message)(error.cause);
101+ failEnvironmentCloudInternalError(error.message)(error);
102102
103103 const requireRelayUrl = relayUrlConfig.pipe(
104104 Effect.mapError(
@@ -126,7 +126,7 @@ export function consumeCloudReplayGuards(input: { · 21 unmodified lines
126126 input.names.map((name) =>
127127 input.secrets.create(name, input.value).pipe(
128128 Effect.as(true),
129− Effect.catchTag("SecretStoreError", (error) =>
129+ Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) =>
130130 ServerSecretStore.isSecretAlreadyExistsError(error)
131131 ? Effect.succeed(false)
132132 : Effect.fail(error),
@@ -211,8 +211,7 @@ function validateLinkedCloudUser(input: { · 78 unmodified lines
211211 return input.secrets.get(CLOUD_LINKED_USER_ID).pipe(
212212 Effect.mapError(
213213 (cause) =>
214− new EnvironmentAuth.ServerAuthInternalError({
215− message: "Could not verify the linked cloud account.",
214+ new EnvironmentAuth.ServerAuthLinkedCloudAccountVerificationError({
216215 cause,
217216 }),
218217 ),
@@ -239,19 +238,14 @@ function readInstalledCloudUserId( · 20 unmodified lines
239238 return secrets.get(CLOUD_LINKED_USER_ID).pipe(
240239 Effect.mapError(
241240 (cause) =>
242− new EnvironmentAuth.ServerAuthInternalError({
243− message: "Could not read the linked cloud account.",
241+ new EnvironmentAuth.ServerAuthLinkedCloudAccountReadError({
244242 cause,
245243 }),
246244 ),
247245 Effect.flatMap((bytes) =>
248246 Option.isSome(bytes)
249247 ? Effect.succeed(bytesToString(bytes.value))
250− : Effect.fail(
251− new EnvironmentAuth.ServerAuthInternalError({
252− message: "Cloud linked user is not installed for this environment.",
253− }),
254− ),
248+ : Effect.fail(new EnvironmentAuth.ServerAuthLinkedCloudAccountMissingError({})),
255249 ),
256250 );
257251 }
@@ -394,8 +388,7 @@ const makeCloudLinkProof = Effect.fn("environment.cloud.makeLinkProof")(function · 136 unmodified lines
394388 }).pipe(
395389 Effect.mapError(
396390 (cause) =>
397− new EnvironmentAuth.ServerAuthInternalError({
398− message: "Failed to sign cloud link JWT.",
391+ new EnvironmentAuth.ServerAuthCloudLinkJwtSigningError({
399392 cause,
400393 }),
401394 ),
@@ -416,15 +409,17 @@ const cloudLinkProofHandler = Effect.fn("environment.cloud.linkProof")( · 14 unmodified lines
416409 yield* appendCloudCredentialResponseHeaders;
417410 return proof satisfies RelayEnvironmentLinkProof;
418411 },
419− Effect.catchTag("ServerAuthInternalError", (error) =>
420− failEnvironmentCloudInternalError(error.message)(error.cause),
412+ Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
413+ failEnvironmentCloudInternalError(error.message)(error),
414+ ),
415+ Effect.catchIf(
416+ ServerSecretStore.isSecretStoreError,
417+ failEnvironmentCloudInternalError("Could not generate environment link proof."),
418+ ),
419+ Effect.catchTag(
420+ "PlatformError",
421+ failEnvironmentCloudInternalError("Could not generate environment link proof."),
421422 ),
422− Effect.catchTags({
423− PlatformError: failEnvironmentCloudInternalError("Could not generate environment link proof."),
424− SecretStoreError: failEnvironmentCloudInternalError(
425− "Could not generate environment link proof.",
426− ),
427− }),
428423 );
429424
430425 const applyCloudRelayConfig = Effect.fn("environment.cloud.applyRelayConfig")(function* (
@@ -477,17 +472,17 @@ const cloudRelayConfigHandler = Effect.fn("environment.cloud.relayConfig")( · 46 unmodified lines
477472 yield* requireEnvironmentScope(AuthRelayWriteScope);
478473 return yield* applyCloudRelayConfig(dependencies, payload);
479474 },
480− Effect.catchTag("ServerAuthInternalError", (error) =>
481− failEnvironmentCloudInternalError(error.message)(error.cause),
475+ Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
476+ failEnvironmentCloudInternalError(error.message)(error),
477+ ),
478+ Effect.catchIf(
479+ ServerSecretStore.isSecretStoreError,
480+ failEnvironmentCloudInternalError("Could not persist environment relay configuration."),
481+ ),
482+ Effect.catchTag(
483+ "SchemaError",
484+ failEnvironmentCloudInternalError("Could not persist environment relay configuration."),
482485 ),
483− Effect.catchTags({
484− SchemaError: failEnvironmentCloudInternalError(
485− "Could not persist environment relay configuration.",
486− ),
487− SecretStoreError: failEnvironmentCloudInternalError(
488− "Could not persist environment relay configuration.",
489− ),
490− }),
491486 );
492487
493488 const relayClientRequest = <A>(
@@ -581,7 +576,7 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi · 87 unmodified lines
581576 },
582577 schema: RelayEnvironmentLinkResponse,
583578 });
584− yield* CliState.setCliDesiredCloudLink(true);
579+ yield* setCliDesiredCloudLink(true);
585580 return yield* applyCloudRelayConfig(dependencies, {
586581 relayUrl,
587582 relayIssuer: link.relayIssuer,
@@ -591,15 +586,16 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi · 3 unmodified lines
591586 endpointRuntime: link.endpointRuntime,
592587 });
593588 },
589+ Effect.catchIf(
590+ ServerSecretStore.isSecretStoreError,
591+ failEnvironmentCloudInternalError("Could not persist desired T3 Connect link state."),
592+ ),
594593 Effect.catchTags({
595594 CloudCliCredentialRemovalError: failCloudCliTokenManagerError,
596595 CloudCliCredentialRefreshError: failCloudCliTokenManagerError,
597596 CloudCliCredentialReadError: failCloudCliTokenManagerError,
598597 CloudCliAuthorizationError: failCloudCliTokenManagerError,
599598 CloudCliAuthorizationTimeoutError: failCloudCliTokenManagerError,
600− SecretStoreError: failEnvironmentCloudInternalError(
601− "Could not persist desired T3 Connect link state.",
602− ),
603599 }),
604600 );
605601
@@ -637,8 +633,8 @@ const cloudLinkStateHandler = Effect.fn("environment.cloud.linkState")( · 31 unmodified lines
637633 yield* requireEnvironmentScope(AuthRelayReadScope);
638634 return yield* readCloudLinkState(dependencies);
639635 },
640− Effect.catchTag(
641− "SecretStoreError",
636+ Effect.catchIf(
637+ ServerSecretStore.isSecretStoreError,
642638 failEnvironmentCloudInternalError("Could not read environment relay configuration."),
643639 ),
644640 );
@@ -659,11 +655,11 @@ const cloudUnlinkHandler = Effect.fn("environment.cloud.unlink")( · 14 unmodified lines
659655 ],
660656 { concurrency: 7 },
661657 );
662− yield* CliState.setCliDesiredCloudLink(false);
658+ yield* setCliDesiredCloudLink(false);
663659 return { ok: true, endpointRuntimeStatus } satisfies EnvironmentCloudRelayConfigResult;
664660 },
665− Effect.catchTag(
666− "SecretStoreError",
661+ Effect.catchIf(
662+ ServerSecretStore.isSecretStoreError,
667663 failEnvironmentCloudInternalError("Could not remove environment relay configuration."),
668664 ),
669665 );
@@ -680,42 +676,40 @@ const cloudPreferencesHandler = Effect.fn("environment.cloud.preferences")( · 10 unmodified lines
680676 );
681677 return yield* readCloudLinkState(dependencies);
682678 },
683− Effect.catchTag(
684− "SecretStoreError",
679+ Effect.catchIf(
680+ ServerSecretStore.isSecretStoreError,
685681 failEnvironmentCloudInternalError("Could not persist environment cloud preferences."),
686682 ),
687683 );
688684
689685 const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")(
690686 function* (dependencies: CloudHttpDependencies, request: RelayCloudEnvironmentHealthRequest) {
691− const cloudMintPublicKey = yield* dependencies.secrets.get(CLOUD_MINT_PUBLIC_KEY).pipe(
692− Effect.flatMap((bytes) =>
693− Option.isSome(bytes)
694− ? Effect.succeed(bytesToString(bytes.value))
695− : Effect.fail(
696− new EnvironmentAuth.ServerAuthInternalError({
697− message: "Cloud mint public key is not installed for this environment.",
698− }),
699− ),
700− ),
701− );
702− const relayIssuer = yield* dependencies.secrets.get(RELAY_ISSUER_SECRET).pipe(
703− Effect.flatMap((bytes) =>
704− Option.isSome(bytes)
705− ? Effect.succeed(bytesToString(bytes.value))
706− : dependencies.secrets.get(RELAY_URL_SECRET).pipe(
707− Effect.flatMap((fallbackBytes) =>
708− Option.isSome(fallbackBytes)
709− ? Effect.succeed(bytesToString(fallbackBytes.value))
710− : Effect.fail(
711− new EnvironmentAuth.ServerAuthInternalError({
712− message: "Cloud relay issuer is not installed for this environment.",
713− }),
714− ),
715− ),
716− ),
717− ),
718− );
687+ const cloudMintPublicKey = yield* dependencies.secrets
688+ .get(CLOUD_MINT_PUBLIC_KEY)
689+ .pipe(
690+ Effect.flatMap((bytes) =>
691+ Option.isSome(bytes)
692+ ? Effect.succeed(bytesToString(bytes.value))
693+ : Effect.fail(new EnvironmentAuth.ServerAuthCloudMintPublicKeyMissingError({})),
694+ ),
695+ );
696+ const relayIssuer = yield* dependencies.secrets
697+ .get(RELAY_ISSUER_SECRET)
698+ .pipe(
699+ Effect.flatMap((bytes) =>
700+ Option.isSome(bytes)
701+ ? Effect.succeed(bytesToString(bytes.value))
702+ : dependencies.secrets
703+ .get(RELAY_URL_SECRET)
704+ .pipe(
705+ Effect.flatMap((fallbackBytes) =>
706+ Option.isSome(fallbackBytes)
707+ ? Effect.succeed(bytesToString(fallbackBytes.value))
708+ : Effect.fail(new EnvironmentAuth.ServerAuthCloudRelayIssuerMissingError({})),
709+ ),
710+ ),
711+ ),
712+ );
719713 const environmentId = yield* dependencies.environment.getEnvironmentId;
720714 const linkedCloudUserId = yield* readInstalledCloudUserId(dependencies.secrets);
721715 const now = yield* DateTime.now;
@@ -777,8 +771,7 @@ const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")( · 55 unmodified lines
777771 }).pipe(
778772 Effect.mapError(
779773 (cause) =>
780− new EnvironmentAuth.ServerAuthInternalError({
781− message: "Failed to sign cloud health JWT.",
774+ new EnvironmentAuth.ServerAuthCloudHealthJwtSigningError({
782775 cause,
783776 }),
784777 ),
@@ -794,45 +787,47 @@ const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")( · 9 unmodified lines
794787 yield* appendCloudCredentialResponseHeaders;
795788 return response;
796789 },
797− Effect.catchTag("ServerAuthInternalError", (error) =>
798− failEnvironmentCloudInternalError(error.message)(error.cause),
790+ Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
791+ failEnvironmentCloudInternalError(error.message)(error),
792+ ),
793+ Effect.catchIf(
794+ ServerSecretStore.isSecretStoreError,
795+ failEnvironmentCloudInternalError("Could not answer cloud health request."),
796+ ),
797+ Effect.catchTag(
798+ "PlatformError",
799+ failEnvironmentCloudInternalError("Could not answer cloud health request."),
799800 ),
800− Effect.catchTags({
801− PlatformError: failEnvironmentCloudInternalError("Could not answer cloud health request."),
802− SecretStoreError: failEnvironmentCloudInternalError("Could not answer cloud health request."),
803− }),
804801 );
805802
806803 const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential")(
807804 function* (dependencies: CloudHttpDependencies, request: RelayCloudMintCredentialRequest) {
808− const cloudMintPublicKey = yield* dependencies.secrets.get(CLOUD_MINT_PUBLIC_KEY).pipe(
809− Effect.flatMap((bytes) =>
810− Option.isSome(bytes)
811− ? Effect.succeed(bytesToString(bytes.value))
812− : Effect.fail(
813− new EnvironmentAuth.ServerAuthInternalError({
814− message: "Cloud mint public key is not installed for this environment.",
815− }),
816− ),
817− ),
818− );
819− const relayIssuer = yield* dependencies.secrets.get(RELAY_ISSUER_SECRET).pipe(
820− Effect.flatMap((bytes) =>
821− Option.isSome(bytes)
822− ? Effect.succeed(bytesToString(bytes.value))
823− : dependencies.secrets.get(RELAY_URL_SECRET).pipe(
824− Effect.flatMap((fallbackBytes) =>
825− Option.isSome(fallbackBytes)
826− ? Effect.succeed(bytesToString(fallbackBytes.value))
827− : Effect.fail(
828− new EnvironmentAuth.ServerAuthInternalError({
829− message: "Cloud relay issuer is not installed for this environment.",
830− }),
831− ),
832− ),
833− ),
834− ),
835− );
805+ const cloudMintPublicKey = yield* dependencies.secrets
806+ .get(CLOUD_MINT_PUBLIC_KEY)
807+ .pipe(
808+ Effect.flatMap((bytes) =>
809+ Option.isSome(bytes)
810+ ? Effect.succeed(bytesToString(bytes.value))
811+ : Effect.fail(new EnvironmentAuth.ServerAuthCloudMintPublicKeyMissingError({})),
812+ ),
813+ );
814+ const relayIssuer = yield* dependencies.secrets
815+ .get(RELAY_ISSUER_SECRET)
816+ .pipe(
817+ Effect.flatMap((bytes) =>
818+ Option.isSome(bytes)
819+ ? Effect.succeed(bytesToString(bytes.value))
820+ : dependencies.secrets
821+ .get(RELAY_URL_SECRET)
822+ .pipe(
823+ Effect.flatMap((fallbackBytes) =>
824+ Option.isSome(fallbackBytes)
825+ ? Effect.succeed(bytesToString(fallbackBytes.value))
826+ : Effect.fail(new EnvironmentAuth.ServerAuthCloudRelayIssuerMissingError({})),
827+ ),
828+ ),
829+ ),
830+ );
836831 const environmentId = yield* dependencies.environment.getEnvironmentId;
837832 const linkedCloudUserId = yield* readInstalledCloudUserId(dependencies.secrets);
838833 const now = yield* DateTime.now;
@@ -899,8 +894,7 @@ const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential") · 60 unmodified lines
899894 }).pipe(
900895 Effect.mapError(
901896 (cause) =>
902− new EnvironmentAuth.ServerAuthInternalError({
903− message: "Failed to sign cloud mint JWT.",
897+ new EnvironmentAuth.ServerAuthCloudMintJwtSigningError({
904898 cause,
905899 }),
906900 ),
@@ -914,17 +908,17 @@ const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential") · 7 unmodified lines
914908 yield* appendCloudCredentialResponseHeaders;
915909 return response;
916910 },
917− Effect.catchTag("ServerAuthInternalError", (error) =>
918− failEnvironmentCloudInternalError(error.message)(error.cause),
911+ Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
912+ failEnvironmentCloudInternalError(error.message)(error),
913+ ),
914+ Effect.catchIf(
915+ ServerSecretStore.isSecretStoreError,
916+ failEnvironmentCloudInternalError("Could not issue cloud connection credential."),
917+ ),
918+ Effect.catchTag(
919+ "PlatformError",
920+ failEnvironmentCloudInternalError("Could not issue cloud connection credential."),
919921 ),
920− Effect.catchTags({
921− PlatformError: failEnvironmentCloudInternalError(
922− "Could not issue cloud connection credential.",
923− ),
924− SecretStoreError: failEnvironmentCloudInternalError(
925− "Could not issue cloud connection credential.",
926− ),
927− }),
928922 );
929923
930924 export const connectHttpApiLayer = HttpApiBuilder.group(
apps/server/src/http.ts+6−4
@@ -81,10 +81,12 @@ const authenticateRawRouteWithScope = ( · 80 unmodified lines
8181 const request = yield* HttpServerRequest.HttpServerRequest;
8282 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
8383 const session = yield* serverAuth.authenticateHttpRequest(request).pipe(
84− Effect.catchTags({
85− ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason),
86− ServerAuthInternalError: (error) => failEnvironmentInternal("internal_error", error),
87− }),
84+ Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>
85+ failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)),
86+ ),
87+ Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
88+ failEnvironmentInternal("internal_error", error),
89+ ),
8890 );
8991 if (!session.scopes.includes(scope)) {
9092 return yield* failEnvironmentScopeRequired(scope);
apps/server/src/relay/AgentAwarenessRelay.test.ts+6−6
@@ -67,15 +67,15 @@ function makeMemorySecretStore() { · 66 unmodified lines
6767 Effect.sync(() => {
6868 const value = values.get(name);
6969 return value === undefined ? Option.none() : Option.some(Uint8Array.from(value));
70− })) satisfies ServerSecretStore.ServerSecretStoreShape["get"],
70+ })) satisfies ServerSecretStore.ServerSecretStore["Service"]["get"],
7171 set: ((name, value) =>
7272 Effect.sync(() => {
7373 values.set(name, Uint8Array.from(value));
74− })) satisfies ServerSecretStore.ServerSecretStoreShape["set"],
74+ })) satisfies ServerSecretStore.ServerSecretStore["Service"]["set"],
7575 create: ((name, value) =>
7676 Effect.sync(() => {
7777 values.set(name, Uint8Array.from(value));
78− })) satisfies ServerSecretStore.ServerSecretStoreShape["create"],
78+ })) satisfies ServerSecretStore.ServerSecretStore["Service"]["create"],
7979 getOrCreateRandom: ((name, bytes) =>
8080 Effect.sync(() => {
8181 const existing = values.get(name);
@@ -85,12 +85,12 @@ function makeMemorySecretStore() { · 3 unmodified lines
8585 const generated = new Uint8Array(bytes);
8686 values.set(name, generated);
8787 return generated;
88− })) satisfies ServerSecretStore.ServerSecretStoreShape["getOrCreateRandom"],
88+ })) satisfies ServerSecretStore.ServerSecretStore["Service"]["getOrCreateRandom"],
8989 remove: ((name) =>
9090 Effect.sync(() => {
9191 values.delete(name);
92− })) satisfies ServerSecretStore.ServerSecretStoreShape["remove"],
93− } satisfies ServerSecretStore.ServerSecretStoreShape;
92+ })) satisfies ServerSecretStore.ServerSecretStore["Service"]["remove"],
93+ } satisfies ServerSecretStore.ServerSecretStore["Service"];
9494 return {
9595 store,
9696 setString: (name: string, value: string) => store.set(name, encodeSecret(value)),
apps/server/src/ws.ts+6−4
@@ -1726,10 +1726,12 @@ export const websocketRpcRouteLayer = Layer.unwrap( · 1725 unmodified lines
17261726 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
17271727 const sessions = yield* SessionStore.SessionStore;
17281728 const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe(
1729− Effect.catchTags({
1730− ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason),
1731− ServerAuthInternalError: (error) => failEnvironmentInternal("internal_error", error),
1732− }),
1729+ Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>
1730+ failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)),
1731+ ),
1732+ Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
1733+ failEnvironmentInternal("internal_error", error),
1734+ ),
17331735 );
17341736 const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, {
17351737 disableTracing: true,