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

Unified · Split

17 files
  1. apps/cloud/autumn.config.ts +38 −49
  2. apps/cloud/src/api/core-shared-services.ts +2 −0
  3. apps/cloud/src/api/error-response.ts +22 −5
  4. apps/cloud/src/api/router.ts +2 −0
  5. apps/cloud/src/api/slack.ts +153 −0
  6. apps/cloud/src/auth/handlers.ts +9 −8
  7. apps/cloud/src/env-augment.d.ts +11 −0
  8. apps/cloud/src/org/handlers.ts +32 −5
  9. apps/cloud/src/routes/billing.tsx +34 −6
  10. apps/cloud/src/routes/billing_.plans.tsx +351 −47
  11. apps/cloud/src/routes/org.tsx +33 −5
  12. apps/cloud/src/services/autumn.ts +23 −8
  13. apps/cloud/src/services/slack.ts +142 −0
  14. apps/cloud/wrangler.jsonc +15 −0
  15. apps/marketing/src/pages/privacy.astro +1 −1
  16. apps/marketing/src/pages/terms.astro +4 −3
  17. autumn.config.ts +47 −52
apps/cloud/autumn.config.ts+38−49
@@ -1,9 +1,9 @@
11 import { feature, item, plan } from "atmn";
22
33 // Features
4−export const seats = feature({
5− id: "seats",
6− name: "Seats",
4+export const members = feature({
5+ id: "members",
6+ name: "Members",
77 type: "metered",
88 consumable: false,
99 });
@@ -15,86 +15,75 @@ export const executions = feature({ · 5 unmodified lines
1515 consumable: true,
1616 });
1717
18+export const domainVerification = feature({
19+ id: "domain-verification",
20+ name: "Domain Verification",
21+ type: "boolean",
22+});
23+
1824 // Plans
1925 export const free = plan({
2026 id: "free",
2127 name: "Free",
2228 autoEnable: true,
2329 items: [
30+ item({
31+ featureId: members.id,
32+ included: 3,
33+ }),
2434 item({
2535 featureId: executions.id,
26− included: 5000,
36+ included: 10000,
2737 reset: { interval: "month" },
2838 }),
2939 ],
3040 });
3141
32−export const hobby = plan({
33− id: "hobby",
34− name: "Hobby",
35− price: {
36− amount: 10,
37− interval: "month",
38− },
42+export const freePayAsYouGo = plan({
43+ id: "free-pay-as-you-go",
44+ name: "Free Pay As You Go",
3945 items: [
4046 item({
41− featureId: seats.id,
42− included: 1,
47+ featureId: members.id,
48+ included: 3,
49+ }),
50+ item({
51+ featureId: executions.id,
52+ included: 10000,
4353 price: {
44− amount: 10,
45− billingUnits: 1,
54+ amount: 0.2,
55+ billingUnits: 1000,
4656 billingMethod: "usage_based",
4757 interval: "month",
4858 },
4959 }),
50− item({
51− featureId: executions.id,
52− included: 50000,
53− reset: { interval: "month" },
54− }),
5560 ],
5661 });
5762
58−export const professional = plan({
59− id: "professional",
60− name: "Professional",
63+export const team = plan({
64+ id: "team",
65+ name: "Team",
6166 price: {
62− amount: 40,
67+ amount: 49,
6368 interval: "month",
6469 },
6570 items: [
6671 item({
67− featureId: seats.id,
68− included: 1,
69− price: {
70− amount: 40,
71− billingUnits: 1,
72− billingMethod: "usage_based",
73− interval: "month",
74− },
75− }),
76− item({
77− featureId: executions.id,
78− included: 100000,
79− reset: { interval: "month" },
72+ featureId: members.id,
73+ unlimited: true,
8074 }),
81− ],
82−});
83−
84−// Overage add-on
85−export const executionTopUp = plan({
86− id: "execution-top-up",
87− name: "Execution Top-Up",
88− addOn: true,
89− items: [
9075 item({
9176 featureId: executions.id,
77+ included: 250000,
9278 price: {
93− amount: 1,
94− billingUnits: 10000,
95− billingMethod: "prepaid",
79+ amount: 0.2,
80+ billingUnits: 1000,
81+ billingMethod: "usage_based",
9682 interval: "month",
9783 },
9884 }),
85+ item({
86+ featureId: domainVerification.id,
87+ }),
9988 ],
10089 });
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);
apps/cloud/src/auth/handlers.ts+9−8
@@ -10,6 +10,7 @@ import { authorizeOrganization } from "./authorize-organization"; · 9 unmodified lines
1010 import { env } from "cloudflare:workers";
1111 import { WorkOSError } from "./errors";
1212 import { WorkOSAuth } from "./workos";
13+import { AutumnService } from "../services/autumn";
1314
1415 const COOKIE_OPTIONS = {
1516 path: "/",
@@ -69,10 +70,8 @@ const setResponseCookie = ( · 53 unmodified lines
6970 options: typeof RESPONSE_COOKIE_OPTIONS,
7071 ) => HttpServerResponse.setCookieUnsafe(response, name, value, options);
7172
72−const deleteResponseCookie = (
73− response: HttpServerResponse.HttpServerResponse,
74− name: string,
75−) => HttpServerResponse.setCookieUnsafe(response, name, "", DELETE_COOKIE_OPTIONS);
73+const deleteResponseCookie = (response: HttpServerResponse.HttpServerResponse, name: string) =>
74+ HttpServerResponse.setCookieUnsafe(response, name, "", DELETE_COOKIE_OPTIONS);
7675
7776 // ---------------------------------------------------------------------------
7877 // Single non-protected API surface — public (login/callback) + session
@@ -208,7 +207,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 129 unmodified lines
208207 );
209208
210209 return {
211− organizations: organizations.filter((org): org is NonNullable<typeof org> => org !== null),
210+ organizations: organizations.filter(
211+ (org): org is NonNullable<typeof org> => org !== null,
212+ ),
212213 activeOrganizationId: session.organizationId,
213214 };
214215 }),
@@ -231,11 +232,13 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 16 unmodified lines
231232 Effect.gen(function* () {
232233 const workos = yield* WorkOSAuth;
233234 const users = yield* UserStoreService;
235+ const autumn = yield* AutumnService;
234236 const session = yield* SessionContext;
235237
236238 const name = payload.name.trim();
237239 const org = yield* workos.createOrganization(name);
238240 yield* workos.createMembership(org.id, session.accountId, "admin");
241+ yield* autumn.trackMemberChange(org.id, 1);
239242 yield* users.use((s) => s.upsertOrganization({ id: org.id, name: org.name }));
240243
241244 // Try to attach the new org to the current session. This can fail
@@ -246,9 +249,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 4 unmodified lines
246249 // cookie and fail loudly; the frontend will bounce to login and
247250 // the callback's rehydrate path will pick up the new membership.
248251 const refreshed = yield* workos.refreshSession(session.sealedSession, org.id);
249− const verified = refreshed
250− ? yield* workos.authenticateSealedSession(refreshed)
251− : null;
252+ const verified = refreshed ? yield* workos.authenticateSealedSession(refreshed) : null;
252253
253254 if (!refreshed || !verified || verified.organizationId !== org.id) {
254255 yield* Effect.logWarning(
apps/cloud/src/env-augment.d.ts+11−0
@@ -23,6 +23,17 @@ declare global { · 22 unmodified lines
2323 // Billing
2424 AUTUMN_SECRET_KEY?: string;
2525
26+ // Contact / Slack Connect
27+ SLACK_BOT_TOKEN?: string;
28+ // Turnstile (Cloudflare CAPTCHA) — used to gate the public Slack-contact
29+ // endpoint. Sitekey is public and ships in `vars`; secret is set via
30+ // `wrangler secret put TURNSTILE_SECRET_KEY`.
31+ TURNSTILE_SECRET_KEY?: string;
32+ VITE_PUBLIC_TURNSTILE_SITEKEY?: string;
33+ // Cloudflare ratelimit binding declared in wrangler.jsonc — caps total
34+ // Slack-contact channel creations across all callers.
35+ SLACK_INVITE_LIMITER: { limit: (input: { key: string }) => Promise<{ success: boolean }> };
36+
2637 // MCP
2738 EXECUTOR_MCP_DEBUG?: string;
2839 MCP_AUTHKIT_DOMAIN?: string;
apps/cloud/src/org/handlers.ts+32−5
@@ -52,6 +52,25 @@ const assertDomainInSessionOrg = (domainId: string) => · 51 unmodified lines
5252 }
5353 });
5454
55+const reserveMemberSlot = Effect.gen(function* () {
56+ const auth = yield* AuthContext;
57+ const autumn = yield* AutumnService;
58+ const check = yield* autumn
59+ .use((client) =>
60+ client.check({
61+ customerId: auth.organizationId,
62+ featureId: "members",
63+ requiredBalance: 1,
64+ sendEvent: true,
65+ }),
66+ )
67+ .pipe(Effect.orElseSucceed(() => ({ allowed: true })));
68+
69+ if (!check.allowed) {
70+ return yield* new Forbidden();
71+ }
72+});
73+
5574 export const OrgHandlers = HttpApiBuilder.group(OrgHttpApi, "org", (handlers) =>
5675 handlers
5776 .handle("listMembers", () =>
@@ -104,12 +123,17 @@ export const OrgHandlers = HttpApiBuilder.group(OrgHttpApi, "org", (handlers) => · 46 unmodified lines
104123 yield* requireAdmin;
105124 const auth = yield* AuthContext;
106125 const workos = yield* WorkOSAuth;
126+ const autumn = yield* AutumnService;
107127
108− const invitation = yield* workos.sendInvitation({
109− email: payload.email,
110− organizationId: auth.organizationId,
111− roleSlug: payload.roleSlug,
112− });
128+ yield* reserveMemberSlot;
129+
130+ const invitation = yield* workos
131+ .sendInvitation({
132+ email: payload.email,
133+ organizationId: auth.organizationId,
134+ roleSlug: payload.roleSlug,
135+ })
136+ .pipe(Effect.tapError(() => autumn.trackMemberChange(auth.organizationId, -1)));
113137
114138 return { id: invitation.id, email: invitation.email };
115139 }),
@@ -119,7 +143,10 @@ export const OrgHandlers = HttpApiBuilder.group(OrgHttpApi, "org", (handlers) => · 3 unmodified lines
119143 yield* requireAdmin;
120144 yield* assertMembershipInSessionOrg(params.membershipId);
121145 const workos = yield* WorkOSAuth;
146+ const auth = yield* AuthContext;
147+ const autumn = yield* AutumnService;
122148 yield* workos.deleteOrgMembership(params.membershipId);
149+ yield* autumn.trackMemberChange(auth.organizationId, -1);
123150 return { success: true };
124151 }),
125152 )
apps/cloud/src/routes/billing.tsx+34−6
@@ -10,9 +10,8 @@ export const Route = createFileRoute("/billing")({ · 9 unmodified lines
1010 });
1111
1212 const PLAN_TAGLINES: Record<string, string> = {
13− free: "For trying things out",
14− hobby: "For individuals and small teams",
15− professional: "For teams that need more",
13+ free: "Free for up to 3 members",
14+ team: "$49 per organization",
1615 };
1716
1817 function BillingPage() {
@@ -53,6 +52,7 @@ function BillingPage() { · 34 unmodified lines
5352 );
5453
5554 const executions = customer?.balances?.executions;
55+ const members = customer?.balances?.members;
5656
5757 return (
5858 <div className="min-h-0 flex-1 overflow-y-auto">
@@ -65,9 +65,7 @@ function BillingPage() { · 6 unmodified lines
6565 <div className="flex items-center justify-between py-4">
6666 <div>
6767 <div className="flex items-center gap-2">
68− <p className="text-sm font-medium text-foreground leading-none">
69− {planName}
70− </p>
68+ <p className="text-sm font-medium text-foreground leading-none">{planName}</p>
7169 {isSwitching && (
7270 <Badge className="bg-amber-500/10 text-amber-600 dark:text-amber-400">
7371 Switching
@@ -113,6 +111,36 @@ function BillingPage() { · 39 unmodified lines
113111 <div className="h-px bg-border/50 my-2" />
114112
115113 {/* Usage */}
114+ {members && (
115+ <div className="py-4">
116+ <div className="flex items-center justify-between mb-2">
117+ <p className="text-sm font-medium text-foreground">Members</p>
118+ <p className="text-sm tabular-nums text-muted-foreground">
119+ {members.usage.toLocaleString()}
120+ {!members.unlimited && (
121+ <span className="text-muted-foreground">
122+ {" / "}
123+ {members.granted.toLocaleString()}
124+ </span>
125+ )}
126+ </p>
127+ </div>
128+ {!members.unlimited && members.granted > 0 && (
129+ <div className="h-1.5 w-full overflow-hidden rounded-full bg-border">
130+ <div
131+ className="h-full rounded-full bg-primary transition-all duration-300"
132+ style={
133+ {
134+ "--progress": `${Math.min(100, (members.usage / members.granted) * 100)}%`,
135+ width: "var(--progress)",
136+ } as React.CSSProperties
137+ }
138+ />
139+ </div>
140+ )}
141+ </div>
142+ )}
143+
116144 {executions && (
117145 <div className="py-4">
118146 <div className="flex items-center justify-between mb-2">
apps/cloud/src/routes/billing_.plans.tsx+351−47
@@ -1,8 +1,22 @@
1−import { useState } from "react";
1+import { useEffect, useRef, useState } from "react";
22 import { createFileRoute, Link } from "@tanstack/react-router";
3+import { useAuth } from "../web/auth";
34 import { useCustomer, useListPlans } from "autumn-js/react";
45 import { Button } from "@executor-js/react/components/button";
56 import { Badge } from "@executor-js/react/components/badge";
7+import {
8+ Dialog,
9+ DialogClose,
10+ DialogContent,
11+ DialogDescription,
12+ DialogFooter,
13+ DialogHeader,
14+ DialogTitle,
15+ DialogTrigger,
16+} from "@executor-js/react/components/dialog";
17+import { Input } from "@executor-js/react/components/input";
18+import { Label } from "@executor-js/react/components/label";
19+import { Textarea } from "@executor-js/react/components/textarea";
620
721 type Plan = NonNullable<ReturnType<typeof useListPlans>["data"]>[number];
822
@@ -10,28 +24,24 @@ export const Route = createFileRoute("/billing_/plans")({ · 1 unmodified line
1024 component: PlansPage,
1125 });
1226
13−const PLAN_META: Record<
14− string,
15− { tagline: string; inherits?: string; features: string[] }
16−> = {
17− hobby: {
18− tagline: "For individuals and small teams",
27+const PLAN_META: Record<string, { tagline: string; inherits?: string; features: string[] }> = {
28+ free: {
29+ tagline: "For small teams getting started",
1930 features: [
20− "50,000 included executions per seat",
21− "Up to 5 seats",
22− "60s execution timeout",
31+ "Up to 3 members",
32+ "10,000 included executions per month",
33+ "$0.20 per 1,000 additional executions",
2334 "Unlimited sources",
24− "$0.30 per 1,000 extra executions",
2535 ],
2636 },
27− professional: {
28− tagline: "For teams that need more",
29− inherits: "Hobby",
37+ team: {
38+ tagline: "For growing organizations",
3039 features: [
31− "100,000 included executions per seat",
32− "Unlimited seats",
40+ "Unlimited members",
41+ "250,000 included executions per month",
3342 "5 minute execution timeout",
3443 "Join by team domain",
44+ "$0.20 per 1,000 additional executions",
3545 ],
3646 },
3747 };
@@ -45,7 +55,7 @@ const ACTION_LABELS: Record<string, string> = { · 7 unmodified lines
4555 };
4656
4757 const ENTERPRISE_FEATURES = [
48− "Self-hosted or dedicated cloud deployment",
58+ "Self-hosted or dedicated cloud deployment support",
4959 "SSO / SAML & SCIM provisioning",
5060 "Audit logs for every tool call",
5161 "Dedicated support & onboarding",
@@ -74,8 +84,8 @@ function PlansPage() { · 22 unmodified lines
7484
7585 const isLoading = customerLoading || plansLoading;
7686
77− const paidPlans = (plans ?? ([] as Plan[])).filter(
78− (p: Plan) => p.id === "hobby" || p.id === "professional",
87+ const selfServePlans = (plans ?? ([] as Plan[])).filter(
88+ (p: Plan) => p.id === "free" || p.id === "team",
7989 );
8090
8191 return (
@@ -118,7 +128,7 @@ function PlansPage() { · 36 unmodified lines
118128 isFetching ? "opacity-50 pointer-events-none" : "",
119129 ].join(" ")}
120130 >
121− {paidPlans.map((plan: Plan) => {
131+ {selfServePlans.map((plan: Plan) => {
122132 const meta = PLAN_META[plan.id];
123133 if (!meta) return null;
124134
@@ -143,7 +153,7 @@ function PlansPage() { · 18 unmodified lines
143153 : "border-border",
144154 ].join(" ")}
145155 >
146− <div className="flex items-center justify-between">
156+ <div className="flex h-6 items-center justify-between">
147157 <p className="text-base font-semibold text-foreground leading-none">
148158 {plan.name}
149159 </p>
@@ -171,9 +181,12 @@ function PlansPage() { · 21 unmodified lines
171181 </span>
172182 {plan.price?.interval && (
173183 <span className="text-sm text-muted-foreground">
174− USD / seat / {plan.price.interval}
184+ USD / org / {plan.price.interval}
175185 </span>
176186 )}
187+ {!plan.price?.interval && (
188+ <span className="text-sm text-muted-foreground">USD</span>
189+ )}
177190 </div>
178191
179192 <div className="mt-4">
@@ -218,16 +231,10 @@ function PlansPage() { · 38 unmodified lines
218231 )}
219232 <ul
220233 role="list"
221− className={[
222− "space-y-2",
223− meta.inherits ? "mt-2" : "mt-5",
224− ].join(" ")}
234+ className={["space-y-2", meta.inherits ? "mt-2" : "mt-5"].join(" ")}
225235 >
226236 {meta.features.map((f) => (
227− <li
228− key={f}
229− className="flex items-start gap-2 text-xs text-muted-foreground"
230− >
237+ <li key={f} className="flex items-start gap-2 text-xs text-muted-foreground">
231238 <svg
232239 viewBox="0 0 16 16"
233240 fill="none"
@@ -250,19 +257,13 @@ function PlansPage() { · 16 unmodified lines
250257 })}
251258
252259 <div className="flex flex-col rounded-xl border border-border p-5">
253− <div className="flex items-center justify-between">
254− <p className="text-base font-semibold text-foreground leading-none">
255− Enterprise
256− </p>
260+ <div className="flex h-6 items-center justify-between">
261+ <p className="text-base font-semibold text-foreground leading-none">Enterprise</p>
257262 </div>
258− <p className="mt-1 text-sm text-muted-foreground">
259− For orgs with custom needs
260− </p>
263+ <p className="mt-1 text-sm text-muted-foreground">For orgs with custom needs</p>
261264
262265 <div className="mt-4 flex items-baseline gap-1.5">
263− <span className="text-2xl font-semibold text-foreground tabular-nums">
264− Custom
265− </span>
266+ <span className="text-2xl font-semibold text-foreground tabular-nums">Custom</span>
266267 </div>
267268
268269 <div className="mt-4">
@@ -274,15 +275,10 @@ function PlansPage() { · 5 unmodified lines
274275 </a>
275276 </div>
276277
277− <p className="mt-5 text-xs font-medium text-foreground">
278− Everything in Professional, plus
279− </p>
278+ <p className="mt-5 text-xs font-medium text-foreground">Everything in Team, plus</p>
280279 <ul role="list" className="mt-2 space-y-2">
281280 {ENTERPRISE_FEATURES.map((f) => (
282− <li
283− key={f}
284− className="flex items-start gap-2 text-xs text-muted-foreground"
285− >
281+ <li key={f} className="flex items-start gap-2 text-xs text-muted-foreground">
286282 <svg
287283 viewBox="0 0 16 16"
288284 fill="none"
@@ -303,7 +299,315 @@ function PlansPage() { · 14 unmodified lines
303299 </div>
304300 </div>
305301 )}
302+
303+ <SlackContactCta />
304+ </div>
305+ </div>
306+ );
307+}
308+
309+function SlackContactCta() {
310+ const auth = useAuth();
311+ const signedIn = auth.status === "authenticated" ? auth : null;
312+ const prefillEmail = signedIn?.user.email ?? "";
313+ const prefillName = signedIn?.user.name ?? "";
314+ const orgName = signedIn?.organization?.name ?? "";
315+
316+ const turnstileSiteKey = import.meta.env.VITE_PUBLIC_TURNSTILE_SITEKEY ?? "";
317+ const [open, setOpen] = useState(false);
318+ const [email, setEmail] = useState(prefillEmail);
319+ const [name, setName] = useState(prefillName);
320+ const [note, setNote] = useState("");
321+ const [submitting, setSubmitting] = useState(false);
322+ const [error, setError] = useState<string | null>(null);
323+ const [inviteUrl, setInviteUrl] = useState<string | null>(null);
324+ const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
325+
326+ // Hydrate prefill once auth resolves (it starts as `loading` on first render).
327+ useEffect(() => {
328+ if (prefillEmail && !email) setEmail(prefillEmail);
329+ if (prefillName && !name) setName(prefillName);
330+ }, [prefillEmail, prefillName]); // eslint-disable-line react-hooks/exhaustive-deps
331+
332+ const reset = () => {
333+ setEmail(prefillEmail);
334+ setName(prefillName);
335+ setNote("");
336+ setError(null);
337+ setInviteUrl(null);
338+ setTurnstileToken(null);
339+ };
340+
341+ const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
342+ e.preventDefault();
343+ if (!turnstileToken) {
344+ setError("Please complete the captcha first.");
345+ return;
346+ }
347+ setSubmitting(true);
348+ setError(null);
349+ try {
350+ const res = await fetch("/api/contact/slack", {
351+ method: "POST",
352+ headers: { "content-type": "application/json" },
353+ body: JSON.stringify({
354+ email,
355+ name: name || undefined,
356+ note: note || undefined,
357+ organization: orgName || undefined,
358+ turnstileToken,
359+ }),
360+ });
361+ const data = (await res.json().catch(() => ({}))) as { url?: string; error?: string };
362+ if (!res.ok) {
363+ setError(data.error ?? "Something went wrong. Please try again.");
364+ return;
365+ }
366+ setInviteUrl(data.url ?? null);
367+ } catch {
368+ setError("Network error. Please try again.");
369+ } finally {
370+ setSubmitting(false);
371+ // Turnstile tokens are single-use server-side — clear so the widget
372+ // re-issues a fresh one on retry.
373+ setTurnstileToken(null);
374+ }
375+ };
376+
377+ return (
378+ <div className="mt-10 flex flex-col items-center gap-3 border-t border-border pt-8 text-center">
379+ <p className="text-sm text-muted-foreground">Got questions?</p>
380+ <div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-2 text-sm font-medium">
381+ <Dialog
382+ open={open}
383+ onOpenChange={(next) => {
384+ setOpen(next);
385+ if (!next) reset();
386+ }}
387+ >
388+ <DialogTrigger asChild>
389+ <button
390+ type="button"
391+ className="inline-flex items-center gap-1.5 text-foreground hover:text-primary transition-colors"
392+ >
393+ <SlackMark className="size-4" />
394+ Get in touch on Slack
395+ <span aria-hidden>→</span>
396+ </button>
397+ </DialogTrigger>
398+ <DialogContent>
399+ {inviteUrl ? (
400+ <>
401+ <DialogHeader>
402+ <DialogTitle>Check your inbox</DialogTitle>
403+ <DialogDescription>
404+ We've created a private Slack channel and emailed you an invite. You can also
405+ open it directly:
406+ </DialogDescription>
407+ </DialogHeader>
408+ <a
409+ href={inviteUrl}
410+ target="_blank"
411+ rel="noreferrer"
412+ className="inline-flex h-9 w-full items-center justify-center gap-2 rounded-md bg-primary text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
413+ >
414+ <SlackMark className="size-4" />
415+ Open Slack invite
416+ </a>
417+ <DialogFooter>
418+ <DialogClose asChild>
419+ <Button type="button" variant="outline">
420+ Done
421+ </Button>
422+ </DialogClose>
423+ </DialogFooter>
424+ </>
425+ ) : (
426+ <form onSubmit={onSubmit}>
427+ <DialogHeader>
428+ <DialogTitle>Get in touch on Slack</DialogTitle>
429+ <DialogDescription>
430+ We'll create a private Slack Connect channel between you and the Executor team.
431+ </DialogDescription>
432+ </DialogHeader>
433+ <div className="grid gap-4 py-4">
434+ <div className="grid gap-2">
435+ <Label htmlFor="slack-contact-email">Work email</Label>
436+ <Input
437+ id="slack-contact-email"
438+ type="email"
439+ autoComplete="email"
440+ required
441+ value={email}
442+ onChange={(e) => setEmail(e.currentTarget.value)}
443+ placeholder="you@company.com"
444+ />
445+ </div>
446+ <div className="grid gap-2">
447+ <Label htmlFor="slack-contact-name">Name (optional)</Label>
448+ <Input
449+ id="slack-contact-name"
450+ autoComplete="name"
451+ value={name}
452+ onChange={(e) => setName(e.currentTarget.value)}
453+ placeholder="Ada Lovelace"
454+ />
455+ </div>
456+ <div className="grid gap-2">
457+ <Label htmlFor="slack-contact-note">What's on your mind? (optional)</Label>
458+ <Textarea
459+ id="slack-contact-note"
460+ rows={3}
461+ value={note}
462+ onChange={(e) => setNote(e.currentTarget.value)}
463+ placeholder="Pricing question, integration help, anything…"
464+ />
465+ </div>
466+ {turnstileSiteKey && (
467+ <TurnstileWidget siteKey={turnstileSiteKey} onToken={setTurnstileToken} />
468+ )}
469+ {error && <p className="text-sm text-destructive">{error}</p>}
470+ </div>
471+ <DialogFooter>
472+ <DialogClose asChild>
473+ <Button type="button" variant="outline" disabled={submitting}>
474+ Cancel
475+ </Button>
476+ </DialogClose>
477+ <Button
478+ type="submit"
479+ disabled={submitting || !email || (!!turnstileSiteKey && !turnstileToken)}
480+ >
481+ {submitting ? "Sending…" : "Send invite"}
482+ </Button>
483+ </DialogFooter>
484+ </form>
485+ )}
486+ </DialogContent>
487+ </Dialog>
488+ <span className="text-muted-foreground/60" aria-hidden>
489+ ·
490+ </span>
491+ <a
492+ href="mailto:rhys@executor.sh?subject=Executor%20question"
493+ className="inline-flex items-center gap-1.5 text-foreground hover:text-primary transition-colors"
494+ >
495+ <MailIcon className="size-4" />
496+ Email us
497+ <span aria-hidden>→</span>
498+ </a>
306499 </div>
307500 </div>
308501 );
309502 }
503+
504+function MailIcon({ className }: { className?: string }) {
505+ return (
506+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className={className} aria-hidden>
507+ <path strokeLinecap="round" strokeLinejoin="round" d="M3 7l9 6 9-6M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
508+ </svg>
509+ );
510+}
511+
512+type TurnstileApi = {
513+ render: (
514+ el: HTMLElement,
515+ opts: {
516+ sitekey: string;
517+ callback?: (token: string) => void;
518+ "error-callback"?: () => void;
519+ "expired-callback"?: () => void;
520+ theme?: "light" | "dark" | "auto";
521+ },
522+ ) => string;
523+ remove: (id: string) => void;
524+};
525+
526+declare global {
527+ interface Window {
528+ turnstile?: TurnstileApi;
529+ }
530+}
531+
532+function TurnstileWidget({
533+ siteKey,
534+ onToken,
535+}: {
536+ siteKey: string;
537+ onToken: (token: string | null) => void;
538+}) {
539+ const containerRef = useRef<HTMLDivElement>(null);
540+ const widgetIdRef = useRef<string | null>(null);
541+ const onTokenRef = useRef(onToken);
542+ onTokenRef.current = onToken;
543+
544+ useEffect(() => {
545+ let cancelled = false;
546+ const render = () => {
547+ if (cancelled || !containerRef.current || !window.turnstile) return;
548+ if (widgetIdRef.current) {
549+ window.turnstile.remove(widgetIdRef.current);
550+ widgetIdRef.current = null;
551+ }
552+ widgetIdRef.current = window.turnstile.render(containerRef.current, {
553+ sitekey: siteKey,
554+ callback: (token) => onTokenRef.current(token),
555+ "error-callback": () => onTokenRef.current(null),
556+ "expired-callback": () => onTokenRef.current(null),
557+ });
558+ };
559+
560+ if (window.turnstile) {
561+ render();
562+ } else {
563+ const existing = document.querySelector<HTMLScriptElement>("script[data-turnstile]");
564+ if (existing) {
565+ existing.addEventListener("load", render);
566+ return () => {
567+ cancelled = true;
568+ existing.removeEventListener("load", render);
569+ };
570+ }
571+ const script = document.createElement("script");
572+ script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js";
573+ script.async = true;
574+ script.defer = true;
575+ script.dataset.turnstile = "1";
576+ script.addEventListener("load", render);
577+ document.head.appendChild(script);
578+ }
579+
580+ return () => {
581+ cancelled = true;
582+ if (widgetIdRef.current && window.turnstile) {
583+ window.turnstile.remove(widgetIdRef.current);
584+ widgetIdRef.current = null;
585+ }
586+ };
587+ }, [siteKey]);
588+
589+ return <div ref={containerRef} className="flex justify-center" />;
590+}
591+
592+function SlackMark({ className }: { className?: string }) {
593+ return (
594+ <svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
595+ <path
596+ d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313z"
597+ fill="#E01E5A"
598+ />
599+ <path
600+ d="M8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.527 2.527 0 0 1 2.521 2.521 2.527 2.527 0 0 1-2.521 2.521H2.522A2.527 2.527 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312z"
601+ fill="#36C5F0"
602+ />
603+ <path
604+ d="M18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.272 0a2.528 2.528 0 0 1-2.521 2.521 2.527 2.527 0 0 1-2.521-2.521V2.522A2.527 2.527 0 0 1 15.163 0a2.528 2.528 0 0 1 2.521 2.522v6.312z"
605+ fill="#2EB67D"
606+ />
607+ <path
608+ d="M15.163 18.956a2.528 2.528 0 0 1 2.521 2.522A2.528 2.528 0 0 1 15.163 24a2.527 2.527 0 0 1-2.521-2.522v-2.522h2.521zm0-1.272a2.527 2.527 0 0 1-2.521-2.521 2.527 2.527 0 0 1 2.521-2.521h6.315A2.527 2.527 0 0 1 24 15.163a2.528 2.528 0 0 1-2.522 2.521h-6.315z"
609+ fill="#ECB22E"
610+ />
611+ </svg>
612+ );
613+}
apps/cloud/src/routes/org.tsx+33−5
@@ -124,6 +124,9 @@ function OrgPage() { · 123 unmodified lines
124124 const canUseDomains = customerLoading
125125 ? false
126126 : check({ featureId: "domain-verification" }).allowed;
127+ const canInviteMember = customerLoading
128+ ? false
129+ : check({ featureId: "members", requiredBalance: 1 }).allowed;
127130 const [inviteOpen, setInviteOpen] = useState(false);
128131 const [editName, setEditName] = useState(orgName);
129132 const [savingName, setSavingName] = useState(false);
@@ -253,7 +256,7 @@ function OrgPage() { · 123 unmodified lines
253256 {!canUseDomains && (
254257 <div className="mb-3 flex items-center justify-between rounded-lg border border-border px-4 py-3">
255258 <p className="text-sm text-muted-foreground">
256− Domain verification is available on the Professional plan.
259+ Domain verification is available on the Team plan.
257260 </p>
258261 <Link to="/billing/plans">
259262 <Button size="sm" variant="outline">
@@ -303,11 +306,36 @@ function OrgPage() { · 43 unmodified lines
303306 {/* Members */}
304307 <section className="mb-10">
305308 <div className="flex items-center justify-between mb-4">
306− <h2 className="text-sm font-medium text-foreground">Members</h2>
307− <Button size="sm" className="min-w-32" onClick={() => setInviteOpen(true)}>
308− Invite member
309− </Button>
309+ <div>
310+ <h2 className="text-sm font-medium text-foreground">Members</h2>
311+ <p className="mt-0.5 text-sm text-muted-foreground">
312+ Free organizations can include up to 3 members.
313+ </p>
314+ </div>
315+ {canInviteMember ? (
316+ <Button size="sm" className="min-w-32" onClick={() => setInviteOpen(true)}>
317+ Invite member
318+ </Button>
319+ ) : (
320+ <Link to="/billing/plans">
321+ <Button size="sm" className="min-w-32">
322+ Upgrade
323+ </Button>
324+ </Link>
325+ )}
310326 </div>
327+ {!canInviteMember && (
328+ <div className="mb-3 flex items-center justify-between rounded-lg border border-border px-4 py-3">
329+ <p className="text-sm text-muted-foreground">
330+ Upgrade to Team to add more than 3 members.
331+ </p>
332+ <Link to="/billing/plans">
333+ <Button size="sm" variant="outline">
334+ View plans
335+ </Button>
336+ </Link>
337+ </div>
338+ )}
311339 <Input
312340 type="text"
313341 placeholder="Search by name or email..."
apps/cloud/src/services/autumn.ts+23−8
@@ -28,6 +28,7 @@ export type IAutumnService = Readonly<{ · 27 unmodified lines
2828 * user-facing request.
2929 */
3030 trackExecution: (organizationId: string) => Effect.Effect<void, never, never>;
31+ trackMemberChange: (organizationId: string, value: number) => Effect.Effect<void, never, never>;
3132 }>;
3233
3334 // ---------------------------------------------------------------------------
@@ -44,6 +45,7 @@ const make = Effect.sync(() => { · 10 unmodified lines
4445 return {
4546 use: () => notConfigured,
4647 trackExecution: () => Effect.void,
48+ trackMemberChange: () => Effect.void,
4749 } satisfies IAutumnService;
4850 }
4951
@@ -59,9 +61,7 @@ const make = Effect.sync(() => { · 9 unmodified lines
5961 Effect.gen(function* () {
6062 yield* Effect.annotateCurrentSpan({ "autumn.customer.id": organizationId });
6163 const outcome = yield* Effect.result(
62− use((c) =>
63− c.track({ customerId: organizationId, featureId: "executions", value: 1 }),
64− ),
64+ use((c) => c.track({ customerId: organizationId, featureId: "executions", value: 1 })),
6565 );
6666 if (outcome._tag === "Failure") {
6767 // Silent billing data loss is worth paging on — autumn.trackExecution
@@ -72,12 +72,27 @@ const make = Effect.sync(() => { · 4 unmodified lines
7272 }
7373 }).pipe(Effect.withSpan("autumn.trackExecution"));
7474
75− return { use, trackExecution } satisfies IAutumnService;
75+ const trackMemberChange = (organizationId: string, value: number) =>
76+ Effect.gen(function* () {
77+ yield* Effect.annotateCurrentSpan({
78+ "autumn.customer.id": organizationId,
79+ "autumn.members.delta": value,
80+ });
81+ const outcome = yield* Effect.result(
82+ use((c) => c.track({ customerId: organizationId, featureId: "members", value })),
83+ );
84+ if (outcome._tag === "Failure") {
85+ console.error("[billing] member tracking failed:", outcome.failure);
86+ Sentry.captureException(outcome.failure);
87+ yield* Effect.annotateCurrentSpan({ "autumn.track.members.failed": true });
88+ }
89+ }).pipe(Effect.withSpan("autumn.trackMemberChange"));
90+
91+ return { use, trackExecution, trackMemberChange } satisfies IAutumnService;
7692 });
7793
78−export class AutumnService extends Context.Service<
79− AutumnService,
80− IAutumnService
81−>()("@executor-js/cloud/AutumnService") {
94+export class AutumnService extends Context.Service<AutumnService, IAutumnService>()(
95+ "@executor-js/cloud/AutumnService",
96+) {
8297 static Default = Layer.effect(this)(make);
8398 }
apps/cloud/src/services/slack.ts+142−0
@@ -0,0 +1,142 @@
1+// ---------------------------------------------------------------------------
2+// Slack service — creates a per-customer Slack Connect channel and emails an
3+// invite. Used by the pricing-page "Get in touch on Slack" CTA, modeled on
4+// WorkOS's onboarding flow.
5+// ---------------------------------------------------------------------------
6+
7+import { env } from "cloudflare:workers";
8+import { Context, Data, Effect, Layer } from "effect";
9+
10+export class SlackError extends Data.TaggedError("SlackError")<{
11+ method: string;
12+ error: string;
13+}> {}
14+
15+export type ISlackService = Readonly<{
16+ createConnectInvite: (input: {
17+ email: string;
18+ name?: string;
19+ note?: string;
20+ organization?: string;
21+ }) => Effect.Effect<
22+ {
23+ channel: { id: string; name: string };
24+ invite: { invite_id: string; url: string };
25+ },
26+ SlackError
27+ >;
28+}>;
29+
30+const slugifyEmail = (email: string): string =>
31+ email
32+ .toLowerCase()
33+ .replace(/[^a-z0-9]+/g, "-")
34+ .replace(/^-+|-+$/g, "")
35+ .slice(0, 40);
36+
37+const randomSuffix = (): string => {
38+ const bytes = new Uint8Array(3);
39+ crypto.getRandomValues(bytes);
40+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
41+};
42+
43+type SlackResponse = { ok: boolean; error?: string } & Record<string, unknown>;
44+
45+const make = Effect.sync(() => {
46+ const token = env.SLACK_BOT_TOKEN;
47+
48+ if (!token) {
49+ const notConfigured = (method: string) =>
50+ Effect.fail(
51+ new SlackError({ method, error: "SLACK_BOT_TOKEN is not configured" }),
52+ );
53+ return {
54+ createConnectInvite: () => notConfigured("createConnectInvite"),
55+ } satisfies ISlackService;
56+ }
57+
58+ const call = <A extends SlackResponse>(method: string, body: Record<string, unknown>) =>
59+ Effect.tryPromise({
60+ try: async (): Promise<A> => {
61+ const res = await fetch(`https://slack.com/api/${method}`, {
62+ method: "POST",
63+ headers: {
64+ "content-type": "application/json; charset=utf-8",
65+ authorization: `Bearer ${token}`,
66+ },
67+ body: JSON.stringify(body),
68+ });
69+ const json = (await res.json()) as A;
70+ if (!json.ok) throw new Error(json.error ?? "unknown_slack_error");
71+ return json;
72+ },
73+ catch: (cause) =>
74+ new SlackError({
75+ method,
76+ error: cause instanceof Error ? cause.message : String(cause),
77+ }),
78+ }).pipe(Effect.withSpan(`slack.${method}`));
79+
80+ const createConnectInvite: ISlackService["createConnectInvite"] = ({
81+ email,
82+ name,
83+ note,
84+ organization,
85+ }) =>
86+ Effect.gen(function* () {
87+ // Slack channel names: lowercase, no spaces, max 80 chars, unique per workspace.
88+ const baseName = `shared-${slugifyEmail(email)}`.slice(0, 80);
89+ const tryCreate = (n: string) =>
90+ call<SlackResponse & { channel: { id: string; name: string } }>(
91+ "conversations.create",
92+ { name: n, is_private: false },
93+ );
94+
95+ const created = yield* tryCreate(baseName).pipe(
96+ Effect.catchTag("SlackError", (err) =>
97+ err.error === "name_taken"
98+ ? tryCreate(`${baseName}-${randomSuffix()}`.slice(0, 80))
99+ : Effect.fail(err),
100+ ),
101+ );
102+ const channel = created.channel;
103+
104+ const helloLines = [
105+ `:wave: New contact from pricing page: ${email}`,
106+ name ? `Name: ${name}` : null,
107+ organization ? `Org: ${organization}` : null,
108+ note ? `Note: ${note}` : null,
109+ ].filter(Boolean) as string[];
110+
111+ yield* call<SlackResponse>("chat.postMessage", {
112+ channel: channel.id,
113+ text: helloLines.join("\n"),
114+ });
115+
116+ const invite = yield* call<SlackResponse & { invite_id: string; url: string }>(
117+ "conversations.inviteShared",
118+ {
119+ channel: channel.id,
120+ emails: [email],
121+ // `external_limited: true` scopes the guest to just this channel,
122+ // which is what we want for a focused 1:1 support conversation.
123+ external_limited: true,
124+ },
125+ );
126+
127+ return {
128+ channel: { id: channel.id, name: channel.name },
129+ invite: { invite_id: invite.invite_id, url: invite.url },
130+ };
131+ }).pipe(
132+ Effect.withSpan("slack.createConnectInvite", { attributes: { "slack.email": email } }),
133+ );
134+
135+ return { createConnectInvite } satisfies ISlackService;
136+});
137+
138+export class SlackService extends Context.Service<SlackService, ISlackService>()(
139+ "@executor-js/cloud/SlackService",
140+) {
141+ static Default = Layer.effect(this)(make);
142+}
apps/cloud/wrangler.jsonc+15−0
@@ -54,5 +54,20 @@ · 53 unmodified lines
5454 "vars": {
5555 "VITE_PUBLIC_SITE_URL": "https://executor.sh",
5656 "VITE_PUBLIC_POSTHOG_KEY": "phc_nNLrNMALpRsfrEkZovUkfMxYbcJvHnsJHeoSPavprgLL",
57+ // Replace with your Turnstile production sitekey before going live. The
58+ // test key always passes — fine for local dev, useless in prod.
59+ "VITE_PUBLIC_TURNSTILE_SITEKEY": "1x00000000000000000000AA",
5760 },
61+ // Cloudflare's `simple` ratelimit only supports 10s or 60s windows — it's
62+ // burst protection, not a true daily cap. With Turnstile gating the form,
63+ // 5/min keeps the worst-case sustained throughput to ~7k channels/day,
64+ // which is bounded enough as a backstop. For an actual daily ceiling,
65+ // layer a Durable Object counter on top.
66+ "ratelimits": [
67+ {
68+ "name": "SLACK_INVITE_LIMITER",
69+ "namespace_id": "1001",
70+ "simple": { "limit": 5, "period": 60 },
71+ },
72+ ],
5873 }
apps/marketing/src/pages/privacy.astro+1−1
@@ -42,7 +42,7 @@ import LegalLayout from "../components/LegalLayout.astro"; · 41 unmodified lines
4242 <li>source and integration metadata;</li>
4343 <li>tool catalogs, schemas, policies, and related configuration;</li>
4444 <li>secret metadata and connection settings; and</li>
45− <li>billing plan, seat, and execution-usage records.</li>
45+ <li>billing plan, membership, and execution-usage records.</li>
4646 </ul>
4747
4848 <h3>Credentials, secrets, and connected-service data</h3>
apps/marketing/src/pages/terms.astro+4−3
@@ -45,7 +45,7 @@ import LegalLayout from "../components/LegalLayout.astro"; · 44 unmodified lines
4545
4646 <p>
4747 We may modify, improve, add, or remove features from time to time. We may also impose or change limits on storage,
48− seat counts, execution volume, session duration, integration access, or other usage thresholds.
48+ organization membership counts, execution volume, session duration, integration access, or other usage thresholds.
4949 </p>
5050
5151 <h2>3. Your Content and Responsibilities</h2>
@@ -87,8 +87,9 @@ import LegalLayout from "../components/LegalLayout.astro"; · 35 unmodified lines
8787 <h2>6. Subscriptions, Billing, and Auto-Renewal</h2>
8888 <p>
8989 Some parts of the Services are offered on a free basis, while others require a paid subscription or usage-based
90− charges. Paid plans may include seat-based pricing, included execution allowances, overage charges, prepaid top-up
91− packages, or similar billing mechanics described in the product at the time of purchase.
90+ charges. Paid plans may include organization-level pricing, included member limits, included execution allowances,
91+ overage charges, prepaid top-up packages, or similar billing mechanics described in the product at the time of
92+ purchase.
9293 </p>
9394
9495 <p>
autumn.config.ts+47−52
@@ -1,9 +1,9 @@
11 import { feature, item, plan } from "atmn";
22
33 // Features
4−export const seats = feature({
5− id: "seats",
6− name: "Seats",
4+export const members = feature({
5+ id: "members",
6+ name: "Members",
77 type: "metered",
88 consumable: false,
99 });
@@ -21,89 +21,84 @@ export const domainVerification = feature({ · 11 unmodified lines
2121 type: "boolean",
2222 });
2323
24+export const seats = feature({
25+ id: "seats",
26+ name: "Seats",
27+ type: "metered",
28+ consumable: false,
29+ archived: true,
30+});
31+
2432 // Plans
2533 export const free = plan({
2634 id: "free",
2735 name: "Free",
36+ addOn: false,
2837 autoEnable: true,
2938 items: [
39+ item({
40+ featureId: members.id,
41+ included: 3,
42+ }),
3043 item({
3144 featureId: executions.id,
32− included: 5000,
33− reset: { interval: "month" },
45+ included: 10000,
46+ reset: {
47+ interval: "month",
48+ },
3449 }),
3550 ],
3651 });
3752
38−export const hobby = plan({
39− id: "hobby",
40− name: "Hobby",
41− price: {
42− amount: 10,
43− interval: "month",
44− },
53+export const freePayAsYouGo = plan({
54+ id: "free-pay-as-you-go",
55+ name: "Free Pay As You Go",
56+ addOn: false,
57+ autoEnable: false,
4558 items: [
4659 item({
47− featureId: seats.id,
48− included: 1,
60+ featureId: members.id,
61+ included: 3,
62+ }),
63+ item({
64+ featureId: executions.id,
65+ included: 10000,
4966 price: {
50− amount: 10,
51− billingUnits: 1,
67+ amount: 0.2,
68+ billingUnits: 1000,
5269 billingMethod: "usage_based",
5370 interval: "month",
5471 },
5572 }),
56− item({
57− featureId: executions.id,
58− included: 50000,
59− reset: { interval: "month" },
60− }),
6173 ],
6274 });
6375
64−export const professional = plan({
65− id: "professional",
66− name: "Professional",
76+export const team = plan({
77+ id: "team",
78+ name: "Team",
79+ addOn: false,
80+ autoEnable: false,
6781 price: {
68− amount: 40,
82+ amount: 49,
6983 interval: "month",
7084 },
7185 items: [
7286 item({
73− featureId: seats.id,
74− included: 1,
87+ featureId: members.id,
88+ unlimited: true,
89+ }),
90+ item({
91+ featureId: executions.id,
92+ included: 250000,
7593 price: {
76− amount: 40,
77− billingUnits: 1,
94+ amount: 0.2,
95+ billingUnits: 1000,
7896 billingMethod: "usage_based",
7997 interval: "month",
8098 },
8199 }),
82− item({
83− featureId: executions.id,
84− included: 100000,
85− reset: { interval: "month" },
86− }),
87100 item({
88101 featureId: domainVerification.id,
89102 }),
90103 ],
91104 });
92−
93−// Overage add-on
94−export const executionTopUp = plan({
95− id: "execution-top-up",
96− name: "Execution Top-Up",
97− addOn: true,
98− items: [
99− item({
100− featureId: executions.id,
101− price: {
102− amount: 1,
103− billingUnits: 10000,
104− billingMethod: "prepaid",
105− interval: "month",
106− },
107− }),
108− ],
109−});