@exvio/os-backend-core 0.4.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.
@@ -0,0 +1,142 @@
1
+ const REDACTED = '[REDACTED]'
2
+ const SENSITIVE_KEY = /^(?:authorization|proxy[_-]?authorization|authorization[_-]?code|cookie|set[_-]?cookie|password(?:[_-]?hash)?|(?:current|new|confirm|old)[_-]?password|passcode|secret|token|state|ticket|ott|api[_-]?key|credential|verifier|(?:password|token|secret|credential|api[_-]?key)[_-]?hash|(?:x[_-]?)?visitor[_-]?id|session[_-]?id|client[_-]?secret|code[_-]?verifier|oauth[_-]?state|(?:access|refresh|id|reset|challenge|backup|recovery|csrf|oauth|one[_-]?time|exchange)[_-]?(?:token|secret|code|verifier|ticket)|raw|cause)$/iu
3
+ const BEARER_VALUE = /\b(Bearer|Basic)\s+[^\s,;]+/giu
4
+ const QUERY_SECRET = /([?&#](?:t|access[_-]?token|refresh[_-]?token|id[_-]?token|reset[_-]?token|challenge[_-]?token|visitor[_-]?id|code[_-]?verifier|token|code|state|secret|api[_-]?key)=)[^&#\s]*/giu
5
+ /* Generic auth namespaces only. An application's own credential-bearing
6
+ prefixes are passed to redactAuthStorageKey(); the package does not publish
7
+ any product's storage layout. */
8
+ const AUTH_STORAGE_KEY_PREFIXES = Object.freeze([
9
+ 'session:',
10
+ 'oauth:pkce:',
11
+ 'oauth:state:',
12
+ 'auth:oauth-state:',
13
+ 'auth:oauth-browser-exchange:',
14
+ 'passkey:auth:',
15
+ 'passkey:reg:',
16
+ 'webauthn:challenge:',
17
+ '2fa:challenge:',
18
+ 'password:reset:',
19
+ 'password_reset:',
20
+ ] as const)
21
+
22
+ function redactText(text: string, secrets: readonly string[]): string {
23
+ let redacted = text
24
+ .replace(BEARER_VALUE, '$1 [REDACTED]')
25
+ .replace(QUERY_SECRET, '$1[REDACTED]')
26
+ for (const secret of secrets) {
27
+ if (secret.length > 0) redacted = redacted.replaceAll(secret, REDACTED)
28
+ }
29
+ return redacted
30
+ }
31
+
32
+ /**
33
+ * Redact the credential-bearing suffix of a Redis/storage key while retaining
34
+ * enough namespace information for operational diagnostics.
35
+ *
36
+ * `prefixes` adds the application's own credential-bearing namespaces to the
37
+ * built-in generic set. Application key namespaces are supported, so both
38
+ * `session:<secret>` and `APP:session:<secret>` become safe to log. A key that
39
+ * matches no prefix is returned unchanged.
40
+ */
41
+ export function redactAuthStorageKey(key: string, prefixes: readonly string[] = []): string {
42
+ if (typeof key !== 'string' || key.length === 0) return REDACTED
43
+
44
+ const normalized = key.toLowerCase()
45
+ let redactionAt = Number.POSITIVE_INFINITY
46
+ for (const prefix of [...AUTH_STORAGE_KEY_PREFIXES, ...normalizePrefixes(prefixes)]) {
47
+ let index = normalized.indexOf(prefix)
48
+ while (index >= 0) {
49
+ const isSegmentBoundary = index === 0 || normalized[index - 1] === ':'
50
+ if (isSegmentBoundary) {
51
+ redactionAt = Math.min(redactionAt, index + prefix.length)
52
+ break
53
+ }
54
+ index = normalized.indexOf(prefix, index + 1)
55
+ }
56
+ }
57
+
58
+ return Number.isFinite(redactionAt)
59
+ ? `${key.slice(0, redactionAt)}${REDACTED}`
60
+ : key
61
+ }
62
+
63
+ function normalizePrefixes(prefixes: readonly string[]): readonly string[] {
64
+ return prefixes
65
+ .filter(prefix => typeof prefix === 'string' && prefix.trim().length > 0)
66
+ .map((prefix) => {
67
+ const lower = prefix.trim().toLowerCase()
68
+ return lower.endsWith(':') ? lower : `${lower}:`
69
+ })
70
+ }
71
+
72
+ /**
73
+ * Return a detached, JSON-safe value suitable for auth diagnostics. Sensitive
74
+ * fields, raw causes, known secret values, and credentials in URLs/headers are
75
+ * removed. The original object is never mutated.
76
+ */
77
+ export function redactAuthSecrets(
78
+ value: unknown,
79
+ secrets: readonly string[] = [],
80
+ ): unknown {
81
+ const secretSnapshot = Object.freeze([...secrets].filter(secret => typeof secret === 'string'))
82
+ const seen = new WeakSet<object>()
83
+
84
+ const visit = (current: unknown, depth: number): unknown => {
85
+ if (depth > 12) return '[MAX_DEPTH]'
86
+ if (current === null || current === undefined || typeof current === 'boolean') return current
87
+ if (typeof current === 'string') return redactText(current, secretSnapshot)
88
+ if (typeof current === 'number') return Number.isFinite(current) ? current : String(current)
89
+ if (typeof current === 'bigint') return current.toString()
90
+ if (typeof current === 'symbol' || typeof current === 'function') return REDACTED
91
+ if (current instanceof Date) {
92
+ return Number.isNaN(current.valueOf()) ? REDACTED : current.toISOString()
93
+ }
94
+ if (current instanceof Error) {
95
+ let name = 'Error'
96
+ try {
97
+ if (/^[A-Za-z][A-Za-z0-9]{0,63}Error$/u.test(current.name)) name = current.name
98
+ } catch {
99
+ // Even Error.name can be replaced with an accessor. Do not invoke it again.
100
+ }
101
+ return {
102
+ name,
103
+ // An arbitrary Error message has no safe schema. It can include a
104
+ // database URL, provider response, reset token, or other unknown secret.
105
+ message: REDACTED,
106
+ }
107
+ }
108
+ if (typeof current !== 'object') return REDACTED
109
+ if (seen.has(current)) return '[CIRCULAR]'
110
+ seen.add(current)
111
+
112
+ let descriptors: Record<string, PropertyDescriptor>
113
+ try {
114
+ descriptors = Object.getOwnPropertyDescriptors(current)
115
+ } catch {
116
+ return REDACTED
117
+ }
118
+
119
+ if (Array.isArray(current)) {
120
+ const length = Math.min(current.length, 1_000)
121
+ const output: unknown[] = []
122
+ for (let index = 0; index < length; index += 1) {
123
+ const descriptor = descriptors[String(index)]
124
+ output.push(descriptor && 'value' in descriptor
125
+ ? visit(descriptor.value, depth + 1)
126
+ : REDACTED)
127
+ }
128
+ if (current.length > length) output.push('[TRUNCATED]')
129
+ return output
130
+ }
131
+
132
+ const output: Record<string, unknown> = Object.create(null) as Record<string, unknown>
133
+ for (const [key, descriptor] of Object.entries(descriptors)) {
134
+ output[key] = SENSITIVE_KEY.test(key) || !('value' in descriptor)
135
+ ? REDACTED
136
+ : visit(descriptor.value, depth + 1)
137
+ }
138
+ return output
139
+ }
140
+
141
+ return visit(value, 0)
142
+ }
@@ -0,0 +1,106 @@
1
+ import { AuthError } from './errors.ts'
2
+ import type { SessionTtlInput } from './types.ts'
3
+
4
+ const SESSION_BEARER_TOKEN_PATTERN = /^sat_v1_[A-Za-z0-9_-]{43}$/
5
+ // Compatibility is deliberately limited to the UUIDv7 values issued by the
6
+ // pre-Core session runtime. Broad base64url input would also accept other auth
7
+ // artefacts (OAuth state, management handles, one-time tickets) as bearers.
8
+ const LEGACY_SESSION_BEARER_TOKEN_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu
9
+ const SESSION_HANDLE_PATTERN = /^smh_v1_[A-Za-z0-9_-]{43}$/
10
+
11
+ function base64Url(bytes: Uint8Array): string {
12
+ let binary = ''
13
+ for (const byte of bytes) binary += String.fromCharCode(byte)
14
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/u, '')
15
+ }
16
+
17
+ function assertSessionToken(token: string): void {
18
+ if (!isSessionBearerToken(token)) {
19
+ throw new AuthError({ code: 'invalid_session_token', status: 400 })
20
+ }
21
+ }
22
+
23
+ export function createSecureAuthToken(byteLength = 32): string {
24
+ if (!Number.isSafeInteger(byteLength) || byteLength < 24 || byteLength > 128) {
25
+ throw new AuthError({ code: 'invalid_input', status: 400 })
26
+ }
27
+ return base64Url(crypto.getRandomValues(new Uint8Array(byteLength)))
28
+ }
29
+
30
+ /** Issue a bearer in a namespace that cannot be confused with other auth tokens. */
31
+ export function createSessionBearerToken(): string {
32
+ return `sat_v1_${createSecureAuthToken()}`
33
+ }
34
+
35
+ /**
36
+ * Return whether `token` is a session bearer issued by this runtime.
37
+ *
38
+ * UUIDv7 is accepted only as a temporary compatibility format for sessions
39
+ * created before the `sat_v1_` namespace was introduced.
40
+ */
41
+ export function isSessionBearerToken(token: unknown): token is string {
42
+ return typeof token === 'string'
43
+ && (SESSION_BEARER_TOKEN_PATTERN.test(token)
44
+ || LEGACY_SESSION_BEARER_TOKEN_PATTERN.test(token))
45
+ }
46
+
47
+ export async function createSessionManagementHandle(token: string): Promise<string> {
48
+ assertSessionToken(token)
49
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(token))
50
+ return `smh_v1_${base64Url(new Uint8Array(digest))}`
51
+ }
52
+
53
+ function constantTimeEqual(left: string, right: string): boolean {
54
+ if (left.length !== right.length) return false
55
+ let difference = 0
56
+ for (let index = 0; index < left.length; index += 1) {
57
+ difference |= left.charCodeAt(index) ^ right.charCodeAt(index)
58
+ }
59
+ return difference === 0
60
+ }
61
+
62
+ export async function matchesSessionManagementHandle(
63
+ token: string,
64
+ handle: string,
65
+ ): Promise<boolean> {
66
+ assertSessionToken(token)
67
+ if (typeof handle !== 'string' || !SESSION_HANDLE_PATTERN.test(handle)) {
68
+ throw new AuthError({ code: 'invalid_session_handle', status: 400 })
69
+ }
70
+ return constantTimeEqual(await createSessionManagementHandle(token), handle)
71
+ }
72
+
73
+ export function remainingSessionTtl(input: SessionTtlInput): number {
74
+ if (!input || typeof input !== 'object') {
75
+ throw new AuthError({ code: 'invalid_input', status: 400 })
76
+ }
77
+ const nowMs = input.nowMs ?? Date.now()
78
+ const values = [
79
+ input.createdAtMs,
80
+ input.lastActiveAtMs,
81
+ input.absoluteTtlSeconds,
82
+ input.inactivityTtlSeconds,
83
+ nowMs,
84
+ ]
85
+ if (values.some(value => !Number.isSafeInteger(value) || value < 0)) {
86
+ throw new AuthError({ code: 'invalid_input', status: 400 })
87
+ }
88
+ if (
89
+ input.createdAtMs < 0
90
+ || input.lastActiveAtMs < input.createdAtMs
91
+ || !Number.isSafeInteger(input.absoluteTtlSeconds)
92
+ || input.absoluteTtlSeconds <= 0
93
+ || !Number.isSafeInteger(input.inactivityTtlSeconds)
94
+ || input.inactivityTtlSeconds <= 0
95
+ ) {
96
+ throw new AuthError({ code: 'invalid_input', status: 400 })
97
+ }
98
+
99
+ const absoluteExpiry = input.createdAtMs + (input.absoluteTtlSeconds * 1_000)
100
+ const inactivityExpiry = input.lastActiveAtMs + (input.inactivityTtlSeconds * 1_000)
101
+ if (!Number.isSafeInteger(absoluteExpiry) || !Number.isSafeInteger(inactivityExpiry)) {
102
+ throw new AuthError({ code: 'invalid_input', status: 400 })
103
+ }
104
+ const remainingMs = Math.min(absoluteExpiry, inactivityExpiry) - nowMs
105
+ return remainingMs <= 0 ? 0 : Math.ceil(remainingMs / 1_000)
106
+ }
@@ -0,0 +1,72 @@
1
+ export type LoginMode = 'hub_managed' | 'standalone' | 'hybrid'
2
+
3
+ export type LoginSurface =
4
+ | 'email'
5
+ | 'google'
6
+ | 'microsoft'
7
+ | 'passkey'
8
+ | 'hub_ott'
9
+
10
+ export type LoginPhase = 'start' | 'callback' | 'finish'
11
+
12
+ export interface LoginPolicyCheck {
13
+ readonly surface: LoginSurface
14
+ readonly phase: LoginPhase
15
+ }
16
+
17
+ export interface LoginPolicy {
18
+ readonly mode: LoginMode
19
+ readonly enabledSurfaces: readonly LoginSurface[]
20
+ allows(check: LoginPolicyCheck): boolean
21
+ assertAllowed(check: LoginPolicyCheck): void
22
+ }
23
+
24
+ /**
25
+ * The backing implementation MUST make `take` an atomic read-and-delete.
26
+ * A GET followed by DEL does not satisfy this contract.
27
+ */
28
+ export interface OneTimeAuthStore {
29
+ put(key: string, value: string, ttlSeconds: number): Promise<void>
30
+ take(key: string): Promise<string | null>
31
+ }
32
+
33
+ export interface OAuthStateIssueInput {
34
+ readonly tenantId: number
35
+ readonly provider: string
36
+ readonly redirectUri: string
37
+ readonly verifier: string
38
+ readonly initiatingSessionId?: string
39
+ readonly returnTo?: string
40
+ }
41
+
42
+ export interface OAuthStateConsumeInput {
43
+ readonly state: string
44
+ readonly tenantId: number
45
+ readonly provider: string
46
+ readonly redirectUri: string
47
+ readonly initiatingSessionId?: string
48
+ }
49
+
50
+ export interface OAuthStatePayload {
51
+ readonly tenantId: number
52
+ readonly provider: string
53
+ readonly redirectUri: string
54
+ readonly verifier: string
55
+ readonly initiatingSessionId?: string
56
+ readonly returnTo?: string
57
+ readonly issuedAt: number
58
+ readonly expiresAt: number
59
+ }
60
+
61
+ export interface OAuthStateManager {
62
+ issue(input: OAuthStateIssueInput): Promise<string>
63
+ consume(input: OAuthStateConsumeInput): Promise<OAuthStatePayload>
64
+ }
65
+
66
+ export interface SessionTtlInput {
67
+ readonly createdAtMs: number
68
+ readonly lastActiveAtMs: number
69
+ readonly absoluteTtlSeconds: number
70
+ readonly inactivityTtlSeconds: number
71
+ readonly nowMs?: number
72
+ }
@@ -0,0 +1,140 @@
1
+ import { createAcceptLanguageResolver } from './locale.ts'
2
+ import { ChangelogError } from './types.ts'
3
+ import type {
4
+ ChangelogCatalogue,
5
+ ChangelogCatalogueOptions,
6
+ ChangelogEntry,
7
+ LocalizedChangelogEntry,
8
+ } from './types.ts'
9
+
10
+ const VERSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/
11
+
12
+ export function createChangelogCatalogue<Locale extends string>(
13
+ options: ChangelogCatalogueOptions<Locale>,
14
+ ): ChangelogCatalogue<Locale> {
15
+ validateOptions(options)
16
+ const locales = Object.freeze([...options.locales])
17
+ const localeSet = new Set<string>(locales)
18
+ const entries = Object.freeze(options.entries.map(entry => snapshotEntry(entry, locales, options)))
19
+ if (new Set(entries.map(entry => entry.version)).size !== entries.length) {
20
+ invalidCatalogue('Changelog versions must be unique')
21
+ }
22
+ const latest = entries[0]!
23
+ const knownVersions = new Set(entries.map(entry => entry.version))
24
+ const resolveLocale = createAcceptLanguageResolver({
25
+ locales,
26
+ defaultLocale: options.defaultLocale,
27
+ ...(options.languageTags ? { languageTags: options.languageTags } : {}),
28
+ })
29
+
30
+ const isLocale = (value: unknown): value is Locale => (
31
+ typeof value === 'string' && localeSet.has(value)
32
+ )
33
+
34
+ const bodyFor = (
35
+ entry: ChangelogEntry<Locale>,
36
+ locale: Locale,
37
+ ): { body: string; locale: Locale } => {
38
+ if (!isLocale(locale)) invalidCatalogue('Changelog locale is not supported')
39
+ const requested = entry.body[locale]
40
+ if (nonEmpty(requested)) return { body: requested, locale }
41
+ const fallback = entry.body[options.defaultLocale]
42
+ if (nonEmpty(fallback)) return { body: fallback, locale: options.defaultLocale }
43
+ for (const candidate of locales) {
44
+ const body = entry.body[candidate]
45
+ if (nonEmpty(body)) return { body, locale: candidate }
46
+ }
47
+ invalidCatalogue('Changelog entry has no localized body')
48
+ }
49
+
50
+ const localize = (locale: Locale): readonly LocalizedChangelogEntry<Locale>[] => {
51
+ if (!isLocale(locale)) invalidCatalogue('Changelog locale is not supported')
52
+ return Object.freeze(entries.map((entry) => {
53
+ const localized = bodyFor(entry, locale)
54
+ return Object.freeze({
55
+ version: entry.version,
56
+ date: entry.date,
57
+ title: entry.title,
58
+ body: localized.body,
59
+ locale: localized.locale,
60
+ })
61
+ }))
62
+ }
63
+
64
+ return Object.freeze({
65
+ entries,
66
+ latest,
67
+ locales,
68
+ defaultLocale: options.defaultLocale,
69
+ maxVersionLength: options.maxVersionLength,
70
+ resolveLocale,
71
+ isLocale,
72
+ isKnownVersion: (version: string) => knownVersions.has(version),
73
+ bodyFor,
74
+ localize,
75
+ })
76
+ }
77
+
78
+ function validateOptions<Locale extends string>(options: ChangelogCatalogueOptions<Locale>): void {
79
+ if (
80
+ !options
81
+ || !Array.isArray(options.entries)
82
+ || options.entries.length === 0
83
+ || !Array.isArray(options.locales)
84
+ || options.locales.length === 0
85
+ || new Set(options.locales).size !== options.locales.length
86
+ || !options.locales.includes(options.defaultLocale)
87
+ || !Number.isSafeInteger(options.maxVersionLength)
88
+ || options.maxVersionLength <= 0
89
+ || options.maxVersionLength > 256
90
+ ) invalidCatalogue('Changelog catalogue configuration is invalid')
91
+ }
92
+
93
+ function snapshotEntry<Locale extends string>(
94
+ entry: ChangelogEntry<Locale>,
95
+ locales: readonly Locale[],
96
+ options: ChangelogCatalogueOptions<Locale>,
97
+ ): ChangelogEntry<Locale> {
98
+ if (
99
+ !entry
100
+ || typeof entry.version !== 'string'
101
+ || entry.version.length > options.maxVersionLength
102
+ || !VERSION_PATTERN.test(entry.version)
103
+ || typeof entry.date !== 'string'
104
+ || entry.date.trim().length === 0
105
+ || entry.date.length > 256
106
+ || typeof entry.title !== 'string'
107
+ || entry.title.trim().length === 0
108
+ || entry.title.length > 512
109
+ || !entry.body
110
+ || typeof entry.body !== 'object'
111
+ || Array.isArray(entry.body)
112
+ ) invalidCatalogue('Changelog entry is invalid')
113
+
114
+ const body: Partial<Record<Locale, string>> = Object.create(null) as Partial<Record<Locale, string>>
115
+ for (const locale of locales) {
116
+ const value = entry.body[locale]
117
+ if (value !== undefined) {
118
+ if (!nonEmpty(value)) invalidCatalogue('Changelog body is invalid')
119
+ body[locale] = value
120
+ }
121
+ }
122
+ if (!nonEmpty(body[options.defaultLocale])) {
123
+ invalidCatalogue('Changelog entry is missing its default-locale body')
124
+ }
125
+
126
+ return Object.freeze({
127
+ version: entry.version,
128
+ date: entry.date,
129
+ title: entry.title,
130
+ body: Object.freeze(body),
131
+ })
132
+ }
133
+
134
+ function nonEmpty(value: unknown): value is string {
135
+ return typeof value === 'string' && value.trim().length > 0
136
+ }
137
+
138
+ function invalidCatalogue(message: string): never {
139
+ throw new ChangelogError({ code: 'invalid_catalogue', message })
140
+ }
@@ -0,0 +1,5 @@
1
+ export { createChangelogCatalogue } from './catalogue.ts'
2
+ export { createAcceptLanguageResolver } from './locale.ts'
3
+ export { createChangelogService } from './service.ts'
4
+ export type * from './types.ts'
5
+ export { ChangelogError } from './types.ts'
@@ -0,0 +1,122 @@
1
+ import { ChangelogError } from './types.ts'
2
+ import type {
3
+ AcceptLanguageResolver,
4
+ AcceptLanguageResolverOptions,
5
+ } from './types.ts'
6
+
7
+ interface LanguagePreference {
8
+ readonly tag: string
9
+ readonly quality: number
10
+ readonly order: number
11
+ }
12
+
13
+ /** RFC-style weighted negotiation mapped onto application-owned locale keys. */
14
+ export function createAcceptLanguageResolver<Locale extends string>(
15
+ options: AcceptLanguageResolverOptions<Locale>,
16
+ ): AcceptLanguageResolver<Locale> {
17
+ const locales = validateLocales(options.locales, options.defaultLocale)
18
+ const supported = new Set<string>(locales)
19
+ const aliases = new Map<string, Locale>()
20
+
21
+ for (const locale of locales) aliases.set(locale.toLowerCase(), locale)
22
+ for (const [rawTag, locale] of Object.entries(options.languageTags ?? {})) {
23
+ const tag = normalizeLanguageTag(rawTag)
24
+ if (!tag || !supported.has(locale)) invalidLocale()
25
+ const existing = aliases.get(tag)
26
+ if (existing !== undefined && existing !== locale) invalidLocale()
27
+ aliases.set(tag, locale)
28
+ }
29
+
30
+ return (acceptLanguage?: string | null): Locale => {
31
+ if (typeof acceptLanguage !== 'string' || !acceptLanguage || acceptLanguage.length > 4_096) {
32
+ return options.defaultLocale
33
+ }
34
+ const preferences = parseAcceptLanguage(acceptLanguage)
35
+ const explicitQuality = new Map<Locale, number>()
36
+ for (const preference of preferences) {
37
+ if (preference.tag === '*') continue
38
+ const locale = resolveTag(preference.tag, aliases)
39
+ if (locale !== undefined) {
40
+ explicitQuality.set(locale, Math.max(explicitQuality.get(locale) ?? 0, preference.quality))
41
+ }
42
+ }
43
+ for (const preference of preferences) {
44
+ if (preference.quality === 0) continue
45
+ if (preference.tag === '*') {
46
+ const wildcard = locales.find(locale => !explicitQuality.has(locale))
47
+ if (wildcard !== undefined) return wildcard
48
+ continue
49
+ }
50
+ const matched = resolveTag(preference.tag, aliases)
51
+ if (matched !== undefined && explicitQuality.get(matched) !== 0) return matched
52
+ }
53
+ if (explicitQuality.get(options.defaultLocale) === 0) {
54
+ return locales.find(locale => explicitQuality.get(locale) !== 0) ?? options.defaultLocale
55
+ }
56
+ return options.defaultLocale
57
+ }
58
+ }
59
+
60
+ function resolveTag<Locale extends string>(
61
+ tag: string,
62
+ aliases: ReadonlyMap<string, Locale>,
63
+ ): Locale | undefined {
64
+ let candidate = tag
65
+ while (candidate) {
66
+ const matched = aliases.get(candidate)
67
+ if (matched !== undefined) return matched
68
+ const separator = candidate.lastIndexOf('-')
69
+ if (separator < 0) break
70
+ candidate = candidate.slice(0, separator)
71
+ }
72
+ return undefined
73
+ }
74
+
75
+ function validateLocales<Locale extends string>(
76
+ values: readonly Locale[],
77
+ defaultLocale: Locale,
78
+ ): readonly Locale[] {
79
+ if (
80
+ !Array.isArray(values)
81
+ || values.length === 0
82
+ || values.some(value => typeof value !== 'string' || value.trim() !== value || value.length === 0)
83
+ || new Set(values).size !== values.length
84
+ || new Set(values.map(value => value.toLowerCase())).size !== values.length
85
+ || !values.includes(defaultLocale)
86
+ ) invalidLocale()
87
+ return [...values]
88
+ }
89
+
90
+ function parseAcceptLanguage(header: string): LanguagePreference[] {
91
+ const preferences: LanguagePreference[] = []
92
+ for (const [order, rawPart] of header.split(',').entries()) {
93
+ const segments = rawPart.trim().split(';')
94
+ const tag = normalizeLanguageTag(segments.shift() ?? '')
95
+ if (!tag) continue
96
+ let quality = 1
97
+ let valid = true
98
+ for (const parameter of segments) {
99
+ const match = /^q\s*=\s*(0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/i.exec(parameter.trim())
100
+ if (!match) {
101
+ valid = false
102
+ break
103
+ }
104
+ quality = Number(match[1])
105
+ }
106
+ if (valid) preferences.push({ tag, quality, order })
107
+ }
108
+ return preferences.sort((left, right) => right.quality - left.quality || left.order - right.order)
109
+ }
110
+
111
+ function normalizeLanguageTag(value: string): string | null {
112
+ const normalized = value.trim().toLowerCase()
113
+ if (normalized === '*') return normalized
114
+ return /^[a-z]{1,8}(?:-[a-z0-9]{1,8})*$/.test(normalized) ? normalized : null
115
+ }
116
+
117
+ function invalidLocale(): never {
118
+ throw new ChangelogError({
119
+ code: 'invalid_locale',
120
+ message: 'Changelog locale configuration is invalid',
121
+ })
122
+ }