diff --git a/.agents/skills/edge-config/SKILL.md b/.agents/skills/edge-config/SKILL.md new file mode 100644 index 000000000..7f8a241f5 --- /dev/null +++ b/.agents/skills/edge-config/SKILL.md @@ -0,0 +1,171 @@ +--- +name: edge-config +description: Understand and create S3-backed edge configs with poll-based caching. Use when adding new runtime configs (feature rollouts, request blocking, operational toggles), debugging edge config polling, or working with the EdgeConfigStore factory. +--- + +# Edge Configs + +Edge configs are JSON files stored in S3 that are polled into server memory at a regular cadence. They let us change server behavior at runtime without deploys -- load shedding, feature rollouts, operational toggles, etc. + +## Architecture + +Each edge config has its own S3 file under the `admin/` prefix in the admin S3 bucket (`autumn-dev-server` / `autumn-prod-server`). Configs are independent -- a parse error in one does not affect another. + +### Key files + +| File | Purpose | +|------|---------| +| `server/src/internal/misc/edgeConfig/edgeConfigStore.ts` | `createEdgeConfigStore` factory -- generic S3 read/write, polling, status tracking | +| `server/src/internal/misc/edgeConfig/edgeConfigRegistry.ts` | Registry -- `registerEdgeConfig`, `startAllEdgeConfigPolling`, `stopAllEdgeConfigPolling` | +| `server/src/init.ts` | Calls `startAllEdgeConfigPolling` on boot, `stopAllEdgeConfigPolling` on shutdown | +| `server/src/external/aws/s3/adminS3Config.ts` | Bucket/region resolution (dev vs prod) | + +### Existing configs + +| Config | S3 Key | Module | +|--------|--------|--------| +| Request blocks | `admin/request-block-config.json` | `server/src/internal/misc/requestBlocks/requestBlockStore.ts` | + +## Lifecycle + +``` +Boot: + 1. Config module is imported (top-level side effect) -> calls registerEdgeConfig() + 2. init.ts calls startAllEdgeConfigPolling({ logger }) + 3. Each store: await refresh() (initial S3 fetch, blocks until done) + 4. Each store: setInterval(refresh, pollIntervalMs) + 5. Server starts accepting traffic + +Runtime: + - get() reads from in-memory cache (zero I/O, sync) + - refresh() runs every pollIntervalMs (fire-and-forget) + - writeToSource() writes to S3 + updates local cache immediately + +Shutdown: + - stopAllEdgeConfigPolling() clears all intervals +``` + +## Fail-Open Guarantee + +On **any** S3 failure (network error, auth error, bad JSON, schema mismatch), `runtimeConfig` resets to `defaultValue()`. This means: + +- No request is ever blocked due to infrastructure failure +- If S3 goes down, configs degrade to their default (empty/safe) state +- The store does NOT hold onto stale data -- it resets to default + +`NoSuchKey` (file doesn't exist yet) is treated as a normal empty state, not an error. + +## How to Add a New Edge Config + +### 1. Define the schema + +Create a schemas file with a Zod schema: + +```typescript +// server/src/internal/misc/featureRollouts/featureRolloutSchemas.ts +import { z } from "zod/v4"; + +export const FeatureRolloutConfigSchema = z.object({ + features: z.record(z.string(), z.object({ + percentage: z.number().min(0).max(100).default(0), + enabled: z.boolean().default(false), + })).default({}), +}); + +export type FeatureRolloutConfig = z.infer; +``` + +### 2. Create the store module + +```typescript +// server/src/internal/misc/featureRollouts/featureRolloutStore.ts +import { createEdgeConfigStore } from "@/internal/misc/edgeConfig/edgeConfigStore.js"; +import { registerEdgeConfig } from "@/internal/misc/edgeConfig/edgeConfigRegistry.js"; +import { + type FeatureRolloutConfig, + FeatureRolloutConfigSchema, +} from "./featureRolloutSchemas.js"; + +const store = createEdgeConfigStore({ + s3Key: "admin/feature-rollout-config.json", + schema: FeatureRolloutConfigSchema, + defaultValue: () => ({ features: {} }), + pollIntervalMs: 300_000, // 5 minutes +}); + +registerEdgeConfig({ store }); + +export const getFeatureRolloutConfig = () => store.get(); + +export const isFeatureEnabled = ({ + featureId, +}: { + featureId: string; +}): boolean => { + const feature = store.get().features[featureId]; + return feature?.enabled ?? false; +}; +``` + +### 3. Register the import in init.ts + +Add a side-effect import so the module runs `registerEdgeConfig` at boot: + +```typescript +// server/src/init.ts +import "./internal/misc/featureRollouts/featureRolloutStore.js"; +``` + +### 4. (Optional) Add the S3 key constant + +```typescript +// server/src/external/aws/s3/adminS3Config.ts +export const ADMIN_FEATURE_ROLLOUT_CONFIG_KEY = "admin/feature-rollout-config.json"; +``` + +### 5. Use it + +```typescript +import { isFeatureEnabled } from "@/internal/misc/featureRollouts/featureRolloutStore.js"; + +if (isFeatureEnabled({ featureId: "new-billing-engine" })) { + // new path +} +``` + +## Factory API Reference + +```typescript +const store = createEdgeConfigStore({ + s3Key: string, // S3 object key under admin bucket + schema: z.ZodType, // Zod schema for validation + defaultValue: () => T, // Factory for empty/safe default + pollIntervalMs?: number, // Poll interval (default: 60_000) + s3Client?: S3Client, // Optional DI for testing +}); + +store.get() // T -- in-memory cached config (sync, no I/O) +store.getStatus() // EdgeConfigStatus -- health/timing info +store.refresh({ logger? }) // Re-fetch from S3, update cache +store.startPolling({ logger? }) // Initial fetch + start interval +store.stopPolling() // Clear interval +store.readFromSource() // Direct S3 read (bypasses cache) +store.writeToSource({ config }) // S3 write + update cache immediately +``` + +## Testing + +Unit tests use the `s3Client` DI parameter to inject a mock: + +```typescript +const mockClient = { send: jest.fn(async () => ({ Body: ... })) } as unknown as S3Client; + +const store = createEdgeConfigStore({ + s3Key: "admin/test.json", + schema: MyConfigSchema, + defaultValue: () => ({ ... }), + s3Client: mockClient, +}); +``` + +Existing tests: `server/tests/unit/edge-config/edge-config-store.test.ts` diff --git a/bun.lock b/bun.lock index b39dab7b6..d2e24c0e5 100644 --- a/bun.lock +++ b/bun.lock @@ -281,6 +281,7 @@ "@anthropic-ai/sdk": "^0.32.1", "@autumn/ksuid": "workspace:*", "@autumn/shared": "workspace:*", + "@aws-sdk/client-s3": "^3.1017.0", "@aws-sdk/client-scheduler": "^3.1004.0", "@aws-sdk/client-sqs": "^3.958.0", "@axiomhq/pino": "^1.3.1", @@ -1766,7 +1767,7 @@ "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.12", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg=="], - "@smithy/eventstream-codec": ["@smithy/eventstream-codec@1.1.0", "", { "dependencies": { "@aws-crypto/crc32": "3.0.0", "@smithy/types": "^1.2.0", "@smithy/util-hex-encoding": "^1.1.0", "tslib": "^2.5.0" } }, "sha512-3tEbUb8t8an226jKB6V/Q2XU/J53lCwCzULuBPEaF4JjSh+FlCMp7TmogE/Aij5J9DwlsZ4VAD/IRDuQ/0ZtMw=="], + "@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.12", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA=="], "@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@4.2.12", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-XUSuMxlTxV5pp4VpqZf6Sa3vT/Q75FVkLSpSSE3KkWBvAQWeuWt1msTv8fJfgA4/jcJhrbrbMzN1AC/hvPmm5A=="], @@ -6070,14 +6071,6 @@ "@sentry/react/@sentry/core": ["@sentry/core@10.46.0", "", {}, "sha512-N3fj4zqBQOhXliS1Ne9euqIKuciHCGOJfPGQLwBoW9DNz03jF+NB8+dUKtrJ79YLoftjVgf8nbgwtADK7NR+2Q=="], - "@smithy/eventstream-codec/@aws-crypto/crc32": ["@aws-crypto/crc32@3.0.0", "", { "dependencies": { "@aws-crypto/util": "^3.0.0", "@aws-sdk/types": "^3.222.0", "tslib": "^1.11.1" } }, "sha512-IzSgsrxUcsrejQbPVilIKy16kAT52EwB6zSaI+M3xxIhKh5+aldEyvI+z6erM7TCLB2BJsFrtHjp6/4/sr+3dA=="], - - "@smithy/eventstream-codec/@smithy/types": ["@smithy/types@1.2.0", "", { "dependencies": { "tslib": "^2.5.0" } }, "sha512-z1r00TvBqF3dh4aHhya7nz1HhvCg4TRmw51fjMrh5do3h+ngSstt/yKlNbHeb9QxJmFbmN8KEVSWgb1bRvfEoA=="], - - "@smithy/eventstream-codec/@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@1.1.0", "", { "dependencies": { "tslib": "^2.5.0" } }, "sha512-7UtIE9eH0u41zpB60Jzr0oNCQ3hMJUabMcKRUVjmyHTXiWDE4vjSqN6qlih7rCNeKGbioS7f/y2Jgym4QZcKFg=="], - - "@smithy/eventstream-serde-universal/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.12", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA=="], - "@smithy/middleware-compression/fflate": ["fflate@0.8.1", "", {}, "sha512-/exOvEuc+/iaUm105QIiOt4LpBdMTWsXxqR0HDF35vx3fmaKzw7354gTilCh5rkzEt8WYyG//ku3h3nRmd7CHQ=="], "@stoplight/better-ajv-errors/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], @@ -7104,6 +7097,8 @@ "@aws-sdk/protocol-http/@smithy/protocol-http/@smithy/types": ["@smithy/types@1.2.0", "", { "dependencies": { "tslib": "^2.5.0" } }, "sha512-z1r00TvBqF3dh4aHhya7nz1HhvCg4TRmw51fjMrh5do3h+ngSstt/yKlNbHeb9QxJmFbmN8KEVSWgb1bRvfEoA=="], + "@aws-sdk/signature-v4/@smithy/signature-v4/@smithy/eventstream-codec": ["@smithy/eventstream-codec@1.1.0", "", { "dependencies": { "@aws-crypto/crc32": "3.0.0", "@smithy/types": "^1.2.0", "@smithy/util-hex-encoding": "^1.1.0", "tslib": "^2.5.0" } }, "sha512-3tEbUb8t8an226jKB6V/Q2XU/J53lCwCzULuBPEaF4JjSh+FlCMp7TmogE/Aij5J9DwlsZ4VAD/IRDuQ/0ZtMw=="], + "@aws-sdk/signature-v4/@smithy/signature-v4/@smithy/is-array-buffer": ["@smithy/is-array-buffer@1.1.0", "", { "dependencies": { "tslib": "^2.5.0" } }, "sha512-twpQ/n+3OWZJ7Z+xu43MJErmhB/WO/mMTnqR6PwWQShvSJ/emx5d1N59LQZk6ZpTAeuRWrc+eHhkzTp9NFjNRQ=="], "@aws-sdk/signature-v4/@smithy/signature-v4/@smithy/types": ["@smithy/types@1.2.0", "", { "dependencies": { "tslib": "^2.5.0" } }, "sha512-z1r00TvBqF3dh4aHhya7nz1HhvCg4TRmw51fjMrh5do3h+ngSstt/yKlNbHeb9QxJmFbmN8KEVSWgb1bRvfEoA=="], @@ -7612,10 +7607,6 @@ "@sentry/node/import-in-the-middle/cjs-module-lexer": ["cjs-module-lexer@2.2.0", "", {}, "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ=="], - "@smithy/eventstream-codec/@aws-crypto/crc32/@aws-crypto/util": ["@aws-crypto/util@3.0.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-utf8-browser": "^3.0.0", "tslib": "^1.11.1" } }, "sha512-2OJlpeJpCR48CC8r+uKVChzs9Iungj9wkZrl8Z041DWEWvyIHILYKCPNzJghKsivj+S3mLo6BVc7mBNzdxA46w=="], - - "@smithy/eventstream-codec/@aws-crypto/crc32/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], - "@stoplight/better-ajv-errors/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "@stoplight/spectral-core/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], @@ -8408,6 +8399,8 @@ "@aws-sdk/client-sso/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@3.0.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-+Fsu6Q6C4RSJiy81Y8eApjEB5gVtM+oFKTffg+jSuwtvomJJrhUJBu2zS8wjXSgH/g1MKEWrzyChTBe6clb5FQ=="], + "@aws-sdk/signature-v4/@smithy/signature-v4/@smithy/eventstream-codec/@aws-crypto/crc32": ["@aws-crypto/crc32@3.0.0", "", { "dependencies": { "@aws-crypto/util": "^3.0.0", "@aws-sdk/types": "^3.222.0", "tslib": "^1.11.1" } }, "sha512-IzSgsrxUcsrejQbPVilIKy16kAT52EwB6zSaI+M3xxIhKh5+aldEyvI+z6erM7TCLB2BJsFrtHjp6/4/sr+3dA=="], + "@aws-sdk/signature-v4/@smithy/signature-v4/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@1.1.0", "", { "dependencies": { "@smithy/is-array-buffer": "^1.1.0", "tslib": "^2.5.0" } }, "sha512-9m6NXE0ww+ra5HKHCHig20T+FAwxBAm7DIdwc/767uGWbRcY720ybgPacQNB96JMOI7xVr/CDa3oMzKmW4a+kw=="], "@infisical/sdk/@aws-sdk/credential-providers/@aws-sdk/client-cognito-identity/@aws-sdk/core": ["@aws-sdk/core@3.598.0", "", { "dependencies": { "@smithy/core": "^2.2.1", "@smithy/protocol-http": "^4.0.1", "@smithy/signature-v4": "^3.1.0", "@smithy/smithy-client": "^3.1.2", "@smithy/types": "^3.1.0", "fast-xml-parser": "4.2.5", "tslib": "^2.6.2" } }, "sha512-HaSjt7puO5Cc7cOlrXFCW0rtA0BM9lvzjl56x0A20Pt+0wxXGeTOZZOkXQIepbrFkV2e/HYukuT9e99vXDm59g=="], @@ -9012,6 +9005,10 @@ "@aws-sdk/client-sso/@smithy/smithy-client/@smithy/util-stream/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@3.0.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-+Fsu6Q6C4RSJiy81Y8eApjEB5gVtM+oFKTffg+jSuwtvomJJrhUJBu2zS8wjXSgH/g1MKEWrzyChTBe6clb5FQ=="], + "@aws-sdk/signature-v4/@smithy/signature-v4/@smithy/eventstream-codec/@aws-crypto/crc32/@aws-crypto/util": ["@aws-crypto/util@3.0.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-utf8-browser": "^3.0.0", "tslib": "^1.11.1" } }, "sha512-2OJlpeJpCR48CC8r+uKVChzs9Iungj9wkZrl8Z041DWEWvyIHILYKCPNzJghKsivj+S3mLo6BVc7mBNzdxA46w=="], + + "@aws-sdk/signature-v4/@smithy/signature-v4/@smithy/eventstream-codec/@aws-crypto/crc32/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], + "@infisical/sdk/@aws-sdk/credential-providers/@aws-sdk/client-cognito-identity/@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@3.1.2", "", { "dependencies": { "@smithy/is-array-buffer": "^3.0.0", "@smithy/types": "^3.3.0", "@smithy/util-hex-encoding": "^3.0.0", "@smithy/util-middleware": "^3.0.3", "@smithy/util-uri-escape": "^3.0.0", "@smithy/util-utf8": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-3BcPylEsYtD0esM4Hoyml/+s7WP2LFhcM3J2AGdcL2vx9O60TtfpDOL72gjb4lU8NeRPeKAwR77YNyyGvMbuEA=="], "@infisical/sdk/@aws-sdk/credential-providers/@aws-sdk/client-cognito-identity/@aws-sdk/region-config-resolver/@smithy/util-config-provider": ["@smithy/util-config-provider@3.0.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-pbjk4s0fwq3Di/ANL+rCvJMKM5bzAQdE5S/6RL5NXgMExFAi6UgQMPOm5yPaIWPpr+EOXKXRonJ3FoxKf4mCJQ=="], diff --git a/package.json b/package.json index 552801bb2..726edc35e 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "s": "ENV_FILE=.env.staging infisical run --env=staging -- bun scripts/dev.ts", "l": "bash ./scripts/dev-local.sh", "setup": "node scripts/setup/setup.js", + "setup:s3-admin": "bun scripts/setup/setupS3Admin.ts", "setup:test": "infisical run --env=dev -- bun scripts/setup/setup-test.ts", "migrate-functions": "infisical run --env=dev -- bun scripts/migrations/migrate-functions.ts", "migrate-functions:test": "infisical run --env=test -- bun scripts/migrations/migrate-functions.ts", diff --git a/scripts/package.json b/scripts/package.json index 50f5f6a62..e3baba8f7 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -9,6 +9,7 @@ "replicate": "bun run db/replicate.ts" }, "dependencies": { + "@aws-sdk/client-s3": "^3.1017.0", "@autumn/shared": "workspace:*", "chalk": "^5.3.0", "dotenv": "^16.5.0", diff --git a/scripts/setup/setupS3Admin.ts b/scripts/setup/setupS3Admin.ts new file mode 100644 index 000000000..131af2042 --- /dev/null +++ b/scripts/setup/setupS3Admin.ts @@ -0,0 +1,203 @@ +#!/usr/bin/env bun +import { + BucketAlreadyExists, + BucketAlreadyOwnedByYou, + type BucketLocationConstraint, + CreateBucketCommand, + HeadBucketCommand, + HeadObjectCommand, + PutObjectCommand, + S3Client, +} from "@aws-sdk/client-s3"; +import { + ADMIN_REQUEST_BLOCK_CONFIG_KEY as REQUEST_BLOCK_CONFIG_KEY, + getAdminS3Config, +} from "@server/external/aws/s3/adminS3Config.js"; +const DEFAULT_REQUEST_BLOCK_CONFIG = { + orgs: {}, +}; + +const getTargetFromArgs = () => { + const hasDevFlag = process.argv.includes("--dev"); + const hasProdFlag = process.argv.includes("--prod"); + + if (hasDevFlag && hasProdFlag) { + throw new Error("Use either --dev or --prod, not both"); + } + + if (hasDevFlag) return "dev" as const; + if (hasProdFlag) return "prod" as const; + return undefined; +}; + +const createS3Client = ({ region }: { region: string }) => { + return new S3Client({ region }); +}; + +const getHttpStatusCode = ({ error }: { error: unknown }) => { + if (!error || typeof error !== "object") return undefined; + + const errorWithMetadata = error as { + $metadata?: { + httpStatusCode?: number; + }; + }; + + return errorWithMetadata.$metadata?.httpStatusCode; +}; + +const isMissingS3ResourceError = ({ error }: { error: unknown }) => { + if (error instanceof Error) { + if (error.name === "NotFound" || error.name === "NoSuchBucket") { + return true; + } + } + + return getHttpStatusCode({ error }) === 404; +}; + +const bucketExists = async ({ + s3Client, + bucket, +}: { + s3Client: S3Client; + bucket: string; +}) => { + try { + await s3Client.send( + new HeadBucketCommand({ + Bucket: bucket, + }), + ); + return true; + } catch (error) { + if (isMissingS3ResourceError({ error })) { + return false; + } + + throw error; + } +}; + +const ensureBucketExists = async ({ + s3Client, + bucket, + region, +}: { + s3Client: S3Client; + bucket: string; + region: string; +}) => { + const exists = await bucketExists({ s3Client, bucket }); + if (exists) { + console.log(`Bucket already exists: ${bucket}`); + return; + } + + try { + await s3Client.send( + new CreateBucketCommand({ + Bucket: bucket, + ...(region === "us-east-1" + ? {} + : { + CreateBucketConfiguration: { + LocationConstraint: region as BucketLocationConstraint, + }, + }), + }), + ); + console.log(`Created bucket: ${bucket}`); + } catch (error) { + if ( + error instanceof BucketAlreadyOwnedByYou || + error instanceof BucketAlreadyExists + ) { + console.log(`Bucket already exists: ${bucket}`); + return; + } + + throw error; + } +}; + +const objectExists = async ({ + s3Client, + bucket, + key, +}: { + s3Client: S3Client; + bucket: string; + key: string; +}) => { + try { + await s3Client.send( + new HeadObjectCommand({ + Bucket: bucket, + Key: key, + }), + ); + return true; + } catch (error) { + if (isMissingS3ResourceError({ error })) { + return false; + } + + throw error; + } +}; + +const ensureRequestBlockConfigExists = async ({ + s3Client, + bucket, +}: { + s3Client: S3Client; + bucket: string; +}) => { + const exists = await objectExists({ + s3Client, + bucket, + key: REQUEST_BLOCK_CONFIG_KEY, + }); + + if (exists) { + console.log(`Admin config already exists: ${REQUEST_BLOCK_CONFIG_KEY}`); + return; + } + + await s3Client.send( + new PutObjectCommand({ + Bucket: bucket, + Key: REQUEST_BLOCK_CONFIG_KEY, + Body: JSON.stringify(DEFAULT_REQUEST_BLOCK_CONFIG, null, 2), + ContentType: "application/json", + }), + ); + + console.log(`Created admin config: ${REQUEST_BLOCK_CONFIG_KEY}`); +}; + +const main = async () => { + const target = getTargetFromArgs(); + const { bucket, region } = getAdminS3Config({ target }); + const s3Client = createS3Client({ region }); + + console.log( + `Initializing S3 admin config for ${target || process.env.NODE_ENV || "default"} -> s3://${bucket}/${REQUEST_BLOCK_CONFIG_KEY}`, + ); + + await ensureBucketExists({ + s3Client, + bucket, + region, + }); + + await ensureRequestBlockConfigExists({ + s3Client, + bucket, + }); + + console.log("S3 admin initialization complete."); +}; + +await main(); diff --git a/server/package.json b/server/package.json index 77a05ffe2..5cfedf8a2 100644 --- a/server/package.json +++ b/server/package.json @@ -46,6 +46,7 @@ "@anthropic-ai/sdk": "^0.32.1", "@autumn/ksuid": "workspace:*", "@autumn/shared": "workspace:*", + "@aws-sdk/client-s3": "^3.1017.0", "@aws-sdk/client-scheduler": "^3.1004.0", "@aws-sdk/client-sqs": "^3.958.0", "@axiomhq/pino": "^1.3.1", diff --git a/server/src/external/aws/s3/adminS3Config.ts b/server/src/external/aws/s3/adminS3Config.ts new file mode 100644 index 000000000..b6224a47d --- /dev/null +++ b/server/src/external/aws/s3/adminS3Config.ts @@ -0,0 +1,29 @@ +export const ADMIN_REQUEST_BLOCK_CONFIG_KEY = "admin/request-block-config.json"; + +type AdminS3Target = "dev" | "prod"; + +const isDevTarget = ({ target }: { target?: AdminS3Target }) => { + if (target) return target === "dev"; + return ( + process.env.NODE_ENV === "dev" || + process.env.NODE_ENV === "development" + ); +}; + +export const getAdminS3Config = ({ + target, +}: { + target?: AdminS3Target; +} = {}) => { + if (isDevTarget({ target })) { + return { + bucket: "autumn-dev-server", + region: "eu-west-2", + }; + } + + return { + bucket: "autumn-prod-server", + region: "us-east-2", + }; +}; diff --git a/server/src/external/aws/s3/initS3.ts b/server/src/external/aws/s3/initS3.ts new file mode 100644 index 000000000..251b36a8d --- /dev/null +++ b/server/src/external/aws/s3/initS3.ts @@ -0,0 +1,17 @@ +import { S3Client } from "@aws-sdk/client-s3"; +import { DEFAULT_AWS_REGION } from "@/external/aws/awsRegionUtils.js"; + +const s3ClientsByRegion = new Map(); + +export const getS3Client = ({ + region = DEFAULT_AWS_REGION, +}: { + region?: string; +}) => { + const existingClient = s3ClientsByRegion.get(region); + if (existingClient) return existingClient; + + const s3Client = new S3Client({ region }); + s3ClientsByRegion.set(region, s3Client); + return s3Client; +}; diff --git a/server/src/external/aws/s3/s3Utils.ts b/server/src/external/aws/s3/s3Utils.ts new file mode 100644 index 000000000..f9494765b --- /dev/null +++ b/server/src/external/aws/s3/s3Utils.ts @@ -0,0 +1,11 @@ +export const getS3BodyAsString = async ({ + body, +}: { + body: { transformToString?: () => Promise }; +}) => { + if (typeof body.transformToString === "function") { + return await body.transformToString(); + } + + return await new Response(body as BodyInit).text(); +}; diff --git a/server/src/honoMiddlewares/requestBlockMiddleware.ts b/server/src/honoMiddlewares/requestBlockMiddleware.ts new file mode 100644 index 000000000..e23bb0e2c --- /dev/null +++ b/server/src/honoMiddlewares/requestBlockMiddleware.ts @@ -0,0 +1,60 @@ +import { ErrCode, RecaseError } from "@autumn/shared"; +import type { Context, Next } from "hono"; +import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; +import { getRuntimeRequestBlockEntry } from "@/internal/misc/requestBlocks/requestBlockStore.js"; +import { matchRoute } from "./middlewareUtils.js"; + +export const requestBlockMiddleware = async ( + c: Context, + next: Next, +) => { + const ctx = c.get("ctx"); + const orgId = ctx.org?.id; + + if (!orgId) { + await next(); + return; + } + + const entry = getRuntimeRequestBlockEntry(orgId); + if (!entry) { + await next(); + return; + } + + if (entry.blockAll) { + ctx.logger.warn("Rejecting blocked /v1 request (block all)"); + + throw new RecaseError({ + message: "API access is temporarily disabled for this organization", + code: ErrCode.RequestTemporarilyDisabled, + statusCode: 503, + }); + } + + const matchedRule = entry.blockedEndpoints.find((rule) => + matchRoute({ + url: c.req.path, + method: c.req.method, + pattern: { + url: rule.pattern, + method: rule.method, + }, + }), + ); + + if (!matchedRule) { + await next(); + return; + } + + ctx.logger.warn( + "Rejecting endpoint-blocked /v1 request (blocked endpoint, matched rule)", + ); + + throw new RecaseError({ + message: "This endpoint is temporarily disabled for this organization", + code: ErrCode.RequestTemporarilyDisabled, + statusCode: 503, + }); +}; diff --git a/server/src/init.ts b/server/src/init.ts index 47f090738..215301ba9 100644 --- a/server/src/init.ts +++ b/server/src/init.ts @@ -11,6 +11,13 @@ import { shutdownPgHealthMonitor, } from "./db/pgHealthMonitor.js"; import { logger } from "./external/logtail/logtailUtils.js"; +import { + startAllEdgeConfigPolling, + stopAllEdgeConfigPolling, +} from "./internal/misc/edgeConfig/edgeConfigRegistry.js"; + +// Edge config modules self-register on import +import "./internal/misc/requestBlocks/requestBlockStore.js"; import { warmupRegionalRedis } from "./external/redis/initRedis.js"; import { createHonoApp } from "./initHono.js"; import { otelSdk } from "./instrumentation.js"; @@ -24,6 +31,7 @@ const init = async () => { initPgHealthMonitor({ client: clientCritical }); await Promise.all([warmupRegionalRedis()]); + await startAllEdgeConfigPolling({ logger }); const PORT = process.env.SERVER_PORT ? Number.parseInt(process.env.SERVER_PORT) @@ -83,6 +91,7 @@ async function gracefulShutdown() { await otelSdk.shutdown(); } shutdownPgHealthMonitor(); + stopAllEdgeConfigPolling(); await Promise.all([ client.end(), clientCritical.end(), diff --git a/server/src/internal/admin/adminRouter.ts b/server/src/internal/admin/adminRouter.ts index d6ed0bb05..9ca5668f5 100644 --- a/server/src/internal/admin/adminRouter.ts +++ b/server/src/internal/admin/adminRouter.ts @@ -1,16 +1,20 @@ import { Hono } from "hono"; import type { HonoEnv } from "../../honoUtils/HonoEnv"; import { handleGetInvoiceLineItems } from "./handleGetInvoiceLineItems"; +import { handleGetAdminOrgRequestBlock } from "./handleGetAdminOrgRequestBlock"; import { handleGetMasterStripeAccount } from "./handleGetMasterStripeAccount"; import { handleGetOrgMember } from "./handleGetOrgMember"; import { handleListAdminOrgs } from "./handleListAdminOrgs"; import { handleListAdminUsers } from "./handleListAdminUsers"; import { handleListOAuthClients } from "./handleListOAuthClients"; +import { handleUpsertAdminOrgRequestBlock } from "./handleUpsertAdminOrgRequestBlock"; export const honoAdminRouter = new Hono(); honoAdminRouter.get("/users", ...handleListAdminUsers); honoAdminRouter.get("/orgs", ...handleListAdminOrgs); +honoAdminRouter.get("/orgs/:org_id/request-block", ...handleGetAdminOrgRequestBlock); +honoAdminRouter.put("/orgs/:org_id/request-block", ...handleUpsertAdminOrgRequestBlock); honoAdminRouter.get("/org-member", ...handleGetOrgMember); honoAdminRouter.get("/master-stripe-account", ...handleGetMasterStripeAccount); honoAdminRouter.get("/oauth-clients", ...handleListOAuthClients); diff --git a/server/src/internal/admin/handleGetAdminOrgRequestBlock.ts b/server/src/internal/admin/handleGetAdminOrgRequestBlock.ts new file mode 100644 index 000000000..ca6a2962d --- /dev/null +++ b/server/src/internal/admin/handleGetAdminOrgRequestBlock.ts @@ -0,0 +1,28 @@ +import { z } from "zod/v4"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { + getOrgRequestBlockFromSource, + getRuntimeRequestBlockStatus, +} from "@/internal/misc/requestBlocks/requestBlockStore.js"; + +export const handleGetAdminOrgRequestBlock = createRoute({ + params: z.object({ + org_id: z.string().min(1), + }), + handler: async (c) => { + const { org_id: orgId } = c.req.valid("param"); + const status = getRuntimeRequestBlockStatus(); + const entry = await getOrgRequestBlockFromSource({ orgId }); + + return c.json({ + blockAll: entry?.blockAll ?? false, + blockedEndpoints: entry?.blockedEndpoints ?? [], + updatedAt: entry?.updatedAt ?? null, + updatedBy: entry?.updatedBy ?? null, + configHealthy: status.healthy, + configConfigured: status.configured, + lastSuccessAt: status.lastSuccessAt ?? null, + error: status.error ?? null, + }); + }, +}); diff --git a/server/src/internal/admin/handleListAdminOrgs.ts b/server/src/internal/admin/handleListAdminOrgs.ts index aa257a1cf..9dc47936c 100644 --- a/server/src/internal/admin/handleListAdminOrgs.ts +++ b/server/src/internal/admin/handleListAdminOrgs.ts @@ -1,5 +1,6 @@ import { member, organizations, user } from "@autumn/shared"; import { and, desc, eq, gt, gte, ilike, inArray, lt, or } from "drizzle-orm"; +import { getRequestBlockConfigFromSource } from "../misc/requestBlocks/requestBlockStore.js"; import { createRoute } from "../../honoMiddlewares/routeHandler"; export const handleListAdminOrgs = createRoute({ @@ -69,6 +70,13 @@ export const handleListAdminOrgs = createRoute({ .limit(21); const orgIds = orgs.map((org) => org.id); + let requestBlockConfig = { orgs: {} as Record }; + + try { + requestBlockConfig = await getRequestBlockConfigFromSource(); + } catch { + // Admin list should still render even if S3 is unavailable. + } const memberships = await db .select() @@ -82,6 +90,12 @@ export const handleListAdminOrgs = createRoute({ users: memberships .filter((membership) => membership.member.organizationId === org.id) .map((membership) => membership.user), + requestBlockSummary: { + blockAll: + requestBlockConfig.orgs[org.id]?.blockAll ?? false, + ruleCount: + requestBlockConfig.orgs[org.id]?.blockedEndpoints.length ?? 0, + }, })), hasNextPage: orgs.length > 20, }); diff --git a/server/src/internal/admin/handleUpsertAdminOrgRequestBlock.ts b/server/src/internal/admin/handleUpsertAdminOrgRequestBlock.ts new file mode 100644 index 000000000..f66164aeb --- /dev/null +++ b/server/src/internal/admin/handleUpsertAdminOrgRequestBlock.ts @@ -0,0 +1,30 @@ +import { z } from "zod/v4"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { updateOrgRequestBlockInSource } from "@/internal/misc/requestBlocks/requestBlockStore.js"; +import { RequestBlockUpdateSchema } from "@/internal/misc/requestBlocks/requestBlockSchemas.js"; + +export const handleUpsertAdminOrgRequestBlock = createRoute({ + params: z.object({ + org_id: z.string().min(1), + }), + body: RequestBlockUpdateSchema, + handler: async (c) => { + const ctx = c.get("ctx"); + const { org_id: orgId } = c.req.valid("param"); + const body = c.req.valid("json"); + + const entry = await updateOrgRequestBlockInSource({ + orgId, + update: body, + updatedBy: ctx.userId, + }); + + return c.json({ + success: true, + blockAll: entry?.blockAll ?? false, + blockedEndpoints: entry?.blockedEndpoints ?? [], + updatedAt: entry?.updatedAt ?? null, + updatedBy: entry?.updatedBy ?? null, + }); + }, +}); diff --git a/server/src/internal/debug/debugRouter.ts b/server/src/internal/misc/debug/debugRouter.ts similarity index 100% rename from server/src/internal/debug/debugRouter.ts rename to server/src/internal/misc/debug/debugRouter.ts diff --git a/server/src/internal/misc/edgeConfig/edgeConfigRegistry.ts b/server/src/internal/misc/edgeConfig/edgeConfigRegistry.ts new file mode 100644 index 000000000..42f0d20cd --- /dev/null +++ b/server/src/internal/misc/edgeConfig/edgeConfigRegistry.ts @@ -0,0 +1,28 @@ +import type { Logger } from "@/external/logtail/logtailUtils.js"; + +type EdgeConfigLifecycle = { + startPolling: (options?: { logger?: Logger }) => Promise; + stopPolling: () => void; +}; + +const stores: EdgeConfigLifecycle[] = []; + +export const registerEdgeConfig = ({ + store, +}: { + store: EdgeConfigLifecycle; +}) => { + stores.push(store); +}; + +export const startAllEdgeConfigPolling = async ({ + logger, +}: { + logger?: Logger; +} = {}) => { + await Promise.all(stores.map((store) => store.startPolling({ logger }))); +}; + +export const stopAllEdgeConfigPolling = () => { + for (const store of stores) store.stopPolling(); +}; diff --git a/server/src/internal/misc/edgeConfig/edgeConfigStore.ts b/server/src/internal/misc/edgeConfig/edgeConfigStore.ts new file mode 100644 index 000000000..9e9616645 --- /dev/null +++ b/server/src/internal/misc/edgeConfig/edgeConfigStore.ts @@ -0,0 +1,187 @@ +import { ErrCode, ms } from "@autumn/shared"; +import type { S3Client } from "@aws-sdk/client-s3"; +import { GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"; +import type { z } from "zod/v4"; +import { getAdminS3Config } from "@/external/aws/s3/adminS3Config.js"; +import { getS3Client } from "@/external/aws/s3/initS3.js"; +import { getS3BodyAsString } from "@/external/aws/s3/s3Utils.js"; +import type { Logger } from "@/external/logtail/logtailUtils.js"; +import RecaseError from "@/utils/errorUtils.js"; + +export type EdgeConfigStatus = { + configured: boolean; + healthy: boolean; + lastFetchAt?: string; + lastSuccessAt?: string; + error?: string; +}; + +const nowIso = () => new Date().toISOString(); + +/** + * Factory that creates a typed, poll-based edge config backed by S3. + * Fail-open: any S3 error resets the in-memory config to `defaultValue()`. + */ +export const createEdgeConfigStore = ({ + s3Key, + schema, + defaultValue, + pollIntervalMs = ms.seconds(30), + s3Client: injectedS3Client, +}: { + s3Key: string; + schema: z.ZodType; + defaultValue: () => T; + pollIntervalMs?: number; + s3Client?: S3Client; +}) => { + let runtimeConfig: T = defaultValue(); + let runtimeStatus: EdgeConfigStatus = { + configured: false, + healthy: false, + error: "Edge config not yet initialized", + }; + let pollTimer: ReturnType | null = null; + + const getConfigLocation = () => { + const { bucket, region } = getAdminS3Config(); + return { + bucket, + region, + key: s3Key, + configured: Boolean(bucket && s3Key), + }; + }; + + const resolveClient = () => { + if (injectedS3Client) return injectedS3Client; + const { region } = getConfigLocation(); + return getS3Client({ region }); + }; + + const readFromSource = async (): Promise => { + const { bucket, key, configured } = getConfigLocation(); + + if (!configured || !bucket || !key) return defaultValue(); + + const client = resolveClient(); + try { + const response = await client.send( + new GetObjectCommand({ Bucket: bucket, Key: key }), + ); + + if (!response.Body) return defaultValue(); + + const raw = (await getS3BodyAsString({ body: response.Body })).trim(); + if (!raw) return defaultValue(); + + return schema.parse(JSON.parse(raw)); + } catch (error) { + const name = error instanceof Error ? error.name : ""; + if (name === "NoSuchKey") return defaultValue(); + throw error; + } + }; + + const writeToSource = async ({ config }: { config: T }) => { + const { bucket, key, configured } = getConfigLocation(); + + if (!configured || !bucket || !key) { + throw new RecaseError({ + message: "Edge config S3 is not configured", + code: ErrCode.InvalidRequest, + statusCode: 503, + }); + } + + const client = resolveClient(); + await client.send( + new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: JSON.stringify(config, null, 2), + ContentType: "application/json", + }), + ); + + runtimeConfig = config; + runtimeStatus = { + configured: true, + healthy: true, + lastFetchAt: nowIso(), + lastSuccessAt: nowIso(), + }; + }; + + const refresh = async ({ logger }: { logger?: Logger } = {}) => { + const { configured } = getConfigLocation(); + runtimeStatus = { + ...runtimeStatus, + configured, + lastFetchAt: nowIso(), + }; + + if (!configured) { + runtimeConfig = defaultValue(); + runtimeStatus = { + configured: false, + healthy: false, + lastFetchAt: runtimeStatus.lastFetchAt, + lastSuccessAt: runtimeStatus.lastSuccessAt, + error: "Edge config S3 is not configured", + }; + return; + } + + try { + const config = await readFromSource(); + runtimeConfig = config; + runtimeStatus = { + configured: true, + healthy: true, + lastFetchAt: runtimeStatus.lastFetchAt, + lastSuccessAt: nowIso(), + }; + } catch (error) { + runtimeConfig = defaultValue(); + runtimeStatus = { + configured: true, + healthy: false, + lastFetchAt: runtimeStatus.lastFetchAt, + lastSuccessAt: runtimeStatus.lastSuccessAt, + error: error instanceof Error ? error.message : "Failed to load config", + }; + logger?.warn(`Failed to refresh edge config "${s3Key}": ${error}`); + } + }; + + const startPolling = async ({ logger }: { logger?: Logger } = {}) => { + if (pollTimer) return; + + await refresh({ logger }); + pollTimer = setInterval(() => { + void refresh({ logger }); + }, pollIntervalMs); + }; + + const stopPolling = () => { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + }; + + return { + get: () => runtimeConfig, + getStatus: () => runtimeStatus, + refresh, + startPolling, + stopPolling, + readFromSource, + writeToSource, + }; +}; + +export type EdgeConfigStore = ReturnType< + typeof createEdgeConfigStore +>; diff --git a/server/src/internal/misc/requestBlocks/requestBlockSchemas.ts b/server/src/internal/misc/requestBlocks/requestBlockSchemas.ts new file mode 100644 index 000000000..186b0c9da --- /dev/null +++ b/server/src/internal/misc/requestBlocks/requestBlockSchemas.ts @@ -0,0 +1,42 @@ +import { z } from "zod/v4"; + +export const RequestBlockMethodSchema = z.enum([ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "OPTIONS", + "HEAD", +]); + +export const RequestBlockRuleSchema = z.object({ + method: RequestBlockMethodSchema, + pattern: z + .string() + .min(1) + .refine((value) => value.startsWith("/v1/"), { + message: "Blocked endpoint patterns must start with /v1/", + }), +}); + +export const RequestBlockEntrySchema = z.object({ + blockAll: z.boolean().default(false), + blockedEndpoints: z.array(RequestBlockRuleSchema).default([]), + updatedAt: z.string(), + updatedBy: z.string().optional(), +}); + +export const RequestBlockConfigSchema = z.object({ + orgs: z.record(z.string(), RequestBlockEntrySchema).default({}), +}); + +export const RequestBlockUpdateSchema = z.object({ + blockAll: z.boolean(), + blockedEndpoints: z.array(RequestBlockRuleSchema), +}); + +export type RequestBlockRule = z.infer; +export type RequestBlockEntry = z.infer; +export type RequestBlockConfig = z.infer; +export type RequestBlockUpdate = z.infer; diff --git a/server/src/internal/misc/requestBlocks/requestBlockStore.ts b/server/src/internal/misc/requestBlocks/requestBlockStore.ts new file mode 100644 index 000000000..30a3680f8 --- /dev/null +++ b/server/src/internal/misc/requestBlocks/requestBlockStore.ts @@ -0,0 +1,66 @@ +import { ADMIN_REQUEST_BLOCK_CONFIG_KEY } from "@/external/aws/s3/adminS3Config.js"; +import { registerEdgeConfig } from "@/internal/misc/edgeConfig/edgeConfigRegistry.js"; +import { createEdgeConfigStore } from "@/internal/misc/edgeConfig/edgeConfigStore.js"; +import { + type RequestBlockConfig, + RequestBlockConfigSchema, + type RequestBlockEntry, + type RequestBlockUpdate, +} from "./requestBlockSchemas.js"; + +const nowIso = () => new Date().toISOString(); + +const store = createEdgeConfigStore({ + s3Key: ADMIN_REQUEST_BLOCK_CONFIG_KEY, + schema: RequestBlockConfigSchema, + defaultValue: () => ({ orgs: {} }), +}); + +registerEdgeConfig({ store }); + +export const getRuntimeRequestBlockStatus = () => store.getStatus(); + +export const getRuntimeRequestBlockEntry = ( + orgId: string, +): RequestBlockEntry | undefined => store.get().orgs[orgId]; + +export const getRequestBlockConfigFromSource = async () => { + return await store.readFromSource(); +}; + +export const getOrgRequestBlockFromSource = async ({ + orgId, +}: { + orgId: string; +}) => { + const config = await store.readFromSource(); + return config.orgs[orgId]; +}; + +export const updateOrgRequestBlockInSource = async ({ + orgId, + update, + updatedBy, +}: { + orgId: string; + update: RequestBlockUpdate; + updatedBy?: string; +}) => { + const config = await store.readFromSource(); + const shouldDelete = !update.blockAll && update.blockedEndpoints.length === 0; + + if (shouldDelete) { + delete config.orgs[orgId]; + } else { + config.orgs[orgId] = { + blockAll: update.blockAll, + blockedEndpoints: update.blockedEndpoints, + updatedAt: nowIso(), + ...(updatedBy && { updatedBy }), + }; + } + + await store.writeToSource({ config }); + + return config.orgs[orgId]; +}; diff --git a/server/src/routers/apiRouter.ts b/server/src/routers/apiRouter.ts index 770c93763..2130d3f23 100644 --- a/server/src/routers/apiRouter.ts +++ b/server/src/routers/apiRouter.ts @@ -13,6 +13,7 @@ import { rateLimitMiddleware } from "../honoMiddlewares/rateLimitMiddleware.js"; import { refreshCacheMiddleware } from "../honoMiddlewares/refreshCacheMiddleware.js"; import { refreshProductsCacheMiddleware } from "../honoMiddlewares/refreshProductsCacheMiddleware.js"; import { responseFilterMiddleware } from "../honoMiddlewares/responseFilter/responseFilterMiddleware.js"; +import { requestBlockMiddleware } from "../honoMiddlewares/requestBlockMiddleware.js"; import { secretKeyMiddleware } from "../honoMiddlewares/secretKeyMiddleware.js"; import type { HonoEnv } from "../honoUtils/HonoEnv.js"; import { @@ -41,6 +42,7 @@ export const apiRouter = new Hono(); apiRouter.use("*", criticalDbMiddleware); apiRouter.use("*", responseFilterMiddleware); apiRouter.use("*", secretKeyMiddleware); +apiRouter.use("*", requestBlockMiddleware); apiRouter.use("*", orgConfigMiddleware); apiRouter.use("*", apiVersionMiddleware); apiRouter.use("*", refreshCacheMiddleware); diff --git a/server/tests/unit/edge-config/edge-config-store.test.ts b/server/tests/unit/edge-config/edge-config-store.test.ts new file mode 100644 index 000000000..846068093 --- /dev/null +++ b/server/tests/unit/edge-config/edge-config-store.test.ts @@ -0,0 +1,389 @@ +import { afterEach, describe, expect, jest, test } from "bun:test"; +import type { S3Client } from "@aws-sdk/client-s3"; +import { z } from "zod/v4"; +import { createEdgeConfigStore } from "@/internal/misc/edgeConfig/edgeConfigStore.js"; + +const TestConfigSchema = z.object({ + enabled: z.boolean().default(false), + message: z.string().default("hello"), +}); + +type TestConfig = z.infer; + +const defaultConfig = (): TestConfig => ({ enabled: false, message: "hello" }); + +const createMockS3Client = ({ + getResponse, +}: { + getResponse: () => { + Body?: { transformToString: () => Promise } | null; + }; +}): S3Client => { + const sendFn = jest.fn(async (command: unknown) => { + const commandName = + command?.constructor?.name ?? (command as { name?: string })?.name; + + if (commandName === "GetObjectCommand") { + return getResponse(); + } + + if (commandName === "PutObjectCommand") { + return {}; + } + + throw new Error(`Unexpected command: ${commandName}`); + }); + + return { send: sendFn } as unknown as S3Client; +}; + +const makeBody = (data: unknown) => ({ + Body: { + transformToString: async () => JSON.stringify(data), + }, +}); + +const makeNoSuchKeyError = () => { + const error = new Error("NoSuchKey"); + error.name = "NoSuchKey"; + return error; +}; + +describe("createEdgeConfigStore", () => { + let store: ReturnType>; + + afterEach(() => { + store?.stopPolling(); + }); + + describe("initial fetch via startPolling", () => { + test("populates get() with parsed config from S3", async () => { + const mockClient = createMockS3Client({ + getResponse: () => makeBody({ enabled: true, message: "from-s3" }), + }); + + store = createEdgeConfigStore({ + s3Key: "admin/test-config.json", + schema: TestConfigSchema, + defaultValue: defaultConfig, + s3Client: mockClient, + }); + + await store.startPolling(); + + expect(store.get()).toEqual({ enabled: true, message: "from-s3" }); + expect(store.getStatus().healthy).toBe(true); + expect(store.getStatus().configured).toBe(true); + expect(store.getStatus().lastSuccessAt).toBeDefined(); + }); + + test("uses defaultValue before startPolling is called", () => { + const mockClient = createMockS3Client({ + getResponse: () => makeBody({ enabled: true, message: "from-s3" }), + }); + + store = createEdgeConfigStore({ + s3Key: "admin/test-config.json", + schema: TestConfigSchema, + defaultValue: defaultConfig, + s3Client: mockClient, + }); + + expect(store.get()).toEqual(defaultConfig()); + }); + }); + + describe("fail-open behavior", () => { + test("returns defaultValue when S3 throws a network error", async () => { + const mockClient = createMockS3Client({ + getResponse: () => { + throw new Error("NetworkingError: socket hang up"); + }, + }); + + store = createEdgeConfigStore({ + s3Key: "admin/test-config.json", + schema: TestConfigSchema, + defaultValue: defaultConfig, + s3Client: mockClient, + }); + + await store.startPolling(); + + expect(store.get()).toEqual(defaultConfig()); + expect(store.getStatus().healthy).toBe(false); + expect(store.getStatus().error).toContain("NetworkingError"); + }); + + test("returns defaultValue when S3 file does not exist (NoSuchKey)", async () => { + const mockClient = createMockS3Client({ + getResponse: () => { + throw makeNoSuchKeyError(); + }, + }); + + store = createEdgeConfigStore({ + s3Key: "admin/test-config.json", + schema: TestConfigSchema, + defaultValue: defaultConfig, + s3Client: mockClient, + }); + + await store.startPolling(); + + expect(store.get()).toEqual(defaultConfig()); + expect(store.getStatus().healthy).toBe(true); + }); + + test("returns defaultValue when S3 body is malformed JSON", async () => { + const mockClient = createMockS3Client({ + getResponse: () => ({ + Body: { + transformToString: async () => "not-valid-json{{{", + }, + }), + }); + + store = createEdgeConfigStore({ + s3Key: "admin/test-config.json", + schema: TestConfigSchema, + defaultValue: defaultConfig, + s3Client: mockClient, + }); + + await store.startPolling(); + + expect(store.get()).toEqual(defaultConfig()); + expect(store.getStatus().healthy).toBe(false); + }); + + test("returns defaultValue when S3 body is null", async () => { + const mockClient = createMockS3Client({ + getResponse: () => ({ Body: null }), + }); + + store = createEdgeConfigStore({ + s3Key: "admin/test-config.json", + schema: TestConfigSchema, + defaultValue: defaultConfig, + s3Client: mockClient, + }); + + await store.startPolling(); + + expect(store.get()).toEqual(defaultConfig()); + expect(store.getStatus().healthy).toBe(true); + }); + + test("returns defaultValue when S3 body is empty string", async () => { + const mockClient = createMockS3Client({ + getResponse: () => ({ + Body: { transformToString: async () => " " }, + }), + }); + + store = createEdgeConfigStore({ + s3Key: "admin/test-config.json", + schema: TestConfigSchema, + defaultValue: defaultConfig, + s3Client: mockClient, + }); + + await store.startPolling(); + + expect(store.get()).toEqual(defaultConfig()); + expect(store.getStatus().healthy).toBe(true); + }); + + test("returns defaultValue when schema validation fails", async () => { + const strictSchema = z.object({ + enabled: z.boolean(), + message: z.string(), + requiredField: z.string(), + }); + + const mockClient = createMockS3Client({ + getResponse: () => + makeBody({ enabled: true, message: "no-required-field" }), + }); + + store = createEdgeConfigStore({ + s3Key: "admin/test-config.json", + schema: strictSchema as unknown as z.ZodType, + defaultValue: defaultConfig, + s3Client: mockClient, + }); + + await store.startPolling(); + + expect(store.get()).toEqual(defaultConfig()); + expect(store.getStatus().healthy).toBe(false); + }); + }); + + describe("refresh", () => { + test("updates cached config when S3 content changes", async () => { + let callCount = 0; + const mockClient = createMockS3Client({ + getResponse: () => { + callCount++; + if (callCount === 1) { + return makeBody({ enabled: false, message: "first" }); + } + return makeBody({ enabled: true, message: "second" }); + }, + }); + + store = createEdgeConfigStore({ + s3Key: "admin/test-config.json", + schema: TestConfigSchema, + defaultValue: defaultConfig, + s3Client: mockClient, + }); + + await store.startPolling(); + expect(store.get().message).toBe("first"); + + await store.refresh(); + expect(store.get().message).toBe("second"); + expect(store.get().enabled).toBe(true); + }); + }); + + describe("writeToSource", () => { + test("updates local cache immediately after write", async () => { + const mockClient = createMockS3Client({ + getResponse: () => makeBody(defaultConfig()), + }); + + store = createEdgeConfigStore({ + s3Key: "admin/test-config.json", + schema: TestConfigSchema, + defaultValue: defaultConfig, + s3Client: mockClient, + }); + + await store.startPolling(); + expect(store.get().enabled).toBe(false); + + await store.writeToSource({ + config: { enabled: true, message: "written" }, + }); + + expect(store.get()).toEqual({ enabled: true, message: "written" }); + expect(store.getStatus().healthy).toBe(true); + }); + + test("calls S3 PutObject with correct payload", async () => { + const mockClient = createMockS3Client({ + getResponse: () => makeBody(defaultConfig()), + }); + + store = createEdgeConfigStore({ + s3Key: "admin/test-config.json", + schema: TestConfigSchema, + defaultValue: defaultConfig, + s3Client: mockClient, + }); + + await store.writeToSource({ + config: { enabled: true, message: "test-write" }, + }); + + const sendFn = ( + mockClient as unknown as { send: ReturnType } + ).send; + const calls = sendFn.mock.calls; + const lastCall = calls[calls.length - 1]?.[0]; + expect(lastCall?.constructor?.name).toBe("PutObjectCommand"); + }); + }); + + describe("readFromSource", () => { + test("returns fresh data from S3 without updating cache", async () => { + let callCount = 0; + const mockClient = createMockS3Client({ + getResponse: () => { + callCount++; + return makeBody({ + enabled: callCount > 1, + message: `call-${callCount}`, + }); + }, + }); + + store = createEdgeConfigStore({ + s3Key: "admin/test-config.json", + schema: TestConfigSchema, + defaultValue: defaultConfig, + s3Client: mockClient, + }); + + await store.startPolling(); + expect(store.get().message).toBe("call-1"); + + const fresh = await store.readFromSource(); + expect(fresh.message).toBe("call-2"); + expect(store.get().message).toBe("call-1"); + }); + }); + + describe("polling lifecycle", () => { + test("double startPolling does not create a second interval", async () => { + let callCount = 0; + const mockClient = createMockS3Client({ + getResponse: () => { + callCount++; + return makeBody(defaultConfig()); + }, + }); + + store = createEdgeConfigStore({ + s3Key: "admin/test-config.json", + schema: TestConfigSchema, + defaultValue: defaultConfig, + pollIntervalMs: 50, + s3Client: mockClient, + }); + + await store.startPolling(); + await store.startPolling(); + + const countAfterStart = callCount; + + await new Promise((resolve) => setTimeout(resolve, 130)); + + const countAfterWait = callCount; + const intervalFetchCount = countAfterWait - countAfterStart; + + expect(intervalFetchCount).toBeGreaterThanOrEqual(1); + expect(intervalFetchCount).toBeLessThanOrEqual(3); + }); + + test("stopPolling prevents further refreshes", async () => { + let callCount = 0; + const mockClient = createMockS3Client({ + getResponse: () => { + callCount++; + return makeBody(defaultConfig()); + }, + }); + + store = createEdgeConfigStore({ + s3Key: "admin/test-config.json", + schema: TestConfigSchema, + defaultValue: defaultConfig, + pollIntervalMs: 50, + s3Client: mockClient, + }); + + await store.startPolling(); + store.stopPolling(); + + const countAfterStop = callCount; + await new Promise((resolve) => setTimeout(resolve, 150)); + + expect(callCount).toBe(countAfterStop); + }); + }); +}); diff --git a/shared/enums/ErrCode.ts b/shared/enums/ErrCode.ts index 433820b16..cd44f0cfa 100644 --- a/shared/enums/ErrCode.ts +++ b/shared/enums/ErrCode.ts @@ -25,6 +25,7 @@ export const ErrCode = { InvalidRequest: "invalid_request", InvalidExpand: "invalid_expand", InvalidOptions: "invalid_options", + RequestTemporarilyDisabled: "request_temporarily_disabled", // Org OrgNotFound: "org_not_found", diff --git a/vite/src/views/admin/AdminOrgColumns.tsx b/vite/src/views/admin/AdminOrgColumns.tsx index 857313c2b..bb32ac9ff 100644 --- a/vite/src/views/admin/AdminOrgColumns.tsx +++ b/vite/src/views/admin/AdminOrgColumns.tsx @@ -1,7 +1,9 @@ import type { ColumnDef, Row } from "@tanstack/react-table"; import type { User } from "better-auth"; import { format } from "date-fns"; +import { Badge } from "@/components/v2/badges/Badge"; import { MiniCopyButton } from "@/components/v2/buttons/CopyButton"; +import { Button } from "@/components/v2/buttons/Button"; import { ImpersonateButton } from "./components/ImpersonateBtn"; export type AdminOrg = { @@ -10,9 +12,17 @@ export type AdminOrg = { slug: string; createdAt: string; users: User[]; + requestBlockSummary: { + blockAll: boolean; + ruleCount: number; + }; }; -export const createAdminOrgColumns = (): ColumnDef[] => [ +export const createAdminOrgColumns = ({ + onManageRequestBlocks, +}: { + onManageRequestBlocks: (org: AdminOrg) => void; +}): ColumnDef[] => [ { id: "id", header: "ID", @@ -74,6 +84,32 @@ export const createAdminOrgColumns = (): ColumnDef[] => [ ); }, }, + { + id: "requestBlock", + header: "Request blocks", + accessorKey: "requestBlockSummary", + cell: ({ row }: { row: Row }) => { + const summary = row.original.requestBlockSummary; + + if (summary.blockAll) { + return ( + + Blocked + + ); + } + + if (summary.ruleCount > 0) { + return ( + + {summary.ruleCount} rule{summary.ruleCount === 1 ? "" : "s"} + + ); + } + + return Open; + }, + }, { id: "impersonate", header: "Actions", @@ -87,7 +123,14 @@ export const createAdminOrgColumns = (): ColumnDef[] => [ } return ( -
e.stopPropagation()}> +
e.stopPropagation()} className="flex gap-2"> +
); diff --git a/vite/src/views/admin/AdminOrgTable.tsx b/vite/src/views/admin/AdminOrgTable.tsx index 59c9b48b9..0bcc358de 100644 --- a/vite/src/views/admin/AdminOrgTable.tsx +++ b/vite/src/views/admin/AdminOrgTable.tsx @@ -5,6 +5,7 @@ import { Input } from "@/components/ui/input"; import { Button } from "@/components/v2/buttons/Button"; import { useAxiosInstance } from "@/services/useAxiosInstance"; import { type AdminOrg, createAdminOrgColumns } from "./AdminOrgColumns"; +import { RequestBlockDialog } from "./components/RequestBlockDialog"; import { useAdminTable } from "./hooks/useAdminTable"; export const AdminOrgTable = () => { @@ -13,6 +14,7 @@ export const AdminOrgTable = () => { const [after, setAfter] = useState(undefined); const [before, setBefore] = useState(undefined); const [page, setPage] = useState(1); + const [selectedOrg, setSelectedOrg] = useState(null); const params = new URLSearchParams(); if (search) params.append("search", search); @@ -20,7 +22,7 @@ export const AdminOrgTable = () => { if (before) params.append("before", before); const url = `/admin/orgs${params.toString() ? `?${params.toString()}` : ""}`; - const { data, isLoading } = useQuery({ + const { data, isLoading, refetch } = useQuery({ queryKey: ["admin-orgs", search, after, before], queryFn: async () => { const { data } = await axiosInstance.get(url); @@ -59,7 +61,13 @@ export const AdminOrgTable = () => { setPage((p) => (direction === "next" ? p + 1 : Math.max(1, p - 1))); }; - const columns = useMemo(() => createAdminOrgColumns(), []); + const columns = useMemo( + () => + createAdminOrgColumns({ + onManageRequestBlocks: (org) => setSelectedOrg(org), + }), + [], + ); const table = useAdminTable({ data: rows, @@ -70,6 +78,19 @@ export const AdminOrgTable = () => { return (
+ { + if (!open) { + setSelectedOrg(null); + } + }} + orgId={selectedOrg?.id} + orgName={selectedOrg?.name} + onSaved={async () => { + await refetch(); + }} + />

Organizations

diff --git a/vite/src/views/admin/components/RequestBlockDialog.tsx b/vite/src/views/admin/components/RequestBlockDialog.tsx new file mode 100644 index 000000000..cc9615488 --- /dev/null +++ b/vite/src/views/admin/components/RequestBlockDialog.tsx @@ -0,0 +1,296 @@ +import { Plus, Trash2 } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { Badge } from "@/components/v2/badges/Badge"; +import { Button } from "@/components/v2/buttons/Button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/v2/dialogs/Dialog"; +import { Input } from "@/components/ui/input"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; +import { getBackendErr } from "@/utils/genUtils"; + +type RequestBlockRule = { + method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS" | "HEAD"; + pattern: string; +}; + +type RequestBlockResponse = { + blockAll: boolean; + blockedEndpoints: RequestBlockRule[]; + updatedAt: string | null; + updatedBy: string | null; + configHealthy: boolean; + configConfigured: boolean; + lastSuccessAt: string | null; + error: string | null; +}; + +const METHODS: RequestBlockRule["method"][] = [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "OPTIONS", + "HEAD", +]; + +export function RequestBlockDialog({ + open, + onOpenChange, + orgId, + orgName, + onSaved, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + orgId?: string; + orgName?: string; + onSaved: () => void | Promise; +}) { + const axiosInstance = useAxiosInstance(); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [blockAll, setBlockAll] = useState(false); + const [rules, setRules] = useState([]); + const [status, setStatus] = useState(null); + + useEffect(() => { + if (!open || !orgId) { + return; + } + + let cancelled = false; + setLoading(true); + + void axiosInstance + .get(`/admin/orgs/${orgId}/request-block`) + .then(({ data }) => { + if (cancelled) return; + setStatus(data); + setBlockAll(data.blockAll); + setRules(data.blockedEndpoints); + }) + .catch((error) => { + if (!cancelled) { + toast.error(getBackendErr(error, "Failed to load request block state")); + } + }) + .finally(() => { + if (!cancelled) { + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [axiosInstance, open, orgId]); + + const canSave = useMemo( + () => + rules.every( + (rule) => rule.pattern.trim().startsWith("/v1/") && rule.pattern.trim(), + ), + [rules], + ); + + const updateRule = ( + index: number, + next: Partial, + ) => { + setRules((current) => + current.map((rule, ruleIndex) => + ruleIndex === index ? { ...rule, ...next } : rule, + ), + ); + }; + + const removeRule = (index: number) => { + setRules((current) => current.filter((_, ruleIndex) => ruleIndex !== index)); + }; + + const addRule = () => { + setRules((current) => [...current, { method: "POST", pattern: "/v1/" }]); + }; + + const handleSave = async () => { + if (!orgId) return; + + if (!canSave) { + toast.error("Every blocked endpoint must start with /v1/"); + return; + } + + setSaving(true); + try { + await axiosInstance.put(`/admin/orgs/${orgId}/request-block`, { + blockAll, + blockedEndpoints: rules.map((rule) => ({ + method: rule.method, + pattern: rule.pattern.trim(), + })), + }); + toast.success("Updated request block settings"); + await onSaved(); + onOpenChange(false); + } catch (error) { + toast.error(getBackendErr(error, "Failed to save request block state")); + } finally { + setSaving(false); + } + }; + + return ( + + + + Request blocking + + Manage `/v1` request blocking for {orgName || orgId}. + + + + {loading ? ( +
Loading request block state...
+ ) : ( +
+
+
+
+ Block all `/v1` requests +
+
+ Use this as the org-wide kill switch. +
+
+ +
+ +
+
+
+
+ Selective endpoint rules +
+
+ Method + pattern rules use exact route matching. +
+
+ +
+ +
+ {rules.length === 0 ? ( +
+ No selective rules configured. +
+ ) : ( + rules.map((rule, index) => ( +
+ + + updateRule(index, { + pattern: event.target.value, + }) + } + placeholder="/v1/customers/:customer_id" + /> + +
+ )) + )} +
+
+ +
+
+ + {status?.configHealthy ? "Config healthy" : "Config unavailable"} + + {status?.lastSuccessAt && ( + + Last successful refresh:{" "} + {new Date(status.lastSuccessAt).toLocaleString()} + + )} +
+
+ {status?.configConfigured === false + ? "S3 request block config is not configured in the server environment." + : status?.error || "When config refresh fails, blocking is disabled until the next successful refresh."} +
+
+
+ )} + + + + + +
+
+ ); +}