@vouchington/utils 0.0.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jonathan Ong
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,34 @@
1
+ # @vouchington/utils
2
+
3
+ Dependency-free utilities for Node.js 24 applications. This package deliberately has no root
4
+ export: import the capability you use from an explicit subpath.
5
+
6
+ ```ts
7
+ import { createTokenSecrets } from '@vouchington/utils/token-secrets'
8
+ import { createMoneyCatalog } from '@vouchington/utils/money'
9
+ import { serializeCookie } from '@vouchington/utils/cookies'
10
+ ```
11
+
12
+ ## Subpaths
13
+
14
+ - `token-secrets`: Node-only (`node:crypto`) purpose-bound HMAC and AES-256-GCM encryption.
15
+ `createTokenSecrets({ hashSecret, encryptionKeys })` throws for invalid keys or ciphertext.
16
+ - `deploy-environment`: `getDeployEnvironment(source)` classifies explicit environment inputs without
17
+ throwing.
18
+ - `url-signing`: Node-only HMAC SHA-256 `signPathWithKey(path, key)` and verification; invalid keys
19
+ throw and invalid signatures return `false`.
20
+ - `request-client-info`: `createClientInfoParser(config)` validates caller-selected headers,
21
+ families, platforms, compatibility, and versions.
22
+ - `money`: `createMoneyCatalog(currencies, scale)` supplies exact integer parsing and validation for
23
+ the caller's catalog.
24
+ - `env-contract`: `groupEnvContracts()` and `normalizeEnvContractGroups()` produce delimiter-safe
25
+ metadata keys and surface/sensitivity lookups.
26
+ - `cookies`: `serializeCookie(name, value, attributes)` validates cookie grammar and requires the
27
+ caller to supply every policy attribute. `SameSite=None` requires `Secure`.
28
+
29
+ ```ts
30
+ const money = createMoneyCatalog([{ code: 'credit', minorUnitExponent: 2 }] as const, 6)
31
+ money.parseMajorUnitsToMoney('12.34', 'credit')
32
+ ```
33
+
34
+ Applications supply all product identifiers, environment names, header names, policy, and catalogs.
@@ -0,0 +1,8 @@
1
+ export type CookieAttributes = {
2
+ httpOnly: boolean;
3
+ maxAge: number;
4
+ path: string;
5
+ sameSite: 'lax' | 'none' | 'strict';
6
+ secure: boolean;
7
+ };
8
+ export declare function serializeCookie(name: string, value: string, attributes: CookieAttributes): string;
@@ -0,0 +1,32 @@
1
+ const COOKIE_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
2
+ const COOKIE_VALUE_PATTERN = /^[\x21\x23-\x2b\x2d-\x3a\x3c-\x5b\x5d-\x7e]*$/;
3
+ const COOKIE_PATH_PATTERN = /^[\x20-\x3a\x3c-\x7e]+$/;
4
+ const SAME_SITE_VALUES = new Set(['lax', 'none', 'strict']);
5
+ export function serializeCookie(name, value, attributes) {
6
+ if (!COOKIE_NAME_PATTERN.test(name))
7
+ throw new Error('Invalid cookie name');
8
+ if (!COOKIE_VALUE_PATTERN.test(value))
9
+ throw new Error(`Invalid cookie value for ${name}`);
10
+ if (!Number.isSafeInteger(attributes.maxAge) || attributes.maxAge < 0)
11
+ throw new Error('Cookie Max-Age must be a non-negative safe integer');
12
+ if (!COOKIE_PATH_PATTERN.test(attributes.path) || !attributes.path.startsWith('/'))
13
+ throw new Error('Cookie Path must be an ASCII absolute path without semicolons');
14
+ if (!SAME_SITE_VALUES.has(attributes.sameSite))
15
+ throw new Error('Invalid cookie SameSite value');
16
+ if (typeof attributes.httpOnly !== 'boolean' || typeof attributes.secure !== 'boolean')
17
+ throw new Error('Cookie HttpOnly and Secure attributes must be boolean');
18
+ if (attributes.sameSite === 'none' && !attributes.secure)
19
+ throw new Error('SameSite=None cookies must be Secure');
20
+ const sameSite = `${attributes.sameSite[0].toUpperCase()}${attributes.sameSite.slice(1)}`;
21
+ const parts = [
22
+ `${name}=${value}`,
23
+ `Max-Age=${attributes.maxAge}`,
24
+ `Path=${attributes.path}`,
25
+ `SameSite=${sameSite}`,
26
+ ];
27
+ if (attributes.httpOnly)
28
+ parts.push('HttpOnly');
29
+ if (attributes.secure)
30
+ parts.push('Secure');
31
+ return parts.join('; ');
32
+ }
@@ -0,0 +1,8 @@
1
+ export type DeployEnvironment = 'development' | 'test' | 'staging' | 'production';
2
+ export type DeployEnvironmentSource = {
3
+ ENVIRONMENT?: string | undefined;
4
+ NODE_ENV?: string | undefined;
5
+ };
6
+ export declare function getDeployEnvironment(source?: DeployEnvironmentSource): DeployEnvironment;
7
+ export declare function isDeployedEnvironment(source?: DeployEnvironmentSource): boolean;
8
+ export declare function isProductionEnvironment(source?: DeployEnvironmentSource): boolean;
@@ -0,0 +1,17 @@
1
+ const known = new Set(['development', 'test', 'staging', 'production']);
2
+ export function getDeployEnvironment(source = process.env) {
3
+ return normalize(source.ENVIRONMENT) ?? normalize(source.NODE_ENV) ?? 'development';
4
+ }
5
+ export function isDeployedEnvironment(source = process.env) {
6
+ const environment = getDeployEnvironment(source);
7
+ return environment === 'staging' || environment === 'production';
8
+ }
9
+ export function isProductionEnvironment(source = process.env) {
10
+ return getDeployEnvironment(source) === 'production';
11
+ }
12
+ function normalize(value) {
13
+ const trimmed = value?.trim();
14
+ return trimmed && known.has(trimmed)
15
+ ? trimmed
16
+ : undefined;
17
+ }
@@ -0,0 +1,19 @@
1
+ export type EnvVarSensitivity = 'internal' | 'public' | 'secret';
2
+ export type EnvVarContract<Source extends string = string, Surface extends string = string> = {
3
+ key: string;
4
+ name: string;
5
+ sensitivity: EnvVarSensitivity;
6
+ sourceOfTruth: Source;
7
+ surfaces: readonly Surface[];
8
+ };
9
+ export type EnvVarContractGroup<Source extends string = string, Surface extends string = string> = {
10
+ sourceOfTruth: Source;
11
+ surfaces: readonly Surface[];
12
+ sensitivity: EnvVarSensitivity;
13
+ names: readonly string[];
14
+ };
15
+ export declare function groupEnvContracts<Source extends string, Surface extends string>(sourceOfTruth: Source, sensitivity: EnvVarSensitivity, surfaces: readonly Surface[], names: readonly string[]): EnvVarContractGroup<Source, Surface>;
16
+ export declare function normalizeEnvContractGroups<Source extends string, Surface extends string>(groups: readonly EnvVarContractGroup<Source, Surface>[]): readonly EnvVarContract<Source, Surface>[];
17
+ export declare function envNamesForSurface<Source extends string, Surface extends string>(contract: readonly EnvVarContract<Source, Surface>[], surface: Surface): readonly string[];
18
+ export declare function envContractsByName<Source extends string, Surface extends string>(contract: readonly EnvVarContract<Source, Surface>[]): ReadonlyMap<string, readonly EnvVarContract<Source, Surface>[]>;
19
+ export declare function envNamesBySensitivity<Source extends string, Surface extends string>(contract: readonly EnvVarContract<Source, Surface>[], sensitivity: EnvVarSensitivity): readonly string[];
@@ -0,0 +1,45 @@
1
+ export function groupEnvContracts(sourceOfTruth, sensitivity, surfaces, names) {
2
+ validatePart(sourceOfTruth, 'source');
3
+ for (const surface of surfaces)
4
+ validatePart(surface, 'surface');
5
+ for (const name of names)
6
+ validatePart(name, 'name');
7
+ return { sourceOfTruth, sensitivity, surfaces: [...surfaces], names: [...names] };
8
+ }
9
+ export function normalizeEnvContractGroups(groups) {
10
+ const contract = groups.flatMap((group) => {
11
+ validatePart(group.sourceOfTruth, 'source');
12
+ for (const surface of group.surfaces)
13
+ validatePart(surface, 'surface');
14
+ for (const name of group.names)
15
+ validatePart(name, 'name');
16
+ return group.names.map((name) => ({
17
+ key: `${group.sourceOfTruth}:${group.surfaces.join('+')}:${name}`,
18
+ name,
19
+ sensitivity: group.sensitivity,
20
+ sourceOfTruth: group.sourceOfTruth,
21
+ surfaces: Object.freeze([...group.surfaces]),
22
+ }));
23
+ });
24
+ if (new Set(contract.map((entry) => entry.key)).size !== contract.length)
25
+ throw new Error('Environment contract keys must be unique');
26
+ return contract;
27
+ }
28
+ function validatePart(value, label) {
29
+ if (!value || /[:+]/.test(value))
30
+ throw new TypeError(`Environment contract ${label} cannot contain ':' or '+'`);
31
+ }
32
+ export function envNamesForSurface(contract, surface) {
33
+ return contract.filter((entry) => entry.surfaces.includes(surface)).map((entry) => entry.name);
34
+ }
35
+ export function envContractsByName(contract) {
36
+ const grouped = new Map();
37
+ for (const entry of contract)
38
+ grouped.set(entry.name, [...(grouped.get(entry.name) ?? []), entry]);
39
+ return new Map([...grouped].map(([name, entries]) => [name, Object.freeze(entries)]));
40
+ }
41
+ export function envNamesBySensitivity(contract, sensitivity) {
42
+ return [
43
+ ...new Set(contract.filter((entry) => entry.sensitivity === sensitivity).map((entry) => entry.name)),
44
+ ].toSorted();
45
+ }
@@ -0,0 +1,37 @@
1
+ export declare const MAX_MONEY_AMOUNT: number;
2
+ export type Currency<Code extends string = string> = {
3
+ code: Code;
4
+ minorUnitExponent: number;
5
+ };
6
+ export type Money<Code extends string = string> = {
7
+ amount: number;
8
+ currency: Code;
9
+ };
10
+ export type ScaledMoney<Code extends string = string> = Money<Code> & {
11
+ scale: number;
12
+ };
13
+ export type ScaledMoneyAggregate<Code extends string = string> = {
14
+ amount: string;
15
+ currency: Code;
16
+ scale: number;
17
+ };
18
+ export type MoneyRange<Code extends string = string> = {
19
+ minimum: Money<Code>;
20
+ maximum: Money<Code> | null;
21
+ };
22
+ export declare function createMoneyCatalog<const Currencies extends readonly Currency[]>(entries: Currencies, scale: number): {
23
+ currencies: readonly Readonly<{
24
+ code: string;
25
+ minorUnitExponent: number;
26
+ }>[];
27
+ scale: number;
28
+ isCurrencyCode: (value: unknown) => value is Currencies[number]["code"];
29
+ getCurrency: (code: Currencies[number]["code"]) => Currency<Currencies[number]["code"]>;
30
+ isMoney: (value: unknown) => value is Money<Currencies[number]["code"]>;
31
+ isScaledMoney: (value: unknown) => value is ScaledMoney<Currencies[number]["code"]>;
32
+ isScaledMoneyAggregate: (value: unknown) => value is ScaledMoneyAggregate<Currencies[number]["code"]>;
33
+ isMoneyRange: (value: unknown) => value is MoneyRange<Currencies[number]["code"]>;
34
+ parseMajorUnitsToMoney: (value: string, currency: Currencies[number]["code"]) => Money<Currencies[number]["code"]>;
35
+ parseMajorUnitsToScaledMoney: (value: string, currency: Currencies[number]["code"]) => ScaledMoney<Currencies[number]["code"]>;
36
+ };
37
+ export declare function parsePostgresMoneyAmount(value: string): number;
package/dist/money.mjs ADDED
@@ -0,0 +1,122 @@
1
+ export const MAX_MONEY_AMOUNT = Number.MAX_SAFE_INTEGER;
2
+ export function createMoneyCatalog(entries, scale) {
3
+ validateScale(scale);
4
+ for (const currency of entries)
5
+ validateCurrency(currency, scale);
6
+ const copiedEntries = entries.map((currency) => Object.freeze({ ...currency }));
7
+ const currencies = new Map(copiedEntries.map((currency) => [currency.code, currency]));
8
+ if (currencies.size !== entries.length)
9
+ throw new Error('Currency codes must be unique');
10
+ return {
11
+ currencies: Object.freeze(copiedEntries),
12
+ scale,
13
+ isCurrencyCode: (value) => typeof value === 'string' && currencies.has(value),
14
+ getCurrency: (code) => currencies.get(code) ?? invalidCurrency(code),
15
+ isMoney: (value) => isMoneyValue(value, currencies),
16
+ isScaledMoney: (value) => isScaledMoneyValue(value, currencies, scale),
17
+ isScaledMoneyAggregate: (value) => isScaledAggregateValue(value, currencies, scale),
18
+ isMoneyRange: (value) => isMoneyRangeValue(value, currencies),
19
+ parseMajorUnitsToMoney: (value, currency) => ({
20
+ amount: parseMajorUnits(value, (currencies.get(currency) ?? invalidCurrency(currency)).minorUnitExponent),
21
+ currency,
22
+ }),
23
+ parseMajorUnitsToScaledMoney: (value, currency) => ({
24
+ amount: parseMajorUnits(value, currencies.has(currency) ? scale : invalidCurrency(currency)),
25
+ currency,
26
+ scale,
27
+ }),
28
+ };
29
+ }
30
+ export function parsePostgresMoneyAmount(value) {
31
+ if (!isCanonicalInteger(value))
32
+ throw new TypeError('Stored money amount must be a canonical non-negative integer');
33
+ const amount = BigInt(value);
34
+ if (amount > BigInt(MAX_MONEY_AMOUNT))
35
+ throw new RangeError('Stored money amount exceeds the maximum JSON-safe integer');
36
+ return Number(amount);
37
+ }
38
+ function validateScale(scale) {
39
+ if (!Number.isInteger(scale) || scale < 0 || scale > 15)
40
+ throw new RangeError('Scale must be 0 through 15');
41
+ }
42
+ function validateCurrency(currency, scale) {
43
+ if (!currency.code ||
44
+ !Number.isInteger(currency.minorUnitExponent) ||
45
+ currency.minorUnitExponent < 0 ||
46
+ currency.minorUnitExponent > scale)
47
+ throw new TypeError('Currencies require a non-empty code and an integer exponent within scale');
48
+ }
49
+ function invalidCurrency(code) {
50
+ throw new TypeError(`Unknown currency: ${code}`);
51
+ }
52
+ function isMoneyValue(value, currencies) {
53
+ const fields = fieldsOf(value, ['amount', 'currency']);
54
+ return fields !== null && isAmount(fields.amount) && hasCurrency(currencies, fields.currency);
55
+ }
56
+ function isScaledMoneyValue(value, currencies, scale) {
57
+ const fields = fieldsOf(value, ['amount', 'currency', 'scale']);
58
+ return (fields !== null &&
59
+ isAmount(fields.amount) &&
60
+ hasCurrency(currencies, fields.currency) &&
61
+ fields.scale === scale);
62
+ }
63
+ function isScaledAggregateValue(value, currencies, scale) {
64
+ const fields = fieldsOf(value, ['amount', 'currency', 'scale']);
65
+ return (fields !== null &&
66
+ isCanonicalInteger(fields.amount) &&
67
+ hasCurrency(currencies, fields.currency) &&
68
+ fields.scale === scale);
69
+ }
70
+ function isMoneyRangeValue(value, currencies) {
71
+ const fields = fieldsOf(value, ['minimum', 'maximum']);
72
+ return (fields !== null &&
73
+ isMoneyValue(fields.minimum, currencies) &&
74
+ (fields.maximum === null ||
75
+ (isMoneyValue(fields.maximum, currencies) &&
76
+ fields.maximum.currency === fields.minimum.currency &&
77
+ fields.maximum.amount > fields.minimum.amount)));
78
+ }
79
+ function hasCurrency(currencies, value) {
80
+ return typeof value === 'string' && currencies.has(value);
81
+ }
82
+ function parseMajorUnits(value, scale) {
83
+ const match = /^(0|[1-9]\d*)(?:\.(\d+))?$/.exec(value);
84
+ if (!match)
85
+ throw new TypeError('Money must be a non-negative plain decimal string');
86
+ const fractional = match[2] ?? '';
87
+ if (fractional.length > scale)
88
+ throw new RangeError(`Money supports at most ${scale} decimal places`);
89
+ const amount = BigInt(match[1]) * 10n ** BigInt(scale) + BigInt(fractional.padEnd(scale, '0'));
90
+ if (amount > BigInt(MAX_MONEY_AMOUNT))
91
+ throw new RangeError('Money exceeds the maximum JSON-safe integer amount');
92
+ return Number(amount);
93
+ }
94
+ function isAmount(value) {
95
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
96
+ }
97
+ function isCanonicalInteger(value) {
98
+ return typeof value === 'string' && /^(0|[1-9]\d*)$/.test(value);
99
+ }
100
+ function fieldsOf(value, expected) {
101
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
102
+ return null;
103
+ try {
104
+ const prototype = Object.getPrototypeOf(value);
105
+ if (prototype !== Object.prototype && prototype !== null)
106
+ return null;
107
+ const keys = Reflect.ownKeys(value);
108
+ if (keys.length !== expected.length || !expected.every((key) => Object.hasOwn(value, key)))
109
+ return null;
110
+ const fields = {};
111
+ for (const key of expected) {
112
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
113
+ if (!descriptor || !('value' in descriptor))
114
+ return null;
115
+ fields[key] = descriptor.value;
116
+ }
117
+ return fields;
118
+ }
119
+ catch {
120
+ return null;
121
+ }
122
+ }
@@ -0,0 +1,26 @@
1
+ export type HeaderValue = string | readonly string[] | undefined;
2
+ export type ClientInfoHeaders = {
3
+ client: string;
4
+ platform: string;
5
+ appVersion: string;
6
+ sdkVersion?: string;
7
+ };
8
+ export type ClientInfoConfiguration<Client extends string, Platform extends string> = {
9
+ headers: ClientInfoHeaders;
10
+ clients: readonly Client[];
11
+ platforms: readonly Platform[];
12
+ compatiblePlatforms: Readonly<Record<Client, readonly Platform[]>>;
13
+ versionPattern?: RegExp;
14
+ };
15
+ export type ClientHeaders<Client extends string, Platform extends string> = {
16
+ client: Client;
17
+ platform: Platform;
18
+ appVersion: string;
19
+ sdkVersion?: string;
20
+ };
21
+ export declare class ClientInfoValidationError extends Error {
22
+ readonly code = "INVALID_CLIENT_INFO";
23
+ readonly status = 400;
24
+ constructor(message: string);
25
+ }
26
+ export declare function createClientInfoParser<Client extends string, Platform extends string>(configuration: ClientInfoConfiguration<Client, Platform>): (headers: Record<string, HeaderValue>) => ClientHeaders<Client, Platform>;
@@ -0,0 +1,57 @@
1
+ export class ClientInfoValidationError extends Error {
2
+ code = 'INVALID_CLIENT_INFO';
3
+ status = 400;
4
+ constructor(message) {
5
+ super(message);
6
+ this.name = 'ClientInfoValidationError';
7
+ }
8
+ }
9
+ export function createClientInfoParser(configuration) {
10
+ const clients = new Set(configuration.clients);
11
+ const platforms = new Set(configuration.platforms);
12
+ const compatiblePlatforms = new Map();
13
+ for (const client of clients) {
14
+ const compatible = configuration.compatiblePlatforms[client];
15
+ if (!Array.isArray(compatible))
16
+ throw new TypeError(`Client ${client} requires compatible platforms`);
17
+ if (compatible.some((platform) => !platforms.has(platform)))
18
+ throw new TypeError(`Client ${client} has an unknown compatible platform`);
19
+ compatiblePlatforms.set(client, new Set(compatible));
20
+ }
21
+ const versionPattern = configuration.versionPattern ?? /^[\x20-\x7e]{1,64}$/;
22
+ if (versionPattern.global || versionPattern.sticky)
23
+ throw new TypeError('Version patterns cannot be global or sticky');
24
+ return (headers) => {
25
+ const client = requiredEnum(headers[configuration.headers.client], configuration.headers.client, clients);
26
+ const platform = requiredEnum(headers[configuration.headers.platform], configuration.headers.platform, platforms);
27
+ const appVersion = requiredVersion(headers[configuration.headers.appVersion], configuration.headers.appVersion, true, versionPattern);
28
+ const sdkVersion = configuration.headers.sdkVersion
29
+ ? requiredVersion(headers[configuration.headers.sdkVersion], configuration.headers.sdkVersion, false, versionPattern)
30
+ : undefined;
31
+ if (!compatiblePlatforms.get(client).has(platform))
32
+ throw new ClientInfoValidationError(`${client} and ${platform} are incompatible`);
33
+ return { client, platform, appVersion, ...(sdkVersion === undefined ? {} : { sdkVersion }) };
34
+ };
35
+ }
36
+ function requiredEnum(value, name, allowed) {
37
+ const single = requireSingle(value, name, true);
38
+ if (!allowed.has(single))
39
+ throw new ClientInfoValidationError(`${name} is invalid`);
40
+ return single;
41
+ }
42
+ function requiredVersion(value, name, required, pattern) {
43
+ const single = requireSingle(value, name, required);
44
+ if (single !== undefined && !pattern.test(single))
45
+ throw new ClientInfoValidationError(`${name} is invalid`);
46
+ return single;
47
+ }
48
+ function requireSingle(value, name, required) {
49
+ if (typeof value !== 'string' && value !== undefined)
50
+ throw new ClientInfoValidationError(`${name} must occur exactly once`);
51
+ if (value === undefined || value === '') {
52
+ if (required)
53
+ throw new ClientInfoValidationError(`${name} is required`);
54
+ return undefined;
55
+ }
56
+ return value;
57
+ }
@@ -0,0 +1,14 @@
1
+ export type EncryptionKey = {
2
+ id: string;
3
+ key: Buffer;
4
+ };
5
+ export type TokenSecrets = {
6
+ hashToken(purpose: string, token: string): string;
7
+ encryptSecret(plaintext: string, purpose: string): string;
8
+ decryptSecret(ciphertext: string, purpose: string): string;
9
+ };
10
+ export declare function parseEncryptionKeys(value: string): EncryptionKey[];
11
+ export declare function createTokenSecrets(options: {
12
+ hashSecret: string;
13
+ encryptionKeys: readonly EncryptionKey[];
14
+ }): TokenSecrets;
@@ -0,0 +1,102 @@
1
+ import { createCipheriv, createDecipheriv, createHmac, randomBytes } from 'node:crypto';
2
+ const KEY_FORMAT = 'v1';
3
+ export function parseEncryptionKeys(value) {
4
+ return value
5
+ .split(',')
6
+ .map((part) => part.trim())
7
+ .map(parseEncryptionKey);
8
+ }
9
+ export function createTokenSecrets(options) {
10
+ if (!options.hashSecret.trim())
11
+ throw new Error('Hash secret must not be empty');
12
+ const hashSecret = options.hashSecret;
13
+ if (options.encryptionKeys.length === 0)
14
+ throw new Error('Encryption keys must contain at least one key');
15
+ for (const key of options.encryptionKeys) {
16
+ if (!/^[A-Za-z0-9_-]+$/.test(key.id) || key.key.length !== 32)
17
+ throw new Error('Encryption keys must have a non-empty id and 32 bytes');
18
+ }
19
+ const copiedKeys = options.encryptionKeys.map((key) => ({
20
+ id: key.id,
21
+ key: Buffer.from(key.key),
22
+ }));
23
+ const keys = new Map(copiedKeys.map((key) => [key.id, key]));
24
+ if (keys.size !== options.encryptionKeys.length)
25
+ throw new Error('Encryption key ids must be unique');
26
+ const primary = copiedKeys[0];
27
+ return {
28
+ hashToken: (purpose, token) => createHmac('sha256', hashSecret)
29
+ .update(purpose)
30
+ .update('\0')
31
+ .update(token)
32
+ .digest('hex')
33
+ .toUpperCase(),
34
+ encryptSecret: (plaintext, purpose) => encrypt(plaintext, purpose, primary),
35
+ decryptSecret: (ciphertext, purpose) => decrypt(ciphertext, purpose, keys),
36
+ };
37
+ }
38
+ function parseEncryptionKey(part) {
39
+ const separator = part.indexOf(':');
40
+ const id = separator < 0 ? '' : part.slice(0, separator).trim();
41
+ if (!/^[A-Za-z0-9_-]+$/.test(id))
42
+ throw new Error('Encryption key entries must use a non-empty safe key id');
43
+ const raw = part.slice(separator + 1).trim();
44
+ const value = raw.startsWith('raw32:')
45
+ ? raw.slice(6).trim()
46
+ : raw.startsWith('base64url:')
47
+ ? raw.slice(10).trim()
48
+ : raw;
49
+ if (!value)
50
+ throw new Error('Encryption key entries must contain key material');
51
+ if (!raw.startsWith('raw32:') &&
52
+ (!/^[A-Za-z0-9_-]+$/.test(value) ||
53
+ Buffer.from(value, 'base64url').toString('base64url') !== value))
54
+ throw new Error('Encryption key material must be canonical base64url');
55
+ const key = raw.startsWith('raw32:') ? Buffer.from(value) : Buffer.from(value, 'base64url');
56
+ if (key.length !== 32)
57
+ throw new Error(`Encryption key ${id} must be 32 bytes`);
58
+ return { id, key };
59
+ }
60
+ function encrypt(plaintext, purpose, key) {
61
+ const iv = randomBytes(12);
62
+ const cipher = createCipheriv('aes-256-gcm', key.key, iv);
63
+ cipher.setAAD(Buffer.from(purpose));
64
+ const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
65
+ return [
66
+ KEY_FORMAT,
67
+ key.id,
68
+ iv.toString('base64url'),
69
+ cipher.getAuthTag().toString('base64url'),
70
+ ciphertext.toString('base64url'),
71
+ ].join(':');
72
+ }
73
+ function decrypt(value, purpose, keys) {
74
+ const [format, id, iv, tag, ciphertext, extra] = value.split(':');
75
+ if (format !== KEY_FORMAT ||
76
+ !id ||
77
+ !iv ||
78
+ !tag ||
79
+ ciphertext === undefined ||
80
+ extra !== undefined)
81
+ throw new Error('Invalid encrypted secret format');
82
+ const key = keys.get(id);
83
+ if (!key)
84
+ throw new Error(`Encryption key ${id} is not configured`);
85
+ const ivBytes = decodeCanonicalBase64Url(iv);
86
+ const tagBytes = decodeCanonicalBase64Url(tag);
87
+ const ciphertextBytes = decodeCanonicalBase64Url(ciphertext);
88
+ if (ivBytes.length !== 12 || tagBytes.length !== 16)
89
+ throw new Error('Invalid encrypted secret format');
90
+ const decipher = createDecipheriv('aes-256-gcm', key.key, ivBytes);
91
+ decipher.setAAD(Buffer.from(purpose));
92
+ decipher.setAuthTag(tagBytes);
93
+ return Buffer.concat([decipher.update(ciphertextBytes), decipher.final()]).toString();
94
+ }
95
+ function decodeCanonicalBase64Url(value) {
96
+ if (!/^[A-Za-z0-9_-]*$/.test(value))
97
+ throw new Error('Invalid encrypted secret format');
98
+ const decoded = Buffer.from(value, 'base64url');
99
+ if (decoded.toString('base64url') !== value)
100
+ throw new Error('Invalid encrypted secret format');
101
+ return decoded;
102
+ }
@@ -0,0 +1,2 @@
1
+ export declare function signPathWithKey(path: string, keyHex: string): string;
2
+ export declare function verifyPathWithKey(path: string, signature: string, keyHex: string): boolean;
@@ -0,0 +1,12 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
2
+ export function signPathWithKey(path, keyHex) {
3
+ if (!/^[\da-f]{64}$/i.test(keyHex))
4
+ throw new Error('Signing keys must be 64 hexadecimal characters');
5
+ return createHmac('sha256', Buffer.from(keyHex, 'hex')).update(path).digest('hex');
6
+ }
7
+ export function verifyPathWithKey(path, signature, keyHex) {
8
+ const expected = signPathWithKey(path, keyHex);
9
+ if (expected.length !== signature.length || !/^[\da-f]+$/i.test(signature))
10
+ return false;
11
+ return timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(signature, 'hex'));
12
+ }
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "@vouchington/utils",
3
+ "version": "0.0.0",
4
+ "description": "Dependency-free runtime utilities for Vouchington-style applications.",
5
+ "homepage": "https://github.com/vouchington/vouchington-platform/tree/main/packages/utils#readme",
6
+ "bugs": {
7
+ "url": "https://github.com/vouchington/vouchington-platform/issues"
8
+ },
9
+ "license": "MIT",
10
+ "author": "Jonathan Ong",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/vouchington/vouchington-platform.git",
14
+ "directory": "packages/utils"
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "type": "module",
22
+ "exports": {
23
+ "./token-secrets": {
24
+ "types": "./dist/token-secrets.d.mts",
25
+ "import": "./dist/token-secrets.mjs",
26
+ "default": "./dist/token-secrets.mjs"
27
+ },
28
+ "./deploy-environment": {
29
+ "types": "./dist/deploy-environment.d.mts",
30
+ "import": "./dist/deploy-environment.mjs",
31
+ "default": "./dist/deploy-environment.mjs"
32
+ },
33
+ "./url-signing": {
34
+ "types": "./dist/url-signing.d.mts",
35
+ "import": "./dist/url-signing.mjs",
36
+ "default": "./dist/url-signing.mjs"
37
+ },
38
+ "./request-client-info": {
39
+ "types": "./dist/request-client-info.d.mts",
40
+ "import": "./dist/request-client-info.mjs",
41
+ "default": "./dist/request-client-info.mjs"
42
+ },
43
+ "./money": {
44
+ "types": "./dist/money.d.mts",
45
+ "import": "./dist/money.mjs",
46
+ "default": "./dist/money.mjs"
47
+ },
48
+ "./env-contract": {
49
+ "types": "./dist/env-contract.d.mts",
50
+ "import": "./dist/env-contract.mjs",
51
+ "default": "./dist/env-contract.mjs"
52
+ },
53
+ "./cookies": {
54
+ "types": "./dist/cookies.d.mts",
55
+ "import": "./dist/cookies.mjs",
56
+ "default": "./dist/cookies.mjs"
57
+ }
58
+ },
59
+ "publishConfig": {
60
+ "access": "public"
61
+ },
62
+ "scripts": {
63
+ "build": "tsc --project tsconfig.build.json",
64
+ "prepack": "pnpm run build"
65
+ },
66
+ "engines": {
67
+ "node": ">=24.0.0"
68
+ }
69
+ }