Merge pull request #1137 from malisper/malisper/request-blocking

Add runtime request blocking for orgs
This commit is contained in:
John Yeo
2026-04-01 12:37:56 +01:00
committed by GitHub
26 changed files with 1668 additions and 17 deletions

View File

@@ -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<T>` 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<typeof FeatureRolloutConfigSchema>;
```
### 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<FeatureRolloutConfig>({
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<T>({
s3Key: string, // S3 object key under admin bucket
schema: z.ZodType<T>, // 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<MyConfig>({
s3Key: "admin/test.json",
schema: MyConfigSchema,
defaultValue: () => ({ ... }),
s3Client: mockClient,
});
```
Existing tests: `server/tests/unit/edge-config/edge-config-store.test.ts`

View File

@@ -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=="],

View File

@@ -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",

View File

@@ -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",

View File

@@ -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();

View File

@@ -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",

View File

@@ -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",
};
};

17
server/src/external/aws/s3/initS3.ts vendored Normal file
View File

@@ -0,0 +1,17 @@
import { S3Client } from "@aws-sdk/client-s3";
import { DEFAULT_AWS_REGION } from "@/external/aws/awsRegionUtils.js";
const s3ClientsByRegion = new Map<string, S3Client>();
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;
};

11
server/src/external/aws/s3/s3Utils.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
export const getS3BodyAsString = async ({
body,
}: {
body: { transformToString?: () => Promise<string> };
}) => {
if (typeof body.transformToString === "function") {
return await body.transformToString();
}
return await new Response(body as BodyInit).text();
};

View File

@@ -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<HonoEnv>,
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,
});
};

View File

@@ -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(),

View File

@@ -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<HonoEnv>();
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);

View File

@@ -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,
});
},
});

View File

@@ -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<string, { blockAll: boolean; blockedEndpoints: unknown[] }> };
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,
});

View File

@@ -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,
});
},
});

View File

@@ -0,0 +1,28 @@
import type { Logger } from "@/external/logtail/logtailUtils.js";
type EdgeConfigLifecycle = {
startPolling: (options?: { logger?: Logger }) => Promise<void>;
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();
};

View File

@@ -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 = <T>({
s3Key,
schema,
defaultValue,
pollIntervalMs = ms.seconds(30),
s3Client: injectedS3Client,
}: {
s3Key: string;
schema: z.ZodType<T>;
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<typeof setInterval> | 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<T> => {
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<T = unknown> = ReturnType<
typeof createEdgeConfigStore<T>
>;

View File

@@ -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<typeof RequestBlockRuleSchema>;
export type RequestBlockEntry = z.infer<typeof RequestBlockEntrySchema>;
export type RequestBlockConfig = z.infer<typeof RequestBlockConfigSchema>;
export type RequestBlockUpdate = z.infer<typeof RequestBlockUpdateSchema>;

View File

@@ -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<RequestBlockConfig>({
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];
};

View File

@@ -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<HonoEnv>();
apiRouter.use("*", criticalDbMiddleware);
apiRouter.use("*", responseFilterMiddleware);
apiRouter.use("*", secretKeyMiddleware);
apiRouter.use("*", requestBlockMiddleware);
apiRouter.use("*", orgConfigMiddleware);
apiRouter.use("*", apiVersionMiddleware);
apiRouter.use("*", refreshCacheMiddleware);

View File

@@ -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<typeof TestConfigSchema>;
const defaultConfig = (): TestConfig => ({ enabled: false, message: "hello" });
const createMockS3Client = ({
getResponse,
}: {
getResponse: () => {
Body?: { transformToString: () => Promise<string> } | 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<typeof createEdgeConfigStore<TestConfig>>;
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<TestConfig>({
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<TestConfig>({
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<TestConfig>({
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<TestConfig>({
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<TestConfig>({
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<TestConfig>({
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<TestConfig>({
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<TestConfig>({
s3Key: "admin/test-config.json",
schema: strictSchema as unknown as z.ZodType<TestConfig>,
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<TestConfig>({
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<TestConfig>({
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<TestConfig>({
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<typeof jest.fn> }
).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<TestConfig>({
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<TestConfig>({
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<TestConfig>({
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);
});
});
});

View File

@@ -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",

View File

@@ -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<AdminOrg, unknown>[] => [
export const createAdminOrgColumns = ({
onManageRequestBlocks,
}: {
onManageRequestBlocks: (org: AdminOrg) => void;
}): ColumnDef<AdminOrg, unknown>[] => [
{
id: "id",
header: "ID",
@@ -74,6 +84,32 @@ export const createAdminOrgColumns = (): ColumnDef<AdminOrg, unknown>[] => [
);
},
},
{
id: "requestBlock",
header: "Request blocks",
accessorKey: "requestBlockSummary",
cell: ({ row }: { row: Row<AdminOrg> }) => {
const summary = row.original.requestBlockSummary;
if (summary.blockAll) {
return (
<Badge className="bg-red-50 text-red-700 border-red-200">
Blocked
</Badge>
);
}
if (summary.ruleCount > 0) {
return (
<Badge className="bg-amber-50 text-amber-700 border-amber-200">
{summary.ruleCount} rule{summary.ruleCount === 1 ? "" : "s"}
</Badge>
);
}
return <Badge variant="muted">Open</Badge>;
},
},
{
id: "impersonate",
header: "Actions",
@@ -87,7 +123,14 @@ export const createAdminOrgColumns = (): ColumnDef<AdminOrg, unknown>[] => [
}
return (
<div onClick={(e) => e.stopPropagation()}>
<div onClick={(e) => e.stopPropagation()} className="flex gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => onManageRequestBlocks(row.original)}
>
Block
</Button>
<ImpersonateButton userId={users?.[0]?.id} />
</div>
);

View File

@@ -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<string | undefined>(undefined);
const [before, setBefore] = useState<string | undefined>(undefined);
const [page, setPage] = useState(1);
const [selectedOrg, setSelectedOrg] = useState<AdminOrg | null>(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 (
<div className="space-y-4 flex-1">
<RequestBlockDialog
open={Boolean(selectedOrg)}
onOpenChange={(open) => {
if (!open) {
setSelectedOrg(null);
}
}}
orgId={selectedOrg?.id}
orgName={selectedOrg?.name}
onSaved={async () => {
await refetch();
}}
/>
<div className="flex items-center justify-between">
<h2 className="text-sm font-medium">Organizations</h2>
</div>

View File

@@ -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<void>;
}) {
const axiosInstance = useAxiosInstance();
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [blockAll, setBlockAll] = useState(false);
const [rules, setRules] = useState<RequestBlockRule[]>([]);
const [status, setStatus] = useState<RequestBlockResponse | null>(null);
useEffect(() => {
if (!open || !orgId) {
return;
}
let cancelled = false;
setLoading(true);
void axiosInstance
.get<RequestBlockResponse>(`/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<RequestBlockRule>,
) => {
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl bg-card">
<DialogHeader>
<DialogTitle>Request blocking</DialogTitle>
<DialogDescription>
Manage `/v1` request blocking for {orgName || orgId}.
</DialogDescription>
</DialogHeader>
{loading ? (
<div className="text-sm text-t3">Loading request block state...</div>
) : (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between rounded-lg border border-border p-3">
<div className="flex flex-col gap-1">
<div className="text-sm font-medium text-t1">
Block all `/v1` requests
</div>
<div className="text-xs text-t3">
Use this as the org-wide kill switch.
</div>
</div>
<label className="flex items-center gap-2 text-sm text-t2">
<input
type="checkbox"
checked={blockAll}
onChange={(event) => setBlockAll(event.target.checked)}
/>
Enabled
</label>
</div>
<div className="rounded-lg border border-border p-3">
<div className="mb-3 flex items-center justify-between">
<div>
<div className="text-sm font-medium text-t1">
Selective endpoint rules
</div>
<div className="text-xs text-t3">
Method + pattern rules use exact route matching.
</div>
</div>
<Button
variant="secondary"
size="sm"
onClick={addRule}
>
<Plus className="size-3.5" />
Add rule
</Button>
</div>
<div className="flex flex-col gap-2">
{rules.length === 0 ? (
<div className="text-xs text-t3">
No selective rules configured.
</div>
) : (
rules.map((rule, index) => (
<div key={`${rule.method}-${index}`} className="grid grid-cols-[120px_1fr_auto] gap-2">
<select
className="h-8 rounded-md border border-input bg-input px-2 text-sm"
value={rule.method}
onChange={(event) =>
updateRule(index, {
method: event.target
.value as RequestBlockRule["method"],
})
}
>
{METHODS.map((method) => (
<option key={method} value={method}>
{method}
</option>
))}
</select>
<Input
value={rule.pattern}
onChange={(event) =>
updateRule(index, {
pattern: event.target.value,
})
}
placeholder="/v1/customers/:customer_id"
/>
<Button
variant="secondary"
size="sm"
onClick={() => removeRule(index)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
))
)}
</div>
</div>
<div className="rounded-lg border border-border p-3 text-xs text-t3">
<div className="mb-2 flex items-center gap-2">
<Badge
variant="muted"
className={
status?.configHealthy
? "bg-emerald-50 text-emerald-700 border-emerald-200"
: "bg-amber-50 text-amber-700 border-amber-200"
}
>
{status?.configHealthy ? "Config healthy" : "Config unavailable"}
</Badge>
{status?.lastSuccessAt && (
<span>
Last successful refresh:{" "}
{new Date(status.lastSuccessAt).toLocaleString()}
</span>
)}
</div>
<div>
{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."}
</div>
</div>
</div>
)}
<DialogFooter>
<Button
variant="secondary"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
variant="primary"
onClick={handleSave}
isLoading={saving}
disabled={loading || !canSave}
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}