@starci/hfs 4.9.8 → 4.9.11
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 +28 -0
- package/README.md +14 -0
- package/db/drift.mjs +289 -0
- package/db/dump.mjs +1 -1
- package/db/entity-list.mjs +9 -0
- package/db/migrate.mjs +1 -1
- package/lint/run.mjs +12 -2
- package/package.json +5 -3
- package/release/gate.mjs +81 -18
- package/release/index.mjs +19 -8
- package/release/push.mjs +4 -4
- package/release/records.mjs +54 -0
- package/release/urgent.mjs +129 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +16 -6
- package/runtime/knowledge/hfs/rules.yaml +74 -3
- package/runtime/knowledge/hfs/slots.yaml +36 -3
- package/runtime/modules/kernel/failure-codes.yaml +21 -0
- package/runtime/scripts/hfs/app-wiring.mjs +23 -2
- package/runtime/scripts/hfs/check.mjs +2 -2
- package/runtime/scripts/hfs/manifest-shape.mjs +2 -1
- package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
- package/scaffold/app.mjs +3 -1
- package/src/main.mjs +7 -4
- package/stack/cli.mjs +10 -2
- package/stack/deploy.mjs +21 -4
- package/stack/urgent-floor.mjs +147 -0
- package/sync/ci-commands.mjs +3 -1
- package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +4 -1
- package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +4 -1
- package/templates/app/skeleton-lite/supabase/migrations/__timestamp___baseline.sql +41 -0
- package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +14 -2
- package/templates/be/skeleton/apps/cli/src/compose/platform.ts +3 -2
- package/templates/be/skeleton/src/modules/domain/identity/identity.decorators.ts +18 -7
- package/templates/be/skeleton/src/modules/platform/composition/composition.decorators.ts +3 -0
- package/templates/be/skeleton/src/modules/platform/composition/index.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/errors/errors.filter.spec.ts +15 -1
- package/templates/be/skeleton/src/modules/platform/errors/errors.filter.ts +10 -9
- package/templates/be/skeleton/src/modules/platform/http-security/index.ts +2 -1
- package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.spec.ts +34 -18
- package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.ts +29 -8
- package/templates/be/skeleton/src/modules/platform/http-security/persistence/connection.ts +8 -0
- package/templates/be/skeleton/src/modules/platform/http-security/persistence/entities/rate-window.entity.ts +17 -0
- package/templates/be/skeleton/src/modules/platform/http-security/persistence/migrations/1789900000000-create-rate-windows.ts +24 -0
- package/templates/be/skeleton/src/modules/platform/http-security/persistence/rate-limit.rows.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/http-security/persistence/rate-limit.sql.ts +20 -0
- package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.spec.ts +149 -47
- package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +57 -34
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +18 -8
- package/templates/be/skeleton-lite/src/modules/platform/database/errors/database.error.ts +3 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +2 -1
- package/templates/be/skeleton-lite/src/modules/platform/http-security/index.ts +1 -1
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// urgent-floor.mjs - the safety floor of an urgent deploy (`starci app stack deploy --urgent`, owner ruling urgent-release 2026-10-29). An urgent
|
|
2
|
+
// release skips CI and the full gate, never this floor; each step that fails is a refusal with its own code, never a warning:
|
|
3
|
+
// record the commit carries a pushed URGENT record (release/urgent.mjs) STACK_URGENT_NO_RECORD, STACK_URGENT_NOT_PUSHED
|
|
4
|
+
// backup a fresh full database backup, encrypted to the app's age recipients (db dump) STACK_URGENT_BACKUP_FAILED
|
|
5
|
+
// verified its receipt names this run, its size, age header and sha256 match the archive STACK_URGENT_BACKUP_UNVERIFIED
|
|
6
|
+
// decrypt a test decrypt with the operator's age identity (SOPS_AGE_KEY_FILE) lists every part STACK_URGENT_DECRYPT_UNAVAILABLE, STACK_URGENT_DECRYPT_FAILED
|
|
7
|
+
// render the release renders locally (deploy.mjs inspectRendered) its own STACKS_/STACK_DEPLOY_ refusal
|
|
8
|
+
// rollback an accepted previous release exists, so a red deploy has somewhere to go back to STACK_URGENT_NO_ROLLBACK_TARGET
|
|
9
|
+
// migrate db migrate previews the plan, then executes it STACK_URGENT_MIGRATE_PREVIEW_RED, STACK_URGENT_MIGRATE_FAILED
|
|
10
|
+
// deploy the release with its readiness verify (deploy.verify); red rolls back by itself STACK_URGENT_RED_ROLLED_BACK, STACK_URGENT_ROLLBACK_FAILED,
|
|
11
|
+
// STACK_URGENT_DEPLOY_NOT_STARTED (red before the stack deploy)
|
|
12
|
+
// The public-host checks are never skipped (STACK_URGENT_SKIP_PUBLIC). A rollback cannot undo the migrations: the backup above is their way back.
|
|
13
|
+
// `deps` are the collaborators (backup, decrypt, migrate), each defaulting to the app's own db verbs; a spec injects stubs.
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { readDeclaration } from '../release/index.mjs';
|
|
18
|
+
import { releaseDir } from '../release/gate.mjs';
|
|
19
|
+
import { readUrgent } from '../release/records.mjs';
|
|
20
|
+
import { refusal } from './errors.mjs';
|
|
21
|
+
|
|
22
|
+
const AGE_HEADER = 'age-encryption.org/v1\n';
|
|
23
|
+
const SHA = /^[a-f0-9]{64}$/u;
|
|
24
|
+
|
|
25
|
+
/** The database a floor step reaches: the connection named by --db-url-env, else the linked project of the `database:` block. */
|
|
26
|
+
const targetOf = (options) => (options.dbUrlEnv ? { urlEnv: options.dbUrlEnv } : { linked: true });
|
|
27
|
+
|
|
28
|
+
/** The backups of an urgent deploy live outside every repository: --backup-out, else ~/.starci-backups/<project>. */
|
|
29
|
+
const backupDir = (config, options) => options.backupOut ?? path.join(os.homedir(), '.starci-backups', config.project);
|
|
30
|
+
|
|
31
|
+
async function defaultBackup(ctx, { target, out }) {
|
|
32
|
+
const { dumpDatabase } = await import('../db/dump.mjs');
|
|
33
|
+
return dumpDatabase(ctx, { ...target, out }).data;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function defaultDecrypt(ctx, { archive, identity, parts }) {
|
|
37
|
+
const { backupTool } = await import('../db/backup-receipt.mjs');
|
|
38
|
+
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'starci-decrypt-'));
|
|
39
|
+
try {
|
|
40
|
+
backupTool(ctx, 'age', ['-d', '-i', identity, '-o', path.join(scratch, 'backup.tar'), archive]);
|
|
41
|
+
const listed = String(backupTool(ctx, 'tar', ['-tf', 'backup.tar'], { cwd: scratch }).stdout ?? '').split(/\r?\n/u).map((line) => line.replace(/^\.\//u, '').trim());
|
|
42
|
+
const missing = parts.filter((part) => !listed.includes(part));
|
|
43
|
+
if (missing.length > 0) throw new Error(`the decrypted archive lacks ${missing.join(', ')}`);
|
|
44
|
+
return { parts: listed.filter(Boolean) };
|
|
45
|
+
} finally {
|
|
46
|
+
fs.rmSync(scratch, { recursive: true, force: true });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function defaultMigrate(ctx, { target, execute }) {
|
|
51
|
+
const { loadDatabase, migrateDatabase } = await import('../db/migrate.mjs');
|
|
52
|
+
// The urgent deploy's --execute is the arming of this one migration: the floor runs it, it never waits for a second switch.
|
|
53
|
+
const armed = Object.create(ctx, { env: { value: { ...ctx.env, STARCI_DB_ARMED: '1' } } });
|
|
54
|
+
return migrateDatabase(armed, loadDatabase(ctx.cwd), { ...target, execute });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const FLOOR_DEPS = Object.freeze({ backup: defaultBackup, decrypt: defaultDecrypt, migrate: defaultMigrate });
|
|
58
|
+
|
|
59
|
+
/** A refused floor step: the lines so far and the code; the CLI prints both and exits 2. */
|
|
60
|
+
const refused = (lines, code, message, data = {}) => ({ ok: false, refused: { code, message }, lines, data: { ...data, refused: code } });
|
|
61
|
+
|
|
62
|
+
/** Problems of the backup receipt against its archive: written by this run, a full backup, its size, age header and sha256 equal to the file's. */
|
|
63
|
+
function receiptProblems(receipt, archive, startedMs, sha256) {
|
|
64
|
+
const problems = [];
|
|
65
|
+
if (!receipt || typeof receipt !== 'object') return ['no receipt'];
|
|
66
|
+
if (receipt.schemaOnly !== false) problems.push('the backup is not a full (data) backup');
|
|
67
|
+
if (!Number.isFinite(Date.parse(receipt.at)) || Date.parse(receipt.at) < startedMs - 1000) problems.push('the receipt is not from this run');
|
|
68
|
+
if (!fs.existsSync(archive)) return [...problems, 'the archive is missing'];
|
|
69
|
+
const size = fs.statSync(archive).size;
|
|
70
|
+
const header = Buffer.alloc(AGE_HEADER.length);
|
|
71
|
+
const fd = fs.openSync(archive, 'r');
|
|
72
|
+
try { fs.readSync(fd, header, 0, header.length, 0); } finally { fs.closeSync(fd); }
|
|
73
|
+
if (header.toString('ascii') !== AGE_HEADER) problems.push('the archive has no age header');
|
|
74
|
+
if (size !== receipt.bytes) problems.push(`the archive is ${size} bytes, the receipt says ${receipt.bytes}`);
|
|
75
|
+
if (!SHA.test(String(receipt.sha256 ?? '')) || sha256(archive) !== receipt.sha256) problems.push('the archive sha256 differs from the receipt');
|
|
76
|
+
return problems;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** What the floor needs before anything runs: the pushed urgent record, a declared database, the identity of the test decrypt, the full public verify. */
|
|
80
|
+
function preconditions(ctx, config, options) {
|
|
81
|
+
if (options.skipPublic) throw refusal('STACK_URGENT_SKIP_PUBLIC', 'an urgent deploy verifies the public hosts and accepts the release: drop --skip-public');
|
|
82
|
+
const record = readUrgent(releaseDir(ctx), options.sourceSha);
|
|
83
|
+
if (!record) throw refusal('STACK_URGENT_NO_RECORD', `${options.sourceSha} has no urgent record: push it with starci app release push --urgent --reason "<text>" --execute first`);
|
|
84
|
+
if (record.pushed !== true) throw refusal('STACK_URGENT_NOT_PUSHED', `the urgent release ${record.tag} of ${options.sourceSha.slice(0, 10)} was never pushed`);
|
|
85
|
+
if (!readDeclaration(config.repoRoot ?? ctx.cwd).database) throw refusal('STACK_URGENT_NO_DATABASE', 'the floor backs up and migrates the database the `database:` block declares, and this app declares none');
|
|
86
|
+
const identity = ctx.env.SOPS_AGE_KEY_FILE;
|
|
87
|
+
if (!identity || !fs.existsSync(identity)) throw refusal('STACK_URGENT_DECRYPT_UNAVAILABLE', 'the test decrypt of the backup reads the age identity named by SOPS_AGE_KEY_FILE, which is not set or not a file');
|
|
88
|
+
return { record, identity };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The floor's steps before the deploy: backup, verify and test decrypt (before anything remote), the local render, the rollback target, migrate
|
|
93
|
+
* preview and execute. Returns null when all passed, else the refused result.
|
|
94
|
+
*/
|
|
95
|
+
async function guard(ctx, config, options, { f, run, identity, lines }) {
|
|
96
|
+
const target = targetOf(options);
|
|
97
|
+
const started = ctx.now();
|
|
98
|
+
let backup;
|
|
99
|
+
try { backup = await f.backup(ctx, { target, out: backupDir(config, options) }); } catch (error) {
|
|
100
|
+
return refused(lines, 'STACK_URGENT_BACKUP_FAILED', `the fresh database backup failed (${String(error?.code ?? error?.message ?? error)}); nothing was migrated or deployed`);
|
|
101
|
+
}
|
|
102
|
+
const archive = path.join(path.dirname(backup.receiptPath), backup.receipt?.file ?? '');
|
|
103
|
+
const problems = receiptProblems(backup.receipt, archive, started, run.sha256);
|
|
104
|
+
if (problems.length > 0) return refused(lines, 'STACK_URGENT_BACKUP_UNVERIFIED', `${problems.join('; ')}; nothing was migrated or deployed`);
|
|
105
|
+
lines.push(`floor: backup ${path.basename(archive)} (${backup.receipt.bytes} bytes, sha256 ${backup.receipt.sha256.slice(0, 12)}) receipt ${backup.receiptPath}`);
|
|
106
|
+
try { await f.decrypt(ctx, { archive, identity, parts: backup.receipt.parts }); } catch (error) {
|
|
107
|
+
return refused(lines, 'STACK_URGENT_DECRYPT_FAILED', `the test decrypt of the backup failed (${String(error?.code ?? error?.message ?? error)}); nothing was migrated or deployed`);
|
|
108
|
+
}
|
|
109
|
+
lines.push(`floor: test decrypt listed ${backup.receipt.parts.length} part(s)`);
|
|
110
|
+
const { releaseId } = await run.render();
|
|
111
|
+
lines.push(`floor: release ${releaseId} rendered`);
|
|
112
|
+
const store = await run.openStore();
|
|
113
|
+
if (!(await store.current())) return refused(lines, 'STACK_URGENT_NO_ROLLBACK_TARGET', 'no accepted previous release is stored on the manager, so a red urgent deploy could not roll back; nothing was migrated or deployed');
|
|
114
|
+
let preview;
|
|
115
|
+
try { preview = await f.migrate(ctx, { target, execute: false }); } catch (error) { preview = { ok: false, lines: [String(error?.message ?? error)] }; }
|
|
116
|
+
lines.push(...(preview.lines ?? []).map((line) => `floor: migrate preview: ${line}`));
|
|
117
|
+
if (!preview.ok) return refused(lines, 'STACK_URGENT_MIGRATE_PREVIEW_RED', 'the migration preview is red; nothing was migrated or deployed');
|
|
118
|
+
let applied;
|
|
119
|
+
try { applied = await f.migrate(ctx, { target, execute: true }); } catch (error) { applied = { ok: false, lines: [String(error?.message ?? error)] }; }
|
|
120
|
+
lines.push(...(applied.lines ?? []).map((line) => `floor: migrate: ${line}`));
|
|
121
|
+
if (!applied.ok) return refused(lines, 'STACK_URGENT_MIGRATE_FAILED', `the migration failed; nothing was deployed (restore from ${backup.receiptPath} if the database is half migrated)`);
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* An urgent deploy: the floor's preconditions, then (with execute) its guard steps, then `deployExecute` (the release, its readiness verify and the
|
|
127
|
+
* automatic rollback). `run` = { render(), openStore(), deployExecute(), dryRun(), sha256 }. Returns { ok, lines, data } or a refused result whose
|
|
128
|
+
* data names the urgent release.
|
|
129
|
+
*/
|
|
130
|
+
export async function urgentDeploy(ctx, config, options, run, deps = {}) {
|
|
131
|
+
const f = { ...FLOOR_DEPS, ...deps };
|
|
132
|
+
const { record, identity } = preconditions(ctx, config, options);
|
|
133
|
+
const lines = [`urgent ${record.tag} (${options.sourceSha.slice(0, 10)}) by ${record.role}: ${record.reason}`];
|
|
134
|
+
if (options.execute !== true) {
|
|
135
|
+
const plan = await run.dryRun();
|
|
136
|
+
return { ...plan, lines: [...lines, 'floor (never skipped): fresh encrypted database backup, receipt verify, test decrypt, rollback target, migrate preview then execute, deploy with readiness verify and automatic rollback', ...plan.lines] };
|
|
137
|
+
}
|
|
138
|
+
const stop = await guard(ctx, config, options, { f, run, identity, lines });
|
|
139
|
+
if (stop) return { ...stop, data: { ...stop.data, urgent: record.tag } };
|
|
140
|
+
const deployed = await run.deployExecute();
|
|
141
|
+
const all = [...lines, ...deployed.lines];
|
|
142
|
+
if (deployed.ok) return { ...deployed, lines: all, data: { ...deployed.data, urgent: record.tag } };
|
|
143
|
+
const data = { ...deployed.data, urgent: record.tag };
|
|
144
|
+
if (!(deployed.data?.steps ?? []).some((step) => step.step === 'deploy')) return refused(all, 'STACK_URGENT_DEPLOY_NOT_STARTED', 'the release failed before the stack deploy, so the services are untouched; the migrations stay (backup above)', data);
|
|
145
|
+
if (deployed.data?.rolledBack) return refused(all, 'STACK_URGENT_RED_ROLLED_BACK', `the release failed its deploy or readiness verify and was rolled back to ${deployed.data.previous}; the migrations stay (backup above)`, data);
|
|
146
|
+
return refused(all, 'STACK_URGENT_ROLLBACK_FAILED', 'the release is red and the automatic rollback did not converge: follow the stack runbook, Rollback', data);
|
|
147
|
+
}
|
package/sync/ci-commands.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import { parseYaml } from '../runtime/engine/yaml.mjs';
|
|
3
3
|
import { refusal } from '../stack/errors.mjs';
|
|
4
4
|
import { byCodeUnit } from '../report/order.mjs';
|
|
5
|
+
import { ENTITY_LIST } from '../db/entity-list.mjs';
|
|
5
6
|
|
|
6
7
|
// One command vocabulary for managed CI rendering and the runtime coverage gate.
|
|
7
8
|
const exampleCommand = (source, command) => source?.runtimeExample
|
|
@@ -195,7 +196,8 @@ export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info',
|
|
|
195
196
|
ciLintSonar: exampleCommand(source, 'npm run lint -- --sonar reports/lint.sonar.json'),
|
|
196
197
|
// The coverage judgement alone over the merged report: the fast and lint jobs already ran every other rule of the app check.
|
|
197
198
|
ciCoverageProof: exampleCommand(source, 'npx starci app coverage verify ' + lcovReport),
|
|
198
|
-
|
|
199
|
+
// An app with an entity list also checks its entities against the schema its migrations build, in the build job (a runner with Docker).
|
|
200
|
+
ciDatabaseLint: exampleCommand(source, (source?.files ?? []).includes(ENTITY_LIST.slice('be/'.length)) ? 'npm run db:lint && npx starci app db drift' : 'npm run db:lint'),
|
|
199
201
|
auditCommand: `npm audit${audit.omitDev ? ' --omit=dev' : ''} --audit-level=${audit.level}`,
|
|
200
202
|
sonarTokenSecret: sonar?.token ?? 'SONAR_TOKEN',
|
|
201
203
|
sonarHostVar: sonar?.hostVar ?? 'SONAR_HOST_URL',
|
|
@@ -8,7 +8,8 @@
|
|
|
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
10
|
# secrets SOPS_AGE_KEY (the age identity of .sops.yaml), VPS_SSH_KEY (CI-only deploy key), VPS_KNOWN_HOSTS (pinned host keys)
|
|
11
|
-
# Nothing else is a secret: images are pushed with the workflow token
|
|
11
|
+
# 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
|
+
# waits for (requireCi deploy.prod). Dispatch inputs reach scripts only through env.
|
|
12
13
|
name: deploy stack
|
|
13
14
|
|
|
14
15
|
on:
|
|
@@ -48,6 +49,7 @@ jobs:
|
|
|
48
49
|
permissions:
|
|
49
50
|
contents: read
|
|
50
51
|
packages: write
|
|
52
|
+
actions: read
|
|
51
53
|
env:
|
|
52
54
|
STACK_ENVIRONMENT: ${{ inputs.environment }}
|
|
53
55
|
steps:{{stackGuard}}
|
|
@@ -88,6 +90,7 @@ jobs:
|
|
|
88
90
|
- name: deploy the release
|
|
89
91
|
env:
|
|
90
92
|
XDG_DATA_HOME: ${{ runner.temp }}/starci-data
|
|
93
|
+
GH_TOKEN: ${{ github.token }}
|
|
91
94
|
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}{{stackImageEnv}}{{stackPassEnv}}
|
|
92
95
|
run: |
|
|
93
96
|
npx --no-install starci app stack deploy --env "$STACK_ENVIRONMENT" \{{stackImageArgs}}
|
|
@@ -8,7 +8,8 @@
|
|
|
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
10
|
# secrets SOPS_AGE_KEY (the age identity of .sops.yaml), VPS_SSH_KEY (CI-only deploy key), VPS_KNOWN_HOSTS (pinned host keys)
|
|
11
|
-
# Nothing else is a secret: images are pushed with the workflow token
|
|
11
|
+
# 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
|
+
# waits for (requireCi deploy.prod). Dispatch inputs reach scripts only through env.
|
|
12
13
|
name: deploy stack
|
|
13
14
|
|
|
14
15
|
on:
|
|
@@ -48,6 +49,7 @@ jobs:
|
|
|
48
49
|
permissions:
|
|
49
50
|
contents: read
|
|
50
51
|
packages: write
|
|
52
|
+
actions: read
|
|
51
53
|
env:
|
|
52
54
|
STACK_ENVIRONMENT: ${{ inputs.environment }}
|
|
53
55
|
steps:{{stackGuard}}
|
|
@@ -88,6 +90,7 @@ jobs:
|
|
|
88
90
|
- name: deploy the release
|
|
89
91
|
env:
|
|
90
92
|
XDG_DATA_HOME: ${{ runner.temp }}/starci-data
|
|
93
|
+
GH_TOKEN: ${{ github.token }}
|
|
91
94
|
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}{{stackImageEnv}}{{stackPassEnv}}
|
|
92
95
|
run: |
|
|
93
96
|
npx --no-install starci app stack deploy --env "$STACK_ENVIRONMENT" \{{stackImageArgs}}
|
|
@@ -62,3 +62,44 @@ grant usage on schema public to app_be;
|
|
|
62
62
|
revoke all on table public.profiles from public, anon, authenticated;
|
|
63
63
|
grant select, insert on table public.profiles to app_be;
|
|
64
64
|
grant update (display_name) on table public.profiles to app_be;
|
|
65
|
+
|
|
66
|
+
-- The rate limit windows of platform/http-security: every api replica counts a caller's hits in the same row, and a hit
|
|
67
|
+
-- that opens a fresh window prunes a bounded batch of the ended ones.
|
|
68
|
+
create table public.http_security_rate_windows (
|
|
69
|
+
key varchar(128) primary key,
|
|
70
|
+
hits integer not null check (hits > 0),
|
|
71
|
+
reset_at timestamptz not null
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
create index http_security_rate_windows_reset_at_idx on public.http_security_rate_windows (reset_at);
|
|
75
|
+
|
|
76
|
+
alter table public.http_security_rate_windows enable row level security;
|
|
77
|
+
alter table public.http_security_rate_windows force row level security;
|
|
78
|
+
|
|
79
|
+
create policy http_security_rate_windows_app_be_select
|
|
80
|
+
on public.http_security_rate_windows
|
|
81
|
+
for select
|
|
82
|
+
to app_be
|
|
83
|
+
using (current_user = 'app_be');
|
|
84
|
+
|
|
85
|
+
create policy http_security_rate_windows_app_be_insert
|
|
86
|
+
on public.http_security_rate_windows
|
|
87
|
+
for insert
|
|
88
|
+
to app_be
|
|
89
|
+
with check (current_user = 'app_be');
|
|
90
|
+
|
|
91
|
+
create policy http_security_rate_windows_app_be_update
|
|
92
|
+
on public.http_security_rate_windows
|
|
93
|
+
for update
|
|
94
|
+
to app_be
|
|
95
|
+
using (current_user = 'app_be')
|
|
96
|
+
with check (current_user = 'app_be');
|
|
97
|
+
|
|
98
|
+
create policy http_security_rate_windows_app_be_delete
|
|
99
|
+
on public.http_security_rate_windows
|
|
100
|
+
for delete
|
|
101
|
+
to app_be
|
|
102
|
+
using (current_user = 'app_be');
|
|
103
|
+
|
|
104
|
+
revoke all on table public.http_security_rate_windows from public, anon, authenticated;
|
|
105
|
+
grant select, insert, update, delete on table public.http_security_rate_windows to app_be;
|
|
@@ -6,7 +6,13 @@ import { CONFIG_ERROR_KINDS } from "@modules/platform/config"
|
|
|
6
6
|
import { CqrsModule } from "@modules/platform/cqrs"
|
|
7
7
|
import { DATABASE_ERROR_KINDS, DatabaseModule } from "@modules/platform/database"
|
|
8
8
|
import { ERRORS_MESSAGES, ErrorsModule } from "@modules/platform/errors"
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
HTTP_SECURITY_ERROR_KINDS,
|
|
11
|
+
HTTP_SECURITY_MESSAGES,
|
|
12
|
+
HttpSecurityModule,
|
|
13
|
+
httpSecurityEntities,
|
|
14
|
+
httpSecurityMigrations,
|
|
15
|
+
} from "@modules/platform/http-security"
|
|
10
16
|
import { I18nModule } from "@modules/platform/i18n"
|
|
11
17
|
import { IdsModule } from "@modules/platform/ids"
|
|
12
18
|
import { HTTP_ERROR_KINDS, HTTP_MESSAGES, HttpModule } from "@modules/platform/http"
|
|
@@ -44,6 +50,12 @@ export const platformModules = (options: {{appPascal}}Options): Array<DynamicMod
|
|
|
44
50
|
HttpSecurityModule.register({ isGlobal: true, ...options.httpSecurity }),
|
|
45
51
|
DatabaseModule.register({
|
|
46
52
|
isGlobal: true,
|
|
47
|
-
connections: [
|
|
53
|
+
connections: [
|
|
54
|
+
{
|
|
55
|
+
...options.database,
|
|
56
|
+
entities: [...httpSecurityEntities, ...noteEntities],
|
|
57
|
+
migrations: [...httpSecurityMigrations, ...noteMigrations],
|
|
58
|
+
},
|
|
59
|
+
],
|
|
48
60
|
}),
|
|
49
61
|
]
|
|
@@ -5,14 +5,15 @@ import { DatabaseModule } from "@modules/platform/database"
|
|
|
5
5
|
import type { DatabaseConnectionConfig, DatabaseConnectionOptions } from "@modules/platform/database"
|
|
6
6
|
import { IdsModule } from "@modules/platform/ids"
|
|
7
7
|
import { HttpModule } from "@modules/platform/http"
|
|
8
|
+
import { httpSecurityEntities, httpSecurityMigrations } from "@modules/platform/http-security"
|
|
8
9
|
import { LoggingModule } from "@modules/platform/logging"
|
|
9
10
|
import type { CliAppOptions } from "../cli.options"
|
|
10
11
|
|
|
11
12
|
/** Binds the primary connection to its owners' entities and migrations during composition. */
|
|
12
13
|
const primaryConnectionOf = (primary: DatabaseConnectionConfig): DatabaseConnectionOptions => ({
|
|
13
14
|
...primary,
|
|
14
|
-
entities: noteEntities,
|
|
15
|
-
migrations: noteMigrations,
|
|
15
|
+
entities: [...httpSecurityEntities, ...noteEntities],
|
|
16
|
+
migrations: [...httpSecurityMigrations, ...noteMigrations],
|
|
16
17
|
})
|
|
17
18
|
|
|
18
19
|
/** The clock, logger and declared database connections shared by CLI commands. */
|
|
@@ -1,14 +1,22 @@
|
|
|
1
|
-
import { SetMetadata } from "@nestjs/common"
|
|
1
|
+
import { SetMetadata, applyDecorators } from "@nestjs/common"
|
|
2
2
|
import { injector } from "@modules/platform/composition"
|
|
3
|
-
import type { TypedParameterDecorator } from "@modules/platform/composition"
|
|
3
|
+
import type { DoorDecorator, TypedParameterDecorator } from "@modules/platform/composition"
|
|
4
|
+
import { ServerToServer } from "@modules/platform/http-security"
|
|
4
5
|
import type { CredentialAuthenticator, CredentialMetadata } from "./credential.contracts"
|
|
6
|
+
import { PublicReason } from "./identity.contracts"
|
|
5
7
|
import type { PublicMetadata } from "./identity.contracts"
|
|
6
8
|
|
|
7
9
|
/** Metadata key of `@Public`. */
|
|
8
10
|
export const PUBLIC_KEY = "domain.identity.public"
|
|
9
11
|
|
|
10
|
-
/**
|
|
11
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Opens a door to anonymous callers, stating why; every other door needs a credential or a signed-in caller. A
|
|
14
|
+
* signed webhook proves its caller by its signature, so it owes no browser origin.
|
|
15
|
+
*/
|
|
16
|
+
export const Public = (metadata: PublicMetadata): DoorDecorator =>
|
|
17
|
+
metadata.reason === PublicReason.SignedWebhook
|
|
18
|
+
? applyDecorators(SetMetadata(PUBLIC_KEY, metadata), ServerToServer())
|
|
19
|
+
: applyDecorators(SetMetadata(PUBLIC_KEY, metadata))
|
|
12
20
|
|
|
13
21
|
/** Metadata key of `@Credential`. */
|
|
14
22
|
export const CREDENTIAL_KEY: unique symbol = Symbol("domain.identity.credential")
|
|
@@ -20,6 +28,9 @@ export const CREDENTIAL_AUTHENTICATOR: unique symbol = Symbol("domain.identity.c
|
|
|
20
28
|
export const InjectCredentialAuthenticator = (): TypedParameterDecorator<CredentialAuthenticator> =>
|
|
21
29
|
injector<CredentialAuthenticator>(CREDENTIAL_AUTHENTICATOR)
|
|
22
30
|
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
-
|
|
31
|
+
/**
|
|
32
|
+
* Marks a door authenticated by its own credential; `CredentialGuard` authenticates it and `AuthGuard` admits it. The
|
|
33
|
+
* credential travels in a header no browser attaches by itself, so the door owes no browser origin.
|
|
34
|
+
*/
|
|
35
|
+
export const Credential = (metadata: CredentialMetadata): DoorDecorator =>
|
|
36
|
+
applyDecorators(SetMetadata(CREDENTIAL_KEY, metadata), ServerToServer())
|
|
@@ -8,6 +8,9 @@ export type TypedParameterDecorator<T> = ParameterDecorator & {
|
|
|
8
8
|
readonly __injects?: T
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
/** A decorator a door carries on its method or its class; one built from several metadata decorators is typed with it. */
|
|
12
|
+
export type DoorDecorator = MethodDecorator & ClassDecorator
|
|
13
|
+
|
|
11
14
|
/** Builds the parameter decorator that injects `token`, typed with the value it injects. Every `Inject<Thing>()` is built with it. */
|
|
12
15
|
export const injector = <T>(token: InjectionToken): TypedParameterDecorator<T> => Inject(token)
|
|
13
16
|
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { InjectReflector, injector } from "./composition.decorators"
|
|
2
|
-
export type { TypedParameterDecorator } from "./composition.decorators"
|
|
2
|
+
export type { DoorDecorator, TypedParameterDecorator } from "./composition.decorators"
|
|
@@ -37,7 +37,7 @@ describe("ErrorsFilter", () => {
|
|
|
37
37
|
const { filter, errors, requestLocale } = await build()
|
|
38
38
|
const failure = new Error("note missing")
|
|
39
39
|
const request = mock<Request>({ headers: { "accept-language": "en-US,en;q=0.9" } })
|
|
40
|
-
const response = mock<Response>()
|
|
40
|
+
const response = mock<Response>({ headersSent: false })
|
|
41
41
|
response.status.mockReturnValue(response)
|
|
42
42
|
const http = mock<ReturnType<ArgumentsHost["switchToHttp"]>>()
|
|
43
43
|
http.getRequest.mockReturnValue(request)
|
|
@@ -66,4 +66,18 @@ describe("ErrorsFilter", () => {
|
|
|
66
66
|
params: { noteId: "note-1" },
|
|
67
67
|
})
|
|
68
68
|
})
|
|
69
|
+
|
|
70
|
+
it("leaves a response a guard already sent as it is", async () => {
|
|
71
|
+
const { filter, errors } = await build()
|
|
72
|
+
const response = mock<Response>({ headersSent: true })
|
|
73
|
+
const http = mock<ReturnType<ArgumentsHost["switchToHttp"]>>()
|
|
74
|
+
http.getResponse.mockReturnValue(response)
|
|
75
|
+
const host = mock<ArgumentsHost>()
|
|
76
|
+
host.switchToHttp.mockReturnValue(http)
|
|
77
|
+
|
|
78
|
+
expect(filter.catch(new Error("rate limited"), host)).toBeUndefined()
|
|
79
|
+
|
|
80
|
+
expect(errors.describe).not.toHaveBeenCalled()
|
|
81
|
+
expect(response.status).not.toHaveBeenCalled()
|
|
82
|
+
})
|
|
69
83
|
})
|
|
@@ -9,7 +9,8 @@ import type { ErrorsService } from "./errors.service"
|
|
|
9
9
|
@Catch()
|
|
10
10
|
/**
|
|
11
11
|
* The one REST exception filter of an app: answers `{ code, kind, message, params }` with the status of the kind. In a GraphQL
|
|
12
|
-
* context it hands the failure back untouched, so the GraphQL error formatter (platform/graphql) answers it.
|
|
12
|
+
* context it hands the failure back untouched, so the GraphQL error formatter (platform/graphql) answers it. A response a
|
|
13
|
+
* guard already answered (a redirect-only door over its rate limit) is left as it was sent.
|
|
13
14
|
*/
|
|
14
15
|
export class ErrorsFilter implements ExceptionFilter {
|
|
15
16
|
constructor(
|
|
@@ -21,17 +22,17 @@ export class ErrorsFilter implements ExceptionFilter {
|
|
|
21
22
|
catch(exception: unknown, host: ArgumentsHost): unknown {
|
|
22
23
|
if (host.getType<string>() === "graphql") return exception
|
|
23
24
|
const http = host.switchToHttp()
|
|
25
|
+
const response = http.getResponse<Response>()
|
|
26
|
+
if (response.headersSent) return undefined
|
|
24
27
|
const request = http.getRequest<Request>()
|
|
25
28
|
const description = this.errors.describe(exception)
|
|
26
29
|
const locale = this.requestLocale.of(request.headers["accept-language"])
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
params: description.params,
|
|
34
|
-
})
|
|
30
|
+
response.status(description.status).json({
|
|
31
|
+
code: description.code,
|
|
32
|
+
kind: description.kind,
|
|
33
|
+
message: this.errors.text(description.code, description.params, locale),
|
|
34
|
+
params: description.params,
|
|
35
|
+
})
|
|
35
36
|
return undefined
|
|
36
37
|
}
|
|
37
38
|
}
|
|
@@ -3,7 +3,8 @@ export { parseHttpSecurityConfig } from "./http-security.config"
|
|
|
3
3
|
export { HttpSecurityModule } from "./http-security.module"
|
|
4
4
|
export type { HttpSecurityOptions } from "./http-security.options"
|
|
5
5
|
export { HTTP_SECURITY_MESSAGES } from "./messages/http-security.messages"
|
|
6
|
-
export { OriginGuard } from "./origin.guard"
|
|
6
|
+
export { OriginGuard, ServerToServer } from "./origin.guard"
|
|
7
|
+
export { httpSecurityEntities, httpSecurityMigrations } from "./persistence/connection"
|
|
7
8
|
export { requestOf } from "./execution-request.mapper"
|
|
8
9
|
export { RateLimitGuard } from "./rate-limit.guard"
|
|
9
10
|
export { applyHttpSecurity } from "./edge-security.service"
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import
|
|
1
|
+
import "reflect-metadata"
|
|
2
|
+
import type { ExecutionContext, Type } from "@nestjs/common"
|
|
2
3
|
import type { Request } from "express"
|
|
4
|
+
import { Reflector } from "@nestjs/core"
|
|
3
5
|
import { Test } from "@nestjs/testing"
|
|
4
6
|
import { mock } from "@starci/jest-preset"
|
|
5
7
|
import { HttpSecurityErrorCode } from "./errors/http-security.error"
|
|
6
8
|
import { HTTP_SECURITY_OPTIONS } from "./http-security.decorators"
|
|
7
9
|
import type { HttpSecurityOptions } from "./http-security.options"
|
|
8
|
-
import { OriginGuard } from "./origin.guard"
|
|
10
|
+
import { OriginGuard, ServerToServer } from "./origin.guard"
|
|
9
11
|
|
|
10
12
|
const options: HttpSecurityOptions = {
|
|
11
13
|
allowedOrigins: ["https://app.test"],
|
|
@@ -13,49 +15,63 @@ const options: HttpSecurityOptions = {
|
|
|
13
15
|
webhooks: {},
|
|
14
16
|
}
|
|
15
17
|
|
|
16
|
-
|
|
18
|
+
class OpenDoor {}
|
|
19
|
+
|
|
20
|
+
class SignedDoor {}
|
|
21
|
+
ServerToServer()(SignedDoor)
|
|
22
|
+
|
|
23
|
+
const build = async (method: string, headers: Request["headers"], door: Type<unknown> = OpenDoor) => {
|
|
17
24
|
const request = mock<Request>({ headers, method })
|
|
18
25
|
const http = mock<ReturnType<ExecutionContext["switchToHttp"]>>()
|
|
19
26
|
http.getRequest.mockReturnValue(request)
|
|
20
27
|
const context = mock<ExecutionContext>()
|
|
21
28
|
context.switchToHttp.mockReturnValue(http)
|
|
29
|
+
context.getHandler.mockReturnValue(() => undefined)
|
|
30
|
+
context.getClass.mockReturnValue(door)
|
|
22
31
|
const moduleRef = await Test.createTestingModule({
|
|
23
|
-
providers: [OriginGuard, { provide: HTTP_SECURITY_OPTIONS, useValue: options }],
|
|
32
|
+
providers: [OriginGuard, Reflector, { provide: HTTP_SECURITY_OPTIONS, useValue: options }],
|
|
24
33
|
}).compile()
|
|
25
34
|
return { guard: moduleRef.get(OriginGuard), context }
|
|
26
35
|
}
|
|
27
36
|
|
|
28
37
|
describe("OriginGuard", () => {
|
|
29
|
-
it.each(["GET", "HEAD", "OPTIONS"])("allows the safe %s method
|
|
30
|
-
const { guard, context } = await build(method, {
|
|
38
|
+
it.each(["GET", "HEAD", "OPTIONS"])("allows the safe %s method without an origin", async (method) => {
|
|
39
|
+
const { guard, context } = await build(method, {})
|
|
31
40
|
|
|
32
41
|
expect(guard.canActivate(context)).toBe(true)
|
|
33
42
|
})
|
|
34
43
|
|
|
35
|
-
it.each([{}, { referer: "not a URL" }])(
|
|
36
|
-
"allows a state-changing non-browser request without a usable origin (%o)",
|
|
37
|
-
async (headers) => {
|
|
38
|
-
const { guard, context } = await build("POST", headers)
|
|
39
|
-
|
|
40
|
-
expect(guard.canActivate(context)).toBe(true)
|
|
41
|
-
},
|
|
42
|
-
)
|
|
43
|
-
|
|
44
44
|
it("allows a state-changing request carrying an allowed Origin header", async () => {
|
|
45
45
|
const { guard, context } = await build("POST", { origin: "https://app.test" })
|
|
46
46
|
|
|
47
47
|
expect(guard.canActivate(context)).toBe(true)
|
|
48
48
|
})
|
|
49
49
|
|
|
50
|
-
it("
|
|
50
|
+
it("falls back to the origin of an allowed Referer when Origin is absent", async () => {
|
|
51
51
|
const { guard, context } = await build("PATCH", { referer: "https://app.test/notes/one" })
|
|
52
52
|
|
|
53
53
|
expect(guard.canActivate(context)).toBe(true)
|
|
54
54
|
})
|
|
55
55
|
|
|
56
|
-
it(
|
|
57
|
-
|
|
56
|
+
it.each([
|
|
57
|
+
{ name: "no Origin and no Referer", headers: {} },
|
|
58
|
+
{ name: "a Referer that is not a URL", headers: { referer: "not a URL" } },
|
|
59
|
+
{ name: "an Origin outside the allowlist", headers: { origin: "https://foreign.test" } },
|
|
60
|
+
{ name: "the opaque null Origin", headers: { origin: "null" } },
|
|
61
|
+
{ name: "a Referer outside the allowlist", headers: { referer: "https://foreign.test/form" } },
|
|
62
|
+
{
|
|
63
|
+
name: "a foreign Origin beside an allowed Referer",
|
|
64
|
+
headers: { origin: "https://foreign.test", referer: "https://app.test/notes/one" },
|
|
65
|
+
},
|
|
66
|
+
])("refuses a state-changing request with $name", async ({ headers }) => {
|
|
67
|
+
const { guard, context } = await build("DELETE", headers)
|
|
58
68
|
|
|
59
69
|
expect(() => guard.canActivate(context)).toThrow(HttpSecurityErrorCode.OriginRejected)
|
|
60
70
|
})
|
|
71
|
+
|
|
72
|
+
it("allows a state-changing request without an origin on a door marked server-to-server", async () => {
|
|
73
|
+
const { guard, context } = await build("POST", {}, SignedDoor)
|
|
74
|
+
|
|
75
|
+
expect(guard.canActivate(context)).toBe(true)
|
|
76
|
+
})
|
|
61
77
|
})
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { Injectable } from "@nestjs/common"
|
|
1
|
+
import { Injectable, SetMetadata } from "@nestjs/common"
|
|
2
2
|
import type { CanActivate, ExecutionContext } from "@nestjs/common"
|
|
3
|
+
import type { Reflector } from "@nestjs/core"
|
|
4
|
+
import { InjectReflector } from "@modules/platform/composition"
|
|
3
5
|
import { HttpSecurityError, HttpSecurityErrorCode } from "./errors/http-security.error"
|
|
4
6
|
import { requestOf } from "./execution-request.mapper"
|
|
5
7
|
import { InjectHttpSecurityOptions } from "./http-security.decorators"
|
|
@@ -7,25 +9,44 @@ import type { HttpSecurityOptions } from "./http-security.options"
|
|
|
7
9
|
|
|
8
10
|
const SAFE_METHODS: ReadonlySet<string> = new Set(["GET", "HEAD", "OPTIONS"])
|
|
9
11
|
|
|
12
|
+
const SERVER_TO_SERVER_KEY = "platform.http-security.server-to-server"
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Marks a door whose caller is a server proving itself on every request (a signed webhook, a header credential), so no
|
|
16
|
+
* browser origin is owed. Applied only by the identity decorators that carry that proof: `@Public({ reason:
|
|
17
|
+
* PublicReason.SignedWebhook })` and `@Credential({ kind })`.
|
|
18
|
+
*/
|
|
19
|
+
export const ServerToServer = (): ReturnType<typeof SetMetadata> => SetMetadata(SERVER_TO_SERVER_KEY, true)
|
|
20
|
+
|
|
21
|
+
/** The origin a request names: its Origin header, or the origin of its Referer only when Origin is absent. */
|
|
10
22
|
const originOf = (origin: string | undefined, referer: string | undefined): string | undefined => {
|
|
11
|
-
if (origin) return origin
|
|
12
|
-
return referer && URL.canParse(referer) ? new URL(referer).origin : undefined
|
|
23
|
+
if (origin !== undefined) return origin
|
|
24
|
+
return referer !== undefined && URL.canParse(referer) ? new URL(referer).origin : undefined
|
|
13
25
|
}
|
|
14
26
|
|
|
15
27
|
@Injectable()
|
|
16
28
|
/**
|
|
17
|
-
* The second app guard, the CSRF defence: a state-changing request
|
|
18
|
-
*
|
|
29
|
+
* The second app guard, the CSRF defence: a state-changing request must name an allowed origin, by its Origin header or,
|
|
30
|
+
* when Origin is absent, by its Referer. A request that names none is refused, since a session cookie rides on it
|
|
31
|
+
* whatever sent it. A door marked `ServerToServer` proves its caller itself and owes no origin.
|
|
19
32
|
*/
|
|
20
33
|
export class OriginGuard implements CanActivate {
|
|
21
|
-
constructor(
|
|
34
|
+
constructor(
|
|
35
|
+
@InjectReflector() private readonly reflector: Reflector,
|
|
36
|
+
@InjectHttpSecurityOptions() private readonly options: HttpSecurityOptions,
|
|
37
|
+
) {}
|
|
22
38
|
|
|
23
|
-
/** Refuses a state-changing request
|
|
39
|
+
/** Refuses a state-changing request whose origin is missing or outside the allowlist. */
|
|
24
40
|
canActivate(context: ExecutionContext): boolean {
|
|
25
41
|
const request = requestOf(context)
|
|
26
42
|
if (SAFE_METHODS.has(request.method)) return true
|
|
43
|
+
const exempt = this.reflector.getAllAndOverride<boolean | undefined>(SERVER_TO_SERVER_KEY, [
|
|
44
|
+
context.getHandler(),
|
|
45
|
+
context.getClass(),
|
|
46
|
+
])
|
|
47
|
+
if (exempt === true) return true
|
|
27
48
|
const origin = originOf(request.headers.origin, request.headers.referer)
|
|
28
|
-
if (origin
|
|
49
|
+
if (origin !== undefined && this.options.allowedOrigins.includes(origin)) return true
|
|
29
50
|
throw new HttpSecurityError({ code: HttpSecurityErrorCode.OriginRejected })
|
|
30
51
|
}
|
|
31
52
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { RateWindowEntity } from "./entities/rate-window.entity"
|
|
2
|
+
import { CreateRateWindows1789900000000 } from "./migrations/1789900000000-create-rate-windows"
|
|
3
|
+
|
|
4
|
+
/** The entities of the http-security capability, for the primary connection its rate limit counts in. */
|
|
5
|
+
export const httpSecurityEntities = [RateWindowEntity]
|
|
6
|
+
|
|
7
|
+
/** The migrations of the http-security capability, in the order they run. */
|
|
8
|
+
export const httpSecurityMigrations = [CreateRateWindows1789900000000]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Column, Entity, PrimaryColumn } from "typeorm"
|
|
2
|
+
|
|
3
|
+
@Entity("http_security_rate_windows")
|
|
4
|
+
/** One rate limit window of one caller and tier: the hits it counted and when it ends. */
|
|
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
|
+
}
|