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 @@
1import { feature, item, plan } from "atmn";1import { feature, item, plan } from "atmn";
2 2
3// Features3// Features
4export const seats = feature({4export const members = feature({
5 id: "seats",5 id: "members",
6 name: "Seats",6 name: "Members",
7 type: "metered",7 type: "metered",
8 consumable: false,8 consumable: false,
9});9});
@@ -15,86 +15,75 @@ export const executions = feature({ · 5 unmodified lines
15 consumable: true,15 consumable: true,
16});16});
17 17
18export const domainVerification = feature({
19 id: "domain-verification",
20 name: "Domain Verification",
21 type: "boolean",
22});
23
18// Plans24// Plans
19export const free = plan({25export const free = plan({
20 id: "free",26 id: "free",
21 name: "Free",27 name: "Free",
22 autoEnable: true,28 autoEnable: true,
23 items: [29 items: [
30 item({
31 featureId: members.id,
32 included: 3,
33 }),
24 item({34 item({
25 featureId: executions.id,35 featureId: executions.id,
26 included: 5000,36 included: 10000,
27 reset: { interval: "month" },37 reset: { interval: "month" },
28 }),38 }),
29 ],39 ],
30});40});
31 41
32export const hobby = plan({42export const freePayAsYouGo = plan({
33 id: "hobby",43 id: "free-pay-as-you-go",
34 name: "Hobby",44 name: "Free Pay As You Go",
35 price: {
36 amount: 10,
37 interval: "month",
38 },
39 items: [45 items: [
40 item({46 item({
41 featureId: seats.id,47 featureId: members.id,
42 included: 1,48 included: 3,
49 }),
50 item({
51 featureId: executions.id,
52 included: 10000,
43 price: {53 price: {
44 amount: 10,54 amount: 0.2,
45 billingUnits: 1,55 billingUnits: 1000,
46 billingMethod: "usage_based",56 billingMethod: "usage_based",
47 interval: "month",57 interval: "month",
48 },58 },
49 }),59 }),
50 item({
51 featureId: executions.id,
52 included: 50000,
53 reset: { interval: "month" },
54 }),
55 ],60 ],
56});61});
57 62
58export const professional = plan({63export const team = plan({
59 id: "professional",64 id: "team",
60 name: "Professional",65 name: "Team",
61 price: {66 price: {
62 amount: 40,67 amount: 49,
63 interval: "month",68 interval: "month",
64 },69 },
65 items: [70 items: [
66 item({71 item({
67 featureId: seats.id,72 featureId: members.id,
68 included: 1,73 unlimited: true,
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" },
80 }),74 }),
81 ],
82});
83
84// Overage add-on
85export const executionTopUp = plan({
86 id: "execution-top-up",
87 name: "Execution Top-Up",
88 addOn: true,
89 items: [
90 item({75 item({
91 featureId: executions.id,76 featureId: executions.id,
77 included: 250000,
92 price: {78 price: {
93 amount: 1,79 amount: 0.2,
94 billingUnits: 10000,80 billingUnits: 1000,
95 billingMethod: "prepaid",81 billingMethod: "usage_based",
96 interval: "month",82 interval: "month",
97 },83 },
98 }),84 }),
85 item({
86 featureId: domainVerification.id,
87 }),
99 ],88 ],
100});89});
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);
apps/cloud/src/auth/handlers.ts+9−8
@@ -10,6 +10,7 @@ import { authorizeOrganization } from "./authorize-organization"; · 9 unmodified lines
10import { env } from "cloudflare:workers";10import { env } from "cloudflare:workers";
11import { WorkOSError } from "./errors";11import { WorkOSError } from "./errors";
12import { WorkOSAuth } from "./workos";12import { WorkOSAuth } from "./workos";
13import { AutumnService } from "../services/autumn";
13 14
14const COOKIE_OPTIONS = {15const COOKIE_OPTIONS = {
15 path: "/",16 path: "/",
@@ -69,10 +70,8 @@ const setResponseCookie = ( · 53 unmodified lines
69 options: typeof RESPONSE_COOKIE_OPTIONS,70 options: typeof RESPONSE_COOKIE_OPTIONS,
70) => HttpServerResponse.setCookieUnsafe(response, name, value, options);71) => HttpServerResponse.setCookieUnsafe(response, name, value, options);
71 72
72const deleteResponseCookie = (73const deleteResponseCookie = (response: HttpServerResponse.HttpServerResponse, name: string) =>
73 response: HttpServerResponse.HttpServerResponse,74 HttpServerResponse.setCookieUnsafe(response, name, "", DELETE_COOKIE_OPTIONS);
74 name: string,
75) => HttpServerResponse.setCookieUnsafe(response, name, "", DELETE_COOKIE_OPTIONS);
76 75
77// ---------------------------------------------------------------------------76// ---------------------------------------------------------------------------
78// Single non-protected API surface — public (login/callback) + session77// Single non-protected API surface — public (login/callback) + session
@@ -208,7 +207,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 129 unmodified lines
208 );207 );
209 208
210 return {209 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 ),
212 activeOrganizationId: session.organizationId,213 activeOrganizationId: session.organizationId,
213 };214 };
214 }),215 }),
@@ -231,11 +232,13 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 16 unmodified lines
231 Effect.gen(function* () {232 Effect.gen(function* () {
232 const workos = yield* WorkOSAuth;233 const workos = yield* WorkOSAuth;
233 const users = yield* UserStoreService;234 const users = yield* UserStoreService;
235 const autumn = yield* AutumnService;
234 const session = yield* SessionContext;236 const session = yield* SessionContext;
235 237
236 const name = payload.name.trim();238 const name = payload.name.trim();
237 const org = yield* workos.createOrganization(name);239 const org = yield* workos.createOrganization(name);
238 yield* workos.createMembership(org.id, session.accountId, "admin");240 yield* workos.createMembership(org.id, session.accountId, "admin");
241 yield* autumn.trackMemberChange(org.id, 1);
239 yield* users.use((s) => s.upsertOrganization({ id: org.id, name: org.name }));242 yield* users.use((s) => s.upsertOrganization({ id: org.id, name: org.name }));
240 243
241 // Try to attach the new org to the current session. This can fail244 // 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
246 // cookie and fail loudly; the frontend will bounce to login and249 // cookie and fail loudly; the frontend will bounce to login and
247 // the callback's rehydrate path will pick up the new membership.250 // the callback's rehydrate path will pick up the new membership.
248 const refreshed = yield* workos.refreshSession(session.sealedSession, org.id);251 const refreshed = yield* workos.refreshSession(session.sealedSession, org.id);
249 const verified = refreshed252 const verified = refreshed ? yield* workos.authenticateSealedSession(refreshed) : null;
250 ? yield* workos.authenticateSealedSession(refreshed)
251 : null;
252 253
253 if (!refreshed || !verified || verified.organizationId !== org.id) {254 if (!refreshed || !verified || verified.organizationId !== org.id) {
254 yield* Effect.logWarning(255 yield* Effect.logWarning(
apps/cloud/src/env-augment.d.ts+11−0
@@ -23,6 +23,17 @@ declare global { · 22 unmodified lines
23 // Billing23 // Billing
24 AUTUMN_SECRET_KEY?: string;24 AUTUMN_SECRET_KEY?: string;
25 25
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
26 // MCP37 // MCP
27 EXECUTOR_MCP_DEBUG?: string;38 EXECUTOR_MCP_DEBUG?: string;
28 MCP_AUTHKIT_DOMAIN?: string;39 MCP_AUTHKIT_DOMAIN?: string;
apps/cloud/src/org/handlers.ts+32−5
@@ -52,6 +52,25 @@ const assertDomainInSessionOrg = (domainId: string) => · 51 unmodified lines
52 }52 }
53 });53 });
54 54
55const 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
55export const OrgHandlers = HttpApiBuilder.group(OrgHttpApi, "org", (handlers) =>74export const OrgHandlers = HttpApiBuilder.group(OrgHttpApi, "org", (handlers) =>
56 handlers75 handlers
57 .handle("listMembers", () =>76 .handle("listMembers", () =>
@@ -104,12 +123,17 @@ export const OrgHandlers = HttpApiBuilder.group(OrgHttpApi, "org", (handlers) => · 46 unmodified lines
104 yield* requireAdmin;123 yield* requireAdmin;
105 const auth = yield* AuthContext;124 const auth = yield* AuthContext;
106 const workos = yield* WorkOSAuth;125 const workos = yield* WorkOSAuth;
126 const autumn = yield* AutumnService;
107 127
108 const invitation = yield* workos.sendInvitation({128 yield* reserveMemberSlot;
109 email: payload.email,129
110 organizationId: auth.organizationId,130 const invitation = yield* workos
111 roleSlug: payload.roleSlug,131 .sendInvitation({
112 });132 email: payload.email,
133 organizationId: auth.organizationId,
134 roleSlug: payload.roleSlug,
135 })
136 .pipe(Effect.tapError(() => autumn.trackMemberChange(auth.organizationId, -1)));
113 137
114 return { id: invitation.id, email: invitation.email };138 return { id: invitation.id, email: invitation.email };
115 }),139 }),
@@ -119,7 +143,10 @@ export const OrgHandlers = HttpApiBuilder.group(OrgHttpApi, "org", (handlers) => · 3 unmodified lines
119 yield* requireAdmin;143 yield* requireAdmin;
120 yield* assertMembershipInSessionOrg(params.membershipId);144 yield* assertMembershipInSessionOrg(params.membershipId);
121 const workos = yield* WorkOSAuth;145 const workos = yield* WorkOSAuth;
146 const auth = yield* AuthContext;
147 const autumn = yield* AutumnService;
122 yield* workos.deleteOrgMembership(params.membershipId);148 yield* workos.deleteOrgMembership(params.membershipId);
149 yield* autumn.trackMemberChange(auth.organizationId, -1);
123 return { success: true };150 return { success: true };
124 }),151 }),
125 )152 )
apps/cloud/src/routes/billing.tsx+34−6
@@ -10,9 +10,8 @@ export const Route = createFileRoute("/billing")({ · 9 unmodified lines
10});10});
11 11
12const PLAN_TAGLINES: Record<string, string> = {12const PLAN_TAGLINES: Record<string, string> = {
13 free: "For trying things out",13 free: "Free for up to 3 members",
14 hobby: "For individuals and small teams",14 team: "$49 per organization",
15 professional: "For teams that need more",
16};15};
17 16
18function BillingPage() {17function BillingPage() {
@@ -53,6 +52,7 @@ function BillingPage() { · 34 unmodified lines
53 );52 );
54 53
55 const executions = customer?.balances?.executions;54 const executions = customer?.balances?.executions;
55 const members = customer?.balances?.members;
56 56
57 return (57 return (
58 <div className="min-h-0 flex-1 overflow-y-auto">58 <div className="min-h-0 flex-1 overflow-y-auto">
@@ -65,9 +65,7 @@ function BillingPage() { · 6 unmodified lines
65 <div className="flex items-center justify-between py-4">65 <div className="flex items-center justify-between py-4">
66 <div>66 <div>
67 <div className="flex items-center gap-2">67 <div className="flex items-center gap-2">
68 <p className="text-sm font-medium text-foreground leading-none">68 <p className="text-sm font-medium text-foreground leading-none">{planName}</p>
69 {planName}
70 </p>
71 {isSwitching && (69 {isSwitching && (
72 <Badge className="bg-amber-500/10 text-amber-600 dark:text-amber-400">70 <Badge className="bg-amber-500/10 text-amber-600 dark:text-amber-400">
73 Switching71 Switching
@@ -113,6 +111,36 @@ function BillingPage() { · 39 unmodified lines
113 <div className="h-px bg-border/50 my-2" />111 <div className="h-px bg-border/50 my-2" />
114 112
115 {/* Usage */}113 {/* 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
116 {executions && (144 {executions && (
117 <div className="py-4">145 <div className="py-4">
118 <div className="flex items-center justify-between mb-2">146 <div className="flex items-center justify-between mb-2">
apps/cloud/src/routes/billing_.plans.tsx+351−47
@@ -1,8 +1,22 @@
1import { useState } from "react";1import { useEffect, useRef, useState } from "react";
2import { createFileRoute, Link } from "@tanstack/react-router";2import { createFileRoute, Link } from "@tanstack/react-router";
3import { useAuth } from "../web/auth";
3import { useCustomer, useListPlans } from "autumn-js/react";4import { useCustomer, useListPlans } from "autumn-js/react";
4import { Button } from "@executor-js/react/components/button";5import { Button } from "@executor-js/react/components/button";
5import { Badge } from "@executor-js/react/components/badge";6import { Badge } from "@executor-js/react/components/badge";
7import {
8 Dialog,
9 DialogClose,
10 DialogContent,
11 DialogDescription,
12 DialogFooter,
13 DialogHeader,
14 DialogTitle,
15 DialogTrigger,
16} from "@executor-js/react/components/dialog";
17import { Input } from "@executor-js/react/components/input";
18import { Label } from "@executor-js/react/components/label";
19import { Textarea } from "@executor-js/react/components/textarea";
6 20
7type Plan = NonNullable<ReturnType<typeof useListPlans>["data"]>[number];21type Plan = NonNullable<ReturnType<typeof useListPlans>["data"]>[number];
8 22
@@ -10,28 +24,24 @@ export const Route = createFileRoute("/billing_/plans")({ · 1 unmodified line
10 component: PlansPage,24 component: PlansPage,
11});25});
12 26
13const PLAN_META: Record<27const PLAN_META: Record<string, { tagline: string; inherits?: string; features: string[] }> = {
14 string,28 free: {
15 { tagline: string; inherits?: string; features: string[] }29 tagline: "For small teams getting started",
16> = {
17 hobby: {
18 tagline: "For individuals and small teams",
19 features: [30 features: [
20 "50,000 included executions per seat",31 "Up to 3 members",
21 "Up to 5 seats",32 "10,000 included executions per month",
22 "60s execution timeout",33 "$0.20 per 1,000 additional executions",
23 "Unlimited sources",34 "Unlimited sources",
24 "$0.30 per 1,000 extra executions",
25 ],35 ],
26 },36 },
27 professional: {37 team: {
28 tagline: "For teams that need more",38 tagline: "For growing organizations",
29 inherits: "Hobby",
30 features: [39 features: [
31 "100,000 included executions per seat",40 "Unlimited members",
32 "Unlimited seats",41 "250,000 included executions per month",
33 "5 minute execution timeout",42 "5 minute execution timeout",
34 "Join by team domain",43 "Join by team domain",
44 "$0.20 per 1,000 additional executions",
35 ],45 ],
36 },46 },
37};47};
@@ -45,7 +55,7 @@ const ACTION_LABELS: Record<string, string> = { · 7 unmodified lines
45};55};
46 56
47const ENTERPRISE_FEATURES = [57const ENTERPRISE_FEATURES = [
48 "Self-hosted or dedicated cloud deployment",58 "Self-hosted or dedicated cloud deployment support",
49 "SSO / SAML & SCIM provisioning",59 "SSO / SAML & SCIM provisioning",
50 "Audit logs for every tool call",60 "Audit logs for every tool call",
51 "Dedicated support & onboarding",61 "Dedicated support & onboarding",
@@ -74,8 +84,8 @@ function PlansPage() { · 22 unmodified lines
74 84
75 const isLoading = customerLoading || plansLoading;85 const isLoading = customerLoading || plansLoading;
76 86
77 const paidPlans = (plans ?? ([] as Plan[])).filter(87 const selfServePlans = (plans ?? ([] as Plan[])).filter(
78 (p: Plan) => p.id === "hobby" || p.id === "professional",88 (p: Plan) => p.id === "free" || p.id === "team",
79 );89 );
80 90
81 return (91 return (
@@ -118,7 +128,7 @@ function PlansPage() { · 36 unmodified lines
118 isFetching ? "opacity-50 pointer-events-none" : "",128 isFetching ? "opacity-50 pointer-events-none" : "",
119 ].join(" ")}129 ].join(" ")}
120 >130 >
121 {paidPlans.map((plan: Plan) => {131 {selfServePlans.map((plan: Plan) => {
122 const meta = PLAN_META[plan.id];132 const meta = PLAN_META[plan.id];
123 if (!meta) return null;133 if (!meta) return null;
124 134
@@ -143,7 +153,7 @@ function PlansPage() { · 18 unmodified lines
143 : "border-border",153 : "border-border",
144 ].join(" ")}154 ].join(" ")}
145 >155 >
146 <div className="flex items-center justify-between">156 <div className="flex h-6 items-center justify-between">
147 <p className="text-base font-semibold text-foreground leading-none">157 <p className="text-base font-semibold text-foreground leading-none">
148 {plan.name}158 {plan.name}
149 </p>159 </p>
@@ -171,9 +181,12 @@ function PlansPage() { · 21 unmodified lines
171 </span>181 </span>
172 {plan.price?.interval && (182 {plan.price?.interval && (
173 <span className="text-sm text-muted-foreground">183 <span className="text-sm text-muted-foreground">
174 USD / seat / {plan.price.interval}184 USD / org / {plan.price.interval}
175 </span>185 </span>
176 )}186 )}
187 {!plan.price?.interval && (
188 <span className="text-sm text-muted-foreground">USD</span>
189 )}
177 </div>190 </div>
178 191
179 <div className="mt-4">192 <div className="mt-4">
@@ -218,16 +231,10 @@ function PlansPage() { · 38 unmodified lines
218 )}231 )}
219 <ul232 <ul
220 role="list"233 role="list"
221 className={[234 className={["space-y-2", meta.inherits ? "mt-2" : "mt-5"].join(" ")}
222 "space-y-2",
223 meta.inherits ? "mt-2" : "mt-5",
224 ].join(" ")}
225 >235 >
226 {meta.features.map((f) => (236 {meta.features.map((f) => (
227 <li237 <li key={f} className="flex items-start gap-2 text-xs text-muted-foreground">
228 key={f}
229 className="flex items-start gap-2 text-xs text-muted-foreground"
230 >
231 <svg238 <svg
232 viewBox="0 0 16 16"239 viewBox="0 0 16 16"
233 fill="none"240 fill="none"
@@ -250,19 +257,13 @@ function PlansPage() { · 16 unmodified lines
250 })}257 })}
251 258
252 <div className="flex flex-col rounded-xl border border-border p-5">259 <div className="flex flex-col rounded-xl border border-border p-5">
253 <div className="flex items-center justify-between">260 <div className="flex h-6 items-center justify-between">
254 <p className="text-base font-semibold text-foreground leading-none">261 <p className="text-base font-semibold text-foreground leading-none">Enterprise</p>
255 Enterprise
256 </p>
257 </div>262 </div>
258 <p className="mt-1 text-sm text-muted-foreground">263 <p className="mt-1 text-sm text-muted-foreground">For orgs with custom needs</p>
259 For orgs with custom needs
260 </p>
261 264
262 <div className="mt-4 flex items-baseline gap-1.5">265 <div className="mt-4 flex items-baseline gap-1.5">
263 <span className="text-2xl font-semibold text-foreground tabular-nums">266 <span className="text-2xl font-semibold text-foreground tabular-nums">Custom</span>
264 Custom
265 </span>
266 </div>267 </div>
267 268
268 <div className="mt-4">269 <div className="mt-4">
@@ -274,15 +275,10 @@ function PlansPage() { · 5 unmodified lines
274 </a>275 </a>
275 </div>276 </div>
276 277
277 <p className="mt-5 text-xs font-medium text-foreground">278 <p className="mt-5 text-xs font-medium text-foreground">Everything in Team, plus</p>
278 Everything in Professional, plus
279 </p>
280 <ul role="list" className="mt-2 space-y-2">279 <ul role="list" className="mt-2 space-y-2">
281 {ENTERPRISE_FEATURES.map((f) => (280 {ENTERPRISE_FEATURES.map((f) => (
282 <li281 <li key={f} className="flex items-start gap-2 text-xs text-muted-foreground">
283 key={f}
284 className="flex items-start gap-2 text-xs text-muted-foreground"
285 >
286 <svg282 <svg
287 viewBox="0 0 16 16"283 viewBox="0 0 16 16"
288 fill="none"284 fill="none"
@@ -303,7 +299,315 @@ function PlansPage() { · 14 unmodified lines
303 </div>299 </div>
304 </div>300 </div>
305 )}301 )}
302
303 <SlackContactCta />
304 </div>
305 </div>
306 );
307}
308
309function 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>
306 </div>499 </div>
307 </div>500 </div>
308 );501 );
309}502}
503
504function 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
512type 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
526declare global {
527 interface Window {
528 turnstile?: TurnstileApi;
529 }
530}
531
532function 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
592function 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
124 const canUseDomains = customerLoading124 const canUseDomains = customerLoading
125 ? false125 ? false
126 : check({ featureId: "domain-verification" }).allowed;126 : check({ featureId: "domain-verification" }).allowed;
127 const canInviteMember = customerLoading
128 ? false
129 : check({ featureId: "members", requiredBalance: 1 }).allowed;
127 const [inviteOpen, setInviteOpen] = useState(false);130 const [inviteOpen, setInviteOpen] = useState(false);
128 const [editName, setEditName] = useState(orgName);131 const [editName, setEditName] = useState(orgName);
129 const [savingName, setSavingName] = useState(false);132 const [savingName, setSavingName] = useState(false);
@@ -253,7 +256,7 @@ function OrgPage() { · 123 unmodified lines
253 {!canUseDomains && (256 {!canUseDomains && (
254 <div className="mb-3 flex items-center justify-between rounded-lg border border-border px-4 py-3">257 <div className="mb-3 flex items-center justify-between rounded-lg border border-border px-4 py-3">
255 <p className="text-sm text-muted-foreground">258 <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.
257 </p>260 </p>
258 <Link to="/billing/plans">261 <Link to="/billing/plans">
259 <Button size="sm" variant="outline">262 <Button size="sm" variant="outline">
@@ -303,11 +306,36 @@ function OrgPage() { · 43 unmodified lines
303 {/* Members */}306 {/* Members */}
304 <section className="mb-10">307 <section className="mb-10">
305 <div className="flex items-center justify-between mb-4">308 <div className="flex items-center justify-between mb-4">
306 <h2 className="text-sm font-medium text-foreground">Members</h2>309 <div>
307 <Button size="sm" className="min-w-32" onClick={() => setInviteOpen(true)}>310 <h2 className="text-sm font-medium text-foreground">Members</h2>
308 Invite member311 <p className="mt-0.5 text-sm text-muted-foreground">
309 </Button>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 )}
310 </div>326 </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 )}
311 <Input339 <Input
312 type="text"340 type="text"
313 placeholder="Search by name or email..."341 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
28 * user-facing request.28 * user-facing request.
29 */29 */
30 trackExecution: (organizationId: string) => Effect.Effect<void, never, never>;30 trackExecution: (organizationId: string) => Effect.Effect<void, never, never>;
31 trackMemberChange: (organizationId: string, value: number) => Effect.Effect<void, never, never>;
31}>;32}>;
32 33
33// ---------------------------------------------------------------------------34// ---------------------------------------------------------------------------
@@ -44,6 +45,7 @@ const make = Effect.sync(() => { · 10 unmodified lines
44 return {45 return {
45 use: () => notConfigured,46 use: () => notConfigured,
46 trackExecution: () => Effect.void,47 trackExecution: () => Effect.void,
48 trackMemberChange: () => Effect.void,
47 } satisfies IAutumnService;49 } satisfies IAutumnService;
48 }50 }
49 51
@@ -59,9 +61,7 @@ const make = Effect.sync(() => { · 9 unmodified lines
59 Effect.gen(function* () {61 Effect.gen(function* () {
60 yield* Effect.annotateCurrentSpan({ "autumn.customer.id": organizationId });62 yield* Effect.annotateCurrentSpan({ "autumn.customer.id": organizationId });
61 const outcome = yield* Effect.result(63 const outcome = yield* Effect.result(
62 use((c) =>64 use((c) => c.track({ customerId: organizationId, featureId: "executions", value: 1 })),
63 c.track({ customerId: organizationId, featureId: "executions", value: 1 }),
64 ),
65 );65 );
66 if (outcome._tag === "Failure") {66 if (outcome._tag === "Failure") {
67 // Silent billing data loss is worth paging on — autumn.trackExecution67 // Silent billing data loss is worth paging on — autumn.trackExecution
@@ -72,12 +72,27 @@ const make = Effect.sync(() => { · 4 unmodified lines
72 }72 }
73 }).pipe(Effect.withSpan("autumn.trackExecution"));73 }).pipe(Effect.withSpan("autumn.trackExecution"));
74 74
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;
76});92});
77 93
78export class AutumnService extends Context.Service<94export class AutumnService extends Context.Service<AutumnService, IAutumnService>()(
79 AutumnService,95 "@executor-js/cloud/AutumnService",
80 IAutumnService96) {
81>()("@executor-js/cloud/AutumnService") {
82 static Default = Layer.effect(this)(make);97 static Default = Layer.effect(this)(make);
83}98}
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
7import { env } from "cloudflare:workers";
8import { Context, Data, Effect, Layer } from "effect";
9
10export class SlackError extends Data.TaggedError("SlackError")<{
11 method: string;
12 error: string;
13}> {}
14
15export 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
30const slugifyEmail = (email: string): string =>
31 email
32 .toLowerCase()
33 .replace(/[^a-z0-9]+/g, "-")
34 .replace(/^-+|-+$/g, "")
35 .slice(0, 40);
36
37const 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
43type SlackResponse = { ok: boolean; error?: string } & Record<string, unknown>;
44
45const 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
138export 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
54 "vars": {54 "vars": {
55 "VITE_PUBLIC_SITE_URL": "https://executor.sh",55 "VITE_PUBLIC_SITE_URL": "https://executor.sh",
56 "VITE_PUBLIC_POSTHOG_KEY": "phc_nNLrNMALpRsfrEkZovUkfMxYbcJvHnsJHeoSPavprgLL",56 "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",
57 },60 },
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 ],
58}73}
apps/marketing/src/pages/privacy.astro+1−1
@@ -42,7 +42,7 @@ import LegalLayout from "../components/LegalLayout.astro"; · 41 unmodified lines
42 <li>source and integration metadata;</li>42 <li>source and integration metadata;</li>
43 <li>tool catalogs, schemas, policies, and related configuration;</li>43 <li>tool catalogs, schemas, policies, and related configuration;</li>
44 <li>secret metadata and connection settings; and</li>44 <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>
46 </ul>46 </ul>
47 47
48 <h3>Credentials, secrets, and connected-service data</h3>48 <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
45 45
46 <p>46 <p>
47 We may modify, improve, add, or remove features from time to time. We may also impose or change limits on storage,47 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.
49 </p>49 </p>
50 50
51 <h2>3. Your Content and Responsibilities</h2>51 <h2>3. Your Content and Responsibilities</h2>
@@ -87,8 +87,9 @@ import LegalLayout from "../components/LegalLayout.astro"; · 35 unmodified lines
87 <h2>6. Subscriptions, Billing, and Auto-Renewal</h2>87 <h2>6. Subscriptions, Billing, and Auto-Renewal</h2>
88 <p>88 <p>
89 Some parts of the Services are offered on a free basis, while others require a paid subscription or usage-based89 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-up90 charges. Paid plans may include organization-level pricing, included member limits, included execution allowances,
91 packages, or similar billing mechanics described in the product at the time of purchase.91 overage charges, prepaid top-up packages, or similar billing mechanics described in the product at the time of
92 purchase.
92 </p>93 </p>
93 94
94 <p>95 <p>
autumn.config.ts+47−52
@@ -1,9 +1,9 @@
1import { feature, item, plan } from "atmn";1import { feature, item, plan } from "atmn";
2 2
3// Features3// Features
4export const seats = feature({4export const members = feature({
5 id: "seats",5 id: "members",
6 name: "Seats",6 name: "Members",
7 type: "metered",7 type: "metered",
8 consumable: false,8 consumable: false,
9});9});
@@ -21,89 +21,84 @@ export const domainVerification = feature({ · 11 unmodified lines
21 type: "boolean",21 type: "boolean",
22});22});
23 23
24export const seats = feature({
25 id: "seats",
26 name: "Seats",
27 type: "metered",
28 consumable: false,
29 archived: true,
30});
31
24// Plans32// Plans
25export const free = plan({33export const free = plan({
26 id: "free",34 id: "free",
27 name: "Free",35 name: "Free",
36 addOn: false,
28 autoEnable: true,37 autoEnable: true,
29 items: [38 items: [
39 item({
40 featureId: members.id,
41 included: 3,
42 }),
30 item({43 item({
31 featureId: executions.id,44 featureId: executions.id,
32 included: 5000,45 included: 10000,
33 reset: { interval: "month" },46 reset: {
47 interval: "month",
48 },
34 }),49 }),
35 ],50 ],
36});51});
37 52
38export const hobby = plan({53export const freePayAsYouGo = plan({
39 id: "hobby",54 id: "free-pay-as-you-go",
40 name: "Hobby",55 name: "Free Pay As You Go",
41 price: {56 addOn: false,
42 amount: 10,57 autoEnable: false,
43 interval: "month",
44 },
45 items: [58 items: [
46 item({59 item({
47 featureId: seats.id,60 featureId: members.id,
48 included: 1,61 included: 3,
62 }),
63 item({
64 featureId: executions.id,
65 included: 10000,
49 price: {66 price: {
50 amount: 10,67 amount: 0.2,
51 billingUnits: 1,68 billingUnits: 1000,
52 billingMethod: "usage_based",69 billingMethod: "usage_based",
53 interval: "month",70 interval: "month",
54 },71 },
55 }),72 }),
56 item({
57 featureId: executions.id,
58 included: 50000,
59 reset: { interval: "month" },
60 }),
61 ],73 ],
62});74});
63 75
64export const professional = plan({76export const team = plan({
65 id: "professional",77 id: "team",
66 name: "Professional",78 name: "Team",
79 addOn: false,
80 autoEnable: false,
67 price: {81 price: {
68 amount: 40,82 amount: 49,
69 interval: "month",83 interval: "month",
70 },84 },
71 items: [85 items: [
72 item({86 item({
73 featureId: seats.id,87 featureId: members.id,
74 included: 1,88 unlimited: true,
89 }),
90 item({
91 featureId: executions.id,
92 included: 250000,
75 price: {93 price: {
76 amount: 40,94 amount: 0.2,
77 billingUnits: 1,95 billingUnits: 1000,
78 billingMethod: "usage_based",96 billingMethod: "usage_based",
79 interval: "month",97 interval: "month",
80 },98 },
81 }),99 }),
82 item({
83 featureId: executions.id,
84 included: 100000,
85 reset: { interval: "month" },
86 }),
87 item({100 item({
88 featureId: domainVerification.id,101 featureId: domainVerification.id,
89 }),102 }),
90 ],103 ],
91});104});
92
93// Overage add-on
94export 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});