Merge pull request #1137 from malisper/malisper/request-blocking
Add runtime request blocking for orgs
This commit is contained in:
171
.agents/skills/edge-config/SKILL.md
Normal file
171
.agents/skills/edge-config/SKILL.md
Normal 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`
|
||||
23
bun.lock
23
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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
203
scripts/setup/setupS3Admin.ts
Normal file
203
scripts/setup/setupS3Admin.ts
Normal 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();
|
||||
@@ -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",
|
||||
|
||||
29
server/src/external/aws/s3/adminS3Config.ts
vendored
Normal file
29
server/src/external/aws/s3/adminS3Config.ts
vendored
Normal 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
17
server/src/external/aws/s3/initS3.ts
vendored
Normal 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
11
server/src/external/aws/s3/s3Utils.ts
vendored
Normal 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();
|
||||
};
|
||||
60
server/src/honoMiddlewares/requestBlockMiddleware.ts
Normal file
60
server/src/honoMiddlewares/requestBlockMiddleware.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
28
server/src/internal/admin/handleGetAdminOrgRequestBlock.ts
Normal file
28
server/src/internal/admin/handleGetAdminOrgRequestBlock.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
28
server/src/internal/misc/edgeConfig/edgeConfigRegistry.ts
Normal file
28
server/src/internal/misc/edgeConfig/edgeConfigRegistry.ts
Normal 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();
|
||||
};
|
||||
187
server/src/internal/misc/edgeConfig/edgeConfigStore.ts
Normal file
187
server/src/internal/misc/edgeConfig/edgeConfigStore.ts
Normal 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>
|
||||
>;
|
||||
@@ -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>;
|
||||
66
server/src/internal/misc/requestBlocks/requestBlockStore.ts
Normal file
66
server/src/internal/misc/requestBlocks/requestBlockStore.ts
Normal 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];
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
389
server/tests/unit/edge-config/edge-config-store.test.ts
Normal file
389
server/tests/unit/edge-config/edge-config-store.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
296
vite/src/views/admin/components/RequestBlockDialog.tsx
Normal file
296
vite/src/views/admin/components/RequestBlockDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user