Pricing model - Replace seat-based pricing with org-flat-fee plans: Free (10k execs/mo, 3 members), Free PAYG (10k included + $0.20/1k overage), Team ($49/mo, 250k incl, unlimited members, $0.20/1k overage). Bumped Free included executions from 5k -> 10k for top-of-funnel. - Mark `seats` feature archived; introduce `members` (non-consumable metered) and `domain-verification` (boolean) features. - Update privacy/terms copy from "seat counts" to "membership" / "organization-level" pricing language. - Refresh /billing and /billing/plans UI: trim per-seat add-ons, render Free/Team/Enterprise cards, fix button alignment under conditional badges. Slack/email contact CTA on pricing page - New SlackService wraps Slack Web API: creates `#shared-<email-slug>` channel (retries with random suffix on `name_taken`), posts a hello message, and calls `conversations.inviteShared` with `external_limited`. - POST /api/contact/slack: public endpoint, validates email, verifies a Cloudflare Turnstile token, gates on a 5/min global ratelimit binding, then calls SlackService. Internal SlackError details are logged server-side; the client only sees a generic message. - Pricing page CTA: dialog with Turnstile widget + email/name/note form, prefilled from useAuth() when signed in. Sibling "Email us" mailto: link to rhys@executor.sh. - Wrangler bindings: ratelimit (5/60s), VITE_PUBLIC_TURNSTILE_SITEKEY vars; .env.op gets dev test keys + SLACK_BOT_TOKEN op:// reference.
Showing the changes to apps/cloud/src/api (4 of 17 files). Show all
Unified · Split
| @@ -15,6 +15,7 @@ import { Layer } from "effect"; · 14 unmodified lines | |||
| 15 | 15 | ||
| 16 | import { WorkOSAuth } from "../auth/workos"; | 16 | import { WorkOSAuth } from "../auth/workos"; |
| 17 | import { AutumnService } from "../services/autumn"; | 17 | import { AutumnService } from "../services/autumn"; |
| 18 | import { SlackService } from "../services/slack"; | ||
| 18 | 19 | ||
| 19 | /** | 20 | /** |
| 20 | * Services that are independent of how the DB or tracer is provisioned — | 21 | * Services that are independent of how the DB or tracer is provisioned — |
| @@ -25,4 +26,5 @@ import { AutumnService } from "../services/autumn"; · 4 unmodified lines | |||
| 25 | export const CoreSharedServices = Layer.mergeAll( | 26 | export const CoreSharedServices = Layer.mergeAll( |
| 26 | WorkOSAuth.Default, | 27 | WorkOSAuth.Default, |
| 27 | AutumnService.Default, | 28 | AutumnService.Default, |
| 29 | SlackService.Default, | ||
| 28 | ); | 30 | ); |
| @@ -1,5 +1,5 @@ | |||
| 1 | import * as Sentry from "@sentry/cloudflare"; | 1 | import * as Sentry from "@sentry/cloudflare"; |
| 2 | import { Data, Effect } from "effect"; | 2 | import { Cause, Data, Effect, Result } from "effect"; |
| 3 | import { | 3 | import { |
| 4 | HttpServerRespondable, | 4 | HttpServerRespondable, |
| 5 | HttpServerResponse, | 5 | HttpServerResponse, |
| @@ -25,14 +25,28 @@ export class HttpResponseError extends Data.TaggedError("HttpResponseError")<{ · 19 unmodified lines | |||
| 25 | } | 25 | } |
| 26 | } | 26 | } |
| 27 | 27 | ||
| 28 | const toHttpResponseError = (error: unknown): HttpResponseError => | 28 | const unwrapCause = (error: unknown): unknown => { |
| 29 | error instanceof HttpResponseError | 29 | if (!Cause.isCause(error)) return error; |
| 30 | ? error | 30 | |
| 31 | const failure = Cause.findError(error); | ||
| 32 | if (Result.isSuccess(failure)) return failure.success; | ||
| 33 | |||
| 34 | const defect = Cause.findDefect(error); | ||
| 35 | if (Result.isSuccess(defect)) return defect.success; | ||
| 36 | |||
| 37 | return error; | ||
| 38 | }; | ||
| 39 | |||
| 40 | const toHttpResponseError = (error: unknown): HttpResponseError => { | ||
| 41 | const unwrapped = unwrapCause(error); | ||
| 42 | return unwrapped instanceof HttpResponseError | ||
| 43 | ? unwrapped | ||
| 31 | : new HttpResponseError({ | 44 | : new HttpResponseError({ |
| 32 | status: 500, | 45 | status: 500, |
| 33 | code: "internal_server_error", | 46 | code: "internal_server_error", |
| 34 | message: "Internal server error", | 47 | message: "Internal server error", |
| 35 | }); | 48 | }); |
| 49 | }; | ||
| 36 | 50 | ||
| 37 | export const isServerError = (error: unknown): boolean => toHttpResponseError(error).status >= 500; | 51 | export const isServerError = (error: unknown): boolean => toHttpResponseError(error).status >= 500; |
| 38 | 52 | ||
| @@ -45,7 +59,10 @@ export const toErrorResponse = (error: unknown): Response => { · 6 unmodified lines | |||
| 45 | export const toErrorServerResponse = (error: unknown): HttpServerResponse.HttpServerResponse => { | 59 | export const toErrorServerResponse = (error: unknown): HttpServerResponse.HttpServerResponse => { |
| 46 | const mapped = toHttpResponseError(error); | 60 | const mapped = toHttpResponseError(error); |
| 47 | if (mapped.status >= 500) { | 61 | if (mapped.status >= 500) { |
| 48 | console.error("[api] toErrorServerResponse error:", error instanceof Error ? error.stack : error); | 62 | console.error( |
| 63 | "[api] toErrorServerResponse error:", | ||
| 64 | error instanceof Error ? error.stack : error, | ||
| 65 | ); | ||
| 49 | Sentry.captureException(error); | 66 | Sentry.captureException(error); |
| 50 | } | 67 | } |
| 51 | return HttpServerResponse.jsonUnsafe( | 68 | return HttpServerResponse.jsonUnsafe( |
| @@ -12,6 +12,7 @@ import { · 11 unmodified lines | |||
| 12 | makeOrgApiLive, | 12 | makeOrgApiLive, |
| 13 | } from "./layers"; | 13 | } from "./layers"; |
| 14 | import { makeProtectedApiLive } from "./protected"; | 14 | import { makeProtectedApiLive } from "./protected"; |
| 15 | import { SlackContactRoutesLive } from "./slack"; | ||
| 15 | 16 | ||
| 16 | // One router. Each sub-API contributes its routes via `HttpApiBuilder.layer`, | 17 | // One router. Each sub-API contributes its routes via `HttpApiBuilder.layer`, |
| 17 | // which calls `HttpRouter.use(...)` under the hood. Autumn's catch-all proxy | 18 | // which calls `HttpRouter.use(...)` under the hood. Autumn's catch-all proxy |
| @@ -32,6 +33,7 @@ export const makeApiLive = ( · 14 unmodified lines | |||
| 32 | makeOrgApiLive(requestScopedLive), | 33 | makeOrgApiLive(requestScopedLive), |
| 33 | makeProtectedApiLive(requestScopedLive), | 34 | makeProtectedApiLive(requestScopedLive), |
| 34 | AutumnRoutesLive, | 35 | AutumnRoutesLive, |
| 36 | SlackContactRoutesLive, | ||
| 35 | ).pipe( | 37 | ).pipe( |
| 36 | Layer.provideMerge(RouterConfig), | 38 | Layer.provideMerge(RouterConfig), |
| 37 | Layer.provideMerge(BootSharedServices), | 39 | Layer.provideMerge(BootSharedServices), |
| @@ -0,0 +1,153 @@ | |||
| 1 | import { env } from "cloudflare:workers"; | ||
| 2 | import { Effect } from "effect"; | ||
| 3 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | ||
| 4 | |||
| 5 | import { SlackService } from "../services/slack"; | ||
| 6 | import { | ||
| 7 | HttpResponseError, | ||
| 8 | isServerError, | ||
| 9 | toErrorServerResponse, | ||
| 10 | } from "./error-response"; | ||
| 11 | |||
| 12 | const isValidEmail = (s: string): boolean => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s); | ||
| 13 | |||
| 14 | const verifyTurnstile = (token: string, remoteIp: string | null) => | ||
| 15 | Effect.tryPromise({ | ||
| 16 | try: async () => { | ||
| 17 | const secret = env.TURNSTILE_SECRET_KEY; | ||
| 18 | if (!secret) { | ||
| 19 | // Turnstile is unconfigured — fail closed in prod, but allow dev to | ||
| 20 | // boot without it. We treat empty secret as "skip" so the form works | ||
| 21 | // before secrets are wired; remove this branch once you've set the | ||
| 22 | // secret in every environment. | ||
| 23 | return { success: true, skipped: true }; | ||
| 24 | } | ||
| 25 | const form = new URLSearchParams({ secret, response: token }); | ||
| 26 | if (remoteIp) form.set("remoteip", remoteIp); | ||
| 27 | const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { | ||
| 28 | method: "POST", | ||
| 29 | body: form, | ||
| 30 | }); | ||
| 31 | const json = (await res.json()) as { success: boolean; "error-codes"?: string[] }; | ||
| 32 | return { success: json.success, errorCodes: json["error-codes"] ?? [] }; | ||
| 33 | }, | ||
| 34 | catch: (cause) => ({ success: false as const, fetchError: String(cause) }), | ||
| 35 | }); | ||
| 36 | |||
| 37 | const handler = Effect.gen(function* () { | ||
| 38 | const request = yield* HttpServerRequest.HttpServerRequest; | ||
| 39 | |||
| 40 | if (request.method !== "POST") { | ||
| 41 | return yield* Effect.fail( | ||
| 42 | new HttpResponseError({ | ||
| 43 | status: 405, | ||
| 44 | code: "method_not_allowed", | ||
| 45 | message: "Method not allowed", | ||
| 46 | }), | ||
| 47 | ); | ||
| 48 | } | ||
| 49 | |||
| 50 | const body = (yield* Effect.mapError( | ||
| 51 | request.json, | ||
| 52 | () => | ||
| 53 | new HttpResponseError({ | ||
| 54 | status: 400, | ||
| 55 | code: "invalid_json", | ||
| 56 | message: "Invalid request body", | ||
| 57 | }), | ||
| 58 | )) as { | ||
| 59 | email?: unknown; | ||
| 60 | name?: unknown; | ||
| 61 | note?: unknown; | ||
| 62 | organization?: unknown; | ||
| 63 | turnstileToken?: unknown; | ||
| 64 | }; | ||
| 65 | |||
| 66 | const trimmed = (v: unknown, max: number): string | undefined => | ||
| 67 | typeof v === "string" && v.trim().length > 0 ? v.trim().slice(0, max) : undefined; | ||
| 68 | |||
| 69 | const email = typeof body.email === "string" ? body.email.trim() : ""; | ||
| 70 | const name = trimmed(body.name, 200); | ||
| 71 | const note = trimmed(body.note, 2000); | ||
| 72 | const organization = trimmed(body.organization, 200); | ||
| 73 | const turnstileToken = typeof body.turnstileToken === "string" ? body.turnstileToken : ""; | ||
| 74 | |||
| 75 | if (!isValidEmail(email)) { | ||
| 76 | return yield* Effect.fail( | ||
| 77 | new HttpResponseError({ | ||
| 78 | status: 400, | ||
| 79 | code: "invalid_email", | ||
| 80 | message: "A valid email is required", | ||
| 81 | }), | ||
| 82 | ); | ||
| 83 | } | ||
| 84 | |||
| 85 | if (!turnstileToken) { | ||
| 86 | return yield* Effect.fail( | ||
| 87 | new HttpResponseError({ | ||
| 88 | status: 400, | ||
| 89 | code: "captcha_required", | ||
| 90 | message: "Captcha verification is required.", | ||
| 91 | }), | ||
| 92 | ); | ||
| 93 | } | ||
| 94 | |||
| 95 | const remoteIp = request.headers["cf-connecting-ip"] ?? null; | ||
| 96 | const verification = yield* verifyTurnstile(turnstileToken, remoteIp); | ||
| 97 | if (!verification.success) { | ||
| 98 | console.error("[slack] turnstile verification failed:", verification); | ||
| 99 | return yield* Effect.fail( | ||
| 100 | new HttpResponseError({ | ||
| 101 | status: 403, | ||
| 102 | code: "captcha_failed", | ||
| 103 | message: "Captcha verification failed. Please try again.", | ||
| 104 | }), | ||
| 105 | ); | ||
| 106 | } | ||
| 107 | |||
| 108 | // Global daily channel-creation cap — bounds the worst case if Turnstile is | ||
| 109 | // bypassed somehow. Per-IP gating belongs at the edge (Cloudflare Rules); | ||
| 110 | // this binding is a single shared bucket keyed at "global". | ||
| 111 | const limit = yield* Effect.tryPromise({ | ||
| 112 | try: () => env.SLACK_INVITE_LIMITER.limit({ key: "global" }), | ||
| 113 | catch: (cause) => ({ success: false as const, fetchError: String(cause) }), | ||
| 114 | }); | ||
| 115 | if (!limit.success) { | ||
| 116 | console.error("[slack] global rate limit hit"); | ||
| 117 | return yield* Effect.fail( | ||
| 118 | new HttpResponseError({ | ||
| 119 | status: 429, | ||
| 120 | code: "rate_limited", | ||
| 121 | message: "We're getting more contact requests than usual. Please try again later.", | ||
| 122 | }), | ||
| 123 | ); | ||
| 124 | } | ||
| 125 | |||
| 126 | const slack = yield* SlackService; | ||
| 127 | const { invite } = yield* slack.createConnectInvite({ email, name, note, organization }).pipe( | ||
| 128 | Effect.tapError((err) => | ||
| 129 | Effect.sync(() => { | ||
| 130 | console.error(`[slack] ${err.method} failed:`, err.error); | ||
| 131 | }), | ||
| 132 | ), | ||
| 133 | Effect.mapError( | ||
| 134 | () => | ||
| 135 | new HttpResponseError({ | ||
| 136 | status: 500, | ||
| 137 | code: "slack_invite_failed", | ||
| 138 | message: "Couldn't create your Slack invite. Please try again shortly.", | ||
| 139 | }), | ||
| 140 | ), | ||
| 141 | ); | ||
| 142 | |||
| 143 | return HttpServerResponse.jsonUnsafe({ url: invite.url }, { status: 200 }); | ||
| 144 | }).pipe( | ||
| 145 | Effect.catchCause((err) => { | ||
| 146 | if (isServerError(err)) { | ||
| 147 | console.error("[slack] request failed:", err instanceof Error ? err.stack : err); | ||
| 148 | } | ||
| 149 | return Effect.succeed(toErrorServerResponse(err)); | ||
| 150 | }), | ||
| 151 | ); | ||
| 152 | |||
| 153 | export const SlackContactRoutesLive = HttpRouter.add("POST", "/contact/slack", handler); | ||