@vouchington/utils 0.5.0 → 0.5.2
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 +24 -7
- package/dist/cookies.d.mts +1 -0
- package/dist/cookies.mjs +16 -0
- package/dist/hashtags.d.mts +5 -0
- package/dist/hashtags.mjs +43 -0
- package/dist/http-signatures-digest.d.mts +3 -0
- package/dist/http-signatures-digest.mjs +20 -0
- package/dist/http-signatures-keys.d.mts +7 -0
- package/dist/http-signatures-keys.mjs +27 -0
- package/dist/http-signatures-sign.d.mts +17 -0
- package/dist/http-signatures-sign.mjs +35 -0
- package/dist/http-signatures-string.d.mts +12 -0
- package/dist/http-signatures-string.mjs +40 -0
- package/dist/http-signatures-test-helpers.d.mts +30 -0
- package/dist/http-signatures-test-helpers.mjs +62 -0
- package/dist/http-signatures-verify.d.mts +21 -0
- package/dist/http-signatures-verify.mjs +88 -0
- package/dist/http-signatures.d.mts +4 -0
- package/dist/http-signatures.mjs +4 -0
- package/dist/money.d.mts +9 -0
- package/dist/money.mjs +20 -0
- package/dist/scopes.d.mts +29 -0
- package/dist/scopes.mjs +122 -0
- package/dist/sentry-envelope.d.mts +10 -0
- package/dist/sentry-envelope.mjs +82 -0
- package/dist/slugs.d.mts +2 -0
- package/dist/slugs.mjs +4 -0
- package/dist/utm.d.mts +16 -0
- package/dist/utm.mjs +28 -0
- package/package.json +25 -6
package/README.md
CHANGED
|
@@ -6,14 +6,21 @@ 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'
|
|
14
|
+
import { getSentryEnvelopeDiagnostics } from '@vouchington/utils/sentry-envelope'
|
|
15
|
+
import { createScopeCatalog } from '@vouchington/utils/scopes'
|
|
13
16
|
```
|
|
14
17
|
|
|
15
18
|
## Subpaths
|
|
16
19
|
|
|
20
|
+
- `sentry-envelope`: bounded UTF-8 envelope record scanning and safe item-type diagnostics.
|
|
21
|
+
Callers choose the byte and type-list limits; DSN, tunnel, request, and logging policy stay local.
|
|
22
|
+
- `scopes`: caller-owned scope catalog validation, prerequisite expansion, audience and surface
|
|
23
|
+
filtering, and explicit coverage graph calculation. No default catalog or legacy grants are built in.
|
|
17
24
|
- `token-secrets`: Node-only (`node:crypto`) purpose-bound HMAC and AES-256-GCM encryption.
|
|
18
25
|
`createTokenSecrets({ hashSecret, encryptionKeys })` throws for invalid keys or ciphertext.
|
|
19
26
|
- `deploy-environment`: `getDeployEnvironment(source)` classifies explicit environment inputs without
|
|
@@ -23,11 +30,17 @@ import { createReportInputParser } from '@vouchington/utils/moderation'
|
|
|
23
30
|
- `request-client-info`: `createClientInfoParser(config)` validates caller-selected headers,
|
|
24
31
|
families, platforms, compatibility, and versions.
|
|
25
32
|
- `money`: `createMoneyCatalog(currencies, scale)` supplies exact integer parsing and validation for
|
|
26
|
-
the caller's catalog.
|
|
33
|
+
the caller's catalog. `allocateProportionalAmount(amount, numerator, denominator, rounding)`
|
|
34
|
+
allocates an inclusive fraction with `down` or `up` integer rounding chosen explicitly by the
|
|
35
|
+
caller; it uses exact integer arithmetic rather than floating-point division.
|
|
27
36
|
- `env-contract`: `groupEnvContracts()` and `normalizeEnvContractGroups()` produce delimiter-safe
|
|
28
37
|
metadata keys and surface/sensitivity lookups.
|
|
29
38
|
- `cookies`: `serializeCookie(name, value, attributes)` validates cookie grammar and requires the
|
|
30
39
|
caller to supply every policy attribute. `SameSite=None` requires `Secure`.
|
|
40
|
+
`parseCookies(header)` splits a `Cookie` request header into a name/value `Map`.
|
|
41
|
+
- `http-signatures`: Node-only (`node:crypto`) Cavage-12 HTTP Signatures. Callers pass signed
|
|
42
|
+
headers, allowed algorithms, and `maxAgeSeconds` on both sign and verify. The package has no
|
|
43
|
+
default header set or product keyId policy.
|
|
31
44
|
- `collections`, `async`, and `stable-json`: collection deduplication/merging, bounded async mapping,
|
|
32
45
|
and deterministic JSON serialization.
|
|
33
46
|
- `feature-flags`: base64 JSON feature-flag cookie parsing and serialization with caller-injected
|
|
@@ -40,12 +53,16 @@ import { createReportInputParser } from '@vouchington/utils/moderation'
|
|
|
40
53
|
validation. Applications own catalogs, policy, error mapping, persistence, and HTTP behavior.
|
|
41
54
|
- `query` and `query-string`: generic array/boolean/number parsing, bounded integer parsing, and
|
|
42
55
|
query-string serialization. Cursor parsing intentionally belongs to `@vouchington/pagination`.
|
|
43
|
-
- `slugs` and `urls`: ASCII-only slugification
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
56
|
+
- `slugs` and `urls`: ASCII-only slugification, the `isSlug` lowercase `[a-z0-9-]+` predicate
|
|
57
|
+
(leading, trailing, and consecutive hyphens are allowed), and URL/hostname primitives. URL hostname
|
|
58
|
+
helpers return `null` for invalid input; `normalizeAsciiHostname` names its ASCII-only contract
|
|
59
|
+
directly, while `normalizeHostname` also supports WHATWG IDN conversion.
|
|
60
|
+
`matchesHostnamePattern` supports exact and inclusive `*.` hostname patterns.
|
|
47
61
|
- `hashtags`: caller-configured authored/key length and separator policy for canonical ASCII hashtag
|
|
48
|
-
keys
|
|
62
|
+
keys, plus `maskHashtagBearingUrls` to blank absolute URLs and fragment-bearing relative paths.
|
|
63
|
+
The package does not choose application limits or accepted input separators.
|
|
64
|
+
- `utm`: `createUtmParser({ sourceAliases, fallbackSourceParam? })` lowercases and trims UTM fields
|
|
65
|
+
and resolves `utm_source` through caller-owned aliases. The package has no default shortcode map.
|
|
49
66
|
- `fetch-ports`: the Fetch standard forbidden-port list and membership predicates.
|
|
50
67
|
- `http-body`: bounded Fetch response-body reads with abort propagation and typed missing/oversize
|
|
51
68
|
errors.
|
package/dist/cookies.d.mts
CHANGED
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
|
+
}
|
package/dist/hashtags.d.mts
CHANGED
|
@@ -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,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)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface ScopeDefinition {
|
|
2
|
+
audience: string;
|
|
3
|
+
surfaces: readonly string[];
|
|
4
|
+
requires?: readonly string[];
|
|
5
|
+
covers?: readonly string[];
|
|
6
|
+
}
|
|
7
|
+
export type ScopeValidationCode = 'duplicate-scope' | 'empty-scope-set' | 'missing-prerequisite' | 'mixed-audiences' | 'unsupported-surface' | 'unknown-scope';
|
|
8
|
+
export type ScopeValidationResult = {
|
|
9
|
+
valid: true;
|
|
10
|
+
scopes: string[];
|
|
11
|
+
audiences: string[];
|
|
12
|
+
} | {
|
|
13
|
+
valid: false;
|
|
14
|
+
code: ScopeValidationCode;
|
|
15
|
+
scope?: string;
|
|
16
|
+
requiredScope?: string;
|
|
17
|
+
};
|
|
18
|
+
export interface ScopeCatalog {
|
|
19
|
+
hasScope(granted: readonly string[], required: string): boolean;
|
|
20
|
+
hasEveryScope(granted: readonly string[], required: readonly string[]): boolean;
|
|
21
|
+
expandPrerequisites(scopes: readonly string[]): string[];
|
|
22
|
+
listScopesForAudience(audience: string, surface: string): string[];
|
|
23
|
+
validateScopeSet(input: readonly string[], options: {
|
|
24
|
+
surface: string;
|
|
25
|
+
allowMixedAudiences: boolean;
|
|
26
|
+
}): ScopeValidationResult;
|
|
27
|
+
}
|
|
28
|
+
/** Compile a caller-owned scope graph; product grant and authorization decisions stay in callers. */
|
|
29
|
+
export declare function createScopeCatalog(definitions: Record<string, ScopeDefinition>): ScopeCatalog;
|
package/dist/scopes.mjs
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/** Compile a caller-owned scope graph; product grant and authorization decisions stay in callers. */
|
|
2
|
+
export function createScopeCatalog(definitions) {
|
|
3
|
+
const catalog = new Map();
|
|
4
|
+
for (const [scope, definition] of Object.entries(definitions)) {
|
|
5
|
+
if (!scope ||
|
|
6
|
+
!definition ||
|
|
7
|
+
typeof definition.audience !== 'string' ||
|
|
8
|
+
!definition.audience ||
|
|
9
|
+
!Array.isArray(definition.surfaces) ||
|
|
10
|
+
definition.surfaces.length === 0 ||
|
|
11
|
+
definition.surfaces.some((surface) => typeof surface !== 'string' || !surface)) {
|
|
12
|
+
throw new TypeError(`Invalid scope definition for ${scope}`);
|
|
13
|
+
}
|
|
14
|
+
for (const edges of [definition.requires, definition.covers]) {
|
|
15
|
+
if (edges !== undefined &&
|
|
16
|
+
(!Array.isArray(edges) || edges.some((edge) => typeof edge !== 'string' || !edge))) {
|
|
17
|
+
throw new TypeError(`Invalid scope edges for ${scope}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
catalog.set(scope, {
|
|
21
|
+
audience: definition.audience,
|
|
22
|
+
surfaces: [...definition.surfaces],
|
|
23
|
+
requires: [...(definition.requires ?? [])],
|
|
24
|
+
covers: [...(definition.covers ?? [])],
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
for (const relation of ['requires', 'covers'])
|
|
28
|
+
assertAcyclic(catalog, relation);
|
|
29
|
+
function requireScope(scope) {
|
|
30
|
+
const definition = catalog.get(scope);
|
|
31
|
+
if (!definition)
|
|
32
|
+
throw new RangeError(`Unknown scope: ${scope}`);
|
|
33
|
+
return definition;
|
|
34
|
+
}
|
|
35
|
+
function expandPrerequisites(scopes) {
|
|
36
|
+
const expanded = new Set();
|
|
37
|
+
function visit(scope) {
|
|
38
|
+
if (expanded.has(scope))
|
|
39
|
+
return;
|
|
40
|
+
const definition = requireScope(scope);
|
|
41
|
+
expanded.add(scope);
|
|
42
|
+
for (const required of definition.requires)
|
|
43
|
+
visit(required);
|
|
44
|
+
}
|
|
45
|
+
for (const scope of scopes)
|
|
46
|
+
visit(scope);
|
|
47
|
+
return [...expanded].sort();
|
|
48
|
+
}
|
|
49
|
+
function covers(granted, required, visited) {
|
|
50
|
+
if (granted === required)
|
|
51
|
+
return true;
|
|
52
|
+
if (visited.has(granted))
|
|
53
|
+
return false;
|
|
54
|
+
visited.add(granted);
|
|
55
|
+
return requireScope(granted).covers.some((scope) => covers(scope, required, visited));
|
|
56
|
+
}
|
|
57
|
+
function hasScope(granted, required) {
|
|
58
|
+
requireScope(required);
|
|
59
|
+
return granted.some((scope) => covers(scope, required, new Set()));
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
expandPrerequisites,
|
|
63
|
+
hasScope,
|
|
64
|
+
hasEveryScope(granted, required) {
|
|
65
|
+
return required.every((scope) => hasScope(granted, scope));
|
|
66
|
+
},
|
|
67
|
+
listScopesForAudience(audience, surface) {
|
|
68
|
+
return [...catalog]
|
|
69
|
+
.filter(([, definition]) => definition.audience === audience && definition.surfaces.includes(surface))
|
|
70
|
+
.map(([scope]) => scope)
|
|
71
|
+
.sort();
|
|
72
|
+
},
|
|
73
|
+
validateScopeSet(input, options) {
|
|
74
|
+
if (input.length === 0)
|
|
75
|
+
return { valid: false, code: 'empty-scope-set' };
|
|
76
|
+
const seen = new Set();
|
|
77
|
+
for (const scope of input) {
|
|
78
|
+
const definition = catalog.get(scope);
|
|
79
|
+
if (!definition)
|
|
80
|
+
return { valid: false, code: 'unknown-scope', scope };
|
|
81
|
+
if (seen.has(scope))
|
|
82
|
+
return { valid: false, code: 'duplicate-scope', scope };
|
|
83
|
+
if (!definition.surfaces.includes(options.surface)) {
|
|
84
|
+
return { valid: false, code: 'unsupported-surface', scope };
|
|
85
|
+
}
|
|
86
|
+
seen.add(scope);
|
|
87
|
+
}
|
|
88
|
+
const scopes = [...seen].sort();
|
|
89
|
+
for (const scope of scopes) {
|
|
90
|
+
for (const requiredScope of requireScope(scope).requires) {
|
|
91
|
+
if (!seen.has(requiredScope)) {
|
|
92
|
+
return { valid: false, code: 'missing-prerequisite', scope, requiredScope };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const audiences = [...new Set(scopes.map((scope) => requireScope(scope).audience))].sort();
|
|
97
|
+
if (!options.allowMixedAudiences && audiences.length > 1) {
|
|
98
|
+
return { valid: false, code: 'mixed-audiences' };
|
|
99
|
+
}
|
|
100
|
+
return { valid: true, scopes, audiences };
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function assertAcyclic(catalog, relation) {
|
|
105
|
+
const done = new Set();
|
|
106
|
+
const active = new Set();
|
|
107
|
+
function visit(scope) {
|
|
108
|
+
if (!catalog.has(scope))
|
|
109
|
+
throw new RangeError(`Unknown ${relation} scope: ${scope}`);
|
|
110
|
+
if (active.has(scope))
|
|
111
|
+
throw new RangeError(`Scope ${relation} cycle at ${scope}`);
|
|
112
|
+
if (done.has(scope))
|
|
113
|
+
return;
|
|
114
|
+
active.add(scope);
|
|
115
|
+
for (const next of catalog.get(scope)[relation])
|
|
116
|
+
visit(next);
|
|
117
|
+
active.delete(scope);
|
|
118
|
+
done.add(scope);
|
|
119
|
+
}
|
|
120
|
+
for (const scope of catalog.keys())
|
|
121
|
+
visit(scope);
|
|
122
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface SentryEnvelopeDiagnostics {
|
|
2
|
+
envelopeItemCount?: number;
|
|
3
|
+
envelopeItemTypes?: string[];
|
|
4
|
+
}
|
|
5
|
+
export interface SentryEnvelopeLimits {
|
|
6
|
+
maxBytes: number;
|
|
7
|
+
maxItemTypes: number;
|
|
8
|
+
}
|
|
9
|
+
/** Read bounded envelope item metadata without inspecting payload content. */
|
|
10
|
+
export declare function getSentryEnvelopeDiagnostics(envelope: string, limits: SentryEnvelopeLimits): SentryEnvelopeDiagnostics;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const ITEM_TYPE_PATTERN = /^[a-z0-9_.-]{1,64}$/;
|
|
2
|
+
/** Read bounded envelope item metadata without inspecting payload content. */
|
|
3
|
+
export function getSentryEnvelopeDiagnostics(envelope, limits) {
|
|
4
|
+
if (!Number.isSafeInteger(limits.maxBytes) || limits.maxBytes < 1) {
|
|
5
|
+
throw new RangeError('maxBytes must be a positive safe integer');
|
|
6
|
+
}
|
|
7
|
+
if (!Number.isSafeInteger(limits.maxItemTypes) || limits.maxItemTypes < 0) {
|
|
8
|
+
throw new RangeError('maxItemTypes must be a non-negative safe integer');
|
|
9
|
+
}
|
|
10
|
+
if (envelope.length > limits.maxBytes ||
|
|
11
|
+
new TextEncoder().encode(envelope).length > limits.maxBytes) {
|
|
12
|
+
throw new RangeError('Envelope exceeds maxBytes');
|
|
13
|
+
}
|
|
14
|
+
const firstLineEnd = envelope.indexOf('\n');
|
|
15
|
+
if (firstLineEnd === -1)
|
|
16
|
+
return {};
|
|
17
|
+
const itemTypes = [];
|
|
18
|
+
let itemCount = 0;
|
|
19
|
+
let cursor = firstLineEnd + 1;
|
|
20
|
+
while (cursor < envelope.length) {
|
|
21
|
+
if (envelope[cursor] === '\n') {
|
|
22
|
+
cursor += 1;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
const itemHeaderEnd = envelope.indexOf('\n', cursor);
|
|
26
|
+
if (itemHeaderEnd === -1)
|
|
27
|
+
break;
|
|
28
|
+
const line = envelope.slice(cursor, itemHeaderEnd).replace(/\r$/, '');
|
|
29
|
+
cursor = itemHeaderEnd + 1;
|
|
30
|
+
if (!line)
|
|
31
|
+
continue;
|
|
32
|
+
let header;
|
|
33
|
+
try {
|
|
34
|
+
header = JSON.parse(line);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
if (!header || typeof header !== 'object' || Array.isArray(header))
|
|
40
|
+
break;
|
|
41
|
+
itemCount += 1;
|
|
42
|
+
const type = header.type;
|
|
43
|
+
if (typeof type === 'string' &&
|
|
44
|
+
ITEM_TYPE_PATTERN.test(type) &&
|
|
45
|
+
!itemTypes.includes(type) &&
|
|
46
|
+
itemTypes.length < limits.maxItemTypes) {
|
|
47
|
+
itemTypes.push(type);
|
|
48
|
+
}
|
|
49
|
+
const length = header.length;
|
|
50
|
+
if (typeof length === 'number' && Number.isSafeInteger(length) && length >= 0) {
|
|
51
|
+
const next = advanceByUtf8Bytes(envelope, cursor, length);
|
|
52
|
+
if (next === null)
|
|
53
|
+
break;
|
|
54
|
+
cursor = next;
|
|
55
|
+
if (envelope[cursor] === '\r' && envelope[cursor + 1] === '\n')
|
|
56
|
+
cursor += 2;
|
|
57
|
+
else if (envelope[cursor] === '\n')
|
|
58
|
+
cursor += 1;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const payloadEnd = envelope.indexOf('\n', cursor);
|
|
62
|
+
if (payloadEnd === -1)
|
|
63
|
+
break;
|
|
64
|
+
cursor = payloadEnd + 1;
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
...(itemCount > 0 ? { envelopeItemCount: itemCount } : {}),
|
|
68
|
+
...(itemTypes.length > 0 ? { envelopeItemTypes: itemTypes } : {}),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function advanceByUtf8Bytes(envelope, cursor, length) {
|
|
72
|
+
let bytes = 0;
|
|
73
|
+
while (cursor < envelope.length && bytes < length) {
|
|
74
|
+
const point = envelope.codePointAt(cursor);
|
|
75
|
+
const size = point <= 0x7f ? 1 : point <= 0x7ff ? 2 : point <= 0xffff ? 3 : 4;
|
|
76
|
+
if (bytes + size > length)
|
|
77
|
+
return null;
|
|
78
|
+
bytes += size;
|
|
79
|
+
cursor += point > 0xffff ? 2 : 1;
|
|
80
|
+
}
|
|
81
|
+
return bytes === length ? cursor : null;
|
|
82
|
+
}
|
package/dist/slugs.d.mts
CHANGED
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.
|
|
3
|
+
"version": "0.5.2",
|
|
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": {
|
|
@@ -20,6 +20,16 @@
|
|
|
20
20
|
],
|
|
21
21
|
"type": "module",
|
|
22
22
|
"exports": {
|
|
23
|
+
"./sentry-envelope": {
|
|
24
|
+
"types": "./dist/sentry-envelope.d.mts",
|
|
25
|
+
"import": "./dist/sentry-envelope.mjs",
|
|
26
|
+
"default": "./dist/sentry-envelope.mjs"
|
|
27
|
+
},
|
|
28
|
+
"./scopes": {
|
|
29
|
+
"types": "./dist/scopes.d.mts",
|
|
30
|
+
"import": "./dist/scopes.mjs",
|
|
31
|
+
"default": "./dist/scopes.mjs"
|
|
32
|
+
},
|
|
23
33
|
"./token-secrets": {
|
|
24
34
|
"types": "./dist/token-secrets.d.mts",
|
|
25
35
|
"import": "./dist/token-secrets.mjs",
|
|
@@ -155,6 +165,11 @@
|
|
|
155
165
|
"import": "./dist/hashtags.mjs",
|
|
156
166
|
"default": "./dist/hashtags.mjs"
|
|
157
167
|
},
|
|
168
|
+
"./utm": {
|
|
169
|
+
"types": "./dist/utm.d.mts",
|
|
170
|
+
"import": "./dist/utm.mjs",
|
|
171
|
+
"default": "./dist/utm.mjs"
|
|
172
|
+
},
|
|
158
173
|
"./fetch-ports": {
|
|
159
174
|
"types": "./dist/fetch-ports.d.mts",
|
|
160
175
|
"import": "./dist/fetch-ports.mjs",
|
|
@@ -169,16 +184,20 @@
|
|
|
169
184
|
"types": "./dist/http-link-pagination.d.mts",
|
|
170
185
|
"import": "./dist/http-link-pagination.mjs",
|
|
171
186
|
"default": "./dist/http-link-pagination.mjs"
|
|
187
|
+
},
|
|
188
|
+
"./http-signatures": {
|
|
189
|
+
"types": "./dist/http-signatures.d.mts",
|
|
190
|
+
"import": "./dist/http-signatures.mjs",
|
|
191
|
+
"default": "./dist/http-signatures.mjs"
|
|
172
192
|
}
|
|
173
193
|
},
|
|
174
194
|
"publishConfig": {
|
|
175
195
|
"access": "public"
|
|
176
196
|
},
|
|
177
|
-
"scripts": {
|
|
178
|
-
"build": "tsc --project tsconfig.build.json",
|
|
179
|
-
"prepack": "pnpm run build"
|
|
180
|
-
},
|
|
181
197
|
"engines": {
|
|
182
198
|
"node": ">=24.0.0"
|
|
199
|
+
},
|
|
200
|
+
"scripts": {
|
|
201
|
+
"build": "tsc --project tsconfig.build.json"
|
|
183
202
|
}
|
|
184
|
-
}
|
|
203
|
+
}
|