@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,345 @@
|
|
|
1
|
+
import { parseGuideMarkdown } from './markdown.ts'
|
|
2
|
+
import { isGuideIdentifier } from './source.ts'
|
|
3
|
+
import { GuideError } from './types.ts'
|
|
4
|
+
import type {
|
|
5
|
+
GuideCatalogue,
|
|
6
|
+
GuideCatalogueCategory,
|
|
7
|
+
GuideCategory,
|
|
8
|
+
GuideContentSource,
|
|
9
|
+
GuideEntry,
|
|
10
|
+
GuideListItem,
|
|
11
|
+
GuideLoadInput,
|
|
12
|
+
GuideManifest,
|
|
13
|
+
GuidePolicy,
|
|
14
|
+
GuideService,
|
|
15
|
+
GuideServiceOptions,
|
|
16
|
+
GuideValidationResult,
|
|
17
|
+
LoadedGuide,
|
|
18
|
+
ParsedGuideMarkdown,
|
|
19
|
+
} from './types.ts'
|
|
20
|
+
|
|
21
|
+
export function createGuideService<
|
|
22
|
+
Locale extends string,
|
|
23
|
+
Context = unknown,
|
|
24
|
+
Entry extends GuideEntry<Locale> = GuideEntry<Locale>,
|
|
25
|
+
Category extends GuideCategory<Locale> = GuideCategory<Locale>,
|
|
26
|
+
>(
|
|
27
|
+
options: GuideServiceOptions<Locale, Context, Entry, Category>,
|
|
28
|
+
): GuideService<Locale, Context, Entry, Category> {
|
|
29
|
+
validateOptions(options)
|
|
30
|
+
const defaultLocale = options.defaultLocale
|
|
31
|
+
const listPolicy = options.listPolicy
|
|
32
|
+
const readPolicy = options.readPolicy
|
|
33
|
+
const source: GuideContentSource<Locale> = Object.freeze({
|
|
34
|
+
read: options.source.read.bind(options.source),
|
|
35
|
+
})
|
|
36
|
+
const locales = Object.freeze([...options.locales])
|
|
37
|
+
const localeSet = new Set<string>(locales)
|
|
38
|
+
const categories = Object.freeze(options.categories.map(category => snapshot(category))) as readonly Category[]
|
|
39
|
+
const entries = Object.freeze(options.entries.map(entry => snapshot(entry))) as readonly Entry[]
|
|
40
|
+
validateManifest(locales, defaultLocale, categories, entries)
|
|
41
|
+
|
|
42
|
+
const categoryIndex = new Map(categories.map((category, index) => [category.key, index]))
|
|
43
|
+
const entryIndex = new Map(entries.map((entry, index) => [entryKey(entry.category, entry.slug), index]))
|
|
44
|
+
const categoryByKey = new Map(categories.map(category => [category.key, category]))
|
|
45
|
+
const entryByKey = new Map(entries.map(entry => [entryKey(entry.category, entry.slug), entry]))
|
|
46
|
+
const manifest: GuideManifest<Locale, Entry, Category> = Object.freeze({
|
|
47
|
+
locales,
|
|
48
|
+
defaultLocale,
|
|
49
|
+
categories,
|
|
50
|
+
entries,
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const readDocument = async (
|
|
54
|
+
entry: Entry,
|
|
55
|
+
requestedLocale: Locale,
|
|
56
|
+
): Promise<{ parsed: ParsedGuideMarkdown; resolvedLocale: Locale } | null> => {
|
|
57
|
+
for (const resolvedLocale of fallbackLocales(entry, requestedLocale, defaultLocale)) {
|
|
58
|
+
const raw = await safeRead(source, {
|
|
59
|
+
category: entry.category,
|
|
60
|
+
slug: entry.slug,
|
|
61
|
+
locale: resolvedLocale,
|
|
62
|
+
})
|
|
63
|
+
if (raw !== null) return { parsed: parseGuideMarkdown(raw), resolvedLocale }
|
|
64
|
+
}
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const list = async (input: { readonly locale: Locale; readonly context?: Context }): Promise<
|
|
69
|
+
GuideCatalogue<Locale, Entry, Category>
|
|
70
|
+
> => {
|
|
71
|
+
validateLocaleInput(input, localeSet)
|
|
72
|
+
const requestedLocale = input.locale
|
|
73
|
+
const visible: GuideListItem<Locale, Entry>[] = []
|
|
74
|
+
|
|
75
|
+
for (const entry of sortEntries(entries, entryIndex)) {
|
|
76
|
+
if (!await applyPolicy(listPolicy, entry, input.context)) continue
|
|
77
|
+
const document = await readDocument(entry, requestedLocale)
|
|
78
|
+
if (!document) continue
|
|
79
|
+
visible.push(Object.freeze({
|
|
80
|
+
entry,
|
|
81
|
+
slug: entry.slug,
|
|
82
|
+
category: entry.category,
|
|
83
|
+
title: document.parsed.frontmatter.title || entry.slug,
|
|
84
|
+
description: document.parsed.frontmatter.description ?? '',
|
|
85
|
+
requestedLocale,
|
|
86
|
+
resolvedLocale: document.resolvedLocale,
|
|
87
|
+
availableLocales: entry.locales,
|
|
88
|
+
}))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const grouped: GuideCatalogueCategory<Locale, Entry, Category>[] = []
|
|
92
|
+
for (const category of sortCategories(categories, categoryIndex)) {
|
|
93
|
+
const items = Object.freeze(visible.filter(item => item.category === category.key))
|
|
94
|
+
if (items.length === 0) continue
|
|
95
|
+
grouped.push(Object.freeze({
|
|
96
|
+
category,
|
|
97
|
+
key: category.key,
|
|
98
|
+
title: localizedTitle(category, requestedLocale, defaultLocale, locales),
|
|
99
|
+
order: category.order,
|
|
100
|
+
items,
|
|
101
|
+
}))
|
|
102
|
+
}
|
|
103
|
+
return Object.freeze({ requestedLocale, categories: Object.freeze(grouped) })
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const load = async (
|
|
107
|
+
input: GuideLoadInput<Locale, Context>,
|
|
108
|
+
): Promise<LoadedGuide<Locale, Entry> | null> => {
|
|
109
|
+
validateLocaleInput(input, localeSet)
|
|
110
|
+
if (!isGuideIdentifier(input.category) || !isGuideIdentifier(input.slug)) invalidIdentifier()
|
|
111
|
+
const entry = entryByKey.get(entryKey(input.category, input.slug))
|
|
112
|
+
if (!entry || !await applyPolicy(readPolicy, entry, input.context)) return null
|
|
113
|
+
const document = await readDocument(entry, input.locale)
|
|
114
|
+
if (!document) return null
|
|
115
|
+
return Object.freeze({
|
|
116
|
+
entry,
|
|
117
|
+
frontmatter: document.parsed.frontmatter,
|
|
118
|
+
body: document.parsed.body,
|
|
119
|
+
requestedLocale: input.locale,
|
|
120
|
+
resolvedLocale: document.resolvedLocale,
|
|
121
|
+
availableLocales: entry.locales,
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const validate = async (): Promise<GuideValidationResult> => {
|
|
126
|
+
let documentCount = 0
|
|
127
|
+
for (const entry of entries) {
|
|
128
|
+
for (const locale of entry.locales) {
|
|
129
|
+
const raw = await safeRead(source, {
|
|
130
|
+
category: entry.category,
|
|
131
|
+
slug: entry.slug,
|
|
132
|
+
locale,
|
|
133
|
+
})
|
|
134
|
+
if (raw === null) {
|
|
135
|
+
throw new GuideError({
|
|
136
|
+
code: 'document_not_found',
|
|
137
|
+
message: 'A guide document declared by the manifest is missing',
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
parseGuideMarkdown(raw)
|
|
141
|
+
documentCount += 1
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return Object.freeze({
|
|
145
|
+
categoryCount: categoryByKey.size,
|
|
146
|
+
entryCount: entryByKey.size,
|
|
147
|
+
documentCount,
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return Object.freeze({ manifest, list, load, validate })
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function validateOptions<
|
|
155
|
+
Locale extends string,
|
|
156
|
+
Context,
|
|
157
|
+
Entry extends GuideEntry<Locale>,
|
|
158
|
+
Category extends GuideCategory<Locale>,
|
|
159
|
+
>(options: GuideServiceOptions<Locale, Context, Entry, Category>): void {
|
|
160
|
+
if (!options || !Array.isArray(options.locales) || options.locales.length === 0
|
|
161
|
+
|| !Array.isArray(options.categories) || options.categories.length === 0
|
|
162
|
+
|| !Array.isArray(options.entries) || options.entries.length === 0
|
|
163
|
+
|| !options.source || typeof options.source.read !== 'function') invalidManifest()
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function validateManifest<Locale extends string>(
|
|
167
|
+
locales: readonly Locale[],
|
|
168
|
+
defaultLocale: Locale,
|
|
169
|
+
categories: readonly GuideCategory<Locale>[],
|
|
170
|
+
entries: readonly GuideEntry<Locale>[],
|
|
171
|
+
): void {
|
|
172
|
+
if (new Set(locales).size !== locales.length || !locales.includes(defaultLocale)
|
|
173
|
+
|| locales.some(locale => !isGuideIdentifier(locale))
|
|
174
|
+
|| caseInsensitiveDuplicates(locales)) invalidManifest()
|
|
175
|
+
const categoryKeys = new Set<string>()
|
|
176
|
+
const foldedCategoryKeys = new Set<string>()
|
|
177
|
+
for (const category of categories) {
|
|
178
|
+
if (!category || !isGuideIdentifier(category.key) || categoryKeys.has(category.key)
|
|
179
|
+
|| foldedCategoryKeys.has(category.key.toLocaleLowerCase('en-US'))
|
|
180
|
+
|| !validOrder(category.order) || !category.title || typeof category.title !== 'object') invalidManifest()
|
|
181
|
+
categoryKeys.add(category.key)
|
|
182
|
+
foldedCategoryKeys.add(category.key.toLocaleLowerCase('en-US'))
|
|
183
|
+
if (Object.keys(category.title).some(locale => !locales.includes(locale as Locale))) invalidManifest()
|
|
184
|
+
for (const locale of locales) {
|
|
185
|
+
const value = category.title[locale]
|
|
186
|
+
if (value !== undefined && !nonEmpty(value)) invalidManifest()
|
|
187
|
+
}
|
|
188
|
+
if (!nonEmpty(category.title[defaultLocale])) invalidManifest()
|
|
189
|
+
}
|
|
190
|
+
const entryKeys = new Set<string>()
|
|
191
|
+
const foldedEntryKeys = new Set<string>()
|
|
192
|
+
for (const entry of entries) {
|
|
193
|
+
if (!entry || !isGuideIdentifier(entry.slug) || !isGuideIdentifier(entry.category)
|
|
194
|
+
|| !categoryKeys.has(entry.category) || !validOrder(entry.order)
|
|
195
|
+
|| !Array.isArray(entry.locales) || entry.locales.length === 0
|
|
196
|
+
|| new Set(entry.locales).size !== entry.locales.length
|
|
197
|
+
|| entry.locales.some(locale => !locales.includes(locale))) invalidManifest()
|
|
198
|
+
const key = entryKey(entry.category, entry.slug)
|
|
199
|
+
const foldedKey = entryKey(
|
|
200
|
+
entry.category.toLocaleLowerCase('en-US'),
|
|
201
|
+
entry.slug.toLocaleLowerCase('en-US'),
|
|
202
|
+
)
|
|
203
|
+
if (entryKeys.has(key) || foldedEntryKeys.has(foldedKey)) invalidManifest()
|
|
204
|
+
entryKeys.add(key)
|
|
205
|
+
foldedEntryKeys.add(foldedKey)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function fallbackLocales<Locale extends string>(
|
|
210
|
+
entry: GuideEntry<Locale>,
|
|
211
|
+
requested: Locale,
|
|
212
|
+
defaultLocale: Locale,
|
|
213
|
+
): readonly Locale[] {
|
|
214
|
+
const result: Locale[] = []
|
|
215
|
+
const add = (locale: Locale): void => {
|
|
216
|
+
if (entry.locales.includes(locale) && !result.includes(locale)) result.push(locale)
|
|
217
|
+
}
|
|
218
|
+
add(requested)
|
|
219
|
+
add(defaultLocale)
|
|
220
|
+
for (const locale of entry.locales) add(locale)
|
|
221
|
+
return result
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function safeRead<Locale extends string>(
|
|
225
|
+
source: GuideContentSource<Locale>,
|
|
226
|
+
input: { readonly category: string; readonly slug: string; readonly locale: Locale },
|
|
227
|
+
): Promise<string | null> {
|
|
228
|
+
let value: unknown
|
|
229
|
+
try {
|
|
230
|
+
value = await source.read(Object.freeze(input))
|
|
231
|
+
} catch (error) {
|
|
232
|
+
if (error instanceof GuideError && error.code === 'document_too_large') {
|
|
233
|
+
throw new GuideError({
|
|
234
|
+
code: 'document_too_large',
|
|
235
|
+
message: 'Guide document exceeds the size limit',
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
throw new GuideError({ code: 'source_failure', message: 'Guide content source failed' })
|
|
239
|
+
}
|
|
240
|
+
if (value !== null && typeof value !== 'string') {
|
|
241
|
+
throw new GuideError({ code: 'source_failure', message: 'Guide content source failed' })
|
|
242
|
+
}
|
|
243
|
+
return value
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function applyPolicy<Locale extends string, Context, Entry extends GuideEntry<Locale>>(
|
|
247
|
+
policy: GuidePolicy<Locale, Context, Entry> | undefined,
|
|
248
|
+
entry: Entry,
|
|
249
|
+
context: Context | undefined,
|
|
250
|
+
): Promise<boolean> {
|
|
251
|
+
if (!policy) return true
|
|
252
|
+
try {
|
|
253
|
+
return await policy(entry, context) === true
|
|
254
|
+
} catch {
|
|
255
|
+
throw new GuideError({ code: 'policy_failure', message: 'Guide visibility policy failed' })
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function validateLocaleInput<Locale extends string>(
|
|
260
|
+
input: { readonly locale: Locale } | null | undefined,
|
|
261
|
+
locales: ReadonlySet<string>,
|
|
262
|
+
): asserts input is { readonly locale: Locale } {
|
|
263
|
+
if (!input || typeof input.locale !== 'string' || !locales.has(input.locale)) {
|
|
264
|
+
throw new GuideError({ code: 'invalid_locale', message: 'Guide locale is invalid' })
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function localizedTitle<Locale extends string>(
|
|
269
|
+
category: GuideCategory<Locale>,
|
|
270
|
+
requested: Locale,
|
|
271
|
+
defaultLocale: Locale,
|
|
272
|
+
locales: readonly Locale[],
|
|
273
|
+
): string {
|
|
274
|
+
const requestedTitle = category.title[requested]
|
|
275
|
+
if (nonEmpty(requestedTitle)) return requestedTitle
|
|
276
|
+
const fallback = category.title[defaultLocale]
|
|
277
|
+
if (nonEmpty(fallback)) return fallback
|
|
278
|
+
for (const locale of locales) {
|
|
279
|
+
const title = category.title[locale]
|
|
280
|
+
if (nonEmpty(title)) return title
|
|
281
|
+
}
|
|
282
|
+
invalidManifest()
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function sortEntries<Locale extends string, Entry extends GuideEntry<Locale>>(
|
|
286
|
+
entries: readonly Entry[],
|
|
287
|
+
indices: ReadonlyMap<string, number>,
|
|
288
|
+
): readonly Entry[] {
|
|
289
|
+
return [...entries].sort((left, right) => left.order - right.order
|
|
290
|
+
|| (indices.get(entryKey(left.category, left.slug)) ?? 0)
|
|
291
|
+
- (indices.get(entryKey(right.category, right.slug)) ?? 0))
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function sortCategories<Locale extends string, Category extends GuideCategory<Locale>>(
|
|
295
|
+
categories: readonly Category[],
|
|
296
|
+
indices: ReadonlyMap<string, number>,
|
|
297
|
+
): readonly Category[] {
|
|
298
|
+
return [...categories].sort((left, right) => left.order - right.order
|
|
299
|
+
|| (indices.get(left.key) ?? 0) - (indices.get(right.key) ?? 0))
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function snapshot<Value>(value: Value, ancestors = new WeakSet<object>()): Value {
|
|
303
|
+
if (value === null || typeof value !== 'object') {
|
|
304
|
+
if (typeof value === 'function' || typeof value === 'symbol') invalidManifest()
|
|
305
|
+
return value
|
|
306
|
+
}
|
|
307
|
+
if (ancestors.has(value)) invalidManifest()
|
|
308
|
+
ancestors.add(value)
|
|
309
|
+
if (Array.isArray(value)) {
|
|
310
|
+
const result = Object.freeze(value.map(item => snapshot(item, ancestors))) as Value
|
|
311
|
+
ancestors.delete(value)
|
|
312
|
+
return result
|
|
313
|
+
}
|
|
314
|
+
const prototype = Object.getPrototypeOf(value) as unknown
|
|
315
|
+
if (prototype !== Object.prototype && prototype !== null) invalidManifest()
|
|
316
|
+
const result: Record<string, unknown> = Object.create(null) as Record<string, unknown>
|
|
317
|
+
for (const [key, item] of Object.entries(value)) result[key] = snapshot(item, ancestors)
|
|
318
|
+
ancestors.delete(value)
|
|
319
|
+
return Object.freeze(result) as Value
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function validOrder(value: unknown): value is number {
|
|
323
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function nonEmpty(value: unknown): value is string {
|
|
327
|
+
return typeof value === 'string' && value.trim().length > 0
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function caseInsensitiveDuplicates(values: readonly string[]): boolean {
|
|
331
|
+
const folded = values.map(value => value.toLocaleLowerCase('en-US'))
|
|
332
|
+
return new Set(folded).size !== folded.length
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function entryKey(category: string, slug: string): string {
|
|
336
|
+
return `${category}\u0000${slug}`
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function invalidIdentifier(): never {
|
|
340
|
+
throw new GuideError({ code: 'invalid_identifier', message: 'Guide identifier is invalid' })
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function invalidManifest(): never {
|
|
344
|
+
throw new GuideError({ code: 'invalid_manifest', message: 'Guide manifest is invalid' })
|
|
345
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { open, realpath } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { GuideError } from './types.ts'
|
|
4
|
+
import type {
|
|
5
|
+
FileGuideContentSourceOptions,
|
|
6
|
+
GuideContentRead,
|
|
7
|
+
GuideContentSource,
|
|
8
|
+
} from './types.ts'
|
|
9
|
+
|
|
10
|
+
const IDENTIFIER_PATTERN = /^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$/
|
|
11
|
+
const MAX_IDENTIFIER_LENGTH = 128
|
|
12
|
+
const DEFAULT_MAX_FILE_BYTES = 1024 * 1024
|
|
13
|
+
const MAX_FILE_BYTES = 16 * 1024 * 1024
|
|
14
|
+
const READ_CHUNK_BYTES = 64 * 1024
|
|
15
|
+
|
|
16
|
+
export function createFileGuideContentSource<Locale extends string>(
|
|
17
|
+
options: FileGuideContentSourceOptions,
|
|
18
|
+
): GuideContentSource<Locale> {
|
|
19
|
+
if (!options || typeof options.root !== 'string' || !path.isAbsolute(options.root)) {
|
|
20
|
+
throw new GuideError({ code: 'invalid_source', message: 'Guide source root must be absolute' })
|
|
21
|
+
}
|
|
22
|
+
if (options.maxFileBytes !== undefined && (!Number.isSafeInteger(options.maxFileBytes)
|
|
23
|
+
|| options.maxFileBytes <= 0 || options.maxFileBytes > MAX_FILE_BYTES)) {
|
|
24
|
+
throw new GuideError({ code: 'invalid_source', message: 'Guide source size limit is invalid' })
|
|
25
|
+
}
|
|
26
|
+
const configuredRoot = path.resolve(options.root)
|
|
27
|
+
const maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES
|
|
28
|
+
let realRootPromise: Promise<string> | undefined
|
|
29
|
+
const resolveRoot = (): Promise<string> => {
|
|
30
|
+
realRootPromise ??= realpath(configuredRoot).catch(() => { throw sourceFailure() })
|
|
31
|
+
return realRootPromise
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return Object.freeze({
|
|
35
|
+
async read(input: GuideContentRead<Locale>): Promise<string | null> {
|
|
36
|
+
if (!input || !isGuideIdentifier(input.category) || !isGuideIdentifier(input.slug)
|
|
37
|
+
|| !isGuideIdentifier(input.locale)) {
|
|
38
|
+
throw new GuideError({ code: 'invalid_identifier', message: 'Guide identifier is invalid' })
|
|
39
|
+
}
|
|
40
|
+
const root = await resolveRoot()
|
|
41
|
+
const candidate = path.resolve(configuredRoot, input.category, `${input.slug}.${input.locale}.md`)
|
|
42
|
+
if (!isContained(configuredRoot, candidate)) throw invalidIdentifier()
|
|
43
|
+
|
|
44
|
+
let actual: string
|
|
45
|
+
try {
|
|
46
|
+
actual = await realpath(candidate)
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (isMissingFile(error)) return null
|
|
49
|
+
throw sourceFailure()
|
|
50
|
+
}
|
|
51
|
+
if (!isContained(root, actual)) throw sourceFailure()
|
|
52
|
+
try {
|
|
53
|
+
return await readBounded(actual, maxFileBytes)
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (isMissingFile(error)) return null
|
|
56
|
+
if (error instanceof GuideError && error.code === 'document_too_large') throw error
|
|
57
|
+
throw sourceFailure()
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function readBounded(file: string, maxFileBytes: number): Promise<string> {
|
|
64
|
+
const handle = await open(file, 'r')
|
|
65
|
+
try {
|
|
66
|
+
const info = await handle.stat()
|
|
67
|
+
if (!info.isFile()) throw sourceFailure()
|
|
68
|
+
if (info.size > maxFileBytes) throw tooLarge()
|
|
69
|
+
|
|
70
|
+
const chunks: Buffer[] = []
|
|
71
|
+
let total = 0
|
|
72
|
+
for (;;) {
|
|
73
|
+
const remaining = maxFileBytes + 1 - total
|
|
74
|
+
if (remaining <= 0) throw tooLarge()
|
|
75
|
+
const buffer = Buffer.allocUnsafe(Math.min(READ_CHUNK_BYTES, remaining))
|
|
76
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, null)
|
|
77
|
+
if (bytesRead === 0) break
|
|
78
|
+
total += bytesRead
|
|
79
|
+
if (total > maxFileBytes) throw tooLarge()
|
|
80
|
+
chunks.push(buffer.subarray(0, bytesRead))
|
|
81
|
+
}
|
|
82
|
+
return Buffer.concat(chunks, total).toString('utf8')
|
|
83
|
+
} finally {
|
|
84
|
+
await handle.close().catch(() => undefined)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function isGuideIdentifier(value: unknown): value is string {
|
|
89
|
+
return typeof value === 'string'
|
|
90
|
+
&& value.length > 0
|
|
91
|
+
&& value.length <= MAX_IDENTIFIER_LENGTH
|
|
92
|
+
&& IDENTIFIER_PATTERN.test(value)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isContained(root: string, candidate: string): boolean {
|
|
96
|
+
const relative = path.relative(root, candidate)
|
|
97
|
+
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isMissingFile(error: unknown): boolean {
|
|
101
|
+
if (!error || typeof error !== 'object' || !('code' in error)) return false
|
|
102
|
+
const code = (error as { code?: unknown }).code
|
|
103
|
+
return code === 'ENOENT' || code === 'ENOTDIR'
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function invalidIdentifier(): GuideError {
|
|
107
|
+
return new GuideError({ code: 'invalid_identifier', message: 'Guide identifier is invalid' })
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function sourceFailure(): GuideError {
|
|
111
|
+
return new GuideError({ code: 'source_failure', message: 'Guide content source failed' })
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function tooLarge(): GuideError {
|
|
115
|
+
return new GuideError({ code: 'document_too_large', message: 'Guide document exceeds the size limit' })
|
|
116
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
export interface GuideCategory<Locale extends string> {
|
|
2
|
+
readonly key: string
|
|
3
|
+
readonly order: number
|
|
4
|
+
readonly title: Readonly<Partial<Record<Locale, string>>>
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface GuideEntry<Locale extends string> {
|
|
8
|
+
readonly slug: string
|
|
9
|
+
readonly category: string
|
|
10
|
+
readonly order: number
|
|
11
|
+
readonly locales: readonly Locale[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface GuideFrontmatter {
|
|
15
|
+
readonly title: string
|
|
16
|
+
readonly description?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ParsedGuideMarkdown {
|
|
20
|
+
readonly frontmatter: GuideFrontmatter
|
|
21
|
+
readonly body: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface GuideContentRead<Locale extends string> {
|
|
25
|
+
readonly category: string
|
|
26
|
+
readonly slug: string
|
|
27
|
+
readonly locale: Locale
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface GuideContentSource<Locale extends string> {
|
|
31
|
+
/** Return null only when the document does not exist. */
|
|
32
|
+
read(input: GuideContentRead<Locale>): Promise<string | null>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type GuidePolicy<Locale extends string, Context, Entry extends GuideEntry<Locale>> = (
|
|
36
|
+
entry: Entry,
|
|
37
|
+
context: Context | undefined,
|
|
38
|
+
) => boolean | Promise<boolean>
|
|
39
|
+
|
|
40
|
+
export interface GuideServiceOptions<
|
|
41
|
+
Locale extends string,
|
|
42
|
+
Context,
|
|
43
|
+
Entry extends GuideEntry<Locale>,
|
|
44
|
+
Category extends GuideCategory<Locale>,
|
|
45
|
+
> {
|
|
46
|
+
readonly locales: readonly Locale[]
|
|
47
|
+
readonly defaultLocale: Locale
|
|
48
|
+
readonly categories: readonly Category[]
|
|
49
|
+
readonly entries: readonly Entry[]
|
|
50
|
+
readonly source: GuideContentSource<Locale>
|
|
51
|
+
/** Catalogue/UX visibility. It does not grant or deny detail access. */
|
|
52
|
+
readonly listPolicy?: GuidePolicy<Locale, Context, Entry>
|
|
53
|
+
/** Detail-read policy. Hidden/denied documents resolve to null. */
|
|
54
|
+
readonly readPolicy?: GuidePolicy<Locale, Context, Entry>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface GuideListInput<Locale extends string, Context> {
|
|
58
|
+
readonly locale: Locale
|
|
59
|
+
readonly context?: Context
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface GuideLoadInput<Locale extends string, Context> {
|
|
63
|
+
readonly category: string
|
|
64
|
+
readonly slug: string
|
|
65
|
+
readonly locale: Locale
|
|
66
|
+
readonly context?: Context
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface GuideListItem<Locale extends string, Entry extends GuideEntry<Locale>> {
|
|
70
|
+
readonly entry: Entry
|
|
71
|
+
readonly slug: string
|
|
72
|
+
readonly category: string
|
|
73
|
+
readonly title: string
|
|
74
|
+
readonly description: string
|
|
75
|
+
readonly requestedLocale: Locale
|
|
76
|
+
readonly resolvedLocale: Locale
|
|
77
|
+
readonly availableLocales: readonly Locale[]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface GuideCatalogueCategory<
|
|
81
|
+
Locale extends string,
|
|
82
|
+
Entry extends GuideEntry<Locale>,
|
|
83
|
+
Category extends GuideCategory<Locale>,
|
|
84
|
+
> {
|
|
85
|
+
readonly category: Category
|
|
86
|
+
readonly key: string
|
|
87
|
+
readonly title: string
|
|
88
|
+
readonly order: number
|
|
89
|
+
readonly items: readonly GuideListItem<Locale, Entry>[]
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface GuideCatalogue<
|
|
93
|
+
Locale extends string,
|
|
94
|
+
Entry extends GuideEntry<Locale>,
|
|
95
|
+
Category extends GuideCategory<Locale>,
|
|
96
|
+
> {
|
|
97
|
+
readonly requestedLocale: Locale
|
|
98
|
+
readonly categories: readonly GuideCatalogueCategory<Locale, Entry, Category>[]
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface LoadedGuide<Locale extends string, Entry extends GuideEntry<Locale>> {
|
|
102
|
+
readonly entry: Entry
|
|
103
|
+
readonly frontmatter: GuideFrontmatter
|
|
104
|
+
/** AI-only context comments are removed by default and cannot be requested raw. */
|
|
105
|
+
readonly body: string
|
|
106
|
+
readonly requestedLocale: Locale
|
|
107
|
+
readonly resolvedLocale: Locale
|
|
108
|
+
readonly availableLocales: readonly Locale[]
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface GuideManifest<
|
|
112
|
+
Locale extends string,
|
|
113
|
+
Entry extends GuideEntry<Locale>,
|
|
114
|
+
Category extends GuideCategory<Locale>,
|
|
115
|
+
> {
|
|
116
|
+
readonly locales: readonly Locale[]
|
|
117
|
+
readonly defaultLocale: Locale
|
|
118
|
+
readonly categories: readonly Category[]
|
|
119
|
+
readonly entries: readonly Entry[]
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface GuideValidationResult {
|
|
123
|
+
readonly categoryCount: number
|
|
124
|
+
readonly entryCount: number
|
|
125
|
+
readonly documentCount: number
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface GuideService<
|
|
129
|
+
Locale extends string,
|
|
130
|
+
Context,
|
|
131
|
+
Entry extends GuideEntry<Locale>,
|
|
132
|
+
Category extends GuideCategory<Locale>,
|
|
133
|
+
> {
|
|
134
|
+
readonly manifest: GuideManifest<Locale, Entry, Category>
|
|
135
|
+
list(input: GuideListInput<Locale, Context>): Promise<GuideCatalogue<Locale, Entry, Category>>
|
|
136
|
+
load(input: GuideLoadInput<Locale, Context>): Promise<LoadedGuide<Locale, Entry> | null>
|
|
137
|
+
/** CI/startup audit of every document declared by the manifest. */
|
|
138
|
+
validate(): Promise<GuideValidationResult>
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export type GuideErrorCode =
|
|
142
|
+
| 'invalid_manifest'
|
|
143
|
+
| 'invalid_identifier'
|
|
144
|
+
| 'invalid_locale'
|
|
145
|
+
| 'invalid_source'
|
|
146
|
+
| 'document_not_found'
|
|
147
|
+
| 'document_too_large'
|
|
148
|
+
| 'malformed_document'
|
|
149
|
+
| 'source_failure'
|
|
150
|
+
| 'policy_failure'
|
|
151
|
+
|
|
152
|
+
export interface GuideErrorOptions {
|
|
153
|
+
readonly code: GuideErrorCode
|
|
154
|
+
readonly message: string
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Stable error envelope; filesystem, policy, and parser causes are not retained. */
|
|
158
|
+
export class GuideError extends Error {
|
|
159
|
+
readonly code: GuideErrorCode
|
|
160
|
+
|
|
161
|
+
constructor(options: GuideErrorOptions) {
|
|
162
|
+
super(options.message)
|
|
163
|
+
this.name = 'GuideError'
|
|
164
|
+
this.code = options.code
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface FileGuideContentSourceOptions {
|
|
169
|
+
/**
|
|
170
|
+
* Must be an absolute, application-owned, read-only asset directory.
|
|
171
|
+
* Relative working directories are ambiguous after bundling, and this
|
|
172
|
+
* adapter is not a sandbox for concurrently attacker-writable content.
|
|
173
|
+
*/
|
|
174
|
+
readonly root: string
|
|
175
|
+
/** Defaults to 1 MiB and is capped at 16 MiB. */
|
|
176
|
+
readonly maxFileBytes?: number
|
|
177
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './tenant.ts'
|
package/src/tenant.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export {
|
|
2
|
+
getCurrentTenant,
|
|
3
|
+
tenantContext,
|
|
4
|
+
withBypass,
|
|
5
|
+
withTenant,
|
|
6
|
+
type TenantStore,
|
|
7
|
+
} from './db/tenant-context.ts'
|
|
8
|
+
export { bypassTenant } from './db/bypass-tenant.ts'
|
|
9
|
+
export { TenantFilterPlugin } from './db/plugins/tenant-filter.ts'
|
|
10
|
+
export type {
|
|
11
|
+
MissingTenantContextPolicy,
|
|
12
|
+
TenantContextReader,
|
|
13
|
+
TenantFilterPluginOptions,
|
|
14
|
+
} from './db/plugins/tenant-filter.ts'
|