@starci/hfs 4.11.8 → 4.11.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/README.md +6 -3
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +6 -6
- package/runtime/knowledge/hfs/rules.yaml +2 -2
- package/runtime/knowledge/hfs/slots.yaml +16 -1
- package/runtime/modules/kernel/failure-codes.yaml +5 -5
- package/runtime/scripts/api/sops/lib.mjs +30 -0
- package/runtime/scripts/api/sops/update-keys.mjs +20 -0
- package/runtime/scripts/hfs/path-findings.mjs +4 -2
- package/runtime/scripts/hfs/rules/deploy-workflow.mjs +19 -1
- package/runtime/scripts/hfs/secret-sops.mjs +15 -0
- package/runtime/scripts/hfs/secret-standard.mjs +54 -0
- package/runtime/scripts/hfs/secret.mjs +36 -6
- package/runtime/scripts/hfs/stack-deploy-config.mjs +11 -4
- package/scaffold/add-inbox-lite.mjs +6 -10
- package/scaffold/add-queue-lite.mjs +7 -2
- package/scaffold/add-table.mjs +5 -0
- package/scaffold/app.mjs +15 -3
- package/scaffold/lite-exports.mjs +23 -0
- package/src/main.mjs +1 -0
- package/stack/age-install.mjs +4 -3
- package/stack/context.mjs +32 -2
- package/stack/remote.mjs +4 -2
- package/stack/server-key.mjs +296 -0
- package/stack/ssh-session.mjs +7 -1
- package/sync/index.mjs +3 -0
- package/templates/app/ci-workflows/github/workflows/images.yml +2 -0
- package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +3 -1
- package/templates/app/ci-workflows/github/workflows/stack-key-restore.yml +58 -0
- package/templates/app/ci-workflows-lite/github/workflows/images.yml +2 -0
- package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +3 -1
- package/templates/app/ci-workflows-lite/github/workflows/stack-key-restore.yml +58 -0
- package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.spec.ts +3 -3
- package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.ts +1 -2
- package/templates/be/skeleton-lite/apps/api/src/api.env.ts +4 -0
- package/templates/be/skeleton-lite/apps/api/src/api.options.ts +3 -0
- package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +1 -1
- package/templates/be/skeleton-lite/src/modules/platform/database/database.options.ts +4 -1
- package/templates/be/skeleton-lite/src/modules/platform/database/entities.ts +4 -1
- package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +0 -2
- package/templates/be/skeleton-lite/src/modules/platform/http-security/index.ts +1 -0
- package/templates/be/skeleton-lite/src/modules/platform/http-security/persistence/entities/index.ts +4 -0
- package/templates/be/skeleton-lite/src/modules/platform/http-security/persistence/entities/rate-window.entity.ts +17 -0
- package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.spec.ts +260 -0
- package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.ts +133 -0
- package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +1 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{{header}}
|
|
2
|
+
# Restores the deploy server's age identity from its GitHub Environment backup (starci app secret server-key <env> --backup github
|
|
3
|
+
# stored it; --restore github dispatches this file). It runs by hand only and writes {{serverAgeIdentity}} on the server when no
|
|
4
|
+
# identity is there and the secret's public key is the recipient input (deploy.remote.serverRecipient), with umask 077 and mode 600,
|
|
5
|
+
# owned by root; it never echoes the identity and never overwrites one.
|
|
6
|
+
# The environment input names the protected GitHub environment of the deploy (required reviewers or main only), which holds:
|
|
7
|
+
# vars VPS_HOST, VPS_USER (the swarm manager)
|
|
8
|
+
# secrets SOPS_AGE_KEY (that environment's server identity, the one its deploy jobs read), VPS_SSH_KEY (CI-only deploy key),
|
|
9
|
+
# VPS_KNOWN_HOSTS (pinned host keys)
|
|
10
|
+
name: restore stack key
|
|
11
|
+
|
|
12
|
+
on:
|
|
13
|
+
workflow_dispatch:
|
|
14
|
+
inputs:
|
|
15
|
+
environment:
|
|
16
|
+
description: Protected environment holding the deploy transport and the server identity backup
|
|
17
|
+
required: true
|
|
18
|
+
type: environment
|
|
19
|
+
recipient:
|
|
20
|
+
description: The public age recipient of the server identity, deploy.remote.serverRecipient of that environment
|
|
21
|
+
required: true
|
|
22
|
+
type: string
|
|
23
|
+
|
|
24
|
+
permissions:
|
|
25
|
+
contents: read
|
|
26
|
+
|
|
27
|
+
concurrency:
|
|
28
|
+
group: stack-${{ inputs.environment }}
|
|
29
|
+
cancel-in-progress: false
|
|
30
|
+
|
|
31
|
+
jobs:
|
|
32
|
+
restore:
|
|
33
|
+
runs-on: ubuntu-latest
|
|
34
|
+
environment: ${{ inputs.environment }}
|
|
35
|
+
timeout-minutes: 10
|
|
36
|
+
steps:
|
|
37
|
+
- name: write the identity on the server
|
|
38
|
+
env:
|
|
39
|
+
RECIPIENT: ${{ inputs.recipient }}
|
|
40
|
+
AGE_IDENTITY: ${{ secrets.SOPS_AGE_KEY }}
|
|
41
|
+
VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
|
|
42
|
+
VPS_KNOWN_HOSTS: ${{ secrets.VPS_KNOWN_HOSTS }}
|
|
43
|
+
VPS_HOST: ${{ vars.VPS_HOST }}
|
|
44
|
+
VPS_USER: ${{ vars.VPS_USER }}
|
|
45
|
+
shell: bash
|
|
46
|
+
run: |
|
|
47
|
+
set -euo pipefail
|
|
48
|
+
[[ "$RECIPIENT" =~ ^age1[a-z0-9]+$ ]] || { echo "recipient must be one public age recipient (age1...)" >&2; exit 1; }
|
|
49
|
+
[ -n "$AGE_IDENTITY" ] || { echo "SOPS_AGE_KEY is not set in this environment" >&2; exit 1; }
|
|
50
|
+
umask 077
|
|
51
|
+
dir="$(mktemp -d)"
|
|
52
|
+
trap 'rm -rf "$dir"' EXIT
|
|
53
|
+
printf '%s\n' "$VPS_SSH_KEY" > "$dir/id"
|
|
54
|
+
printf '%s\n' "$VPS_KNOWN_HOSTS" > "$dir/known_hosts"
|
|
55
|
+
# The server writes the identity beside its target, keeps it only when its public key is the recipient, then moves it in.
|
|
56
|
+
remote="f={{serverAgeIdentity}}; r=$RECIPIENT; "'if [ "$(id -u)" -eq 0 ]; then S=; else S="sudo -n"; fi; if $S test -e "$f"; then cat >/dev/null; echo "the server already holds $f; move it aside first" >&2; exit 3; fi; $S sh -c '"'"'umask 077; f="$1"; r="$2"; d="$(dirname "$f")"; mkdir -p "$d"; t="$(mktemp "$d/.restore.XXXXXX")"; cat > "$t"; if [ "$(age-keygen -y "$t" 2>/dev/null)" != "$r" ]; then rm -f "$t"; echo "the secret is not the identity of $r (or age-keygen is missing); nothing was written" >&2; exit 6; fi; chown root:root "$t"; chmod 600 "$t"; mv "$t" "$f"; age-keygen -y "$f"'"'"' sh "$f" "$r"'
|
|
57
|
+
printf '%s\n' "$AGE_IDENTITY" | ssh -T -i "$dir/id" -o IdentitiesOnly=yes -o UserKnownHostsFile="$dir/known_hosts" \
|
|
58
|
+
-o StrictHostKeyChecking=yes -o BatchMode=yes -o ConnectTimeout=20 "$VPS_USER@$VPS_HOST" "$remote"
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
{{header}}
|
|
2
2
|
# Builds each image declared by the lite app. The API image is required; a front-end image is emitted only when declared.
|
|
3
3
|
# It runs on the release tag, so every image is built there, and never pushes.
|
|
4
|
+
# When an environment declares images, the job is bound to that protected GitHub environment and opens its sealed build
|
|
5
|
+
# arguments with that environment's SOPS_AGE_KEY (its server identity, starci app secret server-key <env> --backup github).
|
|
4
6
|
name: images
|
|
5
7
|
|
|
6
8
|
on:
|
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
# operation rollback redeploys a stored release; "release" is 16 lowercase hex characters, empty means the previous accepted release
|
|
8
8
|
# The environment input names a protected GitHub environment (required reviewers) that holds:
|
|
9
9
|
# vars VPS_HOST, VPS_USER (the swarm manager); a variable named in deploy.passEnv, when the stack interpolates one
|
|
10
|
-
# secrets SOPS_AGE_KEY (
|
|
10
|
+
# secrets SOPS_AGE_KEY (that environment's server identity, stored there by starci app secret server-key <env> --backup github;
|
|
11
|
+
# both jobs are bound to the environment, so they read it and never a repository secret of that name), VPS_SSH_KEY
|
|
12
|
+
# (CI-only deploy key), VPS_KNOWN_HOSTS (pinned host keys)
|
|
11
13
|
# Nothing else is a secret: images are pushed with the workflow token, which also reads the CI verdict of the tag's commit a production deploy
|
|
12
14
|
# waits for (requireCi deploy.prod). Dispatch inputs reach scripts only through env.
|
|
13
15
|
name: deploy stack
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{{header}}
|
|
2
|
+
# Restores the deploy server's age identity from its GitHub Environment backup (starci app secret server-key <env> --backup github
|
|
3
|
+
# stored it; --restore github dispatches this file). It runs by hand only and writes {{serverAgeIdentity}} on the server when no
|
|
4
|
+
# identity is there and the secret's public key is the recipient input (deploy.remote.serverRecipient), with umask 077 and mode 600,
|
|
5
|
+
# owned by root; it never echoes the identity and never overwrites one.
|
|
6
|
+
# The environment input names the protected GitHub environment of the deploy (required reviewers or main only), which holds:
|
|
7
|
+
# vars VPS_HOST, VPS_USER (the swarm manager)
|
|
8
|
+
# secrets SOPS_AGE_KEY (that environment's server identity, the one its deploy jobs read), VPS_SSH_KEY (CI-only deploy key),
|
|
9
|
+
# VPS_KNOWN_HOSTS (pinned host keys)
|
|
10
|
+
name: restore stack key
|
|
11
|
+
|
|
12
|
+
on:
|
|
13
|
+
workflow_dispatch:
|
|
14
|
+
inputs:
|
|
15
|
+
environment:
|
|
16
|
+
description: Protected environment holding the deploy transport and the server identity backup
|
|
17
|
+
required: true
|
|
18
|
+
type: environment
|
|
19
|
+
recipient:
|
|
20
|
+
description: The public age recipient of the server identity, deploy.remote.serverRecipient of that environment
|
|
21
|
+
required: true
|
|
22
|
+
type: string
|
|
23
|
+
|
|
24
|
+
permissions:
|
|
25
|
+
contents: read
|
|
26
|
+
|
|
27
|
+
concurrency:
|
|
28
|
+
group: stack-${{ inputs.environment }}
|
|
29
|
+
cancel-in-progress: false
|
|
30
|
+
|
|
31
|
+
jobs:
|
|
32
|
+
restore:
|
|
33
|
+
runs-on: ubuntu-latest
|
|
34
|
+
environment: ${{ inputs.environment }}
|
|
35
|
+
timeout-minutes: 10
|
|
36
|
+
steps:
|
|
37
|
+
- name: write the identity on the server
|
|
38
|
+
env:
|
|
39
|
+
RECIPIENT: ${{ inputs.recipient }}
|
|
40
|
+
AGE_IDENTITY: ${{ secrets.SOPS_AGE_KEY }}
|
|
41
|
+
VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
|
|
42
|
+
VPS_KNOWN_HOSTS: ${{ secrets.VPS_KNOWN_HOSTS }}
|
|
43
|
+
VPS_HOST: ${{ vars.VPS_HOST }}
|
|
44
|
+
VPS_USER: ${{ vars.VPS_USER }}
|
|
45
|
+
shell: bash
|
|
46
|
+
run: |
|
|
47
|
+
set -euo pipefail
|
|
48
|
+
[[ "$RECIPIENT" =~ ^age1[a-z0-9]+$ ]] || { echo "recipient must be one public age recipient (age1...)" >&2; exit 1; }
|
|
49
|
+
[ -n "$AGE_IDENTITY" ] || { echo "SOPS_AGE_KEY is not set in this environment" >&2; exit 1; }
|
|
50
|
+
umask 077
|
|
51
|
+
dir="$(mktemp -d)"
|
|
52
|
+
trap 'rm -rf "$dir"' EXIT
|
|
53
|
+
printf '%s\n' "$VPS_SSH_KEY" > "$dir/id"
|
|
54
|
+
printf '%s\n' "$VPS_KNOWN_HOSTS" > "$dir/known_hosts"
|
|
55
|
+
# The server writes the identity beside its target, keeps it only when its public key is the recipient, then moves it in.
|
|
56
|
+
remote="f={{serverAgeIdentity}}; r=$RECIPIENT; "'if [ "$(id -u)" -eq 0 ]; then S=; else S="sudo -n"; fi; if $S test -e "$f"; then cat >/dev/null; echo "the server already holds $f; move it aside first" >&2; exit 3; fi; $S sh -c '"'"'umask 077; f="$1"; r="$2"; d="$(dirname "$f")"; mkdir -p "$d"; t="$(mktemp "$d/.restore.XXXXXX")"; cat > "$t"; if [ "$(age-keygen -y "$t" 2>/dev/null)" != "$r" ]; then rm -f "$t"; echo "the secret is not the identity of $r (or age-keygen is missing); nothing was written" >&2; exit 6; fi; chown root:root "$t"; chmod 600 "$t"; mv "$t" "$f"; age-keygen -y "$f"'"'"' sh "$f" "$r"'
|
|
57
|
+
printf '%s\n' "$AGE_IDENTITY" | ssh -T -i "$dir/id" -o IdentitiesOnly=yes -o UserKnownHostsFile="$dir/known_hosts" \
|
|
58
|
+
-o StrictHostKeyChecking=yes -o BatchMode=yes -o ConnectTimeout=20 "$VPS_USER@$VPS_HOST" "$remote"
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Test } from "@nestjs/testing"
|
|
2
|
-
import { mock } from "@starci/jest-preset"
|
|
2
|
+
import { builder, mock } from "@starci/jest-preset"
|
|
3
3
|
import { Agent } from "undici"
|
|
4
4
|
import { HttpErrorCode } from "./errors/http.error"
|
|
5
5
|
import { FetchHttpClient } from "./fetch-http-client.client"
|
|
@@ -10,9 +10,9 @@ import type { HttpOptions } from "./http.options"
|
|
|
10
10
|
const anyOf = (type: unknown): unknown => expect.any(type)
|
|
11
11
|
const objectLike = (shape: Record<string, unknown>): unknown => expect.objectContaining(shape)
|
|
12
12
|
|
|
13
|
-
const build = async (
|
|
13
|
+
const build = async (overrides?: Partial<HttpOptions>): Promise<FetchHttpClient> => {
|
|
14
14
|
const moduleRef = await Test.createTestingModule({
|
|
15
|
-
providers: [FetchHttpClient, { provide: HTTP_OPTIONS, useValue:
|
|
15
|
+
providers: [FetchHttpClient, { provide: HTTP_OPTIONS, useValue: builder<HttpOptions>({})(overrides) }],
|
|
16
16
|
}).compile()
|
|
17
17
|
return moduleRef.get(FetchHttpClient)
|
|
18
18
|
}
|
|
@@ -65,9 +65,8 @@ export class FetchHttpClient implements HttpClient, OnModuleDestroy {
|
|
|
65
65
|
method: request.method,
|
|
66
66
|
headers: this.headersOf(request),
|
|
67
67
|
...(body === undefined ? {} : { body }),
|
|
68
|
-
signal,
|
|
69
68
|
}
|
|
70
|
-
return await fetch(request.url, this.routed(init))
|
|
69
|
+
return await fetch(request.url, { ...this.routed(init), signal })
|
|
71
70
|
} catch (cause) {
|
|
72
71
|
throw this.failure(cause)
|
|
73
72
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { EnvSource } from "@modules/platform/config"
|
|
2
2
|
import { parseServerConfig } from "@modules/platform/config"
|
|
3
3
|
import { parsePrimaryDatabaseConfig } from "@modules/platform/database"
|
|
4
|
+
import { parseHttpConfig } from "@modules/platform/http"
|
|
4
5
|
import { parseHttpSecurityConfig } from "@modules/platform/http-security"
|
|
5
6
|
import { parsePassportConfig } from "@modules/integrations/passport"
|
|
6
7
|
import { parseObjectStorageConfig } from "@modules/integrations/object-storage"
|
|
@@ -11,6 +12,8 @@ export interface ApiEnv {
|
|
|
11
12
|
readonly server: ReturnType<typeof parseServerConfig>
|
|
12
13
|
/** The origin allowlist and rate limits. */
|
|
13
14
|
readonly httpSecurity: ReturnType<typeof parseHttpSecurityConfig>
|
|
15
|
+
/** What the outbound HTTP client trusts beyond Node's bundled certificate authorities. */
|
|
16
|
+
readonly http: ReturnType<typeof parseHttpConfig>
|
|
14
17
|
/** The least-privilege PostgreSQL connection to the app's own database. */
|
|
15
18
|
readonly database: ReturnType<typeof parsePrimaryDatabaseConfig>
|
|
16
19
|
/** Google OAuth, the app's own access tokens and the refresh cookie. */
|
|
@@ -23,6 +26,7 @@ export interface ApiEnv {
|
|
|
23
26
|
export const readApiEnv = (env: EnvSource): ApiEnv => ({
|
|
24
27
|
server: parseServerConfig(env),
|
|
25
28
|
httpSecurity: parseHttpSecurityConfig(env),
|
|
29
|
+
http: parseHttpConfig(env),
|
|
26
30
|
database: parsePrimaryDatabaseConfig(env),
|
|
27
31
|
passport: parsePassportConfig(env),
|
|
28
32
|
objectStorage: parseObjectStorageConfig(env),
|
|
@@ -2,6 +2,7 @@ import type { PassportOptions } from "@modules/integrations/passport"
|
|
|
2
2
|
import type { ObjectStorageOptions } from "@modules/integrations/object-storage"
|
|
3
3
|
import type { ServerOptions } from "@modules/platform/config"
|
|
4
4
|
import type { DatabaseConnectionOptions } from "@modules/platform/database"
|
|
5
|
+
import type { HttpOptions } from "@modules/platform/http"
|
|
5
6
|
import type { HttpSecurityOptions } from "@modules/platform/http-security"
|
|
6
7
|
|
|
7
8
|
/** Everything the API needs from its environment, parsed by `api.env.ts` and handed to `AppModule.register`. */
|
|
@@ -10,6 +11,8 @@ export interface ApiOptions {
|
|
|
10
11
|
readonly server: ServerOptions
|
|
11
12
|
/** The origin allowlist and rate limits. */
|
|
12
13
|
readonly httpSecurity: HttpSecurityOptions
|
|
14
|
+
/** What the outbound HTTP client trusts beyond Node's bundled certificate authorities. */
|
|
15
|
+
readonly http: HttpOptions
|
|
13
16
|
/** The least-privilege PostgreSQL connection to the app's own database. */
|
|
14
17
|
readonly database: DatabaseConnectionOptions
|
|
15
18
|
/** Google OAuth, the app's own access tokens and the refresh cookie. */
|
|
@@ -25,7 +25,7 @@ export const platformModules = (options: ApiOptions): Array<DynamicModule> => [
|
|
|
25
25
|
RetryModule.register({ isGlobal: true }),
|
|
26
26
|
IdsModule.register({ isGlobal: true }),
|
|
27
27
|
LoggingModule.register({ isGlobal: true }),
|
|
28
|
-
HttpModule.register({ isGlobal: true }),
|
|
28
|
+
HttpModule.register({ isGlobal: true, ...options.http }),
|
|
29
29
|
I18nModule.register({
|
|
30
30
|
isGlobal: true,
|
|
31
31
|
bundles: [HTTP_MESSAGES, ERRORS_MESSAGES, HTTP_SECURITY_MESSAGES, IDENTITY_MESSAGES],
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { Secret } from "@modules/platform/config"
|
|
2
2
|
|
|
3
|
+
/** An entity class as TypeORM registers it. */
|
|
4
|
+
export type EntityClass = new () => object
|
|
5
|
+
|
|
3
6
|
/** What the primary connection parser reads from the environment. */
|
|
4
7
|
export interface DatabaseConnectionConfig {
|
|
5
8
|
/** The logical connection name declared in `hfs.json`. */
|
|
@@ -21,5 +24,5 @@ export interface DatabaseOptions {
|
|
|
21
24
|
/** The connections this app opens; one per physical database. */
|
|
22
25
|
readonly connections: ReadonlyArray<DatabaseConnectionOptions>
|
|
23
26
|
/** The TypeORM entities the connections map: PRIMARY_ENTITIES of `@modules/platform/database/entities`, passed by the app's composition root only. */
|
|
24
|
-
readonly entities: ReadonlyArray<
|
|
27
|
+
readonly entities: ReadonlyArray<EntityClass>
|
|
25
28
|
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import { httpSecurityEntities } from "@modules/platform/http-security"
|
|
2
|
+
import type { EntityClass } from "./database.options"
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
5
|
* The entity list of the primary connection: each capability's `<c>Entities` array (its `persistence/entities/index.ts`), by
|
|
3
6
|
* name, no glob (the entity registry, slot be.platform.database.entities). Only app composition and the test world import this
|
|
4
7
|
* file, from the deep path `@modules/platform/database/entities`; DatabaseModule and the database barrel never do, since the
|
|
5
8
|
* capability barrels import the database capability back.
|
|
6
9
|
*/
|
|
7
|
-
export const PRIMARY_ENTITIES: ReadonlyArray<
|
|
10
|
+
export const PRIMARY_ENTITIES: ReadonlyArray<EntityClass> = [...httpSecurityEntities]
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
export { DatabaseModule } from "./database.module"
|
|
2
|
-
export { advisoryXactLock, callDbFunction, notify } from "./database.client"
|
|
3
2
|
export { DATABASE_PROBE, InjectDatabaseProbe, InjectPrimaryEntityManager, PRIMARY_ENTITY_MANAGER } from "./primary.decorators"
|
|
4
3
|
export { DatabaseProbeService } from "./database-probe.service"
|
|
5
4
|
export type { DatabaseConnectionOptions } from "./database.options"
|
|
6
5
|
export { PRIMARY_DATA_SOURCE } from "./database.port"
|
|
7
|
-
export { sql } from "./database.sql"
|
|
8
6
|
export { DATABASE_ERROR_KINDS } from "./errors/database.error"
|
|
9
7
|
export { parsePrimaryDatabaseConfig } from "./primary.config"
|
|
@@ -5,6 +5,7 @@ export { HttpSecurityModule } from "./http-security.module"
|
|
|
5
5
|
export type { HttpSecurityOptions } from "./http-security.options"
|
|
6
6
|
export { HTTP_SECURITY_MESSAGES } from "./messages/http-security.messages"
|
|
7
7
|
export { OriginGuard, ServerToServer } from "./origin.guard"
|
|
8
|
+
export { httpSecurityEntities } from "./persistence/entities"
|
|
8
9
|
export { RateLimitGuard } from "./rate-limit.guard"
|
|
9
10
|
export { applyHttpSecurity } from "./edge-security.service"
|
|
10
11
|
export { ReadinessService } from "./readiness.service"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Column, Entity, PrimaryColumn } from "typeorm"
|
|
2
|
+
|
|
3
|
+
@Entity({ schema: "public", name: "http_security_rate_windows" })
|
|
4
|
+
/** One rate limit window of one caller and tier, mapped from public.http_security_rate_windows of the baseline migration in db/migrations. */
|
|
5
|
+
export class RateWindowEntity {
|
|
6
|
+
/** The tier and caller address the window counts. */
|
|
7
|
+
@PrimaryColumn({ name: "key", type: "varchar", length: 128 })
|
|
8
|
+
key!: string
|
|
9
|
+
|
|
10
|
+
/** The hits counted in the window. */
|
|
11
|
+
@Column({ name: "hits", type: "integer" })
|
|
12
|
+
hits!: number
|
|
13
|
+
|
|
14
|
+
/** When the window ends; a hit after it opens a fresh one. */
|
|
15
|
+
@Column({ name: "reset_at", type: "timestamptz" })
|
|
16
|
+
resetAt!: Date
|
|
17
|
+
}
|
package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.spec.ts
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import "reflect-metadata"
|
|
2
|
+
import type { ExecutionContext, Type } from "@nestjs/common"
|
|
3
|
+
import type { Request, Response } from "express"
|
|
4
|
+
import { Reflector } from "@nestjs/core"
|
|
5
|
+
import { Test } from "@nestjs/testing"
|
|
6
|
+
import { In, LessThanOrEqual, MoreThan } from "typeorm"
|
|
7
|
+
import { FakeClock, mock, mockEntityManager, queueQueryBuilders } from "@starci/jest-preset"
|
|
8
|
+
import type { MockEntityManager } from "@starci/jest-preset"
|
|
9
|
+
import { CLOCK } from "@modules/platform/clock"
|
|
10
|
+
import { PRIMARY_ENTITY_MANAGER } from "@modules/platform/database"
|
|
11
|
+
import { HttpSecurityErrorCode } from "./errors/http-security.error"
|
|
12
|
+
import { HTTP_SECURITY_OPTIONS } from "./http-security.decorators"
|
|
13
|
+
import type { HttpSecurityOptions } from "./http-security.options"
|
|
14
|
+
import { RateWindowEntity } from "./persistence/entities/rate-window.entity"
|
|
15
|
+
import { RateLimit, RateLimitGuard, RateTier } from "./rate-limit.guard"
|
|
16
|
+
import type { RateLimitExceeded } from "./rate-limit.guard"
|
|
17
|
+
|
|
18
|
+
const AT = "2026-02-03T04:05:06.000Z"
|
|
19
|
+
const RESET_AT = "2026-02-03T04:06:06.000Z"
|
|
20
|
+
const options: HttpSecurityOptions = {
|
|
21
|
+
allowedOrigins: [],
|
|
22
|
+
rateLimit: { windowMs: 60_000, defaultLimit: 2, strictLimit: 1 },
|
|
23
|
+
webhooks: {},
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The caller's live window holding `hits`. */
|
|
27
|
+
const windowOf = (hits: number): RateWindowEntity => ({ key: "default:127.0.0.1", hits, resetAt: new Date(RESET_AT) })
|
|
28
|
+
|
|
29
|
+
/** The answers of a store whose live window of the caller holds `hits` after this hit's increment. */
|
|
30
|
+
const liveWindow = (hits: number) =>
|
|
31
|
+
({ increment: [RateWindowEntity, { affected: 1 }], findOneBy: [RateWindowEntity, windowOf(hits)] }) as const
|
|
32
|
+
|
|
33
|
+
/** The answers of a store where this hit opens the caller's window (no live or ended one) and the prune finds none ended. */
|
|
34
|
+
const OPENING_WINDOW = {
|
|
35
|
+
increment: [RateWindowEntity, { affected: 0 }],
|
|
36
|
+
update: [RateWindowEntity, { affected: 0 }],
|
|
37
|
+
find: [RateWindowEntity, []],
|
|
38
|
+
} as const
|
|
39
|
+
|
|
40
|
+
const build = async (entityManager: MockEntityManager, tier?: RateTier, exceeded?: RateLimitExceeded) => {
|
|
41
|
+
const reflector = mock<Reflector>()
|
|
42
|
+
reflector.getAllAndOverride.mockImplementation((key: unknown) =>
|
|
43
|
+
key === "platform.http-security.rate-exceeded" ? exceeded : tier,
|
|
44
|
+
)
|
|
45
|
+
const moduleRef = await Test.createTestingModule({
|
|
46
|
+
providers: [
|
|
47
|
+
RateLimitGuard,
|
|
48
|
+
{ provide: Reflector, useValue: reflector },
|
|
49
|
+
{ provide: CLOCK, useValue: new FakeClock(AT) },
|
|
50
|
+
{ provide: HTTP_SECURITY_OPTIONS, useValue: options },
|
|
51
|
+
{ provide: PRIMARY_ENTITY_MANAGER, useValue: entityManager },
|
|
52
|
+
],
|
|
53
|
+
}).compile()
|
|
54
|
+
return { guard: moduleRef.get(RateLimitGuard), reflector }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** An execution context of one POST from `ip` on the door; the handler and the class carry the tier metadata. */
|
|
58
|
+
const contextOf = (
|
|
59
|
+
ip: string | undefined,
|
|
60
|
+
handler = () => undefined,
|
|
61
|
+
door: Type<unknown> = class Door {},
|
|
62
|
+
response = mock<Response>(),
|
|
63
|
+
) => {
|
|
64
|
+
const http = mock<ReturnType<ExecutionContext["switchToHttp"]>>()
|
|
65
|
+
http.getRequest.mockImplementation(() => mock<Request>({ headers: {}, method: "POST", ip }))
|
|
66
|
+
http.getResponse.mockReturnValue(response)
|
|
67
|
+
const context = mock<ExecutionContext>()
|
|
68
|
+
context.getType.mockReturnValue("http")
|
|
69
|
+
context.switchToHttp.mockReturnValue(http)
|
|
70
|
+
context.getHandler.mockReturnValue(handler)
|
|
71
|
+
context.getClass.mockReturnValue(door)
|
|
72
|
+
return context
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe("RateLimit", () => {
|
|
76
|
+
it("marks a door with the tier it declares, and the guard counts it under that tier's limit", async () => {
|
|
77
|
+
class Door {}
|
|
78
|
+
RateLimit(RateTier.Strict)(Door)
|
|
79
|
+
const entityManager = mockEntityManager(liveWindow(2))
|
|
80
|
+
const { guard, reflector } = await build(entityManager, RateTier.Strict)
|
|
81
|
+
const handler = () => undefined
|
|
82
|
+
|
|
83
|
+
expect(Reflect.getMetadata("platform.http-security.rate-tier", Door)).toBe(RateTier.Strict)
|
|
84
|
+
await expect(guard.canActivate(contextOf("127.0.0.1", handler, Door))).rejects.toMatchObject({
|
|
85
|
+
code: HttpSecurityErrorCode.RateLimited,
|
|
86
|
+
})
|
|
87
|
+
expect(reflector.getAllAndOverride).toHaveBeenCalledWith("platform.http-security.rate-tier", [handler, Door])
|
|
88
|
+
expect(entityManager.increment).toHaveBeenCalledWith(
|
|
89
|
+
RateWindowEntity,
|
|
90
|
+
{ key: "strict:127.0.0.1", resetAt: MoreThan(new Date(AT)) },
|
|
91
|
+
"hits",
|
|
92
|
+
1,
|
|
93
|
+
)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it("marks a redirect-only door with its exceeded redirect beside the tier", () => {
|
|
97
|
+
class Door {}
|
|
98
|
+
RateLimit(RateTier.Strict, { redirect: "/sign-in?auth=error" })(Door)
|
|
99
|
+
|
|
100
|
+
expect(Reflect.getMetadata("platform.http-security.rate-tier", Door)).toBe(RateTier.Strict)
|
|
101
|
+
expect(Reflect.getMetadata("platform.http-security.rate-exceeded", Door)).toEqual({
|
|
102
|
+
redirect: "/sign-in?auth=error",
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it.each(["https://foreign.test/sign-in", "//foreign.test/sign-in", "/\\foreign.test", "sign-in"])(
|
|
107
|
+
"refuses an exceeded redirect %s that is not a same-site path",
|
|
108
|
+
(redirect) => {
|
|
109
|
+
expect(() => RateLimit(RateTier.Strict, { redirect })).toThrow(HttpSecurityErrorCode.RequestInvalid)
|
|
110
|
+
},
|
|
111
|
+
)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
describe("RateLimitGuard", () => {
|
|
115
|
+
it("admits a caller under the default limit of an unmarked door, counting in the shared live window", async () => {
|
|
116
|
+
const entityManager = mockEntityManager(liveWindow(2))
|
|
117
|
+
const { guard } = await build(entityManager)
|
|
118
|
+
|
|
119
|
+
await expect(guard.canActivate(contextOf("127.0.0.1"))).resolves.toBe(true)
|
|
120
|
+
expect(entityManager.findOneBy).toHaveBeenCalledWith(RateWindowEntity, { key: "default:127.0.0.1" })
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it("refuses a caller whose shared window is over the default limit", async () => {
|
|
124
|
+
const { guard } = await build(mockEntityManager(liveWindow(3)))
|
|
125
|
+
|
|
126
|
+
await expect(guard.canActivate(contextOf("127.0.0.1"))).rejects.toMatchObject({
|
|
127
|
+
code: HttpSecurityErrorCode.RateLimited,
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it("refuses the request when the counted window vanished before it was read", async () => {
|
|
132
|
+
const entityManager = mockEntityManager({
|
|
133
|
+
increment: [RateWindowEntity, { affected: 1 }],
|
|
134
|
+
findOneBy: [RateWindowEntity, null],
|
|
135
|
+
})
|
|
136
|
+
const { guard } = await build(entityManager)
|
|
137
|
+
|
|
138
|
+
await expect(guard.canActivate(contextOf("127.0.0.1"))).rejects.toMatchObject({
|
|
139
|
+
code: HttpSecurityErrorCode.RateLimited,
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
describe("RateLimitGuard opening a window", () => {
|
|
145
|
+
it("restarts an ended window at one hit and prunes the ended windows", async () => {
|
|
146
|
+
const ended = windowOf(5)
|
|
147
|
+
const entityManager = mockEntityManager({
|
|
148
|
+
increment: [RateWindowEntity, {}],
|
|
149
|
+
update: [RateWindowEntity, { affected: 1 }],
|
|
150
|
+
find: [RateWindowEntity, [ended]],
|
|
151
|
+
delete: [RateWindowEntity, { affected: 1 }],
|
|
152
|
+
})
|
|
153
|
+
const { guard } = await build(entityManager)
|
|
154
|
+
|
|
155
|
+
await expect(guard.canActivate(contextOf("127.0.0.1"))).resolves.toBe(true)
|
|
156
|
+
expect(entityManager.update).toHaveBeenCalledWith(
|
|
157
|
+
RateWindowEntity,
|
|
158
|
+
{ key: "default:127.0.0.1", resetAt: LessThanOrEqual(new Date(AT)) },
|
|
159
|
+
{ hits: 1, resetAt: new Date(RESET_AT) },
|
|
160
|
+
)
|
|
161
|
+
expect(entityManager.find).toHaveBeenCalledWith(RateWindowEntity, {
|
|
162
|
+
select: { key: true },
|
|
163
|
+
where: { resetAt: LessThanOrEqual(new Date(AT)) },
|
|
164
|
+
order: { resetAt: "ASC" },
|
|
165
|
+
take: 100,
|
|
166
|
+
})
|
|
167
|
+
expect(entityManager.delete).toHaveBeenCalledWith(RateWindowEntity, {
|
|
168
|
+
key: In([ended.key]),
|
|
169
|
+
resetAt: LessThanOrEqual(new Date(AT)),
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it("opens a missing window at one hit, ignoring a concurrent opener, and deletes nothing when no window ended", async () => {
|
|
174
|
+
const entityManager = mockEntityManager(OPENING_WINDOW)
|
|
175
|
+
const [opened] = queueQueryBuilders(entityManager, [{ execute: {} }])
|
|
176
|
+
const { guard } = await build(entityManager)
|
|
177
|
+
|
|
178
|
+
await expect(guard.canActivate(contextOf("127.0.0.1"))).resolves.toBe(true)
|
|
179
|
+
expect(opened?.callsOf("values")).toEqual([
|
|
180
|
+
[{ key: "default:127.0.0.1", hits: 1, resetAt: new Date(RESET_AT) }],
|
|
181
|
+
])
|
|
182
|
+
expect(opened?.callsOf("orIgnore")).toEqual([[]])
|
|
183
|
+
expect(entityManager.delete).not.toHaveBeenCalled()
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it("opens a window when the restart answers no affected count", async () => {
|
|
187
|
+
const entityManager = mockEntityManager({
|
|
188
|
+
increment: [RateWindowEntity, { affected: 0 }],
|
|
189
|
+
update: [RateWindowEntity, {}],
|
|
190
|
+
find: [RateWindowEntity, []],
|
|
191
|
+
})
|
|
192
|
+
const [opened] = queueQueryBuilders(entityManager, [{ execute: {} }])
|
|
193
|
+
const { guard } = await build(entityManager)
|
|
194
|
+
|
|
195
|
+
await expect(guard.canActivate(contextOf("127.0.0.1"))).resolves.toBe(true)
|
|
196
|
+
expect(opened?.callsOf("insert")).toEqual([[]])
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
describe("RateLimitGuard keys", () => {
|
|
201
|
+
it("counts a request without an address under the unknown key", async () => {
|
|
202
|
+
const entityManager = mockEntityManager(liveWindow(2))
|
|
203
|
+
const { guard } = await build(entityManager)
|
|
204
|
+
|
|
205
|
+
await expect(guard.canActivate(contextOf(undefined))).resolves.toBe(true)
|
|
206
|
+
expect(entityManager.findOneBy).toHaveBeenCalledWith(RateWindowEntity, { key: "default:unknown" })
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it("bounds the window key to the length the table stores", async () => {
|
|
210
|
+
const entityManager = mockEntityManager(liveWindow(2))
|
|
211
|
+
const { guard } = await build(entityManager)
|
|
212
|
+
|
|
213
|
+
await guard.canActivate(contextOf("f".repeat(300)))
|
|
214
|
+
|
|
215
|
+
expect(entityManager.findOneBy).toHaveBeenCalledWith(RateWindowEntity, { key: `default:${"f".repeat(120)}` })
|
|
216
|
+
})
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
describe("RateLimitGuard on a redirect-only door", () => {
|
|
220
|
+
const EXCEEDED: RateLimitExceeded = { redirect: "/sign-in?auth=error" }
|
|
221
|
+
|
|
222
|
+
/** A response whose redirect is a plain double, so the spec reads the call without the deprecated overload. */
|
|
223
|
+
const responseOf = () => {
|
|
224
|
+
const redirect = jest.fn()
|
|
225
|
+
return { response: mock<Response>({ redirect }), redirect }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
it("answers the door's own 303 when the caller is over the limit, and still refuses", async () => {
|
|
229
|
+
const { guard } = await build(mockEntityManager(liveWindow(2)), RateTier.Strict, EXCEEDED)
|
|
230
|
+
const { response, redirect } = responseOf()
|
|
231
|
+
|
|
232
|
+
await expect(
|
|
233
|
+
guard.canActivate(contextOf("127.0.0.1", () => undefined, class Door {}, response)),
|
|
234
|
+
).rejects.toMatchObject({ code: HttpSecurityErrorCode.RateLimited })
|
|
235
|
+
expect(redirect).toHaveBeenCalledWith(303, "/sign-in?auth=error")
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it("writes no redirect while the caller is under the limit", async () => {
|
|
239
|
+
const entityManager = mockEntityManager(OPENING_WINDOW)
|
|
240
|
+
queueQueryBuilders(entityManager, [{ execute: {} }])
|
|
241
|
+
const { guard } = await build(entityManager, RateTier.Strict, EXCEEDED)
|
|
242
|
+
const { response, redirect } = responseOf()
|
|
243
|
+
|
|
244
|
+
await expect(guard.canActivate(contextOf("127.0.0.1", () => undefined, class Door {}, response))).resolves.toBe(
|
|
245
|
+
true,
|
|
246
|
+
)
|
|
247
|
+
expect(redirect).not.toHaveBeenCalled()
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it("refuses a GraphQL operation over the limit without writing a redirect", async () => {
|
|
251
|
+
const { guard } = await build(mockEntityManager(liveWindow(2)), RateTier.Strict, EXCEEDED)
|
|
252
|
+
const { response, redirect } = responseOf()
|
|
253
|
+
const context = contextOf("127.0.0.1", () => undefined, class Door {}, response)
|
|
254
|
+
context.getType.mockReturnValue("graphql")
|
|
255
|
+
context.getArgs.mockReturnValue([{}, {}, { req: mock<Request>({ headers: {}, ip: "127.0.0.1" }) }, {}])
|
|
256
|
+
|
|
257
|
+
await expect(guard.canActivate(context)).rejects.toMatchObject({ code: HttpSecurityErrorCode.RateLimited })
|
|
258
|
+
expect(redirect).not.toHaveBeenCalled()
|
|
259
|
+
})
|
|
260
|
+
})
|