@vouchington/utils 0.5.1 → 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 CHANGED
@@ -11,10 +11,16 @@ import { buildSignatureHeaders, verifySignature } from '@vouchington/utils/http-
11
11
  import { bestAcceptLanguageMatch } from '@vouchington/utils/language-tags'
12
12
  import { createMessageTranslator } from '@vouchington/utils/message-catalog'
13
13
  import { createReportInputParser } from '@vouchington/utils/moderation'
14
+ import { getSentryEnvelopeDiagnostics } from '@vouchington/utils/sentry-envelope'
15
+ import { createScopeCatalog } from '@vouchington/utils/scopes'
14
16
  ```
15
17
 
16
18
  ## Subpaths
17
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.
18
24
  - `token-secrets`: Node-only (`node:crypto`) purpose-bound HMAC and AES-256-GCM encryption.
19
25
  `createTokenSecrets({ hashSecret, encryptionKeys })` throws for invalid keys or ciphertext.
20
26
  - `deploy-environment`: `getDeployEnvironment(source)` classifies explicit environment inputs without
@@ -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;
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vouchington/utils",
3
- "version": "0.5.1",
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",