History

Switch billing from per-seat to flat-fee per org + add Slack/email contact

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.

Rhys Sullivan committed 6 months ago

Commit 47dfd6b1 parent c5d93a317 files · +919 −189Browse files

Showing the changes to apps/cloud/src/api (4 of 17 files). Show all

Unified · Split

4 files
  1. apps/cloud/src/api/core-shared-services.ts +2 −0
  2. apps/cloud/src/api/error-response.ts +22 −5
  3. apps/cloud/src/api/router.ts +2 −0
  4. apps/cloud/src/api/slack.ts +153 −0
apps/cloud/src/api/core-shared-services.ts+2−0
@@ -15,6 +15,7 @@ import { Layer } from "effect"; · 14 unmodified lines
1515
1616 import { WorkOSAuth } from "../auth/workos";
1717 import { AutumnService } from "../services/autumn";
18+import { SlackService } from "../services/slack";
1819
1920 /**
2021 * Services that are independent of how the DB or tracer is provisioned —
@@ -25,4 +26,5 @@ import { AutumnService } from "../services/autumn"; · 4 unmodified lines
2526 export const CoreSharedServices = Layer.mergeAll(
2627 WorkOSAuth.Default,
2728 AutumnService.Default,
29+ SlackService.Default,
2830 );
apps/cloud/src/api/error-response.ts+22−5
@@ -1,5 +1,5 @@
11 import * as Sentry from "@sentry/cloudflare";
2−import { Data, Effect } from "effect";
2+import { Cause, Data, Effect, Result } from "effect";
33 import {
44 HttpServerRespondable,
55 HttpServerResponse,
@@ -25,14 +25,28 @@ export class HttpResponseError extends Data.TaggedError("HttpResponseError")<{ · 19 unmodified lines
2525 }
2626 }
2727
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
3144 : new HttpResponseError({
3245 status: 500,
3346 code: "internal_server_error",
3447 message: "Internal server error",
3548 });
49+};
3650
3751 export const isServerError = (error: unknown): boolean => toHttpResponseError(error).status >= 500;
3852
@@ -45,7 +59,10 @@ export const toErrorResponse = (error: unknown): Response => { · 6 unmodified lines
4559 export const toErrorServerResponse = (error: unknown): HttpServerResponse.HttpServerResponse => {
4660 const mapped = toHttpResponseError(error);
4761 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+ );
4966 Sentry.captureException(error);
5067 }
5168 return HttpServerResponse.jsonUnsafe(
apps/cloud/src/api/router.ts+2−0
@@ -12,6 +12,7 @@ import { · 11 unmodified lines
1212 makeOrgApiLive,
1313 } from "./layers";
1414 import { makeProtectedApiLive } from "./protected";
15+import { SlackContactRoutesLive } from "./slack";
1516
1617 // One router. Each sub-API contributes its routes via `HttpApiBuilder.layer`,
1718 // which calls `HttpRouter.use(...)` under the hood. Autumn's catch-all proxy
@@ -32,6 +33,7 @@ export const makeApiLive = ( · 14 unmodified lines
3233 makeOrgApiLive(requestScopedLive),
3334 makeProtectedApiLive(requestScopedLive),
3435 AutumnRoutesLive,
36+ SlackContactRoutesLive,
3537 ).pipe(
3638 Layer.provideMerge(RouterConfig),
3739 Layer.provideMerge(BootSharedServices),
apps/cloud/src/api/slack.ts+153−0
@@ -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);