@vouchington/utils 0.5.0 → 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,7 +6,8 @@ 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'
12
13
  import { createReportInputParser } from '@vouchington/utils/moderation'
@@ -23,11 +24,17 @@ import { createReportInputParser } from '@vouchington/utils/moderation'
23
24
  - `request-client-info`: `createClientInfoParser(config)` validates caller-selected headers,
24
25
  families, platforms, compatibility, and versions.
25
26
  - `money`: `createMoneyCatalog(currencies, scale)` supplies exact integer parsing and validation for
26
- 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.
27
30
  - `env-contract`: `groupEnvContracts()` and `normalizeEnvContractGroups()` produce delimiter-safe
28
31
  metadata keys and surface/sensitivity lookups.
29
32
  - `cookies`: `serializeCookie(name, value, attributes)` validates cookie grammar and requires the
30
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.
31
38
  - `collections`, `async`, and `stable-json`: collection deduplication/merging, bounded async mapping,
32
39
  and deterministic JSON serialization.
33
40
  - `feature-flags`: base64 JSON feature-flag cookie parsing and serialization with caller-injected
@@ -40,12 +47,16 @@ import { createReportInputParser } from '@vouchington/utils/moderation'
40
47
  validation. Applications own catalogs, policy, error mapping, persistence, and HTTP behavior.
41
48
  - `query` and `query-string`: generic array/boolean/number parsing, bounded integer parsing, and
42
49
  query-string serialization. Cursor parsing intentionally belongs to `@vouchington/pagination`.
43
- - `slugs` and `urls`: ASCII-only slugification and URL/hostname primitives. URL hostname helpers
44
- return `null` for invalid input; `normalizeAsciiHostname` names its ASCII-only contract directly,
45
- while `normalizeHostname` also supports WHATWG IDN conversion. `matchesHostnamePattern` supports
46
- 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.
47
55
  - `hashtags`: caller-configured authored/key length and separator policy for canonical ASCII hashtag
48
- 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.
49
60
  - `fetch-ports`: the Fetch standard forbidden-port list and membership predicates.
50
61
  - `http-body`: bounded Fetch response-body reads with abort propagation and typed missing/oversize
51
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';
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.5.0",
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": {
@@ -155,6 +155,11 @@
155
155
  "import": "./dist/hashtags.mjs",
156
156
  "default": "./dist/hashtags.mjs"
157
157
  },
158
+ "./utm": {
159
+ "types": "./dist/utm.d.mts",
160
+ "import": "./dist/utm.mjs",
161
+ "default": "./dist/utm.mjs"
162
+ },
158
163
  "./fetch-ports": {
159
164
  "types": "./dist/fetch-ports.d.mts",
160
165
  "import": "./dist/fetch-ports.mjs",
@@ -169,16 +174,20 @@
169
174
  "types": "./dist/http-link-pagination.d.mts",
170
175
  "import": "./dist/http-link-pagination.mjs",
171
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"
172
182
  }
173
183
  },
174
184
  "publishConfig": {
175
185
  "access": "public"
176
186
  },
177
- "scripts": {
178
- "build": "tsc --project tsconfig.build.json",
179
- "prepack": "pnpm run build"
180
- },
181
187
  "engines": {
182
188
  "node": ">=24.0.0"
189
+ },
190
+ "scripts": {
191
+ "build": "tsc --project tsconfig.build.json"
183
192
  }
184
- }
193
+ }