Unified · Split
| @@ -47,6 +47,8 @@ const DELETE_COOKIE_OPTIONS = { · 46 unmodified lines | ||
| 47 | 47 | secure: true, |
| 48 | 48 | }; |
| 49 | 49 | |
| 50 | +const MAX_ORGANIZATIONS_PER_USER = 3; | |
| 51 | + | |
| 50 | 52 | const randomState = (): string => { |
| 51 | 53 | const bytes = new Uint8Array(32); |
| 52 | 54 | crypto.getRandomValues(bytes); |
| @@ -245,6 +247,11 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 192 unmodified lines | ||
| 245 | 247 | const session = yield* SessionContext; |
| 246 | 248 | |
| 247 | 249 | const name = payload.name.trim(); |
| 250 | + const memberships = yield* workos.listUserMemberships(session.accountId); | |
| 251 | + if (memberships.data.length >= MAX_ORGANIZATIONS_PER_USER) { | |
| 252 | + return yield* new WorkOSError(); | |
| 253 | + } | |
| 254 | + | |
| 248 | 255 | const org = yield* workos.createOrganization(name); |
| 249 | 256 | yield* workos.createMembership(org.id, session.accountId, "admin"); |
| 250 | 257 | yield* users.use((s) => s.upsertOrganization({ id: org.id, name: org.name })); |
| @@ -8,6 +8,7 @@ import { WorkOSAuth } from "../auth/workos"; · 7 unmodified lines | ||
| 8 | 8 | import { AutumnService } from "../services/autumn"; |
| 9 | 9 | import { OrgHttpApi } from "./compose"; |
| 10 | 10 | import { Forbidden } from "./api"; |
| 11 | +import { getMemberLimitForPlan, selectActiveMemberLimitPlan } from "./member-limits"; | |
| 11 | 12 | |
| 12 | 13 | const requireAdmin = Effect.gen(function* () { |
| 13 | 14 | const auth = yield* AuthContext; |
| @@ -52,17 +53,6 @@ const assertDomainInSessionOrg = (domainId: string) => · 38 unmodified lines | ||
| 52 | 53 | } |
| 53 | 54 | }); |
| 54 | 55 | |
| 55 | −// Per-plan member limits live in code, not Autumn. Members aren't billable | |
| 56 | −// usage — they're a permission attached to the plan. We still ask Autumn | |
| 57 | −// which plan the org is on (Autumn is the billing source of truth), but | |
| 58 | −// the cap itself is a constant here. `null` = unlimited. | |
| 59 | −const MEMBER_LIMITS: Record<string, number | null> = { | |
| 60 | − free: 3, | |
| 61 | − "free-pay-as-you-go": 3, | |
| 62 | − team: null, | |
| 63 | −}; | |
| 64 | −const DEFAULT_MEMBER_LIMIT = 3; | |
| 65 | − | |
| 66 | 56 | // Compute live seat usage from WorkOS truth (active+pending memberships + |
| 67 | 57 | // pending invitations) and look up the per-plan cap from MEMBER_LIMITS. |
| 68 | 58 | // Recomputed on every call — no event-counting drift. |
| @@ -74,8 +64,8 @@ const getMemberSeats = (organizationId: string) => · 5 unmodified lines | ||
| 74 | 64 | const customer = yield* autumn.use((client) => |
| 75 | 65 | client.customers.getOrCreate({ customerId: organizationId }), |
| 76 | 66 | ); |
| 77 | − const planId = customer.subscriptions[0]?.planId ?? "free"; | |
| 78 | − const limit = planId in MEMBER_LIMITS ? MEMBER_LIMITS[planId] : DEFAULT_MEMBER_LIMIT; | |
| 67 | + const planId = selectActiveMemberLimitPlan(customer.subscriptions); | |
| 68 | + const limit = getMemberLimitForPlan(planId); | |
| 79 | 69 | |
| 80 | 70 | const memberships = yield* workos.listOrgMembers(organizationId); |
| 81 | 71 | const invitations = yield* workos.listPendingInvitations(organizationId); |
| @@ -258,7 +248,7 @@ export const OrgHandlers = HttpApiBuilder.group(OrgHttpApi, "org", (handlers) => · 176 unmodified lines | ||
| 258 | 248 | featureId: "domain-verification", |
| 259 | 249 | }), |
| 260 | 250 | ) |
| 261 | − .pipe(Effect.orElseSucceed(() => ({ allowed: true }))); | |
| 251 | + .pipe(Effect.orElseSucceed(() => ({ allowed: false }))); | |
| 262 | 252 | |
| 263 | 253 | if (!check.allowed) { |
| 264 | 254 | return yield* new Forbidden(); |
| @@ -0,0 +1,38 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | + | |
| 3 | +import { getMemberLimitForPlan, selectActiveMemberLimitPlan } from "./member-limits"; | |
| 4 | + | |
| 5 | +describe("member limits", () => { | |
| 6 | + it("uses an active or trialing subscription before older entries", () => { | |
| 7 | + expect( | |
| 8 | + selectActiveMemberLimitPlan([ | |
| 9 | + { planId: "free", status: "canceled" }, | |
| 10 | + { planId: "team", status: "trialing" }, | |
| 11 | + ]), | |
| 12 | + ).toBe("team"); | |
| 13 | + | |
| 14 | + expect( | |
| 15 | + selectActiveMemberLimitPlan([ | |
| 16 | + { planId: "free", status: "past_due" }, | |
| 17 | + { planId: "team", status: "active" }, | |
| 18 | + ]), | |
| 19 | + ).toBe("team"); | |
| 20 | + }); | |
| 21 | + | |
| 22 | + it("falls back to the first subscription, then the free plan", () => { | |
| 23 | + expect( | |
| 24 | + selectActiveMemberLimitPlan([ | |
| 25 | + { planId: "free-pay-as-you-go", status: "canceled" }, | |
| 26 | + { planId: "team", status: "incomplete" }, | |
| 27 | + ]), | |
| 28 | + ).toBe("free-pay-as-you-go"); | |
| 29 | + | |
| 30 | + expect(selectActiveMemberLimitPlan([])).toBe("free"); | |
| 31 | + }); | |
| 32 | + | |
| 33 | + it("resolves member limits from the selected plan", () => { | |
| 34 | + expect(getMemberLimitForPlan("free")).toBe(3); | |
| 35 | + expect(getMemberLimitForPlan("team")).toBeNull(); | |
| 36 | + expect(getMemberLimitForPlan("unknown")).toBe(3); | |
| 37 | + }); | |
| 38 | +}); | |
| @@ -0,0 +1,25 @@ | ||
| 1 | +const MEMBER_LIMITS: Record<string, number | null> = { | |
| 2 | + free: 3, | |
| 3 | + "free-pay-as-you-go": 3, | |
| 4 | + team: null, | |
| 5 | +}; | |
| 6 | + | |
| 7 | +export const DEFAULT_MEMBER_LIMIT = 3; | |
| 8 | + | |
| 9 | +export type AutumnSubscriptionSummary = { | |
| 10 | + readonly planId?: string | null; | |
| 11 | + readonly status?: string | null; | |
| 12 | +}; | |
| 13 | + | |
| 14 | +export const selectActiveMemberLimitPlan = ( | |
| 15 | + subscriptions: ReadonlyArray<AutumnSubscriptionSummary>, | |
| 16 | +): string => { | |
| 17 | + const active = | |
| 18 | + subscriptions.find((subscription) => | |
| 19 | + ["active", "trialing"].includes(subscription.status ?? ""), | |
| 20 | + ) ?? subscriptions[0]; | |
| 21 | + return active?.planId ?? "free"; | |
| 22 | +}; | |
| 23 | + | |
| 24 | +export const getMemberLimitForPlan = (planId: string): number | null => | |
| 25 | + planId in MEMBER_LIMITS ? MEMBER_LIMITS[planId] : DEFAULT_MEMBER_LIMIT; | |
| @@ -1,12 +1,8 @@ | ||
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | −import { Effect, Result } from "effect"; | |
| 2 | +import { Effect, Predicate, Result } from "effect"; | |
| 3 | 3 | import { HttpClient, HttpClientRequest } from "effect/unstable/http"; |
| 4 | 4 | |
| 5 | −import { | |
| 6 | − HostedOutboundRequestBlocked, | |
| 7 | − makeHostedHttpClientLayer, | |
| 8 | − validateHostedOutboundUrl, | |
| 9 | −} from "./hosted-http-client"; | |
| 5 | +import { makeHostedHttpClientLayer, validateHostedOutboundUrl } from "./hosted-http-client"; | |
| 10 | 6 | |
| 11 | 7 | describe("hosted outbound HTTP client", () => { |
| 12 | 8 | it.effect("allows public HTTP and HTTPS URLs", () => |
| @@ -27,7 +23,22 @@ describe("hosted outbound HTTP client", () => { · 14 unmodified lines | ||
| 27 | 23 | "http://169.254.169.254/latest/meta-data/", |
| 28 | 24 | ]) { |
| 29 | 25 | const error = yield* validateHostedOutboundUrl(url).pipe(Effect.flip); |
| 30 | − expect(error).toBeInstanceOf(HostedOutboundRequestBlocked); | |
| 26 | + expect(Predicate.isTagged(error, "HostedOutboundRequestBlocked")).toBe(true); | |
| 27 | + } | |
| 28 | + }), | |
| 29 | + ); | |
| 30 | + | |
| 31 | + it.effect("rejects IPv4-mapped IPv6 URLs for local and private networks", () => | |
| 32 | + Effect.gen(function* () { | |
| 33 | + for (const url of [ | |
| 34 | + "http://[::ffff:127.0.0.1]:3000", | |
| 35 | + "http://[::ffff:10.0.0.1]/openapi.json", | |
| 36 | + "http://[::ffff:172.16.0.1]/graphql", | |
| 37 | + "http://[::ffff:192.168.1.10]/mcp", | |
| 38 | + "http://[::ffff:169.254.169.254]/latest/meta-data/", | |
| 39 | + ]) { | |
| 40 | + const error = yield* validateHostedOutboundUrl(url).pipe(Effect.flip); | |
| 41 | + expect(Predicate.isTagged(error, "HostedOutboundRequestBlocked")).toBe(true); | |
| 31 | 42 | } |
| 32 | 43 | }), |
| 33 | 44 | ); |
| @@ -63,4 +74,69 @@ describe("hosted outbound HTTP client", () => { · 29 unmodified lines | ||
| 63 | 74 | expect(calls).toBe(1); |
| 64 | 75 | }), |
| 65 | 76 | ); |
| 77 | + | |
| 78 | + it.effect("rejects cross-origin redirects before following them", () => | |
| 79 | + Effect.gen(function* () { | |
| 80 | + let calls = 0; | |
| 81 | + const fakeFetch: typeof globalThis.fetch = (async (input) => { | |
| 82 | + calls++; | |
| 83 | + const url = input instanceof Request ? input.url : String(input); | |
| 84 | + if (url === "https://api.example/start") { | |
| 85 | + return new Response(null, { | |
| 86 | + status: 302, | |
| 87 | + headers: { location: "https://elsewhere.example/next" }, | |
| 88 | + }); | |
| 89 | + } | |
| 90 | + return new Response("unexpected", { status: 200 }); | |
| 91 | + }) as typeof globalThis.fetch; | |
| 92 | + | |
| 93 | + const result = yield* Effect.gen(function* () { | |
| 94 | + const client = yield* HttpClient.HttpClient; | |
| 95 | + return yield* client.execute(HttpClientRequest.get("https://api.example/start")); | |
| 96 | + }).pipe(Effect.provide(makeHostedHttpClientLayer({ fetch: fakeFetch })), Effect.result); | |
| 97 | + | |
| 98 | + expect(Result.isFailure(result)).toBe(true); | |
| 99 | + expect(calls).toBe(1); | |
| 100 | + }), | |
| 101 | + ); | |
| 102 | + | |
| 103 | + it.effect("rejects responses with content-length beyond the configured limit", () => | |
| 104 | + Effect.gen(function* () { | |
| 105 | + const fakeFetch: typeof globalThis.fetch = (async () => | |
| 106 | + new Response("too large", { | |
| 107 | + status: 200, | |
| 108 | + headers: { "content-length": "9" }, | |
| 109 | + })) as typeof globalThis.fetch; | |
| 110 | + | |
| 111 | + const result = yield* Effect.gen(function* () { | |
| 112 | + const client = yield* HttpClient.HttpClient; | |
| 113 | + return yield* client.execute(HttpClientRequest.get("https://public.example/spec")); | |
| 114 | + }).pipe( | |
| 115 | + Effect.provide(makeHostedHttpClientLayer({ fetch: fakeFetch, maxResponseBytes: 8 })), | |
| 116 | + Effect.result, | |
| 117 | + ); | |
| 118 | + | |
| 119 | + expect(Result.isFailure(result)).toBe(true); | |
| 120 | + }), | |
| 121 | + ); | |
| 122 | + | |
| 123 | + it.effect("rejects streamed responses that exceed the configured limit", () => | |
| 124 | + Effect.gen(function* () { | |
| 125 | + const fakeFetch: typeof globalThis.fetch = (async () => | |
| 126 | + new Response("too large", { status: 200 })) as typeof globalThis.fetch; | |
| 127 | + | |
| 128 | + const result = yield* Effect.gen(function* () { | |
| 129 | + const client = yield* HttpClient.HttpClient; | |
| 130 | + const response = yield* client.execute( | |
| 131 | + HttpClientRequest.get("https://public.example/spec"), | |
| 132 | + ); | |
| 133 | + return yield* response.text; | |
| 134 | + }).pipe( | |
| 135 | + Effect.provide(makeHostedHttpClientLayer({ fetch: fakeFetch, maxResponseBytes: 8 })), | |
| 136 | + Effect.result, | |
| 137 | + ); | |
| 138 | + | |
| 139 | + expect(Result.isFailure(result)).toBe(true); | |
| 140 | + }), | |
| 141 | + ); | |
| 66 | 142 | }); |
| @@ -12,9 +12,12 @@ export class HostedOutboundRequestBlocked extends Schema.TaggedErrorClass<Hosted · 11 unmodified lines | ||
| 12 | 12 | export interface HostedHttpClientOptions { |
| 13 | 13 | readonly allowLocalNetwork?: boolean; |
| 14 | 14 | readonly maxRedirects?: number; |
| 15 | + readonly maxResponseBytes?: number; | |
| 15 | 16 | readonly fetch?: typeof globalThis.fetch; |
| 16 | 17 | } |
| 17 | 18 | |
| 19 | +const DEFAULT_MAX_RESPONSE_BYTES = 5 * 1024 * 1024; | |
| 20 | + | |
| 18 | 21 | const parseIpv4 = (hostname: string): readonly [number, number, number, number] | null => { |
| 19 | 22 | const parts = hostname.split("."); |
| 20 | 23 | if (parts.length !== 4) return null; |
| @@ -28,6 +31,36 @@ const parseIpv4 = (hostname: string): readonly [number, number, number, number] · 7 unmodified lines | ||
| 28 | 31 | return parsed as [number, number, number, number]; |
| 29 | 32 | }; |
| 30 | 33 | |
| 34 | +const parseIpv4MappedIpv6 = ( | |
| 35 | + hostname: string, | |
| 36 | +): readonly [number, number, number, number] | null => { | |
| 37 | + const prefix = "::ffff:"; | |
| 38 | + if (!hostname.startsWith(prefix)) return null; | |
| 39 | + const embedded = hostname.slice(prefix.length); | |
| 40 | + const dotted = parseIpv4(embedded); | |
| 41 | + if (dotted) return dotted; | |
| 42 | + | |
| 43 | + const parts = embedded.split(":"); | |
| 44 | + if (parts.length !== 2) return null; | |
| 45 | + | |
| 46 | + const words = parts.map((part) => Number.parseInt(part, 16)); | |
| 47 | + if ( | |
| 48 | + words.some( | |
| 49 | + (word, index) => | |
| 50 | + parts[index] === "" || | |
| 51 | + !/^[0-9a-f]+$/i.test(parts[index]) || | |
| 52 | + !Number.isInteger(word) || | |
| 53 | + word < 0 || | |
| 54 | + word > 0xffff, | |
| 55 | + ) | |
| 56 | + ) { | |
| 57 | + return null; | |
| 58 | + } | |
| 59 | + | |
| 60 | + const [high, low] = words; | |
| 61 | + return [high >> 8, high & 0xff, low >> 8, low & 0xff]; | |
| 62 | +}; | |
| 63 | + | |
| 31 | 64 | const isPrivateIpv4 = ([a, b]: readonly [number, number, number, number]): boolean => |
| 32 | 65 | a === 0 || |
| 33 | 66 | a === 10 || |
| @@ -51,6 +84,8 @@ const isLocalOrPrivateHostname = (hostname: string): boolean => { · 17 unmodified lines | ||
| 51 | 84 | if (normalized === "localhost" || normalized.endsWith(".localhost")) return true; |
| 52 | 85 | const ipv4 = parseIpv4(normalized); |
| 53 | 86 | if (ipv4) return isPrivateIpv4(ipv4); |
| 87 | + const mappedIpv4 = parseIpv4MappedIpv6(normalized); | |
| 88 | + if (mappedIpv4) return isPrivateIpv4(mappedIpv4); | |
| 54 | 89 | return ( |
| 55 | 90 | normalized === "::1" || |
| 56 | 91 | normalized.startsWith("fe80:") || |
| @@ -112,14 +147,80 @@ const guardFetch = ( · 55 unmodified lines | ||
| 112 | 147 | response.headers.has("location") && |
| 113 | 148 | redirects < maxRedirects |
| 114 | 149 | ) { |
| 115 | − current = new URL(response.headers.get("location")!, url).toString(); | |
| 150 | + const next = new URL(response.headers.get("location")!, url); | |
| 151 | + if (next.origin !== new URL(url).origin) { | |
| 152 | + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: fetch-compatible adapter must reject blocked requests | |
| 153 | + throw new HostedOutboundRequestBlocked({ | |
| 154 | + url: next.toString(), | |
| 155 | + reason: "Cross-origin redirects are not allowed", | |
| 156 | + }); | |
| 157 | + } | |
| 158 | + current = next.toString(); | |
| 116 | 159 | continue; |
| 117 | 160 | } |
| 118 | − return response; | |
| 161 | + return guardResponseBody( | |
| 162 | + response, | |
| 163 | + url, | |
| 164 | + options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES, | |
| 165 | + ); | |
| 119 | 166 | } |
| 120 | − return underlying(current, { ...init, redirect: "manual" }); | |
| 167 | + const url = current instanceof Request ? current.url : String(current); | |
| 168 | + const response = await underlying(current, { ...init, redirect: "manual" }); | |
| 169 | + return guardResponseBody(response, url, options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES); | |
| 121 | 170 | }) as typeof globalThis.fetch; |
| 122 | 171 | |
| 172 | +const guardResponseBody = (response: Response, url: string, maxResponseBytes: number): Response => { | |
| 173 | + if (!Number.isFinite(maxResponseBytes) || maxResponseBytes <= 0 || !response.body) { | |
| 174 | + return response; | |
| 175 | + } | |
| 176 | + | |
| 177 | + const contentLength = response.headers.get("content-length"); | |
| 178 | + if (contentLength !== null) { | |
| 179 | + const parsed = Number(contentLength); | |
| 180 | + if (Number.isFinite(parsed) && parsed > maxResponseBytes) { | |
| 181 | + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: fetch-compatible response adapter must reject oversized responses | |
| 182 | + throw new HostedOutboundRequestBlocked({ | |
| 183 | + url, | |
| 184 | + reason: "Response body is too large", | |
| 185 | + }); | |
| 186 | + } | |
| 187 | + } | |
| 188 | + | |
| 189 | + let total = 0; | |
| 190 | + const reader = response.body.getReader(); | |
| 191 | + const limitedBody = new ReadableStream<Uint8Array>({ | |
| 192 | + async pull(controller) { | |
| 193 | + const next = await reader.read(); | |
| 194 | + if (next.done) { | |
| 195 | + controller.close(); | |
| 196 | + return; | |
| 197 | + } | |
| 198 | + | |
| 199 | + total += next.value.byteLength; | |
| 200 | + if (total > maxResponseBytes) { | |
| 201 | + controller.error( | |
| 202 | + new HostedOutboundRequestBlocked({ | |
| 203 | + url, | |
| 204 | + reason: "Response body is too large", | |
| 205 | + }), | |
| 206 | + ); | |
| 207 | + return; | |
| 208 | + } | |
| 209 | + | |
| 210 | + controller.enqueue(next.value); | |
| 211 | + }, | |
| 212 | + cancel(reason) { | |
| 213 | + return reader.cancel(reason); | |
| 214 | + }, | |
| 215 | + }); | |
| 216 | + | |
| 217 | + return new Response(limitedBody, { | |
| 218 | + status: response.status, | |
| 219 | + statusText: response.statusText, | |
| 220 | + headers: response.headers, | |
| 221 | + }); | |
| 222 | +}; | |
| 223 | + | |
| 123 | 224 | export const makeHostedHttpClientLayer = ( |
| 124 | 225 | options: HostedHttpClientOptions = {}, |
| 125 | 226 | ): Layer.Layer<HttpClient.HttpClient> => |