History

Clean cloud auth and Autumn boundaries

Rhys Sullivan committed 6 months ago

Commit d401d651 parent b039b6e4 files · +36 −22Browse files

Unified · Split

4 files
  1. apps/cloud/src/api/autumn.ts +12 −16
  2. apps/cloud/src/auth/handlers.node.test.ts +11 −4
  3. apps/cloud/src/auth/workos.ts +10 −2
  4. apps/cloud/src/observability.test.ts +3 −0
apps/cloud/src/api/autumn.ts+12−16
@@ -1,5 +1,5 @@
1import { env } from "cloudflare:workers";1import { env } from "cloudflare:workers";
2import { Effect } from "effect";2import { Cause, Effect } from "effect";
3import {3import {
4 HttpRouter,4 HttpRouter,
5 HttpServerRequest,5 HttpServerRequest,
@@ -30,13 +30,11 @@ const handler = Effect.gen(function* () { · 24 unmodified lines
30 const session = yield* workos.authenticateRequest(webRequest);30 const session = yield* workos.authenticateRequest(webRequest);
31 31
32 if (!session || !session.organizationId) {32 if (!session || !session.organizationId) {
33 return yield* Effect.fail(33 return yield* new HttpResponseError({
34 new HttpResponseError({34 status: 401,
35 status: 401,35 code: "unauthorized",
36 code: "unauthorized",36 message: "Unauthorized",
37 message: "Unauthorized",37 });
38 }),
39 );
40 }38 }
41 39
42 const url = new URL(webRequest.url);40 const url = new URL(webRequest.url);
@@ -74,20 +72,18 @@ const handler = Effect.gen(function* () { · 31 unmodified lines
74 72
75 if (statusCode >= 400) {73 if (statusCode >= 400) {
76 console.error("[autumn] upstream error:", statusCode, response);74 console.error("[autumn] upstream error:", statusCode, response);
77 return yield* Effect.fail(75 return yield* new HttpResponseError({
78 new HttpResponseError({76 status: statusCode,
79 status: statusCode,77 code: "billing_request_failed",
80 code: "billing_request_failed",78 message: "Billing request failed",
81 message: "Billing request failed",79 });
82 }),
83 );
84 }80 }
85 81
86 return HttpServerResponse.jsonUnsafe(response, { status: statusCode });82 return HttpServerResponse.jsonUnsafe(response, { status: statusCode });
87}).pipe(83}).pipe(
88 Effect.catchCause((err) => {84 Effect.catchCause((err) => {
89 if (isServerError(err)) {85 if (isServerError(err)) {
90 console.error("[autumn] request failed:", err instanceof Error ? err.stack : err);86 console.error("[autumn] request failed:", Cause.pretty(err));
91 }87 }
92 return Effect.succeed(toErrorServerResponse(err));88 return Effect.succeed(toErrorServerResponse(err));
93 }),89 }),
apps/cloud/src/auth/handlers.node.test.ts+11−4
@@ -1,7 +1,7 @@
1import { HttpApiBuilder, HttpApi } from "effect/unstable/httpapi";1import { HttpApiBuilder, HttpApi } from "effect/unstable/httpapi";
2import { HttpRouter, HttpServer } from "effect/unstable/http";2import { HttpRouter, HttpServer } from "effect/unstable/http";
3import { describe, expect, it } from "@effect/vitest";3import { describe, expect, it } from "@effect/vitest";
4import { Effect, Layer } from "effect";4import { Data, Effect, Layer } from "effect";
5import type { Effect as EffectType } from "effect/Effect";5import type { Effect as EffectType } from "effect/Effect";
6 6
7import { CloudAuthPublicApi } from "./api";7import { CloudAuthPublicApi } from "./api";
@@ -31,15 +31,22 @@ const fakeUser: AuthenticateWithCodeResult["user"] = { · 23 unmodified lines
31 metadata: {},31 metadata: {},
32};32};
33 33
34class UnstubbedWorkOSMethod extends Data.TaggedError("UnstubbedWorkOSMethod")<{
35 method: string;
36}> {}
37
34const makeAuthFetch = (workos: Partial<WorkOSAuth["Service"]>) => {38const makeAuthFetch = (workos: Partial<WorkOSAuth["Service"]>) => {
35 const WorkOSTest = Layer.succeed(39 const WorkOSTest = Layer.succeed(
36 WorkOSAuth,40 WorkOSAuth,
37 new Proxy(workos as WorkOSAuth["Service"], {41 new Proxy(workos as WorkOSAuth["Service"], {
38 get: (target, prop) => {42 get: (target, prop) => {
39 if (prop in target) return target[prop as keyof typeof target];43 if (prop in target) return target[prop as keyof typeof target];
40 return () => {44 return () =>
41 throw new Error(`WorkOSAuth.${String(prop)} not stubbed`);45 Effect.fail(
42 };46 new UnstubbedWorkOSMethod({
47 method: typeof prop === "string" ? prop : (prop.description ?? "symbol"),
48 }),
49 );
43 },50 },
44 }),51 }),
45 );52 );
apps/cloud/src/auth/workos.ts+10−2
@@ -3,11 +3,17 @@ · 2 unmodified lines
3// ---------------------------------------------------------------------------3// ---------------------------------------------------------------------------
4 4
5import { env } from "cloudflare:workers";5import { env } from "cloudflare:workers";
6import { Context, Effect, Layer } from "effect";6import { Context, Data, Effect, Layer } from "effect";
7import { GeneratePortalLinkIntent, WorkOS } from "@workos-inc/node/worker";7import { GeneratePortalLinkIntent, WorkOS } from "@workos-inc/node/worker";
8import { WorkOSError, tryPromiseService, withServiceLogging } from "./errors";8import { WorkOSError, tryPromiseService, withServiceLogging } from "./errors";
9 9
10const COOKIE_NAME = "wos-session";10const COOKIE_NAME = "wos-session";
11const INVALID_COOKIE_PASSWORD_MESSAGE =
12 "WORKOS_COOKIE_PASSWORD must be at least 32 characters";
13
14class WorkOSAuthConfigurationError extends Data.TaggedError("WorkOSAuthConfigurationError")<{
15 readonly message: string;
16}> {}
11 17
12// ---------------------------------------------------------------------------18// ---------------------------------------------------------------------------
13// Service19// Service
@@ -19,7 +25,9 @@ const make = Effect.gen(function* () { · 5 unmodified lines
19 const cookiePassword = env.WORKOS_COOKIE_PASSWORD;25 const cookiePassword = env.WORKOS_COOKIE_PASSWORD;
20 26
21 if (!cookiePassword || cookiePassword.length < 32) {27 if (!cookiePassword || cookiePassword.length < 32) {
22 return yield* Effect.die(new Error("WORKOS_COOKIE_PASSWORD must be at least 32 characters"));28 return yield* new WorkOSAuthConfigurationError({
29 message: INVALID_COOKIE_PASSWORD_MESSAGE,
30 });
23 }31 }
24 32
25 const workos = new WorkOS({ apiKey, clientId });33 const workos = new WorkOS({ apiKey, clientId });
apps/cloud/src/observability.test.ts+3−0
@@ -15,6 +15,7 @@ describe("sentryPayloadForCause", () => { · 14 unmodified lines
15 // Reproduces the production chain: an inner runPromise rejects with a15 // Reproduces the production chain: an inner runPromise rejects with a
16 // CauseImpl (from Effect v4's causeSquash), Effect.promise re-wraps it16 // CauseImpl (from Effect v4's causeSquash), Effect.promise re-wraps it
17 // as Die(CauseImpl), and the outer catchCause receives this shape.17 // as Die(CauseImpl), and the outer catchCause receives this shape.
18 // oxlint-disable-next-line executor/no-error-constructor -- boundary: observability test must build a real Error for Sentry-compatible payload assertions
18 const innerCause = Cause.fail(new Error("inner failure"));19 const innerCause = Cause.fail(new Error("inner failure"));
19 const outerCause = Cause.die(innerCause);20 const outerCause = Cause.die(innerCause);
20 21
@@ -25,11 +26,13 @@ describe("sentryPayloadForCause", () => { · 4 unmodified lines
25 });26 });
26 27
27 it("hands Sentry a real Error for an ordinary failed Cause", () => {28 it("hands Sentry a real Error for an ordinary failed Cause", () => {
29 // oxlint-disable-next-line executor/no-error-constructor -- boundary: observability test must build a real Error for Sentry-compatible payload assertions
28 const { primary } = sentryPayloadForCause(Cause.fail(new Error("plain failure")));30 const { primary } = sentryPayloadForCause(Cause.fail(new Error("plain failure")));
29 expect(looksLikeErrorToSentry(primary)).toBe(true);31 expect(looksLikeErrorToSentry(primary)).toBe(true);
30 });32 });
31 33
32 it("forwards non-Cause inputs as-is with no pretty cause attached", () => {34 it("forwards non-Cause inputs as-is with no pretty cause attached", () => {
35 // oxlint-disable-next-line executor/no-error-constructor -- boundary: observability test must build a real Error for Sentry-compatible payload assertions
33 const err = new Error("raw");36 const err = new Error("raw");
34 const { primary, pretty } = sentryPayloadForCause(err);37 const { primary, pretty } = sentryPayloadForCause(err);
35 expect(primary).toBe(err);38 expect(primary).toBe(err);