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
15 15
16import { WorkOSAuth } from "../auth/workos";16import { WorkOSAuth } from "../auth/workos";
17import { AutumnService } from "../services/autumn";17import { AutumnService } from "../services/autumn";
18import { 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
25export const CoreSharedServices = Layer.mergeAll(26export const CoreSharedServices = Layer.mergeAll(
26 WorkOSAuth.Default,27 WorkOSAuth.Default,
27 AutumnService.Default,28 AutumnService.Default,
29 SlackService.Default,
28);30);
apps/cloud/src/api/error-response.ts+22−5
@@ -1,5 +1,5 @@
1import * as Sentry from "@sentry/cloudflare";1import * as Sentry from "@sentry/cloudflare";
2import { Data, Effect } from "effect";2import { Cause, Data, Effect, Result } from "effect";
3import {3import {
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
28const toHttpResponseError = (error: unknown): HttpResponseError =>28const unwrapCause = (error: unknown): unknown => {
29 error instanceof HttpResponseError29 if (!Cause.isCause(error)) return error;
30 ? error30
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
40const 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
37export const isServerError = (error: unknown): boolean => toHttpResponseError(error).status >= 500;51export const isServerError = (error: unknown): boolean => toHttpResponseError(error).status >= 500;
38 52
@@ -45,7 +59,10 @@ export const toErrorResponse = (error: unknown): Response => { · 6 unmodified lines
45export const toErrorServerResponse = (error: unknown): HttpServerResponse.HttpServerResponse => {59export 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(
apps/cloud/src/api/router.ts+2−0
@@ -12,6 +12,7 @@ import { · 11 unmodified lines
12 makeOrgApiLive,12 makeOrgApiLive,
13} from "./layers";13} from "./layers";
14import { makeProtectedApiLive } from "./protected";14import { makeProtectedApiLive } from "./protected";
15import { 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 proxy18// 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),
apps/cloud/src/api/slack.ts+153−0
@@ -0,0 +1,153 @@
1import { env } from "cloudflare:workers";
2import { Effect } from "effect";
3import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
4
5import { SlackService } from "../services/slack";
6import {
7 HttpResponseError,
8 isServerError,
9 toErrorServerResponse,
10} from "./error-response";
11
12const isValidEmail = (s: string): boolean => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s);
13
14const 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
37const 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
153export const SlackContactRoutesLive = HttpRouter.add("POST", "/contact/slack", handler);