@ftisindia/create-app 0.1.6 → 0.2.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 (31) hide show
  1. package/package.json +1 -1
  2. package/template/.env.example +3 -0
  3. package/template/_package.json +4 -1
  4. package/template/docs/FORMS.md +34 -15
  5. package/template/docs/FORMS_CHECKLIST.md +34 -26
  6. package/template/docs/REPORTS.md +12 -3
  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/schema.prisma +5 -1
  10. package/template/src/app.module.ts +4 -3
  11. package/template/src/config/env.validation.ts +3 -0
  12. package/template/src/config/forms.config.ts +1 -0
  13. package/template/src/config/reports.config.ts +2 -0
  14. package/template/src/modules/forms/application/services/handlers/webhook-delivery.transport.ts +319 -0
  15. package/template/src/modules/forms/application/services/handlers/webhook.handler.ts +64 -16
  16. package/template/src/modules/forms/application/services/outbox-dispatcher.service.ts +40 -18
  17. package/template/src/modules/forms/forms.module.ts +2 -0
  18. package/template/src/modules/forms/infrastructure/stores/prisma-outbox.store.ts +82 -59
  19. package/template/src/modules/reports/application/services/reports-export-dispatcher.service.ts +81 -0
  20. package/template/src/modules/reports/application/services/reports-exports.service.ts +6 -2
  21. package/template/src/modules/reports/infrastructure/storage/local-disk-export-storage.adapter.ts +43 -30
  22. package/template/test/forms-captcha.e2e-spec.ts +163 -0
  23. package/template/test/forms-files.e2e-spec.ts +1 -1
  24. package/template/test/forms-outbox.e2e-spec.ts +271 -10
  25. package/template/test/forms-public.e2e-spec.ts +24 -0
  26. package/template/test/forms-throttling.e2e-spec.ts +146 -0
  27. package/template/test/forms-webhooks.e2e-spec.ts +150 -8
  28. package/template/test/jest-e2e.json +1 -0
  29. package/template/test/reports-advanced.e2e-spec.ts +13 -0
  30. package/template/test/reports-query.e2e-spec.ts +52 -0
  31. package/template/test/reports-tiers.e2e-spec.ts +106 -20
@@ -0,0 +1,319 @@
1
+ import { lookup as dnsLookup } from 'node:dns/promises';
2
+ import * as http from 'node:http';
3
+ import * as https from 'node:https';
4
+ import { isIP } from 'node:net';
5
+
6
+ interface LookupAddress {
7
+ address: string;
8
+ family: 4 | 6;
9
+ }
10
+
11
+ interface WebhookDeliveryOptions {
12
+ headers: Record<string, string>;
13
+ body: string;
14
+ timeoutMs: number;
15
+ lookup?: typeof dnsLookup;
16
+ }
17
+
18
+ interface WebhookDeliveryResponse {
19
+ status: number;
20
+ statusText: string;
21
+ }
22
+
23
+ const DEFAULT_HTTPS_PORT = 443;
24
+ const DEFAULT_HTTP_PORT = 80;
25
+
26
+ export async function deliverWebhookRequest(
27
+ rawUrl: string,
28
+ options: WebhookDeliveryOptions,
29
+ ): Promise<WebhookDeliveryResponse> {
30
+ const url = parseWebhookUrl(rawUrl);
31
+ const endpoint = await resolveWebhookEndpoint(url, options.lookup ?? dnsLookup);
32
+
33
+ return sendPinnedRequest(url, endpoint, options);
34
+ }
35
+
36
+ function parseWebhookUrl(rawUrl: string): URL {
37
+ let url: URL;
38
+ try {
39
+ url = new URL(rawUrl);
40
+ } catch {
41
+ throw new Error('Webhook URL is invalid.');
42
+ }
43
+
44
+ if (url.username || url.password) {
45
+ throw new Error('Webhook URL must not include credentials.');
46
+ }
47
+
48
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
49
+ throw new Error('Webhook URL must use http or https.');
50
+ }
51
+
52
+ return url;
53
+ }
54
+
55
+ async function resolveWebhookEndpoint(url: URL, lookup: typeof dnsLookup): Promise<LookupAddress> {
56
+ const hostname = normalizeHostname(url.hostname);
57
+ const literalFamily = isIP(hostname);
58
+ const addresses: LookupAddress[] =
59
+ literalFamily === 0
60
+ ? (await lookup(hostname, { all: true, verbatim: false })).map((entry) => ({
61
+ address: entry.address,
62
+ family: entry.family === 6 ? 6 : 4,
63
+ }))
64
+ : [{ address: hostname, family: literalFamily as 4 | 6 }];
65
+
66
+ if (addresses.length === 0) {
67
+ throw new Error('Webhook hostname did not resolve to an address.');
68
+ }
69
+
70
+ const normalized = addresses.map((entry) => ({
71
+ address: normalizeHostname(entry.address),
72
+ family: entry.family,
73
+ }));
74
+ const allowLocalNetwork = process.env.NODE_ENV !== 'production';
75
+ const unsafe = normalized.filter((entry) => isReservedAddress(entry.address));
76
+
77
+ if (!allowLocalNetwork && unsafe.length > 0) {
78
+ throw new Error(
79
+ `Webhook destination resolves to a private or reserved address (${unsafe[0]!.address}).`,
80
+ );
81
+ }
82
+
83
+ if (url.protocol === 'http:') {
84
+ if (!allowLocalNetwork) {
85
+ throw new Error('Webhook http destinations are allowed only outside production.');
86
+ }
87
+ if (normalized.some((entry) => !isLoopbackAddress(entry.address))) {
88
+ throw new Error('Webhook http destinations must resolve only to loopback addresses.');
89
+ }
90
+ }
91
+
92
+ return normalized[0]!;
93
+ }
94
+
95
+ function sendPinnedRequest(
96
+ url: URL,
97
+ endpoint: LookupAddress,
98
+ options: WebhookDeliveryOptions,
99
+ ): Promise<WebhookDeliveryResponse> {
100
+ const transport = url.protocol === 'https:' ? https : http;
101
+ const requestOptions: https.RequestOptions = {
102
+ protocol: url.protocol,
103
+ hostname: endpoint.address,
104
+ family: endpoint.family,
105
+ port: url.port ? Number(url.port) : defaultPort(url),
106
+ method: 'POST',
107
+ path: `${url.pathname}${url.search}`,
108
+ headers: { ...options.headers, host: url.host },
109
+ timeout: options.timeoutMs,
110
+ };
111
+
112
+ const servername = normalizeHostname(url.hostname);
113
+ if (url.protocol === 'https:' && isIP(servername) === 0) {
114
+ requestOptions.servername = servername;
115
+ }
116
+
117
+ return new Promise((resolve, reject) => {
118
+ const req = transport.request(requestOptions, (res) => {
119
+ const status = res.statusCode ?? 0;
120
+ const statusText = res.statusMessage ?? '';
121
+ res.resume();
122
+ res.once('end', () => resolve({ status, statusText }));
123
+ });
124
+
125
+ req.once('timeout', () => {
126
+ req.destroy(new Error('Webhook request timed out.'));
127
+ });
128
+ req.once('error', reject);
129
+ req.end(options.body);
130
+ });
131
+ }
132
+
133
+ function defaultPort(url: URL): number {
134
+ return url.protocol === 'https:' ? DEFAULT_HTTPS_PORT : DEFAULT_HTTP_PORT;
135
+ }
136
+
137
+ function normalizeHostname(hostname: string): string {
138
+ return hostname.replace(/^\[/u, '').replace(/\]$/u, '').toLowerCase();
139
+ }
140
+
141
+ function isLoopbackAddress(address: string): boolean {
142
+ const family = isIP(address);
143
+ if (family === 4) {
144
+ const value = ipv4ToNumber(address);
145
+ return value !== null && inIpv4Range(value, '127.0.0.0', 8);
146
+ }
147
+
148
+ if (family === 6) {
149
+ const value = ipv6ToBigInt(address);
150
+ return value === 1n;
151
+ }
152
+
153
+ return false;
154
+ }
155
+
156
+ function isReservedAddress(address: string): boolean {
157
+ const family = isIP(address);
158
+ if (family === 4) {
159
+ const value = ipv4ToNumber(address);
160
+ if (value === null) {
161
+ return true;
162
+ }
163
+
164
+ return IPV4_RESERVED_RANGES.some(([base, prefix]) => inIpv4Range(value, base, prefix));
165
+ }
166
+
167
+ if (family === 6) {
168
+ return isReservedIpv6(address);
169
+ }
170
+
171
+ return true;
172
+ }
173
+
174
+ const IPV4_RESERVED_RANGES: Array<[base: string, prefix: number]> = [
175
+ ['0.0.0.0', 8],
176
+ ['10.0.0.0', 8],
177
+ ['100.64.0.0', 10],
178
+ ['127.0.0.0', 8],
179
+ ['169.254.0.0', 16],
180
+ ['172.16.0.0', 12],
181
+ ['192.0.0.0', 24],
182
+ ['192.0.2.0', 24],
183
+ ['192.168.0.0', 16],
184
+ ['198.18.0.0', 15],
185
+ ['198.51.100.0', 24],
186
+ ['203.0.113.0', 24],
187
+ ['224.0.0.0', 4],
188
+ ['240.0.0.0', 4],
189
+ ];
190
+
191
+ function ipv4ToNumber(address: string): number | null {
192
+ const parts = address.split('.');
193
+ if (parts.length !== 4) {
194
+ return null;
195
+ }
196
+
197
+ let value = 0;
198
+ for (const part of parts) {
199
+ if (!/^\d+$/u.test(part)) {
200
+ return null;
201
+ }
202
+ const byte = Number(part);
203
+ if (byte < 0 || byte > 255) {
204
+ return null;
205
+ }
206
+ value = (value << 8) + byte;
207
+ }
208
+
209
+ return value >>> 0;
210
+ }
211
+
212
+ function inIpv4Range(value: number, base: string, prefix: number): boolean {
213
+ const baseValue = ipv4ToNumber(base);
214
+ if (baseValue === null) {
215
+ return false;
216
+ }
217
+
218
+ const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
219
+ return (value & mask) === (baseValue & mask);
220
+ }
221
+
222
+ function isReservedIpv6(address: string): boolean {
223
+ const value = ipv6ToBigInt(address);
224
+ if (value === null) {
225
+ return true;
226
+ }
227
+
228
+ const mappedIpv4 = ipv4MappedAddress(value);
229
+ if (mappedIpv4 !== null) {
230
+ return IPV4_RESERVED_RANGES.some(([base, prefix]) => inIpv4Range(mappedIpv4, base, prefix));
231
+ }
232
+
233
+ return IPV6_RESERVED_RANGES.some(([base, prefix]) => inIpv6Range(value, base, prefix));
234
+ }
235
+
236
+ const IPV6_RESERVED_RANGES: Array<[base: string, prefix: number]> = [
237
+ ['::', 128],
238
+ ['::1', 128],
239
+ ['::', 8],
240
+ ['64:ff9b::', 96],
241
+ ['100::', 64],
242
+ ['2001:2::', 48],
243
+ ['2001:10::', 28],
244
+ ['2001:db8::', 32],
245
+ ['2002::', 16],
246
+ ['fc00::', 7],
247
+ ['fe80::', 10],
248
+ ['ff00::', 8],
249
+ ];
250
+
251
+ function ipv4MappedAddress(value: bigint): number | null {
252
+ const mappedPrefix = 0xffffn << 32n;
253
+ const prefix = value >> 32n;
254
+ if (prefix !== mappedPrefix >> 32n) {
255
+ return null;
256
+ }
257
+
258
+ return Number(value & 0xffffffffn);
259
+ }
260
+
261
+ function inIpv6Range(value: bigint, base: string, prefix: number): boolean {
262
+ const baseValue = ipv6ToBigInt(base);
263
+ if (baseValue === null) {
264
+ return false;
265
+ }
266
+
267
+ const mask = prefix === 0 ? 0n : ((1n << BigInt(prefix)) - 1n) << BigInt(128 - prefix);
268
+ return (value & mask) === (baseValue & mask);
269
+ }
270
+
271
+ function ipv6ToBigInt(address: string): bigint | null {
272
+ let normalized = address.toLowerCase();
273
+ const zoneIndex = normalized.indexOf('%');
274
+ if (zoneIndex !== -1) {
275
+ normalized = normalized.slice(0, zoneIndex);
276
+ }
277
+
278
+ if (normalized.includes('.')) {
279
+ const lastColon = normalized.lastIndexOf(':');
280
+ const embedded = ipv4ToNumber(normalized.slice(lastColon + 1));
281
+ if (lastColon === -1 || embedded === null) {
282
+ return null;
283
+ }
284
+ const high = ((embedded >>> 16) & 0xffff).toString(16);
285
+ const low = (embedded & 0xffff).toString(16);
286
+ normalized = `${normalized.slice(0, lastColon)}:${high}:${low}`;
287
+ }
288
+
289
+ const halves = normalized.split('::');
290
+ if (halves.length > 2) {
291
+ return null;
292
+ }
293
+
294
+ const head = splitIpv6Half(halves[0] ?? '');
295
+ const tail = splitIpv6Half(halves[1] ?? '');
296
+ const missing = halves.length === 2 ? 8 - head.length - tail.length : 0;
297
+ if (missing < 0 || (halves.length === 1 && head.length !== 8)) {
298
+ return null;
299
+ }
300
+
301
+ const parts = [...head, ...Array.from({ length: missing }, () => '0'), ...tail];
302
+ if (parts.length !== 8) {
303
+ return null;
304
+ }
305
+
306
+ let value = 0n;
307
+ for (const part of parts) {
308
+ if (!/^[\da-f]{1,4}$/u.test(part)) {
309
+ return null;
310
+ }
311
+ value = (value << 16n) + BigInt(Number.parseInt(part, 16));
312
+ }
313
+
314
+ return value;
315
+ }
316
+
317
+ function splitIpv6Half(half: string): string[] {
318
+ return half.length === 0 ? [] : half.split(':');
319
+ }
@@ -1,41 +1,89 @@
1
1
  import { createHmac } from 'node:crypto';
2
2
  import { Injectable } from '@nestjs/common';
3
+ import type { Prisma } from '@prisma/client';
3
4
  import type { OutboxJobHandler, OutboxJobRecord } from '@ftisindia/form-builder';
5
+ import { PrismaService } from '../../../../../database/prisma/prisma.service';
6
+ import { deliverWebhookRequest } from './webhook-delivery.transport';
4
7
 
5
8
  /**
6
- * Generic webhook delivery. Payload contract: { url: string, body?: object,
7
- * secret?: string }. When a secret is set, the request carries
8
- * `x-forms-signature: sha256=<hmac-sha256(rawBody)>` so receivers can verify
9
- * authenticity. Non-2xx responses and timeouts throw, which the dispatcher
10
- * turns into a retry with backoff (then parks as FAILED after max attempts).
9
+ * Generic webhook delivery. Payload contract: { url: string, rawBody: string,
10
+ * signature?: string }. The engine computes the optional signature before
11
+ * enqueueing so the shared secret is not copied into the outbox payload.
12
+ * Non-2xx responses and timeouts throw, which the dispatcher turns into a
13
+ * retry with backoff (then parks as FAILED after max attempts).
11
14
  */
12
15
  @Injectable()
13
16
  export class WebhookOutboxHandler implements OutboxJobHandler {
14
17
  readonly type = 'webhook';
15
18
 
19
+ constructor(private readonly prisma: PrismaService) {}
20
+
16
21
  async handle(job: OutboxJobRecord): Promise<void> {
17
- const { url, body, secret } = job.payload as {
22
+ const { url, rawBody, body, signature, secret } = job.payload as {
18
23
  url?: unknown;
24
+ rawBody?: unknown;
19
25
  body?: unknown;
26
+ signature?: unknown;
27
+ /** Backward-compatible support for jobs enqueued before secrets moved out of payloads. */
20
28
  secret?: unknown;
21
29
  };
22
30
  if (typeof url !== 'string' || url.length === 0) {
23
31
  throw new Error('Webhook job payload requires a "url" string.');
24
32
  }
25
- const rawBody = JSON.stringify(body ?? {});
26
- const headers: Record<string, string> = { 'content-type': 'application/json' };
27
- if (typeof secret === 'string' && secret.length > 0) {
28
- headers['x-forms-signature'] =
29
- 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex');
33
+ const requestBody = typeof rawBody === 'string' ? rawBody : JSON.stringify(body ?? {});
34
+ const headers: Record<string, string> = {
35
+ 'content-type': 'application/json',
36
+ };
37
+ if (job.idempotencyKey) {
38
+ headers['x-forms-idempotency-key'] = job.idempotencyKey;
39
+ }
40
+ let resolvedSignature: string | undefined;
41
+ if (typeof signature === 'string' && signature.length > 0) {
42
+ resolvedSignature = signature;
43
+ } else if (typeof secret === 'string' && secret.length > 0) {
44
+ resolvedSignature =
45
+ 'sha256=' + createHmac('sha256', secret).update(requestBody).digest('hex');
46
+ await this.sanitizeLegacyPayload(job, url, requestBody, resolvedSignature);
30
47
  }
31
- const response = await fetch(url, {
32
- method: 'POST',
48
+ if (resolvedSignature) {
49
+ headers['x-forms-signature'] = resolvedSignature;
50
+ }
51
+ const response = await deliverWebhookRequest(url, {
33
52
  headers,
34
- body: rawBody,
35
- signal: AbortSignal.timeout(10_000),
53
+ body: requestBody,
54
+ timeoutMs: 10_000,
36
55
  });
37
- if (!response.ok) {
56
+ if (response.status >= 300 && response.status < 400) {
57
+ throw new Error(`Webhook redirects are not allowed (status ${response.status}).`);
58
+ }
59
+ if (response.status < 200 || response.status >= 300) {
38
60
  throw new Error(`Webhook endpoint responded with status ${response.status}.`);
39
61
  }
40
62
  }
63
+
64
+ private async sanitizeLegacyPayload(
65
+ job: OutboxJobRecord,
66
+ url: string,
67
+ rawBody: string,
68
+ signature: string,
69
+ ): Promise<void> {
70
+ const claimedBy = typeof job.claimedBy === 'string' ? job.claimedBy : '';
71
+ if (!claimedBy) {
72
+ throw new Error('Webhook job is missing a claim token.');
73
+ }
74
+
75
+ const result = await this.prisma.formOutboxJob.updateMany({
76
+ where: { id: job.id, status: 'PROCESSING', claimedBy },
77
+ data: {
78
+ payload: {
79
+ url,
80
+ rawBody,
81
+ signature,
82
+ } satisfies Prisma.InputJsonValue,
83
+ },
84
+ });
85
+ if (result.count !== 1) {
86
+ throw new Error('Webhook job lease was lost before legacy payload cleanup.');
87
+ }
88
+ }
41
89
  }
@@ -7,6 +7,8 @@ import { AuditService } from '../../../audit/application/services/audit.service'
7
7
  import { RequestContextService } from '../../../request-context/application/services/request-context.service';
8
8
  import { PrismaOutboxStore } from '../../infrastructure/stores/prisma-outbox.store';
9
9
 
10
+ const DEFAULT_PROCESSING_HEARTBEAT_MS = 60_000;
11
+
10
12
  /**
11
13
  * In-process outbox poller (ecosystem guide §3/§8.3). Every claimed job runs
12
14
  * inside requestContext.run({ source: 'worker', jobId, orgId, userId,
@@ -15,9 +17,9 @@ import { PrismaOutboxStore } from '../../infrastructure/stores/prisma-outbox.sto
15
17
  * originating request. Successful delivery writes an audit row.
16
18
  *
17
19
  * Deterministic in tests: FORMS_OUTBOX_ENABLED=false disables the timer and
18
- * runOnce() drives a single cycle by hand. Single-instance by design a
19
- * multi-node deployment should swap the store's claim for SKIP LOCKED or a
20
- * real queue (BullMQ) without touching the engine.
20
+ * runOnce() drives a single cycle by hand. The default Prisma store claims
21
+ * jobs with row-level locks; apps that outgrow the starter poller can swap
22
+ * this boundary for a queue-backed implementation without touching handlers.
21
23
  */
22
24
  @Injectable()
23
25
  export class OutboxDispatcherService implements OnApplicationBootstrap, OnModuleDestroy {
@@ -53,6 +55,8 @@ export class OutboxDispatcherService implements OnApplicationBootstrap, OnModule
53
55
  }
54
56
 
55
57
  async runOnce(): Promise<DispatchCycleResult> {
58
+ const heartbeatMs =
59
+ this.config.get<number>('forms.outboxHeartbeatMs') ?? DEFAULT_PROCESSING_HEARTBEAT_MS;
56
60
  return runDispatchCycle(this.store, this.handlers, {
57
61
  wrapJob: (job, run) =>
58
62
  this.requestContext.run(
@@ -64,25 +68,43 @@ export class OutboxDispatcherService implements OnApplicationBootstrap, OnModule
64
68
  requestId: job.originRequestId ?? undefined,
65
69
  },
66
70
  async () => {
67
- const outcome = await run();
68
- if (outcome === 'done') {
69
- try {
70
- await this.audit.write(this.prisma, {
71
- orgId: job.orgId ?? null,
72
- actorUserId: job.actorUserId ?? null,
73
- action: 'forms.outbox.delivered',
74
- targetType: 'FormOutboxJob',
75
- targetId: job.id,
76
- metadata: { type: job.type, attempts: job.attempts + 1 },
77
- });
78
- } catch (error) {
71
+ const claimedBy = typeof job.claimedBy === 'string' ? job.claimedBy : '';
72
+ const heartbeat = setInterval(() => {
73
+ if (!claimedBy) {
74
+ return;
75
+ }
76
+ void this.store.touchProcessing(job.id, claimedBy).catch((error: unknown) => {
79
77
  this.logger.warn(
80
- `Outbox delivery audit failed for job ${job.id}.`,
78
+ `Outbox heartbeat failed for job ${job.id}.`,
81
79
  error instanceof Error ? error.stack : String(error),
82
80
  );
81
+ });
82
+ }, heartbeatMs);
83
+ heartbeat.unref?.();
84
+
85
+ try {
86
+ const outcome = await run();
87
+ if (outcome === 'done') {
88
+ try {
89
+ await this.audit.write(this.prisma, {
90
+ orgId: job.orgId ?? null,
91
+ actorUserId: job.actorUserId ?? null,
92
+ action: 'forms.outbox.delivered',
93
+ targetType: 'FormOutboxJob',
94
+ targetId: job.id,
95
+ metadata: { type: job.type, attempts: job.attempts + 1 },
96
+ });
97
+ } catch (error) {
98
+ this.logger.warn(
99
+ `Outbox delivery audit failed for job ${job.id}.`,
100
+ error instanceof Error ? error.stack : String(error),
101
+ );
102
+ }
83
103
  }
104
+ return outcome;
105
+ } finally {
106
+ clearInterval(heartbeat);
84
107
  }
85
- return outcome;
86
108
  },
87
109
  ),
88
110
  });
@@ -97,7 +119,7 @@ export class OutboxDispatcherService implements OnApplicationBootstrap, OnModule
97
119
  const result = await this.runOnce();
98
120
  if (result.claimed > 0) {
99
121
  this.logger.log(
100
- `Outbox cycle: claimed ${result.claimed}, done ${result.done}, retried ${result.retried}, failed ${result.failed}.`,
122
+ `Outbox cycle: claimed ${result.claimed}, done ${result.done}, retried ${result.retried}, failed ${result.failed}, stale ${result.stale}.`,
101
123
  );
102
124
  }
103
125
  } catch (error) {
@@ -43,6 +43,7 @@ import { FormsFilesController } from './presentation/forms-files.controller';
43
43
  import { FormsSubmissionsController } from './presentation/forms-submissions.controller';
44
44
  import { FormsUploadInterceptor } from './presentation/forms-upload.interceptor';
45
45
  import { PublicFormsController } from './presentation/public-forms.controller';
46
+ import { FORMS_CAPTCHA_VERIFIER } from './forms.tokens';
46
47
 
47
48
  /**
48
49
  * The forms glue module — the ONLY place that binds the framework-free
@@ -75,6 +76,7 @@ import { PublicFormsController } from './presentation/public-forms.controller';
75
76
  PrismaFileStore,
76
77
  PrismaActionLogStore,
77
78
  FormsSettingsReader,
79
+ { provide: FORMS_CAPTCHA_VERIFIER, useValue: undefined },
78
80
  // Engine registries — singletons; core field types registered up front.
79
81
  {
80
82
  provide: FieldTypeRegistry,