@vouchington/utils 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,9 +6,11 @@ export: import the capability you use from an explicit subpath.
6
6
  ```ts
7
7
  import { createTokenSecrets } from '@vouchington/utils/token-secrets'
8
8
  import { createMoneyCatalog } from '@vouchington/utils/money'
9
- import { serializeCookie } from '@vouchington/utils/cookies'
9
+ import { parseCookies, serializeCookie } from '@vouchington/utils/cookies'
10
+ import { buildSignatureHeaders, verifySignature } from '@vouchington/utils/http-signatures'
10
11
  import { bestAcceptLanguageMatch } from '@vouchington/utils/language-tags'
11
12
  import { createMessageTranslator } from '@vouchington/utils/message-catalog'
13
+ import { createReportInputParser } from '@vouchington/utils/moderation'
12
14
  ```
13
15
 
14
16
  ## Subpaths
@@ -22,11 +24,17 @@ import { createMessageTranslator } from '@vouchington/utils/message-catalog'
22
24
  - `request-client-info`: `createClientInfoParser(config)` validates caller-selected headers,
23
25
  families, platforms, compatibility, and versions.
24
26
  - `money`: `createMoneyCatalog(currencies, scale)` supplies exact integer parsing and validation for
25
- the caller's catalog.
27
+ the caller's catalog. `allocateProportionalAmount(amount, numerator, denominator, rounding)`
28
+ allocates an inclusive fraction with `down` or `up` integer rounding chosen explicitly by the
29
+ caller; it uses exact integer arithmetic rather than floating-point division.
26
30
  - `env-contract`: `groupEnvContracts()` and `normalizeEnvContractGroups()` produce delimiter-safe
27
31
  metadata keys and surface/sensitivity lookups.
28
32
  - `cookies`: `serializeCookie(name, value, attributes)` validates cookie grammar and requires the
29
33
  caller to supply every policy attribute. `SameSite=None` requires `Secure`.
34
+ `parseCookies(header)` splits a `Cookie` request header into a name/value `Map`.
35
+ - `http-signatures`: Node-only (`node:crypto`) Cavage-12 HTTP Signatures. Callers pass signed
36
+ headers, allowed algorithms, and `maxAgeSeconds` on both sign and verify. The package has no
37
+ default header set or product keyId policy.
30
38
  - `collections`, `async`, and `stable-json`: collection deduplication/merging, bounded async mapping,
31
39
  and deterministic JSON serialization.
32
40
  - `feature-flags`: base64 JSON feature-flag cookie parsing and serialization with caller-injected
@@ -35,14 +43,20 @@ import { createMessageTranslator } from '@vouchington/utils/message-catalog'
35
43
  formatting, text normalization, title casing, and word/sentence helpers.
36
44
  - `gtin`, `bigint-ids`, and `validation`: GTIN predicates, canonical positive PostgreSQL bigint IDs,
37
45
  and basic email/UUID predicates.
46
+ - `moderation`: caller-configured report target, reason, identifier, note, and cross-field input
47
+ validation. Applications own catalogs, policy, error mapping, persistence, and HTTP behavior.
38
48
  - `query` and `query-string`: generic array/boolean/number parsing, bounded integer parsing, and
39
49
  query-string serialization. Cursor parsing intentionally belongs to `@vouchington/pagination`.
40
- - `slugs` and `urls`: ASCII-only slugification and URL/hostname primitives. URL hostname helpers
41
- return `null` for invalid input; `normalizeAsciiHostname` names its ASCII-only contract directly,
42
- while `normalizeHostname` also supports WHATWG IDN conversion. `matchesHostnamePattern` supports
43
- exact and inclusive `*.` hostname patterns.
50
+ - `slugs` and `urls`: ASCII-only slugification, the `isSlug` lowercase `[a-z0-9-]+` predicate
51
+ (leading, trailing, and consecutive hyphens are allowed), and URL/hostname primitives. URL hostname
52
+ helpers return `null` for invalid input; `normalizeAsciiHostname` names its ASCII-only contract
53
+ directly, while `normalizeHostname` also supports WHATWG IDN conversion.
54
+ `matchesHostnamePattern` supports exact and inclusive `*.` hostname patterns.
44
55
  - `hashtags`: caller-configured authored/key length and separator policy for canonical ASCII hashtag
45
- keys. The package does not choose application limits or accepted input separators.
56
+ keys, plus `maskHashtagBearingUrls` to blank absolute URLs and fragment-bearing relative paths.
57
+ The package does not choose application limits or accepted input separators.
58
+ - `utm`: `createUtmParser({ sourceAliases, fallbackSourceParam? })` lowercases and trims UTM fields
59
+ and resolves `utm_source` through caller-owned aliases. The package has no default shortcode map.
46
60
  - `fetch-ports`: the Fetch standard forbidden-port list and membership predicates.
47
61
  - `http-body`: bounded Fetch response-body reads with abort propagation and typed missing/oversize
48
62
  errors.
@@ -6,3 +6,4 @@ export type CookieAttributes = {
6
6
  secure: boolean;
7
7
  };
8
8
  export declare function serializeCookie(name: string, value: string, attributes: CookieAttributes): string;
9
+ export declare function parseCookies(cookieHeader?: string | null): Map<string, string>;
package/dist/cookies.mjs CHANGED
@@ -30,3 +30,19 @@ export function serializeCookie(name, value, attributes) {
30
30
  parts.push('Secure');
31
31
  return parts.join('; ');
32
32
  }
33
+ const COOKIE_SPLIT_REGEX = /;\s*/;
34
+ export function parseCookies(cookieHeader) {
35
+ const cookies = new Map();
36
+ if (!cookieHeader)
37
+ return cookies;
38
+ for (const part of cookieHeader.split(COOKIE_SPLIT_REGEX)) {
39
+ const separatorIndex = part.indexOf('=');
40
+ if (separatorIndex <= 0)
41
+ continue;
42
+ const name = part.slice(0, separatorIndex).trim();
43
+ const value = part.slice(separatorIndex + 1).trim();
44
+ if (name.length > 0)
45
+ cookies.set(name, value);
46
+ }
47
+ return cookies;
48
+ }
@@ -13,3 +13,8 @@ export interface HashtagNormalizer {
13
13
  }
14
14
  /** Creates an ASCII hashtag normalizer whose length and separator policy is caller-owned. */
15
15
  export declare function createHashtagNormalizer(options: HashtagNormalizerOptions): HashtagNormalizer;
16
+ /**
17
+ * Blanks absolute URLs and relative paths that contain `#` so fragment text is
18
+ * not treated as a hashtag. Parentheses stay balanced for Wikipedia-style paths.
19
+ */
20
+ export declare function maskHashtagBearingUrls(input: string): string;
package/dist/hashtags.mjs CHANGED
@@ -42,3 +42,46 @@ function createSeparatorPattern(separators) {
42
42
  function escapeRegularExpression(value) {
43
43
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
44
44
  }
45
+ const absoluteUrlPrefix = /(?:https?:\/\/|www\.)/gi;
46
+ const urlBoundary = /[\s<>]/;
47
+ /**
48
+ * Blanks absolute URLs and relative paths that contain `#` so fragment text is
49
+ * not treated as a hashtag. Parentheses stay balanced for Wikipedia-style paths.
50
+ */
51
+ export function maskHashtagBearingUrls(input) {
52
+ const masked = input.split('');
53
+ for (const match of input.matchAll(absoluteUrlPrefix)) {
54
+ const start = match.index;
55
+ maskRange(masked, start, findUrlEnd(input, start));
56
+ }
57
+ for (let start = 0; start < input.length; start += 1) {
58
+ if (input[start] !== '/')
59
+ continue;
60
+ const end = findUrlEnd(input, start);
61
+ if (input.slice(start, end).includes('#'))
62
+ maskRange(masked, start, end);
63
+ start = end - 1;
64
+ }
65
+ return masked.join('');
66
+ }
67
+ function findUrlEnd(input, start) {
68
+ let parenthesisDepth = 0;
69
+ for (let index = start; index < input.length; index += 1) {
70
+ const character = input[index];
71
+ if (urlBoundary.test(character))
72
+ return index;
73
+ if (character === '(') {
74
+ parenthesisDepth += 1;
75
+ continue;
76
+ }
77
+ if (character === ')') {
78
+ if (parenthesisDepth === 0)
79
+ return index;
80
+ parenthesisDepth -= 1;
81
+ }
82
+ }
83
+ return input.length;
84
+ }
85
+ function maskRange(masked, start, end) {
86
+ masked.fill(' ', start, end);
87
+ }
@@ -0,0 +1,3 @@
1
+ export declare function computeDigest(body: string | Buffer): string;
2
+ export declare function extractDigestHash(digestHeader: string): string;
3
+ export declare function verifyDigest(body: string | Buffer, expectedDigestHeader: string): boolean;
@@ -0,0 +1,20 @@
1
+ import { createHash, timingSafeEqual } from 'node:crypto';
2
+ const DIGEST_HEADER_PATTERN = /^SHA-256=(.+)$/;
3
+ export function computeDigest(body) {
4
+ const bodyBuffer = typeof body === 'string' ? Buffer.from(body, 'utf-8') : body;
5
+ const hash = createHash('sha256').update(bodyBuffer).digest('base64');
6
+ return `SHA-256=${hash}`;
7
+ }
8
+ export function extractDigestHash(digestHeader) {
9
+ const match = DIGEST_HEADER_PATTERN.exec(digestHeader);
10
+ if (!match?.[1])
11
+ throw new Error('Invalid Digest header format');
12
+ return match[1];
13
+ }
14
+ export function verifyDigest(body, expectedDigestHeader) {
15
+ const computed = Buffer.from(computeDigest(body));
16
+ const expected = Buffer.from(expectedDigestHeader);
17
+ if (computed.length !== expected.length)
18
+ return false;
19
+ return timingSafeEqual(computed, expected);
20
+ }
@@ -0,0 +1,7 @@
1
+ export type RsaSha256KeyPair = {
2
+ publicKeyPem: string;
3
+ privateKeyPem: string;
4
+ };
5
+ export declare function generateRsaSha256KeyPair(): RsaSha256KeyPair;
6
+ export declare function assertPublicKeyPem(pem: string): void;
7
+ export declare function assertPrivateKeyPem(pem: string): void;
@@ -0,0 +1,27 @@
1
+ import { createPrivateKey, createPublicKey, generateKeyPairSync } from 'node:crypto';
2
+ export function generateRsaSha256KeyPair() {
3
+ const { publicKey, privateKey } = generateKeyPairSync('rsa', {
4
+ modulusLength: 2048,
5
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
6
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
7
+ });
8
+ return { publicKeyPem: publicKey, privateKeyPem: privateKey };
9
+ }
10
+ export function assertPublicKeyPem(pem) {
11
+ if (pem.includes('PRIVATE'))
12
+ throw new Error('Invalid public key PEM format');
13
+ try {
14
+ createPublicKey(pem);
15
+ }
16
+ catch {
17
+ throw new Error('Invalid public key PEM format');
18
+ }
19
+ }
20
+ export function assertPrivateKeyPem(pem) {
21
+ try {
22
+ createPrivateKey(pem);
23
+ }
24
+ catch {
25
+ throw new Error('Invalid private key PEM format');
26
+ }
27
+ }
@@ -0,0 +1,17 @@
1
+ export type SignatureHeaders = {
2
+ digest: string;
3
+ signature: string;
4
+ date: string;
5
+ };
6
+ export type BuildSignatureHeadersOptions = {
7
+ method: string;
8
+ url: string;
9
+ body: string | Buffer;
10
+ keyId: string;
11
+ privateKeyPem: string;
12
+ signedHeaders: readonly string[];
13
+ algorithm: string;
14
+ now?: Date;
15
+ additionalHeaders?: Record<string, string>;
16
+ };
17
+ export declare function buildSignatureHeaders(options: BuildSignatureHeadersOptions): SignatureHeaders;
@@ -0,0 +1,35 @@
1
+ import { createPrivateKey, sign } from 'node:crypto';
2
+ import { computeDigest } from './http-signatures-digest.mjs';
3
+ import { assertPrivateKeyPem } from './http-signatures-keys.mjs';
4
+ import { buildSigningString, isSha256SignatureAlgorithm } from './http-signatures-string.mjs';
5
+ export function buildSignatureHeaders(options) {
6
+ if (options.signedHeaders.length === 0)
7
+ throw new Error('signedHeaders must not be empty');
8
+ if (!isSha256SignatureAlgorithm(options.algorithm)) {
9
+ throw new Error(`Unsupported signature algorithm: ${options.algorithm}`);
10
+ }
11
+ assertPrivateKeyPem(options.privateKeyPem);
12
+ const digest = computeDigest(options.body);
13
+ const date = (options.now ?? new Date()).toUTCString();
14
+ const url = new URL(options.url);
15
+ const host = url.host;
16
+ const path = url.pathname + url.search;
17
+ const signingString = buildSigningString(options.signedHeaders, {
18
+ method: options.method,
19
+ path,
20
+ host,
21
+ date,
22
+ digest,
23
+ additionalHeaders: options.additionalHeaders,
24
+ });
25
+ if (typeof signingString !== 'string')
26
+ throw new Error(signingString.error);
27
+ const signature = sign('sha256', Buffer.from(signingString), createPrivateKey(options.privateKeyPem)).toString('base64');
28
+ const signatureHeaderValue = [
29
+ `keyId="${options.keyId}"`,
30
+ `algorithm="${options.algorithm}"`,
31
+ `headers="${options.signedHeaders.map((name) => name.toLowerCase()).join(' ')}"`,
32
+ `signature="${signature}"`,
33
+ ].join(',');
34
+ return { digest, signature: signatureHeaderValue, date };
35
+ }
@@ -0,0 +1,12 @@
1
+ export type SigningHeaderValues = {
2
+ method: string;
3
+ path: string;
4
+ host: string;
5
+ date: string;
6
+ digest: string;
7
+ additionalHeaders?: Record<string, string> | undefined;
8
+ };
9
+ export declare function isSha256SignatureAlgorithm(algorithm: string): boolean;
10
+ export declare function buildSigningString(headerNames: readonly string[], values: SigningHeaderValues): string | {
11
+ error: string;
12
+ };
@@ -0,0 +1,40 @@
1
+ const SHA256_ALGORITHMS = new Set(['rsa-sha256', 'hs2019']);
2
+ export function isSha256SignatureAlgorithm(algorithm) {
3
+ return SHA256_ALGORITHMS.has(algorithm);
4
+ }
5
+ function additionalHeaderValue(headers, name) {
6
+ if (!headers)
7
+ return undefined;
8
+ if (Object.hasOwn(headers, name))
9
+ return headers[name];
10
+ const lower = name.toLowerCase();
11
+ for (const [key, value] of Object.entries(headers)) {
12
+ if (key.toLowerCase() === lower)
13
+ return value;
14
+ }
15
+ return undefined;
16
+ }
17
+ function signingLineForHeader(name, values) {
18
+ const lower = name.toLowerCase();
19
+ if (lower === '(request-target)') {
20
+ return `(request-target): ${values.method.toLowerCase()} ${values.path}`;
21
+ }
22
+ if (lower === 'host')
23
+ return `host: ${values.host}`;
24
+ if (lower === 'date')
25
+ return `date: ${values.date}`;
26
+ if (lower === 'digest')
27
+ return `digest: ${values.digest}`;
28
+ const extra = additionalHeaderValue(values.additionalHeaders, name);
29
+ return extra === undefined ? undefined : `${lower}: ${extra}`;
30
+ }
31
+ export function buildSigningString(headerNames, values) {
32
+ const lines = [];
33
+ for (const name of headerNames) {
34
+ const line = signingLineForHeader(name, values);
35
+ if (line === undefined)
36
+ return { error: `Unknown signed header: ${name}` };
37
+ lines.push(line);
38
+ }
39
+ return lines.join('\n');
40
+ }
@@ -0,0 +1,30 @@
1
+ import type { VerifySignatureOptions } from './http-signatures-verify.mts';
2
+ export declare const KEY_ID = "https://example.test/users/alice#main-key";
3
+ export declare const BODY: string;
4
+ export declare const FULL_HEADERS: readonly ['(request-target)', 'host', 'date', 'digest'];
5
+ export declare const defaultVerifyPolicy: {
6
+ readonly requiredHeaders: readonly ["(request-target)", "host", "date", "digest"];
7
+ readonly allowedAlgorithms: readonly ["rsa-sha256", "hs2019"];
8
+ readonly maxAgeSeconds: 3600;
9
+ };
10
+ export declare const defaultSignPolicy: {
11
+ readonly signedHeaders: readonly ["(request-target)", "host", "date", "digest"];
12
+ readonly algorithm: 'rsa-sha256';
13
+ };
14
+ export declare function digestAndDate(body?: string | Buffer): {
15
+ digestHeader: string;
16
+ dateHeader: string;
17
+ };
18
+ export declare function verifyArgs(publicKeyPem: string, signatureHeader: string, extras?: Partial<VerifySignatureOptions>): VerifySignatureOptions;
19
+ export declare function buildManualSignature(options: {
20
+ privateKeyPem: string;
21
+ dateHeader: string;
22
+ digestHeader: string;
23
+ headerNames: readonly string[];
24
+ additionalHeaders?: Record<string, string>;
25
+ keyId?: string;
26
+ algorithm?: string | null;
27
+ method?: string;
28
+ path?: string;
29
+ host?: string;
30
+ }): string;
@@ -0,0 +1,62 @@
1
+ import { createPrivateKey, sign as cryptoSign } from 'node:crypto';
2
+ import { computeDigest } from './http-signatures-digest.mjs';
3
+ export const KEY_ID = 'https://example.test/users/alice#main-key';
4
+ const METHOD = 'POST';
5
+ const PATH = '/inbox';
6
+ const HOST = 'mastodon.example';
7
+ export const BODY = JSON.stringify({ type: 'Follow', object: 'https://example.test' });
8
+ export const FULL_HEADERS = ['(request-target)', 'host', 'date', 'digest'];
9
+ const ALLOWED_ALGORITHMS = ['rsa-sha256', 'hs2019'];
10
+ const MAX_AGE_SECONDS = 3600;
11
+ export const defaultVerifyPolicy = {
12
+ requiredHeaders: FULL_HEADERS,
13
+ allowedAlgorithms: ALLOWED_ALGORITHMS,
14
+ maxAgeSeconds: MAX_AGE_SECONDS,
15
+ };
16
+ export const defaultSignPolicy = {
17
+ signedHeaders: FULL_HEADERS,
18
+ algorithm: 'rsa-sha256',
19
+ };
20
+ export function digestAndDate(body = BODY) {
21
+ return { digestHeader: computeDigest(body), dateHeader: new Date().toUTCString() };
22
+ }
23
+ export function verifyArgs(publicKeyPem, signatureHeader, extras = {}) {
24
+ const { digestHeader, dateHeader } = digestAndDate(extras.body ?? BODY);
25
+ return {
26
+ method: METHOD,
27
+ path: PATH,
28
+ host: HOST,
29
+ body: BODY,
30
+ signatureHeader,
31
+ digestHeader,
32
+ dateHeader,
33
+ publicKeyPem,
34
+ ...defaultVerifyPolicy,
35
+ ...extras,
36
+ };
37
+ }
38
+ export function buildManualSignature(options) {
39
+ const headerNames = options.headerNames;
40
+ const signingString = headerNames
41
+ .map((name) => {
42
+ if (name === '(request-target)') {
43
+ return `(request-target): ${(options.method ?? METHOD).toLowerCase()} ${options.path ?? PATH}`;
44
+ }
45
+ if (name === 'host')
46
+ return `host: ${options.host ?? HOST}`;
47
+ if (name === 'date')
48
+ return `date: ${options.dateHeader}`;
49
+ if (name === 'digest')
50
+ return `digest: ${options.digestHeader}`;
51
+ return `${name}: ${options.additionalHeaders?.[name]}`;
52
+ })
53
+ .join('\n');
54
+ const signature = cryptoSign('sha256', Buffer.from(signingString), createPrivateKey(options.privateKeyPem)).toString('base64');
55
+ const headerParts = [`keyId="${options.keyId ?? KEY_ID}"`];
56
+ if (options.algorithm !== null) {
57
+ headerParts.push(`algorithm="${options.algorithm ?? 'rsa-sha256'}"`);
58
+ }
59
+ headerParts.push(`headers="${headerNames.join(' ')}"`);
60
+ headerParts.push(`signature="${signature}"`);
61
+ return headerParts.join(',');
62
+ }
@@ -0,0 +1,21 @@
1
+ export type VerifySignatureOptions = {
2
+ method: string;
3
+ path: string;
4
+ host: string;
5
+ body: string | Buffer;
6
+ signatureHeader: string;
7
+ digestHeader: string;
8
+ dateHeader: string;
9
+ publicKeyPem: string;
10
+ requiredHeaders: readonly string[];
11
+ allowedAlgorithms: readonly string[];
12
+ maxAgeSeconds: number;
13
+ additionalHeaders?: Record<string, string>;
14
+ referenceTime?: Date;
15
+ };
16
+ export type SignatureVerificationResult = {
17
+ valid: boolean;
18
+ error?: string;
19
+ };
20
+ export declare function extractSignatureKeyId(signatureHeader: string): string | undefined;
21
+ export declare function verifySignature(options: VerifySignatureOptions): SignatureVerificationResult;
@@ -0,0 +1,88 @@
1
+ import { verify } from 'node:crypto';
2
+ import { verifyDigest } from './http-signatures-digest.mjs';
3
+ import { assertPublicKeyPem } from './http-signatures-keys.mjs';
4
+ import { buildSigningString, isSha256SignatureAlgorithm } from './http-signatures-string.mjs';
5
+ const SIGNATURE_VALUE_PATTERN = /signature="([^"]+)"/;
6
+ const HEADERS_VALUE_PATTERN = /headers="([^"]+)"/;
7
+ const ALGORITHM_VALUE_PATTERN = /algorithm="([^"]+)"/;
8
+ const KEY_ID_VALUE_PATTERN = /keyId="([^"]+)"/;
9
+ export function extractSignatureKeyId(signatureHeader) {
10
+ return KEY_ID_VALUE_PATTERN.exec(signatureHeader)?.[1];
11
+ }
12
+ function parseSignatureHeader(header) {
13
+ const signatureMatch = SIGNATURE_VALUE_PATTERN.exec(header);
14
+ const headersMatch = HEADERS_VALUE_PATTERN.exec(header);
15
+ if (!signatureMatch?.[1] || !headersMatch?.[1])
16
+ return null;
17
+ return {
18
+ signature: signatureMatch[1],
19
+ headers: headersMatch[1].split(' ').map((name) => name.toLowerCase()),
20
+ algorithm: ALGORITHM_VALUE_PATTERN.exec(header)?.[1] ?? null,
21
+ };
22
+ }
23
+ export function verifySignature(options) {
24
+ try {
25
+ return verifySignatureBody(options);
26
+ }
27
+ catch (err) {
28
+ return {
29
+ valid: false,
30
+ error: `Signature verification exception: ${err.message}`,
31
+ };
32
+ }
33
+ }
34
+ function verifySignatureBody(options) {
35
+ assertPublicKeyPem(options.publicKeyPem);
36
+ const requestDate = new Date(options.dateHeader);
37
+ if (Number.isNaN(requestDate.getTime()))
38
+ return { valid: false, error: 'Invalid date header' };
39
+ const ageSeconds = ((options.referenceTime?.getTime() ?? Date.now()) - requestDate.getTime()) / 1000;
40
+ if (!Number.isFinite(options.maxAgeSeconds) || options.maxAgeSeconds < 0) {
41
+ return { valid: false, error: 'maxAgeSeconds must be a finite non-negative number' };
42
+ }
43
+ if (Math.abs(ageSeconds) > options.maxAgeSeconds) {
44
+ return {
45
+ valid: false,
46
+ error: `Date header too old: ${ageSeconds}s > ${options.maxAgeSeconds}s`,
47
+ };
48
+ }
49
+ if (!verifyDigest(options.body, options.digestHeader)) {
50
+ return { valid: false, error: 'Digest verification failed' };
51
+ }
52
+ const sigParts = parseSignatureHeader(options.signatureHeader);
53
+ if (!sigParts)
54
+ return { valid: false, error: 'Invalid signature header format' };
55
+ const { signature, headers, algorithm } = sigParts;
56
+ if (algorithm === null ||
57
+ !options.allowedAlgorithms.includes(algorithm) ||
58
+ !isSha256SignatureAlgorithm(algorithm)) {
59
+ return {
60
+ valid: false,
61
+ error: algorithm === null
62
+ ? 'Signature algorithm is required'
63
+ : `Unsupported signature algorithm: ${algorithm}`,
64
+ };
65
+ }
66
+ const covered = new Set(headers);
67
+ const missingRequired = options.requiredHeaders
68
+ .map((name) => name.toLowerCase())
69
+ .filter((name) => !covered.has(name));
70
+ if (missingRequired.length > 0) {
71
+ return {
72
+ valid: false,
73
+ error: `Signature must cover ${options.requiredHeaders.join(', ')} (missing: ${missingRequired.join(', ')})`,
74
+ };
75
+ }
76
+ const signingString = buildSigningString(headers, {
77
+ method: options.method,
78
+ path: options.path,
79
+ host: options.host,
80
+ date: options.dateHeader,
81
+ digest: options.digestHeader,
82
+ additionalHeaders: options.additionalHeaders,
83
+ });
84
+ if (typeof signingString !== 'string')
85
+ return { valid: false, error: signingString.error };
86
+ const isValid = verify('sha256', Buffer.from(signingString), options.publicKeyPem, Buffer.from(signature, 'base64'));
87
+ return isValid ? { valid: true } : { valid: false, error: 'Signature verification failed' };
88
+ }
@@ -0,0 +1,4 @@
1
+ export { computeDigest, extractDigestHash, verifyDigest } from './http-signatures-digest.mts';
2
+ export { assertPrivateKeyPem, assertPublicKeyPem, generateRsaSha256KeyPair, type RsaSha256KeyPair, } from './http-signatures-keys.mts';
3
+ export { buildSignatureHeaders, type BuildSignatureHeadersOptions, type SignatureHeaders, } from './http-signatures-sign.mts';
4
+ export { extractSignatureKeyId, verifySignature, type SignatureVerificationResult, type VerifySignatureOptions, } from './http-signatures-verify.mts';
@@ -0,0 +1,4 @@
1
+ export { computeDigest, extractDigestHash, verifyDigest } from './http-signatures-digest.mjs';
2
+ export { assertPrivateKeyPem, assertPublicKeyPem, generateRsaSha256KeyPair, } from './http-signatures-keys.mjs';
3
+ export { buildSignatureHeaders, } from './http-signatures-sign.mjs';
4
+ export { extractSignatureKeyId, verifySignature, } from './http-signatures-verify.mjs';
@@ -0,0 +1,34 @@
1
+ interface ReportTarget<TType extends string, TId> {
2
+ type: TType;
3
+ id: TId;
4
+ }
5
+ interface ReportDraft<TType extends string, TId, TReason extends string> {
6
+ target: ReportTarget<TType, TId>;
7
+ reason: TReason;
8
+ note: string | null;
9
+ }
10
+ export interface ReportInputConfig<TType extends string, TId, TReason extends string> {
11
+ targetTypes: readonly TType[];
12
+ reasons: readonly TReason[];
13
+ parseTargetId(value: string): TId | null;
14
+ maxNoteLength: number;
15
+ validate?(draft: ReportDraft<TType, TId, TReason>): void;
16
+ }
17
+ interface RawReportInput {
18
+ targetType?: unknown;
19
+ targetId?: unknown;
20
+ reason?: unknown;
21
+ note?: unknown;
22
+ }
23
+ export interface ReportInputParser<TType extends string, TId, TReason extends string> {
24
+ parse(raw: RawReportInput): ReportDraft<TType, TId, TReason>;
25
+ readonly targetTypes: readonly TType[];
26
+ readonly reasons: readonly TReason[];
27
+ }
28
+ export type ReportValidationCode = 'invalid_target_type' | 'invalid_target_identifier' | 'invalid_reason' | 'invalid_note' | 'note_too_long';
29
+ export declare class ReportValidationError extends Error {
30
+ readonly code: ReportValidationCode;
31
+ constructor(code: ReportValidationCode, message: string);
32
+ }
33
+ export declare function createReportInputParser<TType extends string, TId, TReason extends string>(config: ReportInputConfig<TType, TId, TReason>): ReportInputParser<TType, TId, TReason>;
34
+ export {};
@@ -0,0 +1,49 @@
1
+ export class ReportValidationError extends Error {
2
+ code;
3
+ constructor(code, message) {
4
+ super(message);
5
+ this.name = 'ReportValidationError';
6
+ this.code = code;
7
+ }
8
+ }
9
+ export function createReportInputParser(config) {
10
+ const targetTypes = readonlyCatalog(config.targetTypes, 'targetTypes');
11
+ const reasons = readonlyCatalog(config.reasons, 'reasons');
12
+ if (!Number.isSafeInteger(config.maxNoteLength) || config.maxNoteLength < 0)
13
+ throw new TypeError('maxNoteLength must be a non-negative safe integer');
14
+ return {
15
+ targetTypes,
16
+ reasons,
17
+ parse(raw) {
18
+ const targetType = enumValue(raw.targetType, targetTypes, 'invalid_target_type', 'target type');
19
+ if (typeof raw.targetId !== 'string')
20
+ invalid('invalid_target_identifier', 'target identifier');
21
+ const targetId = config.parseTargetId(raw.targetId);
22
+ if (targetId === null)
23
+ invalid('invalid_target_identifier', 'target identifier');
24
+ const reason = enumValue(raw.reason, reasons, 'invalid_reason', 'reason');
25
+ if (raw.note !== undefined && raw.note !== null && typeof raw.note !== 'string')
26
+ invalid('invalid_note', 'note');
27
+ if (typeof raw.note === 'string' && raw.note.length > config.maxNoteLength)
28
+ throw new ReportValidationError('note_too_long', 'Note is too long');
29
+ const note = typeof raw.note === 'string' ? raw.note.trim() || null : null;
30
+ const draft = { target: { type: targetType, id: targetId }, reason, note };
31
+ config.validate?.(draft);
32
+ return draft;
33
+ },
34
+ };
35
+ }
36
+ function readonlyCatalog(values, name) {
37
+ if (values.length === 0 ||
38
+ values.some((value) => typeof value !== 'string' || value.length === 0))
39
+ throw new TypeError(`${name} must contain non-empty strings`);
40
+ return Object.freeze([...new Set(values)]);
41
+ }
42
+ function enumValue(value, catalog, code, name) {
43
+ if (typeof value !== 'string' || !catalog.includes(value))
44
+ invalid(code, name);
45
+ return value;
46
+ }
47
+ function invalid(code, name) {
48
+ throw new ReportValidationError(code, `Invalid ${name}`);
49
+ }
package/dist/money.d.mts CHANGED
@@ -19,6 +19,15 @@ export type MoneyRange<Code extends string = string> = {
19
19
  minimum: Money<Code>;
20
20
  maximum: Money<Code> | null;
21
21
  };
22
+ export type ProportionalRounding = 'down' | 'up';
23
+ /**
24
+ * Allocates a non-negative integer amount by an inclusive fraction.
25
+ *
26
+ * The result is exact when `amount * numerator` divides by `denominator`; otherwise callers
27
+ * explicitly select integer rounding down or up. All arithmetic is performed with `bigint` so
28
+ * safe integer inputs never pass through floating-point division.
29
+ */
30
+ export declare function allocateProportionalAmount(amount: number, numerator: number, denominator: number, rounding: ProportionalRounding): number;
22
31
  export declare function createMoneyCatalog<const Currencies extends readonly Currency[]>(entries: Currencies, scale: number): {
23
32
  currencies: readonly Readonly<{
24
33
  code: string;
package/dist/money.mjs CHANGED
@@ -1,4 +1,24 @@
1
1
  export const MAX_MONEY_AMOUNT = Number.MAX_SAFE_INTEGER;
2
+ /**
3
+ * Allocates a non-negative integer amount by an inclusive fraction.
4
+ *
5
+ * The result is exact when `amount * numerator` divides by `denominator`; otherwise callers
6
+ * explicitly select integer rounding down or up. All arithmetic is performed with `bigint` so
7
+ * safe integer inputs never pass through floating-point division.
8
+ */
9
+ export function allocateProportionalAmount(amount, numerator, denominator, rounding) {
10
+ if (!isAmount(amount) || !isAmount(numerator) || !isAmount(denominator))
11
+ throw new TypeError('Proportional allocation requires non-negative safe integer inputs');
12
+ if (denominator === 0 || numerator > denominator)
13
+ throw new RangeError('Proportional allocation requires a fraction from zero through one');
14
+ if (rounding !== 'down' && rounding !== 'up')
15
+ throw new TypeError('Proportional allocation rounding must be down or up');
16
+ const product = BigInt(amount) * BigInt(numerator);
17
+ const divisor = BigInt(denominator);
18
+ const quotient = product / divisor;
19
+ const result = rounding === 'up' && product % divisor !== 0n ? quotient + 1n : quotient;
20
+ return Number(result);
21
+ }
2
22
  export function createMoneyCatalog(entries, scale) {
3
23
  validateScale(scale);
4
24
  for (const currency of entries)
package/dist/slugs.d.mts CHANGED
@@ -1 +1,3 @@
1
1
  export declare function slugifyAscii(value: string): string;
2
+ /** True for a non-empty string of lowercase ASCII letters, digits, and hyphens. */
3
+ export declare function isSlug(value: string): boolean;
package/dist/slugs.mjs CHANGED
@@ -7,3 +7,7 @@ export function slugifyAscii(value) {
7
7
  .replace(/-+/g, '-')
8
8
  .replace(/^-|-$/g, '');
9
9
  }
10
+ /** True for a non-empty string of lowercase ASCII letters, digits, and hyphens. */
11
+ export function isSlug(value) {
12
+ return value.length > 0 && /^[a-z0-9-]+$/.test(value);
13
+ }
package/dist/utm.d.mts ADDED
@@ -0,0 +1,16 @@
1
+ export interface UtmParserOptions {
2
+ sourceAliases: Readonly<Record<string, string>>;
3
+ fallbackSourceParam?: string;
4
+ }
5
+ export interface ExtractedUtmParams {
6
+ utmSource: string | null;
7
+ utmMedium: string | null;
8
+ utmCampaign: string | null;
9
+ utmContent: string | null;
10
+ }
11
+ export interface UtmParser {
12
+ resolveSource(value: string): string;
13
+ extractFromUrl(url: URL): ExtractedUtmParams;
14
+ }
15
+ /** Creates a UTM parser whose source aliases and fallback param are caller-owned. */
16
+ export declare function createUtmParser(options: UtmParserOptions): UtmParser;
package/dist/utm.mjs ADDED
@@ -0,0 +1,28 @@
1
+ /** Creates a UTM parser whose source aliases and fallback param are caller-owned. */
2
+ export function createUtmParser(options) {
3
+ const sourceAliases = {};
4
+ for (const [key, value] of Object.entries(options.sourceAliases)) {
5
+ sourceAliases[key.toLowerCase().trim()] = value.toLowerCase().trim();
6
+ }
7
+ const fallbackSourceParam = options.fallbackSourceParam;
8
+ function resolveSource(value) {
9
+ const lower = value.toLowerCase().trim();
10
+ return sourceAliases[lower] ?? lower;
11
+ }
12
+ return {
13
+ resolveSource,
14
+ extractFromUrl(url) {
15
+ const rawSource = url.searchParams.get('utm_source');
16
+ const fallback = fallbackSourceParam === undefined ? null : url.searchParams.get(fallbackSourceParam);
17
+ const normalizedSource = rawSource ? resolveSource(rawSource) || null : null;
18
+ const normalizedFallback = fallback ? resolveSource(fallback) || null : null;
19
+ const utmSource = normalizedSource ?? normalizedFallback;
20
+ return {
21
+ utmSource,
22
+ utmMedium: url.searchParams.get('utm_medium')?.toLowerCase().trim() || null,
23
+ utmCampaign: url.searchParams.get('utm_campaign')?.toLowerCase().trim() || null,
24
+ utmContent: url.searchParams.get('utm_content')?.toLowerCase().trim() || null,
25
+ };
26
+ },
27
+ };
28
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vouchington/utils",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "description": "Dependency-free runtime utilities for Vouchington-style applications.",
5
5
  "homepage": "https://github.com/vouchington/vouchington-platform/tree/main/packages/utils#readme",
6
6
  "bugs": {
@@ -130,6 +130,11 @@
130
130
  "import": "./dist/validation.mjs",
131
131
  "default": "./dist/validation.mjs"
132
132
  },
133
+ "./moderation": {
134
+ "types": "./dist/moderation.d.mts",
135
+ "import": "./dist/moderation.mjs",
136
+ "default": "./dist/moderation.mjs"
137
+ },
133
138
  "./async": {
134
139
  "types": "./dist/async.d.mts",
135
140
  "import": "./dist/async.mjs",
@@ -150,6 +155,11 @@
150
155
  "import": "./dist/hashtags.mjs",
151
156
  "default": "./dist/hashtags.mjs"
152
157
  },
158
+ "./utm": {
159
+ "types": "./dist/utm.d.mts",
160
+ "import": "./dist/utm.mjs",
161
+ "default": "./dist/utm.mjs"
162
+ },
153
163
  "./fetch-ports": {
154
164
  "types": "./dist/fetch-ports.d.mts",
155
165
  "import": "./dist/fetch-ports.mjs",
@@ -164,16 +174,20 @@
164
174
  "types": "./dist/http-link-pagination.d.mts",
165
175
  "import": "./dist/http-link-pagination.mjs",
166
176
  "default": "./dist/http-link-pagination.mjs"
177
+ },
178
+ "./http-signatures": {
179
+ "types": "./dist/http-signatures.d.mts",
180
+ "import": "./dist/http-signatures.mjs",
181
+ "default": "./dist/http-signatures.mjs"
167
182
  }
168
183
  },
169
184
  "publishConfig": {
170
185
  "access": "public"
171
186
  },
172
- "scripts": {
173
- "build": "tsc --project tsconfig.build.json",
174
- "prepack": "pnpm run build"
175
- },
176
187
  "engines": {
177
188
  "node": ">=24.0.0"
189
+ },
190
+ "scripts": {
191
+ "build": "tsc --project tsconfig.build.json"
178
192
  }
179
- }
193
+ }