hanzio 1.2.0 → 2.1.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.
Files changed (155) hide show
  1. package/README.md +786 -239
  2. package/dist/api-wrapper/builders.d.ts +4 -3
  3. package/dist/api-wrapper/create-client.d.ts +1 -1
  4. package/dist/api-wrapper/describe.d.ts +16 -0
  5. package/dist/api-wrapper/endpoint.d.ts +18 -0
  6. package/dist/api-wrapper/errors.d.ts +39 -2
  7. package/dist/api-wrapper/index.d.ts +5 -0
  8. package/dist/api-wrapper/otel.d.ts +44 -0
  9. package/dist/api-wrapper/paginate.d.ts +12 -0
  10. package/dist/api-wrapper/redaction.d.ts +2 -0
  11. package/dist/api-wrapper/shared.d.ts +16 -2
  12. package/dist/api-wrapper/testing.d.ts +23 -0
  13. package/dist/api-wrapper/transport.d.ts +4 -5
  14. package/dist/api-wrapper/types.d.ts +103 -18
  15. package/dist/array/index.d.ts +50 -4
  16. package/dist/cache/createCache.d.ts +31 -0
  17. package/dist/cache/index.d.ts +19 -2
  18. package/dist/color/contrast.d.ts +40 -0
  19. package/dist/color/index.d.ts +2 -0
  20. package/dist/{math → color}/metricsSeriesColors.d.ts +14 -7
  21. package/dist/cool-console-log/index.d.ts +34 -5
  22. package/dist/crypto/encoding.d.ts +18 -0
  23. package/dist/crypto/hash.d.ts +33 -0
  24. package/dist/crypto/index.d.ts +4 -0
  25. package/dist/crypto/sealer.d.ts +25 -0
  26. package/dist/crypto/webhook.d.ts +20 -0
  27. package/dist/date/index.d.ts +46 -2
  28. package/dist/emitter/index.d.ts +21 -0
  29. package/dist/error/index.d.ts +12 -0
  30. package/dist/format/index.d.ts +102 -0
  31. package/dist/function/index.d.ts +60 -0
  32. package/dist/guard/index.d.ts +20 -0
  33. package/dist/i18n/index.d.ts +37 -0
  34. package/dist/index-0zvtcbzy.js +224 -0
  35. package/dist/index-8r2vdzjg.js +87 -0
  36. package/dist/index-hy7kbb99.js +157 -0
  37. package/dist/index-hyt6c56h.js +290 -0
  38. package/dist/{src/secrets/infisical.js → index-j1ybktbr.js} +500 -538
  39. package/dist/index-k9yx6bne.js +89 -0
  40. package/dist/index-kj1phgrx.js +169 -0
  41. package/dist/index-ktynw3vs.js +37 -0
  42. package/dist/index-mnkcfyx8.js +78 -0
  43. package/dist/{p-queue/index.js → index-pwt0dz63.js} +7 -11
  44. package/dist/index-rp5pqghz.js +157 -0
  45. package/dist/index-w882zry3.js +29 -0
  46. package/dist/index.d.ts +7 -7
  47. package/dist/jwt/index.d.ts +50 -2
  48. package/dist/math/index.d.ts +2 -2
  49. package/dist/math/linearRegressionTrend.d.ts +14 -1
  50. package/dist/math/scale.d.ts +8 -0
  51. package/dist/object/index.d.ts +19 -0
  52. package/dist/p-queue/index.d.ts +1 -1
  53. package/dist/promise/index.d.ts +97 -2
  54. package/dist/secrets/core.d.ts +7 -0
  55. package/dist/secrets/index.d.ts +3 -1
  56. package/dist/secrets/public-config.d.ts +7 -0
  57. package/dist/secrets/vite.d.ts +1 -0
  58. package/dist/secrets/worker.d.ts +1 -0
  59. package/dist/sitemap/index.d.ts +48 -6
  60. package/dist/src/api-wrapper/index.js +60 -1130
  61. package/dist/src/api-wrapper/testing.js +20 -0
  62. package/dist/{cool-console-log → src/cool-console-log}/index.js +80 -33
  63. package/dist/src/crypto/index.js +195 -0
  64. package/dist/src/format/index.js +211 -0
  65. package/dist/src/i18n/index.js +36 -0
  66. package/dist/src/index.js +739 -3145
  67. package/dist/src/jwt/index.js +138 -92
  68. package/dist/src/p-queue/index.js +9 -437
  69. package/dist/src/secrets/index.js +50 -1428
  70. package/dist/src/secrets/vite.js +10 -39
  71. package/dist/src/secrets/worker.js +14 -273
  72. package/dist/src/sitemap/index.js +183 -511
  73. package/dist/src/storage/index.js +119 -0
  74. package/dist/src/zod/index.js +340 -167
  75. package/dist/state/index.d.ts +21 -0
  76. package/dist/storage/index.d.ts +58 -0
  77. package/dist/string/index.d.ts +75 -2
  78. package/dist/typedswitch/index.d.ts +31 -24
  79. package/dist/types/index.d.ts +11 -0
  80. package/dist/url/index.d.ts +50 -1
  81. package/dist/zod/index.d.ts +32 -3
  82. package/dist/zod/introspect.d.ts +19 -0
  83. package/dist/zod/jsonToZodSchemaMapper.d.ts +3 -1
  84. package/dist/zod/zodMinMaxFilter.d.ts +22 -3
  85. package/dist/zod/zodToTypeString.d.ts +8 -3
  86. package/package.json +78 -7
  87. package/src/api-wrapper/actions-impl.ts +11 -72
  88. package/src/api-wrapper/builders.ts +32 -25
  89. package/src/api-wrapper/create-client.ts +171 -41
  90. package/src/api-wrapper/describe.ts +157 -0
  91. package/src/api-wrapper/endpoint.ts +26 -0
  92. package/src/api-wrapper/error-reporting.ts +7 -1
  93. package/src/api-wrapper/errors.ts +107 -2
  94. package/src/api-wrapper/headers.ts +11 -6
  95. package/src/api-wrapper/index.ts +5 -0
  96. package/src/api-wrapper/otel.ts +139 -0
  97. package/src/api-wrapper/paginate.ts +28 -0
  98. package/src/api-wrapper/redaction.ts +42 -0
  99. package/src/api-wrapper/shared.ts +21 -2
  100. package/src/api-wrapper/testing.ts +48 -0
  101. package/src/api-wrapper/transport.ts +41 -43
  102. package/src/api-wrapper/types.ts +185 -18
  103. package/src/array/index.ts +195 -42
  104. package/src/cache/createCache.ts +157 -0
  105. package/src/cache/index.ts +132 -147
  106. package/src/color/contrast.ts +163 -0
  107. package/src/color/index.ts +22 -0
  108. package/src/{math → color}/metricsSeriesColors.ts +47 -49
  109. package/src/cool-console-log/index.ts +128 -41
  110. package/src/crypto/encoding.ts +83 -0
  111. package/src/crypto/hash.ts +170 -0
  112. package/src/crypto/index.ts +26 -0
  113. package/src/crypto/sealer.ts +168 -0
  114. package/src/crypto/webhook.ts +86 -0
  115. package/src/date/index.ts +109 -34
  116. package/src/emitter/index.ts +82 -0
  117. package/src/error/index.ts +63 -0
  118. package/src/format/index.ts +389 -0
  119. package/src/function/index.ts +201 -0
  120. package/src/guard/index.ts +80 -0
  121. package/src/i18n/index.ts +125 -0
  122. package/src/index.ts +11 -7
  123. package/src/jwt/index.ts +199 -125
  124. package/src/math/index.ts +5 -10
  125. package/src/math/linearRegressionTrend.ts +33 -8
  126. package/src/math/scale.ts +17 -0
  127. package/src/object/index.ts +99 -0
  128. package/src/p-queue/index.ts +2 -0
  129. package/src/promise/index.ts +304 -9
  130. package/src/secrets/core.ts +72 -16
  131. package/src/secrets/index.ts +16 -4
  132. package/src/secrets/public-config.ts +47 -0
  133. package/src/secrets/vite.ts +2 -0
  134. package/src/secrets/worker.ts +2 -0
  135. package/src/sitemap/index.ts +293 -130
  136. package/src/state/index.ts +133 -81
  137. package/src/storage/index.ts +187 -0
  138. package/src/string/index.ts +261 -23
  139. package/src/typedswitch/index.ts +58 -38
  140. package/src/types/index.ts +14 -0
  141. package/src/url/index.ts +146 -1
  142. package/src/zod/index.ts +105 -9
  143. package/src/zod/introspect.ts +103 -0
  144. package/src/zod/jsonToZodSchemaMapper.ts +44 -21
  145. package/src/zod/zodMinMaxFilter.ts +37 -13
  146. package/src/zod/zodToTypeString.ts +184 -181
  147. package/dist/api-wrapper/index.js +0 -250
  148. package/dist/gzip/index.d.ts +0 -2
  149. package/dist/gzip/index.js +0 -16
  150. package/dist/index.js +0 -1823
  151. package/dist/jwt/index.js +0 -19
  152. package/dist/sitemap/index.js +0 -552
  153. package/dist/src/gzip/index.js +0 -16
  154. package/dist/src/secrets/loaders.js +0 -1529
  155. package/dist/zod/index.js +0 -210
@@ -0,0 +1,125 @@
1
+ export type PlaceholderValue = string | number
2
+
3
+ // `\w` in PLACEHOLDER_PATTERN, spelled out so the types match the runtime exactly
4
+ type Chars<S extends string> = S extends `${infer C}${infer Rest}`
5
+ ? C | Chars<Rest>
6
+ : never
7
+ type Letter = Chars<'abcdefghijklmnopqrstuvwxyz'>
8
+ type WordChar = Letter | Uppercase<Letter> | Chars<'0123456789_'>
9
+
10
+ type IsWord<S extends string> = S extends `${infer C}${infer Rest}`
11
+ ? C extends WordChar
12
+ ? Rest extends ''
13
+ ? true
14
+ : IsWord<Rest>
15
+ : false
16
+ : false
17
+
18
+ // `{a {b}` holds the placeholder `b`: keep the text after the last `{`
19
+ type AfterLastBrace<S extends string> = S extends `${string}{${infer Rest}`
20
+ ? AfterLastBrace<Rest>
21
+ : S
22
+
23
+ type ExtractNames<
24
+ S extends string,
25
+ Found extends string = never
26
+ > = S extends `${string}{${infer Body}}${infer Rest}`
27
+ ? ExtractNames<
28
+ Rest,
29
+ | Found
30
+ | (IsWord<AfterLastBrace<Body>> extends true
31
+ ? AfterLastBrace<Body>
32
+ : never)
33
+ >
34
+ : Found
35
+
36
+ /** `'Hi {name}, you have {count}'` → `'name' | 'count'`; `string` for non-literal templates. */
37
+ export type PlaceholderNames<S extends string> = string extends S
38
+ ? string
39
+ : ExtractNames<S>
40
+
41
+ export type PlaceholderParams<S extends string> = Record<
42
+ PlaceholderNames<S>,
43
+ PlaceholderValue
44
+ >
45
+
46
+ /** Params argument: required with placeholders, absent without, optional for non-literal templates. */
47
+ export type PlaceholderArgs<S extends string> = string extends S
48
+ ? [params?: PlaceholderParams<S>]
49
+ : [PlaceholderNames<S>] extends [never]
50
+ ? []
51
+ : [params: PlaceholderParams<S>]
52
+
53
+ /** A `{name}` placeholder: word characters only, so `{ x }` and `{n, plural}` are plain text. */
54
+ const PLACEHOLDER_PATTERN = /\{(\w+)\}/g
55
+
56
+ /** Unique placeholder names in order of first appearance. */
57
+ export function extractPlaceholders<S extends string>(
58
+ template: S
59
+ ): PlaceholderNames<S>[] {
60
+ const names = template.matchAll(PLACEHOLDER_PATTERN).map((match) => match[1])
61
+ return [...new Set(names)] as PlaceholderNames<S>[]
62
+ }
63
+
64
+ /**
65
+ * Replaces `{name}` placeholders with `params`. Placeholders without a
66
+ * (non-null) param are left as-is. There is no escape syntax.
67
+ */
68
+ export function interpolate<S extends string>(
69
+ template: S,
70
+ ...args: PlaceholderArgs<S>
71
+ ): string {
72
+ const params: Partial<Record<string, PlaceholderValue>> = args[0] ?? {}
73
+ return template.replace(PLACEHOLDER_PATTERN, (match, name: string) => {
74
+ const value = Object.hasOwn(params, name) ? params[name] : undefined
75
+ return value == null ? match : String(value)
76
+ })
77
+ }
78
+
79
+ export type Messages = { readonly [key: string]: string | Messages }
80
+
81
+ /** Dot-path keys of the string leaves: `{ a: { b: 'x' } }` → `'a.b'`. */
82
+ export type MessageKeys<T> = {
83
+ [K in keyof T & string]: T[K] extends string ? K : `${K}.${MessageKeys<T[K]>}`
84
+ }[keyof T & string]
85
+
86
+ /** Message at a dot-path key. */
87
+ export type MessageAt<T, K extends string> = K extends keyof T
88
+ ? T[K]
89
+ : K extends `${infer Head}.${infer Rest}`
90
+ ? Head extends keyof T
91
+ ? MessageAt<T[Head], Rest>
92
+ : never
93
+ : never
94
+
95
+ /** `{ a: { b: 'x' } }` → `{ 'a.b': 'x' }` */
96
+ export function flattenMessages<const T extends Messages>(
97
+ messages: T
98
+ ): Record<MessageKeys<T>, string> {
99
+ const out: Record<string, string> = {}
100
+ const visit = (node: Messages, prefix: string) => {
101
+ for (const [key, value] of Object.entries(node)) {
102
+ if (typeof value === 'string') out[prefix + key] = value
103
+ else visit(value, `${prefix + key}.`)
104
+ }
105
+ }
106
+ visit(messages, '')
107
+ return out
108
+ }
109
+
110
+ /**
111
+ * Typed `t(key, params)` over a nested messages object. With literal messages
112
+ * (inline `const` objects), params are required exactly when the message has
113
+ * placeholders. Unknown keys at runtime return the key.
114
+ */
115
+ export function createTranslator<const T extends Messages>(messages: T) {
116
+ const flat: Partial<Record<string, string>> = flattenMessages(messages)
117
+ return <K extends MessageKeys<T>>(
118
+ key: K,
119
+ ...args: PlaceholderArgs<Extract<MessageAt<T, K>, string>>
120
+ ): string =>
121
+ interpolate(
122
+ flat[key] ?? key,
123
+ ...(args as [Record<string, PlaceholderValue>?])
124
+ )
125
+ }
package/src/index.ts CHANGED
@@ -1,3 +1,7 @@
1
+ // Dependency-free helpers only. Modules that need zod, network access or a
2
+ // specific runtime live on subpath exports (hanzio/zod, hanzio/api-wrapper,
3
+ // hanzio/jwt, hanzio/sitemap, hanzio/p-queue, hanzio/cool-console-log,
4
+ // hanzio/secrets).
1
5
  export * from './typedswitch'
2
6
  export * from './array'
3
7
  export * from './string'
@@ -5,12 +9,12 @@ export * from './promise'
5
9
  export * from './cache'
6
10
  export * from './state'
7
11
  export * from './math'
12
+ export * from './color'
8
13
  export * from './date'
9
14
  export * from './url'
10
- export * from './zod'
11
- export * from './api-wrapper'
12
- export * from './p-queue'
13
- export * from './cool-console-log'
14
- export * from './jwt'
15
- export * from './sitemap'
16
- export * from './secrets'
15
+ export * from './error'
16
+ export * from './guard'
17
+ export * from './object'
18
+ export * from './types'
19
+ export * from './function'
20
+ export * from './emitter'
package/src/jwt/index.ts CHANGED
@@ -1,171 +1,245 @@
1
+ import {
2
+ decodeBase64Url,
3
+ encodeBase64Url,
4
+ utf8Decode,
5
+ utf8Encode
6
+ } from '../crypto/encoding'
7
+ import { getSubtleCrypto } from '../crypto/hash'
8
+ import type { Result } from '../promise'
9
+ import { isRecord } from '../guard'
10
+
1
11
  export type JWTPayload = Record<string, unknown>
2
12
 
3
- const BYTE_CHUNK_SIZE = 0x8000
4
- const textEncoder = new TextEncoder()
5
- const textDecoder = new TextDecoder()
13
+ export type JwtAlgorithm = 'HS256' | 'HS384' | 'HS512'
14
+
15
+ export type JwtHeader = { alg: string; typ?: string } & Record<string, unknown>
16
+
17
+ export type JwtSignOptions = {
18
+ /** Seconds from now until `exp`. */
19
+ expiresIn?: number
20
+ /** Seconds from now until `nbf`. */
21
+ notBefore?: number
22
+ issuer?: string
23
+ audience?: string | string[]
24
+ /** Default: `'HS256'` */
25
+ algorithm?: JwtAlgorithm
26
+ }
27
+
28
+ export type JwtVerifyOptions = {
29
+ /** Clock skew tolerance for `exp`/`nbf`, in seconds. Default: 0 */
30
+ leewaySec?: number
31
+ /** Required `iss` value. */
32
+ issuer?: string
33
+ /** Required `aud` value (matches a string `aud` or an entry of an array `aud`). */
34
+ audience?: string
35
+ /** Accepted header `alg` values. Default: all of HS256/HS384/HS512. */
36
+ algorithms?: JwtAlgorithm[]
37
+ }
38
+
39
+ export type JwtVerifyErrorCode =
40
+ | 'malformed'
41
+ | 'unsupported_alg'
42
+ | 'invalid_signature'
43
+ | 'expired'
44
+ | 'not_yet_valid'
45
+ | 'invalid_issuer'
46
+ | 'invalid_audience'
47
+
48
+ export class JwtVerifyError extends Error {
49
+ readonly code: JwtVerifyErrorCode
50
+
51
+ constructor(code: JwtVerifyErrorCode) {
52
+ super(`JWT verification failed: ${code}`)
53
+ this.name = 'JwtVerifyError'
54
+ this.code = code
55
+ }
56
+ }
57
+
58
+ const HASHES: Record<JwtAlgorithm, string> = {
59
+ HS256: 'SHA-256',
60
+ HS384: 'SHA-384',
61
+ HS512: 'SHA-512'
62
+ }
63
+ const ALGORITHMS = Object.keys(HASHES) as JwtAlgorithm[]
64
+ const BASE64URL = /^[A-Za-z0-9_-]*$/
65
+ const KEY_CACHE_LIMIT = 100
6
66
 
67
+ /**
68
+ * Signs `payload` as a compact JWS. `iat` is always set to now.
69
+ * Claims derived from `options` (`exp`, `nbf`, `iss`, `aud`) override the same
70
+ * claims in `payload`; payload claims are kept when the option is not given.
71
+ */
7
72
  export async function jwtSign<Payload extends JWTPayload>(
8
73
  payload: Payload,
9
- secret: string
74
+ secret: string,
75
+ options: JwtSignOptions = {}
10
76
  ): Promise<string> {
11
- const header = encodeJson({ alg: 'HS256' })
12
- const body = encodeJson({
13
- ...payload,
14
- iat: Math.floor(Date.now() / 1000)
15
- })
16
- const data = `${header}.${body}`
17
- const signature = await sign(data, secret)
77
+ const {
78
+ expiresIn,
79
+ notBefore,
80
+ issuer,
81
+ audience,
82
+ algorithm = 'HS256'
83
+ } = options
84
+ if (!Object.hasOwn(HASHES, algorithm)) {
85
+ throw new TypeError(`Unsupported JWT algorithm: ${algorithm}`)
86
+ }
87
+
88
+ const now = Math.floor(Date.now() / 1000)
89
+ const claims: JWTPayload = { ...payload, iat: now }
90
+ if (expiresIn !== undefined) claims.exp = now + toSeconds(expiresIn)
91
+ if (notBefore !== undefined) claims.nbf = now + toSeconds(notBefore)
92
+ if (issuer !== undefined) claims.iss = issuer
93
+ if (audience !== undefined) claims.aud = audience
94
+
95
+ const data = `${encodeJson({ alg: algorithm, typ: 'JWT' })}.${encodeJson(claims)}`
96
+ const key = await getKey(secret, algorithm, 'sign')
97
+ const signature = await getSubtleCrypto().sign('HMAC', key, utf8Encode(data))
18
98
 
19
99
  return `${data}.${encodeBase64Url(signature)}`
20
100
  }
21
101
 
102
+ /** Verifies a token. Returns `null` for any failure; use `jwtVerifyResult` for the reason. */
22
103
  export async function jwtVerify<Payload extends JWTPayload>(
23
104
  token: string,
24
- secret: string
105
+ secret: string,
106
+ options?: JwtVerifyOptions
25
107
  ): Promise<Payload | null> {
26
108
  try {
27
- const parts = token.split('.')
28
- if (parts.length !== 3) return null
29
-
30
- const [encodedHeader, encodedPayload, encodedSignature] = parts as [
31
- string,
32
- string,
33
- string
34
- ]
35
- const header = decodeJson(encodedHeader)
36
- if (!isJwtHeader(header)) return null
37
-
38
- const data = `${encodedHeader}.${encodedPayload}`
39
- const signature = decodeBase64Url(encodedSignature)
40
- const isValid = await verify(data, signature, secret)
41
- if (!isValid) return null
42
-
43
- const payload = decodeJson(encodedPayload)
44
- if (!isJwtPayload(payload) || !isTimeValid(payload)) return null
45
-
46
- return payload as Payload
109
+ const result = await jwtVerifyResult<Payload>(token, secret, options)
110
+ return result.ok ? result.data : null
47
111
  } catch (_error) {
48
112
  return null
49
113
  }
50
114
  }
51
115
 
52
- async function sign(data: string, secret: string): Promise<ArrayBuffer> {
53
- const key = await getKey(secret, ['sign'])
54
- return await getSubtleCrypto().sign('HMAC', key, encodeText(data))
55
- }
116
+ /**
117
+ * Verifies signature, `exp`/`nbf` and optional `iss`/`aud`.
118
+ * Token problems are returned as a failed `Result`; configuration problems
119
+ * (no Web Crypto, empty secret) throw.
120
+ */
121
+ export async function jwtVerifyResult<Payload extends JWTPayload>(
122
+ token: string,
123
+ secret: string,
124
+ options: JwtVerifyOptions = {}
125
+ ): Promise<Result<Payload, JwtVerifyError>> {
126
+ const { leewaySec = 0, issuer, audience, algorithms = ALGORITHMS } = options
127
+
128
+ const decoded = jwtDecode<Payload>(token)
129
+ if (!decoded) return fail('malformed')
130
+
131
+ const { alg } = decoded.header
132
+ // Only header algs that are both known HMAC algs and explicitly allowed; never 'none'.
133
+ if (
134
+ !Object.hasOwn(HASHES, alg) ||
135
+ !algorithms.includes(alg as JwtAlgorithm)
136
+ ) {
137
+ return fail('unsupported_alg')
138
+ }
56
139
 
57
- async function verify(
58
- data: string,
59
- signature: ArrayBuffer,
60
- secret: string
61
- ): Promise<boolean> {
62
- const key = await getKey(secret, ['verify'])
63
- return await getSubtleCrypto().verify(
140
+ const lastDot = token.lastIndexOf('.')
141
+ let signature: Uint8Array<ArrayBuffer>
142
+ try {
143
+ signature = decodeBase64Url(token.slice(lastDot + 1))
144
+ } catch (_error) {
145
+ return fail('malformed')
146
+ }
147
+
148
+ const key = await getKey(secret, alg as JwtAlgorithm, 'verify')
149
+ const isValid = await getSubtleCrypto().verify(
64
150
  'HMAC',
65
151
  key,
66
152
  signature,
67
- encodeText(data)
153
+ utf8Encode(token.slice(0, lastDot))
68
154
  )
69
- }
155
+ if (!isValid) return fail('invalid_signature')
70
156
 
71
- async function getKey(
72
- secret: string,
73
- usages: Array<'sign' | 'verify'>
74
- ): Promise<CryptoKey> {
75
- return await getSubtleCrypto().importKey(
76
- 'raw',
77
- encodeText(secret),
78
- {
79
- name: 'HMAC',
80
- hash: 'SHA-256'
81
- },
82
- false,
83
- usages
84
- )
85
- }
157
+ const { payload } = decoded
158
+ const { exp, nbf } = payload
159
+ if (exp !== undefined && typeof exp !== 'number') return fail('malformed')
160
+ if (nbf !== undefined && typeof nbf !== 'number') return fail('malformed')
86
161
 
87
- function encodeJson(value: JWTPayload): string {
88
- return encodeBase64Url(encodeText(JSON.stringify(value)))
89
- }
90
-
91
- function decodeJson(value: string): unknown {
92
- return JSON.parse(textDecoder.decode(decodeBase64Url(value)))
93
- }
94
-
95
- function encodeBase64Url(value: Uint8Array | ArrayBuffer): string {
96
- const bytes = value instanceof ArrayBuffer ? new Uint8Array(value) : value
97
- return btoa(bytesToBinaryString(bytes))
98
- .replaceAll('+', '-')
99
- .replaceAll('/', '_')
100
- .replace(/=+$/, '')
101
- }
102
-
103
- function decodeBase64Url(value: string): ArrayBuffer {
104
- const base64 = value.replaceAll('-', '+').replaceAll('_', '/')
105
- const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, '=')
106
- return binaryStringToBytes(atob(padded)).buffer
107
- }
162
+ const now = Math.floor(Date.now() / 1000)
163
+ if (exp !== undefined && now >= exp + leewaySec) return fail('expired')
164
+ if (nbf !== undefined && now + leewaySec < nbf) return fail('not_yet_valid')
165
+ if (issuer !== undefined && payload.iss !== issuer) {
166
+ return fail('invalid_issuer')
167
+ }
168
+ if (audience !== undefined) {
169
+ const { aud } = payload
170
+ const matches = Array.isArray(aud)
171
+ ? aud.includes(audience)
172
+ : aud === audience
173
+ if (!matches) return fail('invalid_audience')
174
+ }
108
175
 
109
- function encodeText(value: string): ArrayBuffer {
110
- return toArrayBuffer(textEncoder.encode(value))
176
+ return { ok: true, data: payload, error: null }
111
177
  }
112
178
 
113
- function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
114
- return bytes.buffer.slice(
115
- bytes.byteOffset,
116
- bytes.byteOffset + bytes.byteLength
117
- ) as ArrayBuffer
118
- }
179
+ /** Decodes header and payload WITHOUT verifying the signature. Never trust the result for auth. */
180
+ export function jwtDecode<Payload extends JWTPayload = JWTPayload>(
181
+ token: string
182
+ ): { header: JwtHeader; payload: Payload } | null {
183
+ const parts = token.split('.')
184
+ if (parts.length !== 3 || !parts.every((part) => BASE64URL.test(part))) {
185
+ return null
186
+ }
119
187
 
120
- function bytesToBinaryString(bytes: Uint8Array): string {
121
- let binary = ''
188
+ try {
189
+ const header = decodeJson(parts[0] as string)
190
+ const payload = decodeJson(parts[1] as string)
191
+ if (!isRecord(header) || typeof header.alg !== 'string') return null
192
+ if (!isRecord(payload)) return null
122
193
 
123
- for (let offset = 0; offset < bytes.length; offset += BYTE_CHUNK_SIZE) {
124
- binary += String.fromCharCode(
125
- ...bytes.subarray(offset, offset + BYTE_CHUNK_SIZE)
126
- )
194
+ return { header: header as JwtHeader, payload: payload as Payload }
195
+ } catch (_error) {
196
+ return null
127
197
  }
128
-
129
- return binary
130
198
  }
131
199
 
132
- function binaryStringToBytes(binary: string): Uint8Array<ArrayBuffer> {
133
- const bytes = new Uint8Array(binary.length)
200
+ function fail(code: JwtVerifyErrorCode): Result<never, JwtVerifyError> {
201
+ return { ok: false, data: null, error: new JwtVerifyError(code) }
202
+ }
134
203
 
135
- for (let index = 0; index < binary.length; index += 1) {
136
- bytes[index] = binary.charCodeAt(index)
204
+ function toSeconds(value: number): number {
205
+ // NaN/Infinity would serialize to `null` and silently drop the claim.
206
+ if (!Number.isFinite(value)) {
207
+ throw new TypeError(`Expected a finite number of seconds, got ${value}`)
137
208
  }
138
-
139
- return bytes
209
+ return Math.floor(value)
140
210
  }
141
211
 
142
- function getSubtleCrypto(): SubtleCrypto {
143
- const subtle = globalThis.crypto?.subtle
144
- if (!subtle) {
145
- throw new Error('Web Crypto is not available in this runtime')
146
- }
212
+ // ponytail: cleared wholesale at KEY_CACHE_LIMIT entries; swap for an LRU if
213
+ // an app rotates through more than ~100 secrets.
214
+ const keyCache = new Map<string, Promise<CryptoKey>>()
147
215
 
148
- return subtle
149
- }
216
+ function getKey(
217
+ secret: string,
218
+ algorithm: JwtAlgorithm,
219
+ usage: 'sign' | 'verify'
220
+ ): Promise<CryptoKey> {
221
+ const cacheKey = `${algorithm}:${usage}:${secret}`
222
+ const cached = keyCache.get(cacheKey)
223
+ if (cached) return cached
150
224
 
151
- function isJwtHeader(value: unknown): value is { alg: 'HS256' } {
152
- return (
153
- typeof value === 'object' &&
154
- value !== null &&
155
- 'alg' in value &&
156
- value.alg === 'HS256'
225
+ if (keyCache.size >= KEY_CACHE_LIMIT) keyCache.clear()
226
+ const key = getSubtleCrypto().importKey(
227
+ 'raw',
228
+ utf8Encode(secret),
229
+ { name: 'HMAC', hash: HASHES[algorithm] },
230
+ false,
231
+ [usage]
157
232
  )
158
- }
233
+ keyCache.set(cacheKey, key)
234
+ key.catch(() => keyCache.delete(cacheKey))
159
235
 
160
- function isJwtPayload(value: unknown): value is JWTPayload {
161
- return typeof value === 'object' && value !== null && !Array.isArray(value)
236
+ return key
162
237
  }
163
238
 
164
- function isTimeValid(payload: JWTPayload): boolean {
165
- const now = Math.floor(Date.now() / 1000)
166
-
167
- if (typeof payload.exp === 'number' && now >= payload.exp) return false
168
- if (typeof payload.nbf === 'number' && now < payload.nbf) return false
239
+ function encodeJson(value: JWTPayload): string {
240
+ return encodeBase64Url(utf8Encode(JSON.stringify(value)))
241
+ }
169
242
 
170
- return true
243
+ function decodeJson(value: string): unknown {
244
+ return JSON.parse(utf8Decode(decodeBase64Url(value)))
171
245
  }
package/src/math/index.ts CHANGED
@@ -1,11 +1,6 @@
1
- export { linearRegressionTrend } from './linearRegressionTrend'
2
1
  export {
3
- createResolveColorsByKeys,
4
- getHeatmapCellStyle,
5
- hashStringToColorIndex,
6
- METRICS_SERIES_PALETTE_SIZE,
7
- type ResolveColorsByKeysResult,
8
- resolveSeriesColorMap,
9
- seriesColorKey,
10
- withAlpha
11
- } from './metricsSeriesColors'
2
+ type LinearRegression,
3
+ linearRegression,
4
+ linearRegressionTrend
5
+ } from './linearRegressionTrend'
6
+ export { clamp, lerp, normalize } from './scale'
@@ -1,12 +1,21 @@
1
+ export type LinearRegression = {
2
+ slope: number
3
+ intercept: number
4
+ /** Coefficient of determination (0..1). 1 when all values are equal (exact fit). */
5
+ r2: number
6
+ /** Fitted y for each index. */
7
+ fitted: number[]
8
+ }
9
+
1
10
  /**
2
11
  * Least-squares linear regression for y at x = 0, 1, …, n − 1.
3
- * Returns fitted y for each index, or [] if n < 2 or any input is non-finite.
12
+ * Returns `null` if n < 2 or any input is non-finite.
4
13
  */
5
- export function linearRegressionTrend(values: number[]): number[] {
14
+ export function linearRegression(values: number[]): LinearRegression | null {
6
15
  const n = values.length
7
- if (n < 2) return []
16
+ if (n < 2) return null
8
17
  for (const v of values) {
9
- if (!Number.isFinite(v)) return []
18
+ if (!Number.isFinite(v)) return null
10
19
  }
11
20
 
12
21
  let sumX = 0
@@ -23,14 +32,30 @@ export function linearRegressionTrend(values: number[]): number[] {
23
32
  }
24
33
 
25
34
  const denom = n * sumX2 - sumX * sumX
26
- if (denom === 0) return []
35
+ if (denom === 0) return null
27
36
 
28
37
  const slope = (n * sumXY - sumX * sumY) / denom
29
38
  const intercept = (sumY - slope * sumX) / n
39
+ const mean = sumY / n
30
40
 
31
- const out: number[] = []
41
+ const fitted: number[] = []
42
+ let ssRes = 0
43
+ let ssTot = 0
32
44
  for (let i = 0; i < n; i++) {
33
- out.push(intercept + slope * i)
45
+ const y = values[i]!
46
+ const f = intercept + slope * i
47
+ fitted.push(f)
48
+ ssRes += (y - f) ** 2
49
+ ssTot += (y - mean) ** 2
34
50
  }
35
- return out
51
+
52
+ return { slope, intercept, r2: ssTot === 0 ? 1 : 1 - ssRes / ssTot, fitted }
53
+ }
54
+
55
+ /**
56
+ * Fitted y for each index of `values` (see `linearRegression`), or [] if n < 2
57
+ * or any input is non-finite.
58
+ */
59
+ export function linearRegressionTrend(values: number[]): number[] {
60
+ return linearRegression(values)?.fitted ?? []
36
61
  }
@@ -0,0 +1,17 @@
1
+ export function clamp(value: number, min: number, max: number): number {
2
+ return Math.min(max, Math.max(min, value))
3
+ }
4
+
5
+ /** Linear interpolation: `t = 0` → `start`, `t = 1` → `end`. */
6
+ export function lerp(start: number, end: number, t: number): number {
7
+ return start + (end - start) * t
8
+ }
9
+
10
+ /**
11
+ * Position of `value` within `[min, max]` as 0..1 (clamped). Returns 0 when
12
+ * the range is empty.
13
+ */
14
+ export function normalize(value: number, min: number, max: number): number {
15
+ if (max <= min) return 0
16
+ return clamp((value - min) / (max - min), 0, 1)
17
+ }