@ftisindia/create-app 0.1.6 → 0.3.0

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 (45) hide show
  1. package/package.json +1 -1
  2. package/template/.env.example +3 -0
  3. package/template/_package.json +7 -3
  4. package/template/docs/FORMS.md +87 -18
  5. package/template/docs/FORMS_CHECKLIST.md +36 -26
  6. package/template/docs/REPORTS.md +22 -4
  7. package/template/docs/REPORTS_CHECKLIST.md +150 -95
  8. package/template/prisma/migrations/20260616000000_add_form_outbox_claimed_by/migration.sql +5 -0
  9. package/template/prisma/migrations/20260625000000_form_builder_public_uploads/migration.sql +7 -0
  10. package/template/prisma/schema.prisma +13 -6
  11. package/template/scripts/push-report.ts +123 -0
  12. package/template/src/app.module.ts +4 -3
  13. package/template/src/common/swagger/api-error-responses.ts +8 -2
  14. package/template/src/config/env.validation.ts +3 -0
  15. package/template/src/config/forms.config.ts +1 -0
  16. package/template/src/config/reports.config.ts +2 -0
  17. package/template/src/modules/forms/application/services/data-sources/conference-tracks.data-source.ts +32 -0
  18. package/template/src/modules/forms/application/services/forms-files.service.ts +143 -39
  19. package/template/src/modules/forms/application/services/forms-public.service.ts +2 -1
  20. package/template/src/modules/forms/application/services/forms-settings-reader.service.ts +5 -3
  21. package/template/src/modules/forms/application/services/handlers/webhook-delivery.transport.ts +319 -0
  22. package/template/src/modules/forms/application/services/handlers/webhook.handler.ts +64 -16
  23. package/template/src/modules/forms/application/services/outbox-dispatcher.service.ts +40 -18
  24. package/template/src/modules/forms/dto/public-file-upload-response.dto.ts +10 -0
  25. package/template/src/modules/forms/dto/public-submit-form.dto.ts +9 -0
  26. package/template/src/modules/forms/forms.module.ts +12 -2
  27. package/template/src/modules/forms/infrastructure/stores/prisma-file.store.ts +43 -3
  28. package/template/src/modules/forms/infrastructure/stores/prisma-outbox.store.ts +82 -59
  29. package/template/src/modules/forms/presentation/public-forms-files.controller.ts +66 -0
  30. package/template/src/modules/reports/application/services/reports-export-dispatcher.service.ts +81 -0
  31. package/template/src/modules/reports/application/services/reports-exports.service.ts +6 -2
  32. package/template/src/modules/reports/infrastructure/storage/local-disk-export-storage.adapter.ts +43 -30
  33. package/template/src/modules/settings/types/setting-definitions.ts +4 -0
  34. package/template/test/forms-captcha.e2e-spec.ts +163 -0
  35. package/template/test/forms-files.e2e-spec.ts +42 -20
  36. package/template/test/forms-outbox.e2e-spec.ts +271 -10
  37. package/template/test/forms-public.e2e-spec.ts +24 -0
  38. package/template/test/forms-submissions.e2e-spec.ts +2 -11
  39. package/template/test/forms-throttling.e2e-spec.ts +146 -0
  40. package/template/test/forms-webhooks.e2e-spec.ts +150 -8
  41. package/template/test/jest-e2e.json +1 -0
  42. package/template/test/reports-advanced.e2e-spec.ts +13 -0
  43. package/template/test/reports-query.e2e-spec.ts +52 -0
  44. package/template/test/reports-tiers.e2e-spec.ts +106 -20
  45. package/template/test/route-registry.validator.spec.ts +4 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ftisindia/create-app",
3
- "version": "0.1.6",
3
+ "version": "0.3.0",
4
4
  "description": "One-command scaffolder for the Phase 1 NestJS foundation starter.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -48,6 +48,7 @@ TEST_ORG_SLUG=demo
48
48
  # Form builder (@ftisindia/form-builder)
49
49
  FORMS_OUTBOX_ENABLED=true
50
50
  FORMS_OUTBOX_POLL_MS=5000
51
+ FORMS_OUTBOX_HEARTBEAT_MS=60000
51
52
  FORMS_FILE_GC_INTERVAL_MS=3600000
52
53
  FORMS_FILE_TEMP_TTL_HOURS=24
53
54
  FORMS_MAX_UPLOAD_MB=25
@@ -65,3 +66,5 @@ REPORTS_TOKEN_SECRET=
65
66
  REPORTS_EXPORT_WORKER_ENABLED=true
66
67
  REPORTS_EXPORT_POLL_MS=5000
67
68
  REPORTS_EXPORT_STORAGE_DIR=./var/report-exports
69
+ REPORTS_EXPORT_RETENTION_DAYS=7
70
+ REPORTS_EXPORT_RETENTION_SWEEP_MS=3600000
@@ -10,6 +10,7 @@
10
10
  "format": "prettier --write .",
11
11
  "format:check": "prettier --check .",
12
12
  "forms:push": "ts-node scripts/push-form.ts",
13
+ "reports:push": "ts-node scripts/push-report.ts",
13
14
  "gen:form": "node scripts/gen-form.mjs",
14
15
  "gen:module": "node scripts/gen-module.mjs",
15
16
  "lint": "eslint .",
@@ -27,14 +28,14 @@
27
28
  "setup:ci": "node scripts/setup-local.mjs --yes",
28
29
  "test": "jest",
29
30
  "test:cov": "jest --coverage",
30
- "test:e2e": "node scripts/test-db.mjs && jest --config test/jest-e2e.json --runInBand",
31
+ "test:e2e": "node scripts/test-db.mjs && node ./node_modules/jest/bin/jest.js --config test/jest-e2e.json --runInBand",
31
32
  "test:watch": "jest --watch",
32
33
  "db:reset": "prisma migrate reset"
33
34
  },
34
35
  "dependencies": {
35
36
  "@casl/ability": "6.8.1",
36
- "@ftisindia/form-builder": "^0.1.0",
37
- "@ftisindia/report-builder": "^0.1.0",
37
+ "@ftisindia/form-builder": "^1.0.0",
38
+ "@ftisindia/report-builder": "^0.3.0",
38
39
  "@nestjs/common": "11.1.24",
39
40
  "@nestjs/config": "4.0.4",
40
41
  "@nestjs/core": "11.1.24",
@@ -84,6 +85,9 @@
84
85
  "typescript-eslint": "8.60.0",
85
86
  "typescript": "5.9.3"
86
87
  },
88
+ "overrides": {
89
+ "js-yaml": "4.2.0"
90
+ },
87
91
  "lint-staged": {
88
92
  "*.{js,mjs,ts,json,md,yml,yaml}": "prettier --write",
89
93
  "*.{js,mjs,ts}": "eslint --fix"
@@ -38,16 +38,25 @@ json-logic rules + data-source membership + file references) → the action
38
38
  pipeline: validation actions, then **all transactional actions in ONE
39
39
  database transaction** (submission, action logs, audit rows, outbox rows
40
40
  commit or roll back together), then post-commit side effects via the
41
- **transactional outbox** (in-process poller, retries with backoff, parks as
42
- FAILED after max attempts; delivery writes an audit row). A failed email
43
- never rolls back a valid submission; a failed transactional step rolls back
44
- everything.
41
+ **transactional outbox** (in-process poller with Postgres row-lock claiming,
42
+ idempotent enqueue, claim-token fencing, active-job heartbeat, retries with
43
+ backoff, parks as FAILED after max attempts; delivery writes an audit row). A
44
+ failed email never rolls back a valid submission; a failed transactional step
45
+ rolls back everything.
45
46
 
46
47
  ## Adding behavior (the three registries)
47
48
 
48
49
  Implement the engine interface, decorate the provider, list it in a module —
49
50
  the registry bootstrap finds it at startup:
50
51
 
52
+ Definitions can reference a `dataSource.key` only after a matching provider is
53
+ registered. This template ships `ConferenceTracksDataSource` in
54
+ `src/modules/forms/application/services/data-sources/` for the bundled
55
+ `abstract-submission` example (`conference-tracks`). It is intentionally static
56
+ so fresh apps can publish and render the example immediately; replace it with an
57
+ org-scoped Prisma/API lookup before using real production master data. An
58
+ unregistered source fails save/publish lint with `UNKNOWN_DATA_SOURCE`.
59
+
51
60
  ```ts
52
61
  import { FormDataSource } from './modules/forms/infrastructure/registry/form-extension.decorators';
53
62
  import type { DataSourceDef, DataSourceContext, DataSourceOption } from '@ftisindia/form-builder';
@@ -57,11 +66,16 @@ import type { DataSourceDef, DataSourceContext, DataSourceOption } from '@ftisin
57
66
  export class TrackSource implements DataSourceDef {
58
67
  key = 'conference-tracks';
59
68
  constructor(private readonly prisma: PrismaService) {}
60
- async fetch(_params: Record<string, unknown>, ctx: DataSourceContext): Promise<DataSourceOption[]> {
61
- return this.prisma.track.findMany({
62
- where: { orgId: ctx.orgId, active: true },
63
- select: { id: true, name: true },
64
- }).then((rows) => rows.map((row) => ({ value: row.id, label: row.name })));
69
+ async fetch(
70
+ _params: Record<string, unknown>,
71
+ ctx: DataSourceContext,
72
+ ): Promise<DataSourceOption[]> {
73
+ return this.prisma.track
74
+ .findMany({
75
+ where: { orgId: ctx.orgId, active: true },
76
+ select: { id: true, name: true },
77
+ })
78
+ .then((rows) => rows.map((row) => ({ value: row.id, label: row.name })));
65
79
  }
66
80
  }
67
81
  ```
@@ -89,9 +103,51 @@ Flip a published definition with `POST .../public-access { "access": "public" }`
89
103
  (requires `forms.managePublicAccess`, writes an audit row). Public routes are
90
104
  throttled tighter than the app default; the engine enforces the per-IP/day
91
105
  cap (`forms.maxSubmissionsPerIpPerDay` setting, per-form override in the
92
- definition's `settings`) and the captcha seam (`settings.captcha: true`
106
+ definition's `settings`) and the captcha seam (`settings.captcha: true` -
93
107
  publish fails closed unless a `CaptchaVerifier` is bound to the
94
- `FORMS_CAPTCHA_VERIFIER` token). Public forms cannot contain file fields.
108
+ `FORMS_CAPTCHA_VERIFIER` token). Public file fields are allowed through the
109
+ public upload endpoint: upload first, then submit `{ fileId }` in `data` and the
110
+ returned token in `uploadTokens[fileId]`. Anonymous upload volume is capped by
111
+ `forms.maxPublicUploadsPerIpPerDay`. Like the public submission cap, it depends on a resolvable request IP; configure proxy/IP handling correctly in deployments behind a reverse proxy.
112
+
113
+ A captcha-enabled public form keeps failing closed until the app binds a real
114
+ verifier. Example Turnstile-style adapter:
115
+
116
+ ```ts
117
+ import { Injectable, Module } from '@nestjs/common';
118
+ import { ConfigService } from '@nestjs/config';
119
+ import type { CaptchaVerifier, FormsContext } from '@ftisindia/form-builder';
120
+ import { FORMS_CAPTCHA_VERIFIER } from './modules/forms/forms.tokens';
121
+
122
+ @Injectable()
123
+ export class TurnstileCaptchaVerifier implements CaptchaVerifier {
124
+ constructor(private readonly config: ConfigService) {}
125
+
126
+ async verify(token: string, _ctx: FormsContext): Promise<boolean> {
127
+ const body = new URLSearchParams({
128
+ secret: this.config.getOrThrow<string>('TURNSTILE_SECRET_KEY'),
129
+ response: token,
130
+ });
131
+ const response = await fetch(
132
+ 'https://challenges.cloudflare.com/turnstile/v0/siteverify',
133
+ { method: 'POST', body },
134
+ );
135
+ const result = (await response.json()) as { success?: boolean };
136
+ return result.success === true;
137
+ }
138
+ }
139
+
140
+ @Module({
141
+ providers: [
142
+ { provide: FORMS_CAPTCHA_VERIFIER, useClass: TurnstileCaptchaVerifier },
143
+ ],
144
+ })
145
+ export class AppCaptchaModule {}
146
+ ```
147
+
148
+ After that binding is present, a definition can opt in with
149
+ `settings.captcha: true`; without the binding, publish fails before the form can
150
+ go public.
95
151
 
96
152
  ## Files
97
153
 
@@ -119,19 +175,32 @@ A definition can declare webhook destinations that fire when a submission is
119
175
  - **Reliable by construction:** the job is written to the transactional outbox
120
176
  inside the same transaction as the submission, then delivered post-commit
121
177
  with retries/backoff (parks as FAILED after max attempts; success writes a
122
- `forms.outbox.delivered` audit row). Idempotency key: `<submissionId>:webhook:<index>`.
178
+ `forms.outbox.delivered` audit row). The Prisma store claims jobs with
179
+ `FOR UPDATE SKIP LOCKED`, assigns an opaque claim token, heartbeats active
180
+ `PROCESSING` rows with that token, and guards `DONE`/`PENDING`/`FAILED`
181
+ transitions with the same token so stale workers cannot settle reclaimed
182
+ jobs. A duplicate idempotency key is a no-op so retries do not roll back the
183
+ submission. Idempotency key: `<submissionId>:webhook:<index>`, also sent as
184
+ `x-forms-idempotency-key` for receiver-side dedupe.
123
185
  - **SSRF-gated:** the destination host must be in the org's audited
124
186
  `forms.webhookAllowedHosts` typed setting (default empty = webhooks
125
187
  disabled), and the URL must be `https` — `http` is allowed for loopback
126
188
  hosts only (local development). Enforced at save AND publish lint
127
- (`WEBHOOK_HOST_NOT_ALLOWED`, `WEBHOOK_URL_INSECURE`, …).
189
+ (`WEBHOOK_HOST_NOT_ALLOWED`, `WEBHOOK_URL_INSECURE`, …). Delivery does a
190
+ fresh DNS resolution, rejects private/reserved/link-local destinations in
191
+ `NODE_ENV=production`, pins the request to the resolved IP while preserving
192
+ Host/SNI, and refuses redirects instead of following them.
128
193
  - **Payload:** `{ event: 'submitted', orgId, formKey, formVersion, submissionId,
129
- occurredAt, data? }` — `data` passes sensitive-field redaction; set
194
+ occurredAt, data? }` — `data` passes sensitive-field redaction; set
130
195
  `includeData: false` to send a pure notification.
131
196
  - **Signed:** with a `secret`, deliveries carry
132
- `x-forms-signature: sha256=<hmac-sha256(rawBody)>`. The secret lives in the
133
- definition document (DB) treat it as a rotatable shared token, not a
134
- master credential. Max 5 webhooks per definition.
197
+ `x-forms-signature: sha256=<hmac-sha256(rawBody)>`. The engine computes the
198
+ signature before enqueueing; the outbox payload stores the raw body and
199
+ signature, not the shared secret. Old-format jobs containing `secret` are
200
+ sanitized to the new `{ rawBody, signature }` payload before delivery. The
201
+ secret lives only in the definition
202
+ document (DB) — treat it as a rotatable shared token, not a master
203
+ credential. Max 5 webhooks per definition.
135
204
 
136
205
  ## Definitions as code (`gen:form` + `forms:push`)
137
206
 
@@ -142,7 +211,7 @@ npm run forms:push -- --file src/modules/forms/definitions/customer-feedback.for
142
211
  --org demo --user owner@example.com --publish
143
212
  ```
144
213
 
145
- `gen:form` writes `src/modules/forms/definitions/<key>.form.json` and
214
+ Example files under `src/modules/forms/examples/` are reference fixtures; copy or scaffold into `src/modules/forms/definitions/` before pushing. `gen:form` writes `src/modules/forms/definitions/<key>.form.json` and
146
215
  `test/<key>.form.spec.ts` (meta-schema + publish-stage lint — fails `npm test`
147
216
  the moment the definition would be rejected by the API). `forms:push` loads a
148
217
  definition file into an org through the real engine path: it impersonates an
@@ -1,61 +1,71 @@
1
1
  # Forms Module Completion Checklist
2
2
 
3
- The forms module must pass `MODULE_COMPLETION_CHECKLIST.md` verbatim, **plus**
4
- these form-builder-specific items (ecosystem guide §11). Re-verify after any
5
- engine upgrade or forms-module change.
3
+ The forms module must pass `MODULE_COMPLETION_CHECKLIST.md` verbatim, plus
4
+ these form-builder-specific items from the ecosystem guide. Re-verify after
5
+ any engine upgrade or forms-module change.
6
+
7
+ Last verified on 2026-06-13 against the generated dogfood app (`test-out`) with
8
+ local PostgreSQL `postgres/root`: `npm run dogfood`, generated app
9
+ `npm run build`, `npm run lint`, `npm test`, `npm run test:e2e`; engine
10
+ `npm test`, `npm run lint`, `npm run typecheck`.
6
11
 
7
12
  ## Boundary
8
13
 
9
- - [ ] The engine package (`@ftisindia/form-builder`) compiles and unit-tests **without** the template; only `src/modules/forms` imports template services.
10
- - [ ] The engine's boundary lint passes: no `@nestjs/*`, `@prisma/*`, `@casl/*`, or template imports anywhere in the engine core; engine errors are engine-typed and mapped to HTTP exceptions only in the glue (`forms-error.mapper.ts`).
14
+ - [x] The engine package (`@ftisindia/form-builder`) compiles and unit-tests without the template; only `src/modules/forms` imports template services.
15
+ - [x] The engine's boundary lint passes: no `@nestjs/*`, `@prisma/*`, `@casl/*`, or template imports anywhere in the engine core; engine errors are engine-typed and mapped to HTTP exceptions only in the glue (`forms-error.mapper.ts`).
11
16
 
12
17
  ## Schema ownership
13
18
 
14
- - [ ] Boot-time schema check proven both ways: green on a freshly migrated app; clear fail-fast message (naming the engine schema version and the snippet path) on a deliberately stale schema.
15
- - [ ] `prisma/forms.prisma` in the engine matches the models in the app's `schema.prisma`; upgrades follow copy-snippet `prisma migrate dev`.
19
+ - [x] Boot-time schema check proven both ways: green on a freshly migrated app; clear fail-fast message naming the engine schema version and snippet path on a deliberately stale schema.
20
+ - [x] `prisma/forms.prisma` in the engine matches the models in the app's `schema.prisma`; upgrades follow copy-snippet -> `prisma migrate dev`.
16
21
 
17
22
  ## Permissions & registry
18
23
 
19
- - [ ] All 11 form permission keys exist in `permissionKeys`, are seeded, and `test/forms-permission-sync.spec.ts` passes (engine template set equality).
20
- - [ ] Every protected form route is in `routePermissionRegistry` (the boot-time registry validator passes).
24
+ - [x] All 11 form permission keys exist in `permissionKeys`, are seeded, and `test/forms-permission-sync.spec.ts` passes (engine <-> template set equality).
25
+ - [x] Every protected form route is in `routePermissionRegistry` (the boot-time registry validator passes).
26
+ - [x] The bundled `ConferenceTracksDataSource` backs the abstract-submission example; production data sources remain app-provided `@FormDataSource()` providers.
21
27
 
22
28
  ## Gating
23
29
 
24
- - [ ] Attach-time gating proven by test: lacking `forms.wireDangerous` cannot save/publish a definition wiring a `dangerous` action; the `forms.allowedDangerousActions` org setting is respected at save/publish AND at execute time.
25
- - [ ] `forms.managePublicAccess` gating proven: lacking it cannot set or unset `settings.access = 'public'` (including publishing an already-public draft); every flip writes an audit row with previous/next.
30
+ - [x] Attach-time gating proven by test: lacking `forms.wireDangerous` cannot save/publish a definition wiring a `dangerous` action; the `forms.allowedDangerousActions` org setting is respected at save/publish AND at execute time.
31
+ - [x] `forms.managePublicAccess` gating proven: lacking it cannot set or unset `settings.access = 'public'` (including publishing an already-public draft); every flip writes an audit row with previous/next.
26
32
 
27
33
  ## Tenancy
28
34
 
29
- - [ ] Cross-org tests: definition fetch, submission read, and file reference across orgs are rejected as not-found.
35
+ - [x] Cross-org tests: definition fetch, submission read, and file reference across orgs are rejected as not-found.
30
36
 
31
37
  ## Transactionality & outbox
32
38
 
33
- - [ ] Phase-2 transactionality proven: an induced `lockEditing` failure rolls back persist **and** in-tx action logs **and** enqueued outbox rows.
34
- - [ ] Outbox worker runs inside `requestContext.run({ source: 'worker', jobId, orgId, userId, requestId })`; retries honor backoff; exhausted jobs park as `FAILED`; an audit row (`forms.outbox.delivered`) is written on final success.
39
+ - [x] Phase-2 transactionality proven: an induced `lockEditing` failure rolls back persist and in-tx action logs and enqueued outbox rows.
40
+ - [x] Outbox worker runs inside `requestContext.run({ source: 'worker', jobId, orgId, userId, requestId })`; retries honor backoff; exhausted jobs park as `FAILED`; an audit row (`forms.outbox.delivered`) is written on final success.
41
+ - [x] Duplicate outbox enqueue with the same idempotency key is a no-op, not a submission-rolling unique violation.
42
+ - [x] Claimed jobs carry an opaque `claimedBy` lease token; heartbeat and `DONE`/retry/failed terminal transitions are guarded by that token so stale workers cannot settle reclaimed jobs.
43
+ - [x] Active `PROCESSING` jobs heartbeat while handlers run; e2e covers both the store primitive and the actual dispatcher wiring with a slow handler.
35
44
 
36
45
  ## Public forms
37
46
 
38
- - [ ] Public path: `@Public()` render/submit works without membership; a non-public form 404s on the public routes; org-scoped storage still enforced; permissioned paths unaffected.
39
- - [ ] Tier-1 abuse baseline active: public routes throttled tighter than the app default; per-IP/day caps read from typed settings (with per-form override) and enforced; IP/UA stamped on anonymous submissions; captcha seam invoked when enabled; captcha-enabled-without-adapter fails at publish, not at submit.
47
+ - [x] Public path: `@Public()` render/submit works without membership; a non-public form 404s on the public routes; org-scoped storage still enforced; permissioned paths unaffected.
48
+ - [x] Tier-1 abuse baseline active: public routes throttled tighter than the app default; per-IP/day caps read from typed settings (with per-form override) and enforced; IP/UA stamped on anonymous submissions; captcha seam invoked when enabled; captcha-enabled-without-adapter fails at publish, not at submit.
49
+ - [x] Captcha remains fail-closed by default: apps must bind a real `CaptchaVerifier` to `FORMS_CAPTCHA_VERIFIER` before publishing public forms with `settings.captcha: true`.
40
50
 
41
51
  ## Files
42
52
 
43
- - [ ] Upload submit `LINKED` flow proven; a foreign user's `fileId` is rejected (IDOR); GC sweeps orphaned `TEMPORARY` files past TTL; public forms with file fields are rejected at publish.
53
+ - [x] Upload -> submit -> `LINKED` flow proven; a foreign user's `fileId` is rejected (IDOR); public uploads require the per-file token returned by the public upload endpoint; GC sweeps orphaned `TEMPORARY` files past TTL.
44
54
 
45
55
  ## Webhooks
46
56
 
47
- - [ ] Webhook destinations are gated by the `forms.webhookAllowedHosts` setting at save AND publish (`WEBHOOK_HOST_NOT_ALLOWED`); https required except loopback (`WEBHOOK_URL_INSECURE`).
48
- - [ ] Webhook jobs enqueue inside the submit transaction (idempotency key `<submissionId>:webhook:<i>`), never on draft saves or persist-less pipelines.
49
- - [ ] Deliveries are signed (`x-forms-signature`) when a secret is set; payload data passes sensitive-field redaction; failures retry then park as FAILED.
50
- - [ ] `forms:push` loads definitions only through the audited service path (real member RBAC, save-time lint, versioning) never raw DB writes.
57
+ - [x] Webhook destinations are gated by the `forms.webhookAllowedHosts` setting at save AND publish (`WEBHOOK_HOST_NOT_ALLOWED`); https required except loopback (`WEBHOOK_URL_INSECURE`).
58
+ - [x] Webhook jobs enqueue inside the submit transaction (idempotency key `<submissionId>:webhook:<i>`), never on draft saves or persist-less pipelines.
59
+ - [x] Deliveries are signed (`x-forms-signature`) when a secret is set and carry `x-forms-idempotency-key` for receiver-side dedupe; new outbox payloads store the precomputed signature, not the shared secret; legacy secret-bearing payloads are sanitized before delivery; payload data passes sensitive-field redaction; failures retry then park as FAILED.
60
+ - [x] `forms:push` loads definitions only through the audited service path (real member RBAC, save-time lint, versioning) and never raw DB writes.
51
61
 
52
62
  ## Export & redaction
53
63
 
54
- - [ ] `formSubmissions.export` enforced independently of `.read`; **every** export writes an audit row with form key, filters, row count, and included fields.
55
- - [ ] Credential/sensitive fields never appear in `FormActionLog` or `AuditLog` metadata (redaction test); dangerous actions log `[REDACTED]` for input and output.
64
+ - [x] `formSubmissions.export` enforced independently of `.read`; every export writes an audit row with form key, filters, row count, and included fields.
65
+ - [x] Credential/sensitive fields never appear in `FormActionLog` or `AuditLog` metadata (redaction test); dangerous actions log `[REDACTED]` for input and output; unknown credential-like keys (`apiKey`, `token`, etc.) are defensively redacted.
56
66
 
57
67
  ## Envelope & docs
58
68
 
59
- - [ ] Engine validation errors arrive as `{ error: { code, message, details: { errors: [...] } } }` through the global filter; lint failures carry `details.issues`.
60
- - [ ] Engine-thrown denials and not-founds are indistinguishable in shape from the template's own.
61
- - [ ] Swagger documents all form routes (dynamic payloads as `object`).
69
+ - [x] Engine validation errors arrive as `{ error: { code, message, details: { errors: [...] } } }` through the global filter; lint failures carry `details.issues`.
70
+ - [x] Engine-thrown denials and not-founds are indistinguishable in shape from the template's own.
71
+ - [x] Swagger documents all form routes (dynamic payloads as `object`).
@@ -50,8 +50,17 @@ definition AND again at execute time.
50
50
 
51
51
  Example definitions live in `src/modules/reports/examples/` — `org-members`
52
52
  (custom source, no form builder anywhere) and `abstract-review-board` (the
53
- form-backed quick path, indexed tier).
53
+ form-backed quick path, indexed tier).
54
+
55
+ ## Definitions as code (`reports:push`)
54
56
 
57
+ ```bash
58
+ npm run reports:push -- --file src/modules/reports/definitions/member-directory.report.json \
59
+ --org demo --user owner@example.com --publish
60
+ ```
61
+
62
+ `src/modules/reports/definitions/*.report.json` files are source files; they are not loaded automatically at boot. `reports:push` loads a definition into an org through the real service path, so member RBAC, save/publish linting, versioning, and audit rows match the HTTP API. Re-pushing unchanged content is a no-op; changed content creates the next draft version, and `--publish` publishes it.
63
+
55
64
  ## One contract: the QuerySpec
56
65
 
57
66
  The frontend never builds queries. Every grid interaction posts a declarative
@@ -150,8 +159,8 @@ the thinking:
150
159
  against the submission's stamped `formVersion`; `updateStatus` runs the same
151
160
  pipeline as the form. Both require `formSubmissions.update`. There is no
152
161
  generic UPDATE — the report engine never mutates source rows itself.
153
- - Version note: columns resolve against the latest **published** form version.
154
- Renamed-field mapping across versions needs rename metadata the form
162
+ - Known limitation: columns resolve against the latest **published** form
163
+ version. Renamed-field mapping across versions needs rename metadata the form
155
164
  definition format does not yet carry.
156
165
 
157
166
  ## Performance tiers
@@ -202,6 +211,13 @@ row family's previous `label:*`). Curate the vocabulary with the
202
211
  streamed into reports-owned org-scoped file storage
203
212
  (`REPORTS_EXPORT_STORAGE_DIR`), polled at `GET /reports/exports/:jobId`, and
204
213
  downloaded from `GET /reports/exports/:jobId/download`.
214
+ - The default local-disk storage is for a single app instance or for multiple
215
+ instances sharing the same mounted volume. For horizontal scaling without a
216
+ shared volume, bind an S3/GCS-backed `ExportFileSink` so downloads can land on
217
+ any node.
218
+ - Finished async export files are retained for `REPORTS_EXPORT_RETENTION_DAYS`
219
+ (default 7) and swept by the reports export worker. After expiry, the job row
220
+ remains for audit/status history but the file is no longer downloadable.
205
221
  - Every export — sync or async — runs inside ONE `REPEATABLE READ` snapshot
206
222
  (duration-bounded by `reports.exportMaxSnapshotSeconds`); an export that
207
223
  cannot finish in time is rejected with guidance to tighten filters or move
@@ -232,7 +248,9 @@ Env: `REPORTS_SCHEMA_CHECK` (`on`/`off`); `REPORTS_TOKEN_SECRET` (optional, min
232
248
  `JWT_SECRET` via HKDF; rotating it invalidates outstanding cursors/tokens —
233
249
  clients restart from page one); and the reports-owned async-export worker:
234
250
  `REPORTS_EXPORT_WORKER_ENABLED` (default true), `REPORTS_EXPORT_POLL_MS`
235
- (default 5000), `REPORTS_EXPORT_STORAGE_DIR` (default `./var/report-exports`).
251
+ (default 5000), `REPORTS_EXPORT_STORAGE_DIR` (default `./var/report-exports`),
252
+ `REPORTS_EXPORT_RETENTION_DAYS` (default 7; 0 disables cleanup), and
253
+ `REPORTS_EXPORT_RETENTION_SWEEP_MS` (default 3600000).
236
254
 
237
255
  ## Engine upgrades (schema ownership)
238
256