@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.
Files changed (51) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +14 -0
  3. package/db/drift.mjs +289 -0
  4. package/db/dump.mjs +1 -1
  5. package/db/entity-list.mjs +9 -0
  6. package/db/migrate.mjs +1 -1
  7. package/lint/run.mjs +12 -2
  8. package/package.json +5 -3
  9. package/release/gate.mjs +81 -18
  10. package/release/index.mjs +19 -8
  11. package/release/push.mjs +4 -4
  12. package/release/records.mjs +54 -0
  13. package/release/urgent.mjs +129 -0
  14. package/runtime/knowledge/hfs/canon-pins.yaml +16 -6
  15. package/runtime/knowledge/hfs/rules.yaml +74 -3
  16. package/runtime/knowledge/hfs/slots.yaml +36 -3
  17. package/runtime/modules/kernel/failure-codes.yaml +21 -0
  18. package/runtime/scripts/hfs/app-wiring.mjs +23 -2
  19. package/runtime/scripts/hfs/check.mjs +2 -2
  20. package/runtime/scripts/hfs/manifest-shape.mjs +2 -1
  21. package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
  22. package/scaffold/app.mjs +3 -1
  23. package/src/main.mjs +7 -4
  24. package/stack/cli.mjs +10 -2
  25. package/stack/deploy.mjs +21 -4
  26. package/stack/urgent-floor.mjs +147 -0
  27. package/sync/ci-commands.mjs +3 -1
  28. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +4 -1
  29. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +4 -1
  30. package/templates/app/skeleton-lite/supabase/migrations/__timestamp___baseline.sql +41 -0
  31. package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +14 -2
  32. package/templates/be/skeleton/apps/cli/src/compose/platform.ts +3 -2
  33. package/templates/be/skeleton/src/modules/domain/identity/identity.decorators.ts +18 -7
  34. package/templates/be/skeleton/src/modules/platform/composition/composition.decorators.ts +3 -0
  35. package/templates/be/skeleton/src/modules/platform/composition/index.ts +1 -1
  36. package/templates/be/skeleton/src/modules/platform/errors/errors.filter.spec.ts +15 -1
  37. package/templates/be/skeleton/src/modules/platform/errors/errors.filter.ts +10 -9
  38. package/templates/be/skeleton/src/modules/platform/http-security/index.ts +2 -1
  39. package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.spec.ts +34 -18
  40. package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.ts +29 -8
  41. package/templates/be/skeleton/src/modules/platform/http-security/persistence/connection.ts +8 -0
  42. package/templates/be/skeleton/src/modules/platform/http-security/persistence/entities/rate-window.entity.ts +17 -0
  43. package/templates/be/skeleton/src/modules/platform/http-security/persistence/migrations/1789900000000-create-rate-windows.ts +24 -0
  44. package/templates/be/skeleton/src/modules/platform/http-security/persistence/rate-limit.rows.ts +5 -0
  45. package/templates/be/skeleton/src/modules/platform/http-security/persistence/rate-limit.sql.ts +20 -0
  46. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.spec.ts +149 -47
  47. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +57 -34
  48. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +18 -8
  49. package/templates/be/skeleton-lite/src/modules/platform/database/errors/database.error.ts +3 -0
  50. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +2 -1
  51. 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
+ }
@@ -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
- ciDatabaseLint: exampleCommand(source, 'npm run db:lint'),
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. Dispatch inputs reach scripts only through env.
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. Dispatch inputs reach scripts only through env.
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 { HTTP_SECURITY_ERROR_KINDS, HTTP_SECURITY_MESSAGES, HttpSecurityModule } from "@modules/platform/http-security"
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: [{ ...options.database, entities: noteEntities, migrations: noteMigrations }],
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
- /** Opens a door to anonymous callers, stating why; every other door needs a credential or a signed-in caller. */
11
- export const Public = (metadata: PublicMetadata): ReturnType<typeof SetMetadata> => SetMetadata(PUBLIC_KEY, metadata)
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
- /** Marks a door authenticated by its own credential; `CredentialGuard` authenticates it and `AuthGuard` admits it. */
24
- export const Credential = (metadata: CredentialMetadata): ReturnType<typeof SetMetadata> =>
25
- SetMetadata(CREDENTIAL_KEY, metadata)
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
- http.getResponse<Response>()
28
- .status(description.status)
29
- .json({
30
- code: description.code,
31
- kind: description.kind,
32
- message: this.errors.text(description.code, description.params, locale),
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 type { ExecutionContext } from "@nestjs/common"
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
- const build = async (method: string, headers: Request["headers"]) => {
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 regardless of origin", async (method) => {
30
- const { guard, context } = await build(method, { origin: "https://foreign.test" })
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("uses the origin of an allowed Referer URL", async () => {
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("refuses a state-changing request from an origin outside the allowlist", async () => {
57
- const { guard, context } = await build("DELETE", { origin: "https://foreign.test" })
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 that names an origin (browsers always do) must name
18
- * an allowed one. A request without Origin and Referer is not a browser and carries no ambient credential, so it passes.
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(@InjectHttpSecurityOptions() private readonly options: HttpSecurityOptions) {}
34
+ constructor(
35
+ @InjectReflector() private readonly reflector: Reflector,
36
+ @InjectHttpSecurityOptions() private readonly options: HttpSecurityOptions,
37
+ ) {}
22
38
 
23
- /** Refuses a state-changing request from an origin outside the allowlist. */
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 === undefined || this.options.allowedOrigins.includes(origin)) return true
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
+ }