@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,148 @@
|
|
|
1
|
+
import { getCurrentTenant } from '../db/tenant-context.ts'
|
|
2
|
+
import { ChangelogError } from './types.ts'
|
|
3
|
+
import type {
|
|
4
|
+
ChangelogAcknowledgementInput,
|
|
5
|
+
ChangelogAcknowledgementResult,
|
|
6
|
+
ChangelogService,
|
|
7
|
+
ChangelogServiceOptions,
|
|
8
|
+
ChangelogSnapshot,
|
|
9
|
+
ChangelogStateInput,
|
|
10
|
+
ChangelogSubject,
|
|
11
|
+
ChangelogSubjectId,
|
|
12
|
+
} from './types.ts'
|
|
13
|
+
|
|
14
|
+
export function createChangelogService<Locale extends string, SubjectId extends ChangelogSubjectId>(
|
|
15
|
+
options: ChangelogServiceOptions<Locale, SubjectId>,
|
|
16
|
+
): ChangelogService<Locale, SubjectId> {
|
|
17
|
+
if (!options?.catalogue || !options.acknowledgements) {
|
|
18
|
+
throw new ChangelogError({
|
|
19
|
+
code: 'invalid_catalogue',
|
|
20
|
+
message: 'Changelog service configuration is invalid',
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
const tenantResolver = options.tenantResolver ?? (() => {
|
|
24
|
+
const store = getCurrentTenant()
|
|
25
|
+
return store && !store.bypass ? store.tenantId : undefined
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const subjectFor = (subjectId: SubjectId, explicitTenantId?: number): ChangelogSubject<SubjectId> => {
|
|
29
|
+
let tenantId: number
|
|
30
|
+
if (explicitTenantId !== undefined) {
|
|
31
|
+
validateTenant(explicitTenantId)
|
|
32
|
+
const activeTenantId = resolveOptionalTenant(tenantResolver)
|
|
33
|
+
if (activeTenantId !== undefined) {
|
|
34
|
+
validateTenant(activeTenantId)
|
|
35
|
+
if (activeTenantId !== explicitTenantId) {
|
|
36
|
+
throw new ChangelogError({
|
|
37
|
+
code: 'invalid_tenant',
|
|
38
|
+
message: 'Explicit changelog tenant does not match the active tenant',
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
tenantId = explicitTenantId
|
|
43
|
+
} else {
|
|
44
|
+
tenantId = resolveTenant(tenantResolver)
|
|
45
|
+
}
|
|
46
|
+
validateTenant(tenantId)
|
|
47
|
+
validateSubject(subjectId)
|
|
48
|
+
return Object.freeze({ tenantId, subjectId })
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const getState = async (
|
|
52
|
+
input: ChangelogStateInput<Locale, SubjectId>,
|
|
53
|
+
): Promise<ChangelogSnapshot<Locale>> => {
|
|
54
|
+
if (!input || typeof input !== 'object') {
|
|
55
|
+
throw new ChangelogError({ code: 'invalid_subject', message: 'Changelog subject is invalid' })
|
|
56
|
+
}
|
|
57
|
+
const subject = subjectFor(input.subjectId, input.tenantId)
|
|
58
|
+
const locale = input.locale ?? options.catalogue.resolveLocale(input.acceptLanguage)
|
|
59
|
+
if (!options.catalogue.isLocale(locale)) {
|
|
60
|
+
throw new ChangelogError({ code: 'invalid_locale', message: 'Changelog locale is invalid' })
|
|
61
|
+
}
|
|
62
|
+
let seenVersion: string | null
|
|
63
|
+
try {
|
|
64
|
+
seenVersion = await options.acknowledgements.getSeenVersion(subject)
|
|
65
|
+
} catch {
|
|
66
|
+
throw storeFailure()
|
|
67
|
+
}
|
|
68
|
+
if (seenVersion !== null && typeof seenVersion !== 'string') throw storeFailure()
|
|
69
|
+
|
|
70
|
+
return Object.freeze({
|
|
71
|
+
version: options.catalogue.latest.version,
|
|
72
|
+
seen: seenVersion === options.catalogue.latest.version,
|
|
73
|
+
seenVersion,
|
|
74
|
+
locale,
|
|
75
|
+
entries: options.catalogue.localize(locale),
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const acknowledge = async (
|
|
80
|
+
input: ChangelogAcknowledgementInput<SubjectId>,
|
|
81
|
+
): Promise<ChangelogAcknowledgementResult> => {
|
|
82
|
+
if (!input || typeof input !== 'object') {
|
|
83
|
+
return Object.freeze({ ok: false, reason: 'invalid_version' })
|
|
84
|
+
}
|
|
85
|
+
const subject = subjectFor(input.subjectId, input.tenantId)
|
|
86
|
+
const version = typeof input.version === 'string' ? input.version.trim() : ''
|
|
87
|
+
if (
|
|
88
|
+
!version
|
|
89
|
+
|| version !== input.version
|
|
90
|
+
|| version.length > options.catalogue.maxVersionLength
|
|
91
|
+
) return Object.freeze({ ok: false, reason: 'invalid_version' })
|
|
92
|
+
if (!options.catalogue.isKnownVersion(version)) {
|
|
93
|
+
return Object.freeze({ ok: false, reason: 'unknown_version' })
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
await options.acknowledgements.acknowledge(subject, version)
|
|
97
|
+
} catch {
|
|
98
|
+
throw storeFailure()
|
|
99
|
+
}
|
|
100
|
+
return Object.freeze({ ok: true, version })
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return Object.freeze({ getState, acknowledge })
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function resolveTenant(resolver: () => number | null | undefined): number {
|
|
107
|
+
const tenantId = resolveOptionalTenant(resolver)
|
|
108
|
+
if (tenantId === undefined) {
|
|
109
|
+
throw new ChangelogError({ code: 'missing_tenant', message: 'Changelog tenant context is required' })
|
|
110
|
+
}
|
|
111
|
+
return tenantId
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function resolveOptionalTenant(resolver: () => number | null | undefined): number | undefined {
|
|
115
|
+
let tenantId: number | null | undefined
|
|
116
|
+
try {
|
|
117
|
+
tenantId = resolver()
|
|
118
|
+
} catch {
|
|
119
|
+
throw new ChangelogError({ code: 'missing_tenant', message: 'Changelog tenant context is unavailable' })
|
|
120
|
+
}
|
|
121
|
+
return tenantId ?? undefined
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function validateTenant(tenantId: number): void {
|
|
125
|
+
if (!Number.isSafeInteger(tenantId) || tenantId <= 0) {
|
|
126
|
+
throw new ChangelogError({ code: 'invalid_tenant', message: 'Changelog tenant is invalid' })
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function validateSubject(subjectId: ChangelogSubjectId): void {
|
|
131
|
+
const valid = typeof subjectId === 'number'
|
|
132
|
+
? Number.isSafeInteger(subjectId) && subjectId > 0
|
|
133
|
+
: typeof subjectId === 'string'
|
|
134
|
+
? subjectId.length > 0
|
|
135
|
+
&& subjectId.length <= 256
|
|
136
|
+
&& subjectId.trim() === subjectId
|
|
137
|
+
: false
|
|
138
|
+
if (!valid) {
|
|
139
|
+
throw new ChangelogError({ code: 'invalid_subject', message: 'Changelog subject is invalid' })
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function storeFailure(): ChangelogError {
|
|
144
|
+
return new ChangelogError({
|
|
145
|
+
code: 'store_failure',
|
|
146
|
+
message: 'Changelog acknowledgement store failed',
|
|
147
|
+
})
|
|
148
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
export type ChangelogSubjectId = string | number
|
|
2
|
+
|
|
3
|
+
export interface ChangelogEntry<Locale extends string> {
|
|
4
|
+
readonly version: string
|
|
5
|
+
readonly date: string
|
|
6
|
+
readonly title: string
|
|
7
|
+
/** The default locale is required; other locales may fall back to it. */
|
|
8
|
+
readonly body: Readonly<Partial<Record<Locale, string>>>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface LocalizedChangelogEntry<Locale extends string> {
|
|
12
|
+
readonly version: string
|
|
13
|
+
readonly date: string
|
|
14
|
+
readonly title: string
|
|
15
|
+
readonly body: string
|
|
16
|
+
/** Actual body locale after per-entry fallback. */
|
|
17
|
+
readonly locale: Locale
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ChangelogSubject<SubjectId extends ChangelogSubjectId = ChangelogSubjectId> {
|
|
21
|
+
readonly tenantId: number
|
|
22
|
+
readonly subjectId: SubjectId
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ChangelogSnapshot<Locale extends string> {
|
|
26
|
+
readonly version: string
|
|
27
|
+
readonly seen: boolean
|
|
28
|
+
readonly seenVersion: string | null
|
|
29
|
+
/** Requested/negotiated response locale. Entries report their own fallback locale. */
|
|
30
|
+
readonly locale: Locale
|
|
31
|
+
readonly entries: readonly LocalizedChangelogEntry<Locale>[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ChangelogAcknowledgementStore<SubjectId extends ChangelogSubjectId> {
|
|
35
|
+
getSeenVersion(subject: ChangelogSubject<SubjectId>): Promise<string | null>
|
|
36
|
+
acknowledge(subject: ChangelogSubject<SubjectId>, version: string): Promise<void>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type ChangelogAcknowledgementResult =
|
|
40
|
+
| { readonly ok: true; readonly version: string }
|
|
41
|
+
| { readonly ok: false; readonly reason: 'invalid_version' | 'unknown_version' }
|
|
42
|
+
|
|
43
|
+
export type ChangelogErrorCode =
|
|
44
|
+
| 'invalid_catalogue'
|
|
45
|
+
| 'invalid_locale'
|
|
46
|
+
| 'invalid_subject'
|
|
47
|
+
| 'missing_tenant'
|
|
48
|
+
| 'invalid_tenant'
|
|
49
|
+
| 'store_failure'
|
|
50
|
+
|
|
51
|
+
export interface ChangelogErrorOptions {
|
|
52
|
+
readonly code: ChangelogErrorCode
|
|
53
|
+
readonly message: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Stable error envelope; storage/config causes are deliberately not retained. */
|
|
57
|
+
export class ChangelogError extends Error {
|
|
58
|
+
readonly code: ChangelogErrorCode
|
|
59
|
+
|
|
60
|
+
constructor(options: ChangelogErrorOptions) {
|
|
61
|
+
super(options.message)
|
|
62
|
+
this.name = 'ChangelogError'
|
|
63
|
+
this.code = options.code
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface AcceptLanguageResolverOptions<Locale extends string> {
|
|
68
|
+
readonly locales: readonly Locale[]
|
|
69
|
+
readonly defaultLocale: Locale
|
|
70
|
+
/** Lower/upper-case BCP-47 tags are normalized; values are internal locale keys. */
|
|
71
|
+
readonly languageTags?: Readonly<Record<string, Locale>>
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type AcceptLanguageResolver<Locale extends string> = (
|
|
75
|
+
acceptLanguage?: string | null,
|
|
76
|
+
) => Locale
|
|
77
|
+
|
|
78
|
+
export interface ChangelogCatalogueOptions<Locale extends string>
|
|
79
|
+
extends AcceptLanguageResolverOptions<Locale> {
|
|
80
|
+
readonly entries: readonly ChangelogEntry<Locale>[]
|
|
81
|
+
/** Must match the consumer acknowledgement column width. */
|
|
82
|
+
readonly maxVersionLength: number
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface ChangelogCatalogue<Locale extends string> {
|
|
86
|
+
readonly entries: readonly ChangelogEntry<Locale>[]
|
|
87
|
+
readonly latest: ChangelogEntry<Locale>
|
|
88
|
+
readonly locales: readonly Locale[]
|
|
89
|
+
readonly defaultLocale: Locale
|
|
90
|
+
readonly maxVersionLength: number
|
|
91
|
+
resolveLocale(acceptLanguage?: string | null): Locale
|
|
92
|
+
isLocale(value: unknown): value is Locale
|
|
93
|
+
isKnownVersion(version: string): boolean
|
|
94
|
+
bodyFor(entry: ChangelogEntry<Locale>, locale: Locale): { body: string; locale: Locale }
|
|
95
|
+
localize(locale: Locale): readonly LocalizedChangelogEntry<Locale>[]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface ChangelogServiceOptions<Locale extends string, SubjectId extends ChangelogSubjectId> {
|
|
99
|
+
readonly catalogue: ChangelogCatalogue<Locale>
|
|
100
|
+
readonly acknowledgements: ChangelogAcknowledgementStore<SubjectId>
|
|
101
|
+
/** Defaults to the backend-core tenant ALS and ignores bypass scopes. */
|
|
102
|
+
readonly tenantResolver?: () => number | null | undefined
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface ChangelogStateInput<Locale extends string, SubjectId extends ChangelogSubjectId> {
|
|
106
|
+
readonly subjectId: SubjectId
|
|
107
|
+
readonly tenantId?: number
|
|
108
|
+
readonly locale?: Locale
|
|
109
|
+
readonly acceptLanguage?: string | null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface ChangelogAcknowledgementInput<SubjectId extends ChangelogSubjectId> {
|
|
113
|
+
readonly subjectId: SubjectId
|
|
114
|
+
readonly tenantId?: number
|
|
115
|
+
/** Runtime input is unknown so HTTP adapters can pass decoded JSON directly. */
|
|
116
|
+
readonly version: unknown
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface ChangelogService<Locale extends string, SubjectId extends ChangelogSubjectId> {
|
|
120
|
+
getState(input: ChangelogStateInput<Locale, SubjectId>): Promise<ChangelogSnapshot<Locale>>
|
|
121
|
+
acknowledge(input: ChangelogAcknowledgementInput<SubjectId>): Promise<ChangelogAcknowledgementResult>
|
|
122
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Caller-facing alias of withBypass with a name that makes review intent clear.
|
|
3
|
+
*
|
|
4
|
+
* await bypassTenant(() =>
|
|
5
|
+
* db.selectFrom('users').where('email', '=', email).execute()
|
|
6
|
+
* )
|
|
7
|
+
*
|
|
8
|
+
* Use only for legitimate cross-tenant queries:
|
|
9
|
+
* - OAuth callback before tenant is resolved
|
|
10
|
+
* - Hub admin tenant CRUD on the `tenants` table itself
|
|
11
|
+
* - System jobs that need to span tenants
|
|
12
|
+
*
|
|
13
|
+
* Code review should flag any other usage.
|
|
14
|
+
*/
|
|
15
|
+
export { withBypass as bypassTenant } from './tenant-context.ts'
|