@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
package/README.md
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
# @exvio/os-backend-core
|
|
2
|
+
|
|
3
|
+
Shared backend building blocks for the Wellous OS hub-and-spoke fleet.
|
|
4
|
+
|
|
5
|
+
This package plays the same governance role for backend infrastructure that
|
|
6
|
+
`@exvio/os-theme` plays for frontend UI: one implementation, versioned releases,
|
|
7
|
+
and deliberate upgrades in each consumer. It ships TypeScript source because
|
|
8
|
+
every current consumer runs on Bun and already type-checks with bundler module
|
|
9
|
+
resolution.
|
|
10
|
+
|
|
11
|
+
## Scope law
|
|
12
|
+
|
|
13
|
+
The package owns infrastructure that has the same contract in multiple Wellous
|
|
14
|
+
backends. It must not own a product's routes, role model, database schema,
|
|
15
|
+
migrations, environment names, tenant table catalogue, or domain services.
|
|
16
|
+
|
|
17
|
+
Dependency direction is one-way:
|
|
18
|
+
|
|
19
|
+
```text
|
|
20
|
+
consumer backend ---> @exvio/os-backend-core
|
|
21
|
+
adapters ---> db / redis / logger / config supplied by the app
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Core modules never import a consumer repository. Infrastructure that needs an
|
|
25
|
+
application service must be exposed as a factory and receive a narrow adapter.
|
|
26
|
+
|
|
27
|
+
## v0.1 tenant API
|
|
28
|
+
|
|
29
|
+
The first release owns the shared `AsyncLocalStorage` tenant context and the
|
|
30
|
+
Kysely AST plugin that scopes tenant-aware queries.
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import {
|
|
34
|
+
TenantFilterPlugin,
|
|
35
|
+
bypassTenant,
|
|
36
|
+
tenantContext,
|
|
37
|
+
withTenant,
|
|
38
|
+
} from '@exvio/os-backend-core/tenant'
|
|
39
|
+
|
|
40
|
+
const db = new Kysely<Database>({
|
|
41
|
+
dialect,
|
|
42
|
+
plugins: [
|
|
43
|
+
new TenantFilterPlugin(TENANT_AWARE_TABLES, {
|
|
44
|
+
context: tenantContext,
|
|
45
|
+
// Transitional v0.1 policy. Move to `throw` after every non-HTTP entry
|
|
46
|
+
// point has an explicit tenant or bypass scope.
|
|
47
|
+
onMissingContext: 'passthrough',
|
|
48
|
+
}),
|
|
49
|
+
// Query profiler goes after the tenant filter so it sees executed SQL.
|
|
50
|
+
],
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
await tenantContext.run({ tenantId: requestTenantId, bypass: false }, handleRequest)
|
|
54
|
+
await withTenant(jobTenantId, runBackgroundJob)
|
|
55
|
+
await bypassTenant(runReviewedCrossTenantOperation)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The consumer remains the source of truth for `TENANT_AWARE_TABLES`. The plugin
|
|
59
|
+
only receives that `ReadonlySet<string>`; it does not know any product schema.
|
|
60
|
+
The constructor snapshots the set so later mutation cannot alter the boundary.
|
|
61
|
+
Entries are case-sensitive bare table names. A tenant-scoped table migration or
|
|
62
|
+
rename must update the consumer catalogue and its sentinel test in the same PR.
|
|
63
|
+
|
|
64
|
+
### Security contract
|
|
65
|
+
|
|
66
|
+
- HTTP tenant middleware must wrap the entire downstream request in
|
|
67
|
+
`tenantContext.run(...)`.
|
|
68
|
+
- WebSockets, background jobs, cron, queues, workers, and fire-and-forget tasks
|
|
69
|
+
must use `withTenant(...)` for each tenant; HTTP context is not a substitute
|
|
70
|
+
for an explicit non-HTTP entry boundary. `withTenant` accepts only positive
|
|
71
|
+
safe-integer tenant ids.
|
|
72
|
+
- `bypassTenant(...)` is only for reviewed cross-tenant operations.
|
|
73
|
+
- No context currently preserves the fleet's existing behaviour: the plugin
|
|
74
|
+
passes the query through by default. Consumers must explicitly configure the
|
|
75
|
+
transitional policy, audit every entry point, and then switch to
|
|
76
|
+
`onMissingContext: 'throw'`.
|
|
77
|
+
- Raw `sql\`...\`` fragments are opaque to Kysely's AST transformer and must
|
|
78
|
+
include their own tenant predicate.
|
|
79
|
+
- Joined tenant tables are currently supported for INNER and LEFT joins
|
|
80
|
+
(including their lateral forms). RIGHT, FULL, CROSS, USING-join, and APPLY
|
|
81
|
+
variants fail loudly when the joined side is tenant-aware because placing a
|
|
82
|
+
predicate in `ON` would not safely filter their preserved side.
|
|
83
|
+
- `DELETE ... USING` tenant tables are scoped in the outer `WHERE`.
|
|
84
|
+
- A tenant-scoped `UPDATE` cannot assign `tenant_id`. Multi-table targets and
|
|
85
|
+
dynamic/raw update-column names fail loudly because the package cannot prove
|
|
86
|
+
their ownership semantics. Ownership changes require a separately reviewed
|
|
87
|
+
cross-tenant operation.
|
|
88
|
+
- `INSERT ... SELECT` and `INSERT ... DEFAULT VALUES` against a tenant-aware
|
|
89
|
+
table fail loudly. The only escape is a narrowly scoped `bypassTenant(...)`
|
|
90
|
+
operation that explicitly guarantees the projected tenant id.
|
|
91
|
+
- UPSERT and REPLACE variants fail loudly for tenant-aware targets. Until a
|
|
92
|
+
conflict key can be proven to include `tenant_id`, they can otherwise match
|
|
93
|
+
and mutate another tenant's row through a globally unique key.
|
|
94
|
+
- With an active tenant context, `MERGE` currently fails loudly for every table
|
|
95
|
+
because the Kysely AST cannot yet be rewritten safely.
|
|
96
|
+
- Compile and execute a query inside the intended context; do not compile it in
|
|
97
|
+
one scope and execute it in another.
|
|
98
|
+
|
|
99
|
+
All context users and `TenantFilterPlugin` must resolve the same installed
|
|
100
|
+
package instance. Do not mix a copied tenant context with the packaged plugin.
|
|
101
|
+
The optional `context` constructor argument makes this dependency explicit and
|
|
102
|
+
protects linked development/HMR from using a different singleton instance.
|
|
103
|
+
|
|
104
|
+
`tenantContext` is exposed for compatibility with HTTP middleware. Application
|
|
105
|
+
code must not call its `enterWith()` or `disable()` methods; prefer
|
|
106
|
+
`withTenant()`, `bypassTenant()`, and `getCurrentTenant()`.
|
|
107
|
+
|
|
108
|
+
Migration and seed commands must make their policy explicit. During the v0.1
|
|
109
|
+
transition they may rely on `passthrough`; before enabling strict mode, move
|
|
110
|
+
them to a dedicated Kysely instance without the tenant plugin or wrap each
|
|
111
|
+
reviewed operation in the appropriate scope.
|
|
112
|
+
|
|
113
|
+
## v0.2 AI runtime (alpha)
|
|
114
|
+
|
|
115
|
+
The AI subpath centralizes provider transport and runtime behaviour without
|
|
116
|
+
owning a product's prompts, tools, tier rules, RAG, workflows, config tables, or
|
|
117
|
+
usage ledger.
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
import { createAIClient } from '@exvio/os-backend-core/ai'
|
|
121
|
+
import { createDeepSeekProvider } from '@exvio/os-backend-core/ai/providers/deepseek'
|
|
122
|
+
import { createGeminiProvider } from '@exvio/os-backend-core/ai/providers/gemini'
|
|
123
|
+
import { getCurrentTenant } from '@exvio/os-backend-core/tenant'
|
|
124
|
+
|
|
125
|
+
const ai = createAIClient({
|
|
126
|
+
providers: new Map([
|
|
127
|
+
['gemini', createGeminiProvider()],
|
|
128
|
+
['deepseek', createDeepSeekProvider()],
|
|
129
|
+
]),
|
|
130
|
+
// The app still owns DB/Redis config and must keep this tenant-isolated.
|
|
131
|
+
configSource: {
|
|
132
|
+
loadConfig: tenantId => loadAIConfig(tenantId, { fallback: false }),
|
|
133
|
+
},
|
|
134
|
+
tenantResolver: () => {
|
|
135
|
+
const context = getCurrentTenant()
|
|
136
|
+
return context && !context.bypass ? context.tenantId : undefined
|
|
137
|
+
},
|
|
138
|
+
missingTenantPolicy: 'throw',
|
|
139
|
+
telemetry: observeRedactedInvocation,
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
const result = await ai.generateText('Rewrite this clearly.', {
|
|
143
|
+
tenantId,
|
|
144
|
+
lane: 'text',
|
|
145
|
+
metadata: { feature: 'editor.rephrase' },
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
// A reliable product ledger must use the returned result, not telemetry.
|
|
149
|
+
await recordUsage({
|
|
150
|
+
tenantId,
|
|
151
|
+
provider: result.provider,
|
|
152
|
+
model: result.model,
|
|
153
|
+
usage: result.usage,
|
|
154
|
+
})
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
The application config adapter returns explicit provider/model pairs:
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
{
|
|
161
|
+
providers: {
|
|
162
|
+
gemini: { apiKey: '...' },
|
|
163
|
+
deepseek: {
|
|
164
|
+
apiKey: '...',
|
|
165
|
+
endpoint: 'https://api.deepseek.com/chat/completions',
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
lanes: {
|
|
169
|
+
text: { provider: 'gemini', model: 'gemini-2.5-flash' },
|
|
170
|
+
vision: { provider: 'gemini', model: 'gemini-2.5-flash' },
|
|
171
|
+
chat: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
|
172
|
+
embedding: { provider: 'gemini', model: 'gemini-embedding-001' },
|
|
173
|
+
},
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
There is no model-prefix inference or silent provider fallback in the new API.
|
|
178
|
+
`parseAIConfig()` also applies Core's built-in model lifecycle policy to every
|
|
179
|
+
named lane, including custom lanes and explicit `{ provider, model }` entries.
|
|
180
|
+
Retired identifiers fail at config load; only an explicit forward data
|
|
181
|
+
migration may use `replacementForRetiredAIModelId()` to rewrite stored config.
|
|
182
|
+
Credentials are loaded per invocation and provider instances are not shared
|
|
183
|
+
between tenants. Missing tenant context fails by default. The built-in tenant
|
|
184
|
+
resolver intentionally ignores a `bypassTenant(...)` scope; a reviewed
|
|
185
|
+
cross-tenant operation must pass a positive `tenantId` on every AI call. A
|
|
186
|
+
positive explicit tenant must match a normal active tenant context; callers
|
|
187
|
+
cannot use an option to escape request tenancy. Enter a reviewed bypass scope
|
|
188
|
+
before making an intentional cross-tenant call. A
|
|
189
|
+
custom endpoint must be HTTPS, contain no URL credentials, and pass the
|
|
190
|
+
application's `validateEndpoint` policy before an Authorization header is
|
|
191
|
+
created. A provider factory's immutable official endpoint is the only endpoint
|
|
192
|
+
accepted without that hook. The built-in Gemini adapter additionally accepts
|
|
193
|
+
only Google's official API origin.
|
|
194
|
+
|
|
195
|
+
`AIClientOptions.fetch` is an injectable transport for HTTP providers such as
|
|
196
|
+
DeepSeek. The production Gemini adapter uses the official `@google/genai` SDK,
|
|
197
|
+
which cannot honor that injected transport; it fails loudly if the client is
|
|
198
|
+
configured with a custom `fetch` instead of silently bypassing the application's
|
|
199
|
+
transport policy.
|
|
200
|
+
|
|
201
|
+
Every text, content, chat, structured-output, stream, and embedding result uses
|
|
202
|
+
the same actual-model and token-usage envelope. A stream may retry only before
|
|
203
|
+
its first emitted event. Tool arguments and structured JSON fail closed rather
|
|
204
|
+
than becoming `{}`. Telemetry is scalar, secret-free, non-blocking, and
|
|
205
|
+
best-effort: a sink failure never changes a successful AI result, and telemetry
|
|
206
|
+
must not be used as a reliable billing or usage ledger. Persist non-stream usage
|
|
207
|
+
from the returned `AIResult`; for streaming, persist the `result` carried by the
|
|
208
|
+
terminal `finish` event.
|
|
209
|
+
|
|
210
|
+
### Adding providers and lanes
|
|
211
|
+
|
|
212
|
+
The registry and lane names are open strings, so adding OpenAI, Anthropic,
|
|
213
|
+
Azure AI, an on-prem model, or a future vendor does not require changing the
|
|
214
|
+
consumer API. Implement an `AIProviderFactory`, declare its capabilities, add
|
|
215
|
+
provider conformance tests, register it in each application's config adapter,
|
|
216
|
+
and add an explicit `{ provider, model }` selection. Product call sites remain
|
|
217
|
+
on `generateText`, `generateContent`, `generateChat`, `streamChat`,
|
|
218
|
+
`generateObject`, or `embed`.
|
|
219
|
+
|
|
220
|
+
Multiple providers and lanes can be active for the same tenant at the same
|
|
221
|
+
time. For example, `text` may use DeepSeek, `vision` Gemini, `chat` a private
|
|
222
|
+
provider, and `embedding` a dedicated embedding model. New product-specific
|
|
223
|
+
lanes such as `workflow`, `classification`, or `assessment` require only a new
|
|
224
|
+
config entry; Core does not maintain a closed lane enum. Automatic fallback,
|
|
225
|
+
load balancing, or cost routing must be an explicit application policy and may
|
|
226
|
+
never silently change the reported provider/model.
|
|
227
|
+
|
|
228
|
+
Model/lane selection and prices are deliberately application-owned; rates are
|
|
229
|
+
not bundled. Use `createPriceCatalog()` with an operations-owned
|
|
230
|
+
version/effective date and `calculateCostUsd()`. Unknown models return `null`,
|
|
231
|
+
not a misleading zero-dollar value. Product prompts, personas, RAG/vector-store
|
|
232
|
+
policy, tool execution/authorization, and workflows also remain outside core.
|
|
233
|
+
|
|
234
|
+
## v0.3 Changelog runtime (alpha)
|
|
235
|
+
|
|
236
|
+
The Changelog subpath centralizes immutable release-note catalogues, weighted
|
|
237
|
+
language negotiation, and per-subject acknowledgement state without owning an
|
|
238
|
+
application's content, HTTP routes, user table, or migrations.
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
import {
|
|
242
|
+
createChangelogCatalogue,
|
|
243
|
+
createChangelogService,
|
|
244
|
+
} from '@exvio/os-backend-core/changelog'
|
|
245
|
+
|
|
246
|
+
const catalogue = createChangelogCatalogue({
|
|
247
|
+
entries: appReleaseNotes, // newest first; versions are opaque keys
|
|
248
|
+
locales: ['en', 'cn', 'bm'] as const,
|
|
249
|
+
defaultLocale: 'en',
|
|
250
|
+
languageTags: { zh: 'cn', ms: 'bm' },
|
|
251
|
+
maxVersionLength: 64, // must match the consumer database column
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
const changelog = createChangelogService({
|
|
255
|
+
catalogue,
|
|
256
|
+
acknowledgements: appAcknowledgementStore,
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
const state = await changelog.getState({
|
|
260
|
+
subjectId: authenticatedUserId,
|
|
261
|
+
acceptLanguage: request.headers.get('Accept-Language'),
|
|
262
|
+
})
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
The first array entry is the latest release. Core never sorts versions by
|
|
266
|
+
SemVer, text, or display date. Every acknowledgement must name an exact known
|
|
267
|
+
version, so a late acknowledgement for an older screen may make a newer release
|
|
268
|
+
reappear but can never silently hide it. The catalogue snapshots and freezes
|
|
269
|
+
consumer input, requires the default-locale body, and allows older entries to
|
|
270
|
+
fall back when an optional translation is absent.
|
|
271
|
+
|
|
272
|
+
The acknowledgement store always receives both `tenantId` and `subjectId`.
|
|
273
|
+
The default resolver reads the package tenant context, ignores bypass scopes,
|
|
274
|
+
and fails when no tenant exists. An explicit tenant is allowed for reviewed
|
|
275
|
+
background or bypass work, but it may not disagree with an active non-bypass
|
|
276
|
+
tenant. Consumer repositories should use the supplied subject as their scope;
|
|
277
|
+
they must not query by user id alone.
|
|
278
|
+
|
|
279
|
+
Hono status codes, request-body limits, authentication, Kysely queries,
|
|
280
|
+
database migrations, release-note Markdown, and frontend sanitization remain
|
|
281
|
+
application-owned. HTTP adapters should project only the documented response
|
|
282
|
+
fields and send personalized responses with `Cache-Control: no-store, private`.
|
|
283
|
+
|
|
284
|
+
## v0.3 Guide runtime (alpha)
|
|
285
|
+
|
|
286
|
+
The Guide subpath owns the common file-content mechanics used by every backend:
|
|
287
|
+
manifest validation, deterministic grouping, locale fallback, bounded Markdown
|
|
288
|
+
reads, frontmatter parsing, visibility policies, and removal of private
|
|
289
|
+
`ai-context` authoring comments.
|
|
290
|
+
|
|
291
|
+
```ts
|
|
292
|
+
import path from 'node:path'
|
|
293
|
+
import {
|
|
294
|
+
createFileGuideContentSource,
|
|
295
|
+
createGuideService,
|
|
296
|
+
} from '@exvio/os-backend-core/guide'
|
|
297
|
+
|
|
298
|
+
const guides = createGuideService({
|
|
299
|
+
locales: ['en', 'cn'] as const,
|
|
300
|
+
defaultLocale: 'en',
|
|
301
|
+
categories: appGuideCategories,
|
|
302
|
+
entries: appGuideEntries,
|
|
303
|
+
source: createFileGuideContentSource({
|
|
304
|
+
root: path.resolve('src/data/guides'),
|
|
305
|
+
maxFileBytes: 512_000,
|
|
306
|
+
}),
|
|
307
|
+
// These are separate by design: hiding an item in a catalogue is not
|
|
308
|
+
// automatically authorization for its detail route.
|
|
309
|
+
listPolicy: (entry, context) => includeGuideInCatalogue(entry, context),
|
|
310
|
+
readPolicy: (entry, context) => mayReadGuide(entry, context),
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
await guides.validate() // CI/startup asset audit
|
|
314
|
+
const document = await guides.load({ category, slug, locale, context })
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
Category, slug, and locale identifiers are validated before file access. The
|
|
318
|
+
file source requires an explicit absolute root, resolves real paths, rejects
|
|
319
|
+
resolved symlink escapes, and caps reads. The root must be a Git/deployment-owned
|
|
320
|
+
read-only asset directory; this adapter is not a sandbox for a directory that
|
|
321
|
+
an untrusted process can rename or rewrite concurrently. Manifest input is deeply snapshotted and
|
|
322
|
+
case-insensitive filesystem collisions fail at construction. Locale fallback
|
|
323
|
+
returns both the requested and actual resolved locale.
|
|
324
|
+
|
|
325
|
+
Frontmatter intentionally supports only `title` and `description`, handles BOM,
|
|
326
|
+
LF, and CRLF, and rejects duplicate or unknown fields. User-facing bodies always
|
|
327
|
+
remove `<!-- ai-context ... -->`; an unclosed marker removes the rest of the
|
|
328
|
+
document rather than leaking private authoring material. The first API does not
|
|
329
|
+
offer a raw or bulk AI loader.
|
|
330
|
+
|
|
331
|
+
Applications retain their Markdown, manifest metadata, role model, Hono/i18n
|
|
332
|
+
adapter, MCP and AI prompt adapters, and any tenant-specific overrides. Core
|
|
333
|
+
policies receive the app's generic context but do not know `UserRole`. Routes
|
|
334
|
+
must explicitly project response fields instead of serializing the returned
|
|
335
|
+
extensible manifest entry. Markdown-to-HTML rendering and DOM sanitization are
|
|
336
|
+
a frontend/theme responsibility.
|
|
337
|
+
|
|
338
|
+
## v0.4 Auth runtime (alpha)
|
|
339
|
+
|
|
340
|
+
The first Auth slice centralizes framework-neutral login policy, atomic OAuth
|
|
341
|
+
transactions, non-bearer session-management handles, lifetime calculation, and
|
|
342
|
+
secret redaction. It does not own users, roles, Hono routes, Redis, Kysely,
|
|
343
|
+
provider credentials, or product authorization.
|
|
344
|
+
|
|
345
|
+
```ts
|
|
346
|
+
import {
|
|
347
|
+
createLoginPolicy,
|
|
348
|
+
createOAuthBrowserExchangeManager,
|
|
349
|
+
createOAuthStateManager,
|
|
350
|
+
createSessionBearerToken,
|
|
351
|
+
createSessionManagementHandle,
|
|
352
|
+
} from '@exvio/os-backend-core/auth'
|
|
353
|
+
|
|
354
|
+
const policy = createLoginPolicy({
|
|
355
|
+
mode: tenantIsHubManaged ? 'hub_managed' : 'standalone',
|
|
356
|
+
enabled: { email: true, google: true, microsoft: false, passkey: true },
|
|
357
|
+
})
|
|
358
|
+
policy.assertAllowed({ surface: 'google', phase: 'start' })
|
|
359
|
+
|
|
360
|
+
const oneTimeStore = {
|
|
361
|
+
put: (key, value, ttlSeconds) => redis.set(key, value, ttlSeconds),
|
|
362
|
+
// This must be one Redis GETDEL command or one Lua script.
|
|
363
|
+
take: key => redis.getdel(key),
|
|
364
|
+
}
|
|
365
|
+
const oauthState = createOAuthStateManager({ store: oneTimeStore })
|
|
366
|
+
const browserExchange = createOAuthBrowserExchangeManager({ store: oneTimeStore })
|
|
367
|
+
|
|
368
|
+
const state = await oauthState.issue({
|
|
369
|
+
tenantId,
|
|
370
|
+
provider: 'google',
|
|
371
|
+
redirectUri,
|
|
372
|
+
verifier: pkceVerifier,
|
|
373
|
+
initiatingSessionId: browserTransactionCookie,
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
const exchangeCode = await browserExchange.issue({
|
|
377
|
+
tenantId,
|
|
378
|
+
browserTransaction: browserTransactionCookie,
|
|
379
|
+
provider: 'google',
|
|
380
|
+
userId,
|
|
381
|
+
// Optional revocation epoch captured before provider authentication.
|
|
382
|
+
sessionGeneration,
|
|
383
|
+
})
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
The OAuth manager generates the state token and stores only its SHA-256-derived
|
|
387
|
+
key. Consumption binds the exact tenant, provider, redirect URI, and optional
|
|
388
|
+
initiating session, then burns the record before returning the PKCE verifier.
|
|
389
|
+
Missing, malformed, expired, replayed, tenant-swapped, provider-swapped, and
|
|
390
|
+
redirect-swapped transactions fail with a secret-free `AuthError`.
|
|
391
|
+
|
|
392
|
+
After a successful provider callback, the browser exchange manager creates a
|
|
393
|
+
short-lived, one-time code bound to the tenant, browser transaction, provider,
|
|
394
|
+
user, optional session revocation generation, and expiry. It stores only a SHA-256-derived key and atomically burns the
|
|
395
|
+
record before comparing the tenant and browser binding, so mismatch, replay,
|
|
396
|
+
and concurrent consumption fail closed. The application owns the Secure,
|
|
397
|
+
HttpOnly, SameSite=Lax transaction cookie, Hono exchange endpoint, final login
|
|
398
|
+
policy/user checks, and session creation. A callback redirects with only the
|
|
399
|
+
exchange code; a bearer token, PKCE verifier, OAuth state, and user projection
|
|
400
|
+
must never be placed in the URL.
|
|
401
|
+
|
|
402
|
+
### Passkey alpha boundary
|
|
403
|
+
|
|
404
|
+
The Auth alpha also provides framework-neutral Passkey challenge transactions,
|
|
405
|
+
distributed rate-limit decisions, recent-auth evaluation, HMAC-derived WebAuthn
|
|
406
|
+
user handles, and signature-counter compare-and-swap rules. Registration binds
|
|
407
|
+
the positive tenant and user ids, initiating session, recent authentication
|
|
408
|
+
time, RP ID, origin, and excluded credentials. Authentication binds the
|
|
409
|
+
positive tenant id, optional user, RP ID, origin, and allowed credential set.
|
|
410
|
+
Both flows consume stored state atomically before comparison; missing, replayed,
|
|
411
|
+
concurrent, malformed, purpose-swapped, or context-swapped finishes fail with a
|
|
412
|
+
secret-free `PasskeyAuthError`.
|
|
413
|
+
|
|
414
|
+
Core supplies no Redis or SQL client. A challenge adapter must implement atomic
|
|
415
|
+
`take` with Redis `GETDEL` (or one equivalent Lua command), and authentication
|
|
416
|
+
issuance also requires atomic `putIfAbsent`. The application supplies the Redis
|
|
417
|
+
Lua rate-limit adapter and tenant-aware SQL counter CAS, plus Hono routes,
|
|
418
|
+
SimpleWebAuthn verification, RP configuration, trusted-proxy address policy,
|
|
419
|
+
credential persistence, recent-auth UX, security notifications, and durable
|
|
420
|
+
audit storage. Durable Passkey audit is not implemented by this alpha and is a
|
|
421
|
+
release blocker wherever policy or compliance requires it.
|
|
422
|
+
|
|
423
|
+
`createSessionManagementHandle()` derives a stable `smh_v1_...` handle for a
|
|
424
|
+
session-management screen. That handle cannot authenticate a request. A
|
|
425
|
+
consumer must still verify the authenticated owner before revocation and must
|
|
426
|
+
never return or log the underlying bearer. New sessions use the disjoint
|
|
427
|
+
`sat_v1_...` namespace from `createSessionBearerToken()`; raw random values,
|
|
428
|
+
`smh_v1_...` handles, and `oas_v1_...` states are not session bearers. Consumers
|
|
429
|
+
enforce both idle and absolute expiry with `remainingSessionTtl()` and revoke
|
|
430
|
+
the request's previous session after every successful authentication or
|
|
431
|
+
privilege-changing rotation.
|
|
432
|
+
|
|
433
|
+
The mode and surface matrix is shared, but role hierarchies are not. Each
|
|
434
|
+
product's action permissions, object access, workflow ownership, entitlements
|
|
435
|
+
and API-key/MCP scope catalogues remain application policy.
|
|
436
|
+
|
|
437
|
+
`redactAuthSecrets()` and `redactAuthStorageKey()` produce log-safe values. The
|
|
438
|
+
storage-key redactor bundles only generic auth namespaces (`session:`,
|
|
439
|
+
`oauth:state:`, `passkey:auth:`, `password:reset:` and similar); an
|
|
440
|
+
application's own credential-bearing key prefixes are passed on each call, for
|
|
441
|
+
example `redactAuthStorageKey(key, ['ticket:', 'invite:'])`. A key that matches
|
|
442
|
+
no prefix is returned unchanged, so an unlisted namespace is a logging leak, not
|
|
443
|
+
a redaction.
|
|
444
|
+
|
|
445
|
+
## Install
|
|
446
|
+
|
|
447
|
+
The package is on the public npm registry under the `@exvio` scope. No git, ssh
|
|
448
|
+
or AWS credentials are needed on the consumer side, and a Docker build that runs
|
|
449
|
+
`bun install` needs nothing beyond registry access: no Git, no SSH client, no key
|
|
450
|
+
to forward.
|
|
451
|
+
|
|
452
|
+
```powershell
|
|
453
|
+
bun add @exvio/os-backend-core
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
```json
|
|
457
|
+
{ "dependencies": { "@exvio/os-backend-core": "^0.4.0" } }
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
Pin exactly (`0.4.0`, no caret) where a consumer must not pick up a minor: on a
|
|
461
|
+
0.x line every minor may carry a breaking change.
|
|
462
|
+
|
|
463
|
+
Published versions are immutable and every consumer upgrades explicitly. A
|
|
464
|
+
pre-release version (`0.5.0-alpha.1`) is published under the `next` dist-tag and
|
|
465
|
+
never moves `latest`. Contributor workflow, gates and the release procedure are
|
|
466
|
+
in the source repository's `CONTRIBUTING.md`, which is not part of the package.
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@exvio/os-backend-core",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"license": "UNLICENSED",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public",
|
|
7
|
+
"registry": "https://registry.npmjs.org"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"description": "Shared backend building blocks for the Wellous OS hub-and-spoke fleet.",
|
|
11
|
+
"module": "./src/index.ts",
|
|
12
|
+
"types": "./src/index.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./src/index.ts",
|
|
15
|
+
"./tenant": "./src/tenant.ts",
|
|
16
|
+
"./auth": "./src/auth/index.ts",
|
|
17
|
+
"./changelog": "./src/changelog/index.ts",
|
|
18
|
+
"./guide": "./src/guide/index.ts",
|
|
19
|
+
"./ai": "./src/ai/index.ts",
|
|
20
|
+
"./ai/providers/gemini": "./src/ai/providers/gemini.ts",
|
|
21
|
+
"./ai/providers/deepseek": "./src/ai/providers/deepseek.ts"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"src",
|
|
25
|
+
"README.md"
|
|
26
|
+
],
|
|
27
|
+
"sideEffects": false,
|
|
28
|
+
"scripts": {
|
|
29
|
+
"type-check": "bun x tsc -p tsconfig.json --noEmit",
|
|
30
|
+
"test": "bun test",
|
|
31
|
+
"assert:publish-safe": "node scripts/assert-publish-safe.mjs",
|
|
32
|
+
"check": "bun run type-check && bun test"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"bun": ">=1.3.10"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"kysely": ">=0.28.16 <0.29.0"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@google/genai": "2.17.1"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/bun": "^1.3.14",
|
|
45
|
+
"kysely": "^0.28.17",
|
|
46
|
+
"typescript": "^5.9.3"
|
|
47
|
+
}
|
|
48
|
+
}
|