@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.
- package/README.md +466 -0
- package/package.json +48 -0
- package/src/ai/client.ts +1059 -0
- package/src/ai/errors.ts +114 -0
- package/src/ai/index.ts +27 -0
- package/src/ai/model-policy.ts +27 -0
- package/src/ai/pricing.ts +158 -0
- package/src/ai/providers/deepseek.ts +61 -0
- package/src/ai/providers/gemini.ts +919 -0
- package/src/ai/providers/openai-compatible.ts +731 -0
- package/src/ai/providers/sse.ts +163 -0
- package/src/ai/registry.ts +65 -0
- package/src/ai/schema.ts +382 -0
- package/src/ai/types.ts +282 -0
- package/src/auth/browser-exchange.ts +316 -0
- package/src/auth/errors.ts +57 -0
- package/src/auth/index.ts +29 -0
- package/src/auth/login-policy.ts +95 -0
- package/src/auth/oauth-state.ts +332 -0
- package/src/auth/passkey.ts +760 -0
- package/src/auth/redaction.ts +142 -0
- package/src/auth/session.ts +106 -0
- package/src/auth/types.ts +72 -0
- package/src/changelog/catalogue.ts +140 -0
- package/src/changelog/index.ts +5 -0
- package/src/changelog/locale.ts +122 -0
- package/src/changelog/service.ts +148 -0
- package/src/changelog/types.ts +122 -0
- package/src/db/bypass-tenant.ts +15 -0
- package/src/db/plugins/tenant-filter.ts +518 -0
- package/src/db/tenant-context.ts +51 -0
- package/src/guide/index.ts +5 -0
- package/src/guide/markdown.ts +108 -0
- package/src/guide/service.ts +345 -0
- package/src/guide/source.ts +116 -0
- package/src/guide/types.ts +177 -0
- package/src/index.ts +1 -0
- package/src/tenant.ts +14 -0
|
@@ -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,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
|
+
}
|