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 | 16 | import { WorkOSAuth } from "../auth/workos"; |
| 17 | 17 | import { AutumnService } from "../services/autumn"; |
| 18 | +import { SlackService } from "../services/slack"; | |
| 18 | 19 | |
| 19 | 20 | /** |
| 20 | 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 | 26 | export const CoreSharedServices = Layer.mergeAll( |
| 26 | 27 | WorkOSAuth.Default, |
| 27 | 28 | AutumnService.Default, |
| 29 | + SlackService.Default, | |
| 28 | 30 | ); |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import * as Sentry from "@sentry/cloudflare"; |
| 2 | −import { Data, Effect } from "effect"; | |
| 2 | +import { Cause, Data, Effect, Result } from "effect"; | |
| 3 | 3 | import { |
| 4 | 4 | HttpServerRespondable, |
| 5 | 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 => | |
| 29 | − error instanceof HttpResponseError | |
| 30 | − ? error | |
| 28 | +const unwrapCause = (error: unknown): unknown => { | |
| 29 | + if (!Cause.isCause(error)) return 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 | 44 | : new HttpResponseError({ |
| 32 | 45 | status: 500, |
| 33 | 46 | code: "internal_server_error", |
| 34 | 47 | message: "Internal server error", |
| 35 | 48 | }); |
| 49 | +}; | |
| 36 | 50 | |
| 37 | 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 | 59 | export const toErrorServerResponse = (error: unknown): HttpServerResponse.HttpServerResponse => { |
| 46 | 60 | const mapped = toHttpResponseError(error); |
| 47 | 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 | 66 | Sentry.captureException(error); |
| 50 | 67 | } |
| 51 | 68 | return HttpServerResponse.jsonUnsafe( |
| @@ -12,6 +12,7 @@ import { · 11 unmodified lines | ||
| 12 | 12 | makeOrgApiLive, |
| 13 | 13 | } from "./layers"; |
| 14 | 14 | import { makeProtectedApiLive } from "./protected"; |
| 15 | +import { SlackContactRoutesLive } from "./slack"; | |
| 15 | 16 | |
| 16 | 17 | // One router. Each sub-API contributes its routes via `HttpApiBuilder.layer`, |
| 17 | 18 | // which calls `HttpRouter.use(...)` under the hood. Autumn's catch-all proxy |
| @@ -32,6 +33,7 @@ export const makeApiLive = ( · 14 unmodified lines | ||
| 32 | 33 | makeOrgApiLive(requestScopedLive), |
| 33 | 34 | makeProtectedApiLive(requestScopedLive), |
| 34 | 35 | AutumnRoutesLive, |
| 36 | + SlackContactRoutesLive, | |
| 35 | 37 | ).pipe( |
| 36 | 38 | Layer.provideMerge(RouterConfig), |
| 37 | 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); | |