@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,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'