@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
@@ -1,7 +1,9 @@
1
- import { createHash, randomUUID } from 'node:crypto';
1
+ import { createHash, randomBytes, randomUUID } from 'node:crypto';
2
2
  import {
3
3
  BadRequestException,
4
- ConflictException,
4
+ ConflictException,
5
+ HttpException,
6
+ HttpStatus,
5
7
  Injectable,
6
8
  NotFoundException,
7
9
  PayloadTooLargeException,
@@ -12,7 +14,12 @@ import {
12
14
  acceptSatisfied,
13
15
  walkFields,
14
16
  } from '@ftisindia/form-builder';
15
- import type { FieldDef, FormDefinitionRecord, UploadedFileRecord } from '@ftisindia/form-builder';
17
+ import type {
18
+ FieldDef,
19
+ FormDefinitionRecord,
20
+ OrgFormsPolicy,
21
+ UploadedFileRecord,
22
+ } from '@ftisindia/form-builder';
16
23
  import { AuthenticatedUser } from '../../../auth/types/authenticated-user';
17
24
  import { RequestContextService } from '../../../request-context/application/services/request-context.service';
18
25
  import { UploadFileQueryDto } from '../../dto/upload-file-query.dto';
@@ -23,15 +30,20 @@ import { LocalDiskStorageAdapter } from '../../infrastructure/storage/local-disk
23
30
  import { FormsSettingsReader } from './forms-settings-reader.service';
24
31
 
25
32
  const sniffer = new DefaultMagicByteSniffer();
33
+ const DAY_MS = 24 * 60 * 60 * 1000;
34
+
35
+ type UploadedMultipartFile = {
36
+ originalname: string;
37
+ size: number;
38
+ buffer: Buffer;
39
+ };
26
40
 
27
41
  /**
28
- * Upload-before-submit (design doc §10): bytes are stored here, the form
29
- * submission later carries only the file id. Every trust decision is
30
- * server-side MIME type is sniffed from content (never the client header),
31
- * size is enforced against both the field's and the org's caps, sha256 is
32
- * computed, and the record is bound to uploader AND org (two-factor
33
- * ownership). Files start TEMPORARY; submit-time linking promotes to LINKED;
34
- * the GC sweeps the rest after the TTL.
42
+ * Upload-before-submit (design doc section 10): bytes are stored here, the form
43
+ * submission later carries only the file id. Authenticated uploads bind to the
44
+ * user id; public uploads bind to an anonymous claim token returned once by the
45
+ * public upload endpoint. Submit-time linking re-checks the same ownership or
46
+ * token claim before promoting the file to LINKED.
35
47
  */
36
48
  @Injectable()
37
49
  export class FormsFilesService {
@@ -48,24 +60,90 @@ export class FormsFilesService {
48
60
  orgId: string,
49
61
  formKey: string,
50
62
  query: UploadFileQueryDto,
51
- file: { originalname: string; size: number; buffer: Buffer } | undefined,
63
+ file: UploadedMultipartFile | undefined,
52
64
  user: AuthenticatedUser,
53
65
  ) {
54
66
  this.requestContext.assertOrgScope(orgId);
67
+ this.assertUploadRequest(query, file);
68
+
69
+ const definition = await this.resolveTargetDefinition(orgId, formKey, query);
70
+ const field = this.requireFileField(definition, query.field);
71
+ const policy = await this.settings.policyFor(orgId);
72
+ const record = await this.storeUpload(orgId, field, file, policy, {
73
+ ownerId: user.id,
74
+ });
75
+
76
+ return this.toResponse(record);
77
+ }
78
+
79
+ async uploadPublic(
80
+ orgId: string,
81
+ formKey: string,
82
+ query: UploadFileQueryDto,
83
+ file: UploadedMultipartFile | undefined,
84
+ ) {
85
+ this.requestContext.merge({ orgId });
86
+ this.assertUploadRequest(query, file);
87
+ if (query.submissionId) {
88
+ throw new BadRequestException('Public uploads cannot target draft submissions.');
89
+ }
90
+
91
+ const definition = await this.resolvePublicDefinition(orgId, formKey, query);
92
+ const field = this.requireFileField(definition, query.field);
93
+ const policy = await this.settings.policyFor(orgId);
94
+ await this.enforcePublicUploadCap(orgId, policy);
95
+
96
+ const uploadToken = randomBytes(32).toString('base64url');
97
+ const record = await this.storeUpload(orgId, field, file, policy, {
98
+ ownerId: null,
99
+ claimTokenHash: createHash('sha256').update(uploadToken).digest('hex'),
100
+ uploadedIp: this.requestContext.getIpAddress() ?? null,
101
+ });
102
+
103
+ return { ...this.toResponse(record), uploadToken };
104
+ }
105
+
106
+ async getMeta(orgId: string, fileId: string) {
107
+ this.requestContext.assertOrgScope(orgId);
108
+ const record = await this.files.findById(orgId, fileId);
109
+ if (!record) {
110
+ throw new NotFoundException('File was not found.');
111
+ }
112
+ return this.toResponse(record);
113
+ }
114
+
115
+ /** Storage keys never leave the server. */
116
+ private toResponse(record: UploadedFileRecord) {
117
+ return {
118
+ fileId: record.id,
119
+ originalName: record.originalName,
120
+ mimeType: record.mimeType,
121
+ size: record.size,
122
+ checksum: record.checksum,
123
+ status: record.status,
124
+ createdAt: record.createdAt,
125
+ };
126
+ }
127
+
128
+ private assertUploadRequest(
129
+ query: UploadFileQueryDto,
130
+ file: UploadedMultipartFile | undefined,
131
+ ): asserts file is UploadedMultipartFile {
55
132
  if (!file || !file.buffer || file.size === 0) {
56
133
  throw new BadRequestException('A non-empty file is required (multipart field "file").');
57
134
  }
58
135
  if (!query.field) {
59
136
  throw new BadRequestException('The "field" query parameter is required.');
60
137
  }
138
+ }
61
139
 
62
- const definition = await this.resolveTargetDefinition(orgId, formKey, query);
63
- const field = this.findFileField(definition.schema.fields, query.field);
64
- if (!field) {
65
- throw new NotFoundException(`Form has no file field named "${query.field}".`);
66
- }
67
-
68
- const policy = await this.settings.policyFor(orgId);
140
+ private async storeUpload(
141
+ orgId: string,
142
+ field: FieldDef,
143
+ file: UploadedMultipartFile,
144
+ policy: OrgFormsPolicy,
145
+ ownership: { ownerId: string | null; claimTokenHash?: string; uploadedIp?: string | null },
146
+ ) {
69
147
  const capMb = Math.min(field.maxSizeMb ?? Infinity, policy.maxFileSizeMb ?? Infinity);
70
148
  if (Number.isFinite(capMb) && file.size > capMb * 1024 * 1024) {
71
149
  throw new PayloadTooLargeException(`File exceeds the ${capMb} MB limit for this field.`);
@@ -84,39 +162,38 @@ export class FormsFilesService {
84
162
  const checksum = createHash('sha256').update(file.buffer).digest('hex');
85
163
  const storageKey = `${orgId}/${randomUUID()}`;
86
164
  await this.storage.put(storageKey, file.buffer);
87
- const record = await this.files.create({
165
+ return this.files.create({
88
166
  storageKey,
89
167
  originalName: file.originalname,
90
168
  mimeType: sniffed.mimeType,
91
169
  size: file.size,
92
170
  checksum,
93
- ownerId: user.id,
171
+ ownerId: ownership.ownerId,
172
+ claimTokenHash: ownership.claimTokenHash ?? null,
173
+ uploadedIp: ownership.uploadedIp ?? null,
94
174
  orgId,
95
175
  status: 'TEMPORARY',
96
176
  });
97
- return this.toResponse(record);
98
177
  }
99
178
 
100
- async getMeta(orgId: string, fileId: string) {
101
- this.requestContext.assertOrgScope(orgId);
102
- const record = await this.files.findById(orgId, fileId);
103
- if (!record) {
104
- throw new NotFoundException('File was not found.');
179
+ private async enforcePublicUploadCap(orgId: string, policy: OrgFormsPolicy): Promise<void> {
180
+ const cap = policy.maxPublicUploadsPerIpPerDay;
181
+ const ipAddress = this.requestContext.getIpAddress();
182
+ if (!cap || cap <= 0 || !ipAddress) {
183
+ return;
105
184
  }
106
- return this.toResponse(record);
107
- }
108
185
 
109
- /** Storage keys never leave the server. */
110
- private toResponse(record: UploadedFileRecord) {
111
- return {
112
- fileId: record.id,
113
- originalName: record.originalName,
114
- mimeType: record.mimeType,
115
- size: record.size,
116
- checksum: record.checksum,
117
- status: record.status,
118
- createdAt: record.createdAt,
119
- };
186
+ const count = await this.files.countPublicUploadsByIpSince(
187
+ orgId,
188
+ ipAddress,
189
+ new Date(Date.now() - DAY_MS),
190
+ );
191
+ if (count >= cap) {
192
+ throw new HttpException(
193
+ 'Public upload limit reached. Please try again later.',
194
+ HttpStatus.TOO_MANY_REQUESTS,
195
+ );
196
+ }
120
197
  }
121
198
 
122
199
  private async resolveTargetDefinition(
@@ -152,6 +229,33 @@ export class FormsFilesService {
152
229
  return record;
153
230
  }
154
231
 
232
+ private async resolvePublicDefinition(
233
+ orgId: string,
234
+ formKey: string,
235
+ query: UploadFileQueryDto,
236
+ ): Promise<FormDefinitionRecord> {
237
+ const record =
238
+ query.version !== undefined
239
+ ? await this.definitions.findByKeyVersion(orgId, formKey, query.version)
240
+ : await this.definitions.findLatest(orgId, formKey, 'PUBLISHED');
241
+ if (
242
+ !record ||
243
+ record.status !== 'PUBLISHED' ||
244
+ (record.schema.settings?.access ?? 'private') !== 'public'
245
+ ) {
246
+ throw new NotFoundException('Form was not found.');
247
+ }
248
+ return record;
249
+ }
250
+
251
+ private requireFileField(definition: FormDefinitionRecord, name: string): FieldDef {
252
+ const field = this.findFileField(definition.schema.fields, name);
253
+ if (!field) {
254
+ throw new NotFoundException(`Form has no file field named "${name}".`);
255
+ }
256
+ return field;
257
+ }
258
+
155
259
  private findFileField(fields: FieldDef[], name: string): FieldDef | undefined {
156
260
  let found: FieldDef | undefined;
157
261
  walkFields(fields, (field) => {
@@ -38,7 +38,8 @@ export class FormsPublicService {
38
38
  formKey,
39
39
  version: dto.version,
40
40
  data: dto.data,
41
- public: true,
41
+ public: true,
42
+ uploadTokens: dto.uploadTokens,
42
43
  captchaToken: dto.captchaToken,
43
44
  },
44
45
  this.formsContext,
@@ -9,7 +9,8 @@ const FORMS_SETTING_KEYS = [
9
9
  'forms.maxFileSizeMb',
10
10
  'forms.enableRuleIteration',
11
11
  'forms.virusScanRequired',
12
- 'forms.maxSubmissionsPerIpPerDay',
12
+ 'forms.maxSubmissionsPerIpPerDay',
13
+ 'forms.maxPublicUploadsPerIpPerDay',
13
14
  'forms.webhookAllowedHosts',
14
15
  ] as const;
15
16
 
@@ -18,7 +19,7 @@ const FORMS_SETTING_KEYS = [
18
19
  * setting definitions' defaults for unset keys (the SettingsService 404s on
19
20
  * unset keys, which is wrong for policy reads). The result feeds every
20
21
  * engine call that takes an OrgFormsPolicy — dangerous-action gating, rule
21
- * iteration, file caps, public-submission caps.
22
+ * iteration, file caps, public submission/upload caps.
22
23
  */
23
24
  @Injectable()
24
25
  export class FormsSettingsReader {
@@ -45,7 +46,8 @@ export class FormsSettingsReader {
45
46
  enableRuleIteration: read<boolean>('forms.enableRuleIteration'),
46
47
  maxFileSizeMb: read<number>('forms.maxFileSizeMb'),
47
48
  virusScanRequired: read<boolean>('forms.virusScanRequired'),
48
- maxSubmissionsPerIpPerDay: read<number>('forms.maxSubmissionsPerIpPerDay'),
49
+ maxSubmissionsPerIpPerDay: read<number>('forms.maxSubmissionsPerIpPerDay'),
50
+ maxPublicUploadsPerIpPerDay: read<number>('forms.maxPublicUploadsPerIpPerDay'),
49
51
  webhookAllowedHosts: read<string[]>('forms.webhookAllowedHosts'),
50
52
  captchaConfigured: this.captcha != null,
51
53
  };
@@ -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
+ }