@freema/drobek-modules 0.3.3

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.
Files changed (33) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +53 -0
  3. package/dist/chunk-WHKPW5MP.js +6611 -0
  4. package/dist/index.d.ts +1604 -0
  5. package/dist/index.js +373 -0
  6. package/dist/mail-guard.d-BKJhsVA2.d.ts +2097 -0
  7. package/dist/migrations/core/0000_dusty_scarecrow.sql +84 -0
  8. package/dist/migrations/core/0001_busy_orphan.sql +69 -0
  9. package/dist/migrations/core/0002_odd_alex_power.sql +17 -0
  10. package/dist/migrations/core/0003_nosy_shatterstar.sql +26 -0
  11. package/dist/migrations/core/0004_nice_ben_parker.sql +27 -0
  12. package/dist/migrations/core/0005_panoramic_bill_hollister.sql +4 -0
  13. package/dist/migrations/core/0006_outgoing_ricochet.sql +29 -0
  14. package/dist/migrations/core/0007_app_versions.sql +95 -0
  15. package/dist/migrations/core/0008_user_bound_tokens.sql +42 -0
  16. package/dist/migrations/core/0009_app_name.sql +1 -0
  17. package/dist/migrations/core/0010_apps_origin.sql +12 -0
  18. package/dist/migrations/core/0011_modules.sql +32 -0
  19. package/dist/migrations/core/0012_data_module_tables.sql +9 -0
  20. package/dist/migrations/core/0014_get_logs.sql +26 -0
  21. package/dist/migrations/core/0016_apps_slug_release.sql +8 -0
  22. package/dist/migrations/core/0018_custom_domains.sql +22 -0
  23. package/dist/migrations/core/0021_abuse_reports.sql +23 -0
  24. package/dist/migrations/core/0022_gallery.sql +20 -0
  25. package/dist/migrations/core/0023_workspace_modules.sql +13 -0
  26. package/dist/migrations/core/0024_app_assets.sql +18 -0
  27. package/dist/migrations/core/0025_app_asset_snapshots.sql +28 -0
  28. package/dist/migrations/core/0026_publish_approval.sql +13 -0
  29. package/dist/migrations/core/0027_publish_block.sql +6 -0
  30. package/dist/migrations/core/meta/_journal.json +167 -0
  31. package/dist/testing.d.ts +269 -0
  32. package/dist/testing.js +1569 -0
  33. package/package.json +63 -0
@@ -0,0 +1,1604 @@
1
+ import { ZodType, z } from 'zod';
2
+ export { z } from 'zod';
3
+ import { L as Logger, M as ModuleSecretDoc, H as HookApp, E as EndUser, D as DB, R as Rule, P as Principal, A as AccessDecision, a as ModuleLimit, b as Limits, C as ConfirmRole, c as AnyModule, d as EmailMessage, e as EmailKind, f as EmailRecipient, U as UploadedFile, g as EndUserListQuery, h as EndUserPage, i as EndUserRecord, O as OwnerFilesPage, j as OwnerFile, k as RecordsCollection, l as RecordsQuery, m as RecordsPage, S as SubmissionsQuery, n as SubmissionsPage, o as MailEnvelope, p as RateLimitResult, q as MailGuard, r as ModuleAvailability, s as ModuleDashboardEditor, t as ModuleErrorDoc, u as SdkBundle, v as ModuleServices, w as EndUserCallbackInput, x as EndUserCallbackResult } from './mail-guard.d-BKJhsVA2.js';
4
+ export { B as BEACON_SCRIPT_PATH, y as BeaconScript, z as ComposedModuleParts, F as ConfirmContext, G as ConfirmItem, I as ConfirmedContext, J as DrobekModule, K as EndUserAuthority, N as EndUserCallbackApp, Q as FilesAuthority, T as MAIL_COUNTER_KEYS, V as MAIL_PAUSE_KEYS, W as MODULE_CONTRACT_VERSION, X as MODULE_ERROR_CODE_RE, Y as MODULE_NAME_RE, Z as MailAuthority, _ as MailBudgets, $ as MailClass, a0 as MailGuardConfig, a1 as MailGuardMeta, a2 as MailGuardRedis, a3 as MailPrepareInput, a4 as ModuleAppView, a5 as ModuleComposeInput, a6 as ModuleContext, a7 as ModuleDashboard, a8 as ModuleHooks, a9 as ModuleMigrations, aa as ModuleRequest, ab as ModuleResponse, ac as ModuleRouter, ad as ModuleSdk, ae as ModuleSkill, af as ModuleSlot, ag as OwnerSubmission, ah as OwnerView, ai as RECORDS_IMPORT_MAX_ROWS, aj as RecordsAuthority, ak as RecordsView, al as RouteHandler, am as RouteOptions, an as RouteRateLimit, ao as SDK_PATH, ap as SDK_TYPES_PATH, aq as SLOT_NAME_RE, ar as SubmissionsAuthority, as as buildBeaconScript, at as buildSdk, au as defineModule, av as inlineSpecifier, aw as isDefinedModule, ax as mailAppCounterKey, ay as mailBudgets, az as mailGuardConfigFromEnv, aA as memoryMailGuard, aB as memoryMailGuardRedis, aC as normalizeConfirmItems, aD as redisMailGuard, aE as respond, aF as sdkDeclarations } from './mail-guard.d-BKJhsVA2.js';
5
+ export { SdkCore } from '@drobek/sdk';
6
+ import { Readable } from 'node:stream';
7
+ import { Redis } from 'ioredis';
8
+ import 'drizzle-orm/postgres-js';
9
+
10
+ /** Lazy shared ioredis client. Fails fast so /healthz can flip to 503. */
11
+ declare function getRedis(): Redis {
12
+ if (!client) {
13
+ client = new Redis(requireRedisUrl(), { maxRetriesPerRequest: 2 });
14
+ }
15
+ return client;
16
+ }
17
+
18
+ /**
19
+ * Per-client-IP rate-limit keys (NSO-309, NSO-328). One implementation for
20
+ * every per-IP bucket in drobek: the dashboard sign-in guards, DCR, the app
21
+ * password gate, the module router's `per: 'ip'`, the proxy's `public-ip`, the
22
+ * error beacon and the abuse report form.
23
+ *
24
+ * A request whose client IP could not be resolved (no trusted proxy header —
25
+ * the plain-HTTP dev stack, a request that bypassed the proxy, a misconfigured
26
+ * `TRUST_PROXY`) gets NO per-IP bucket: the caller skips that check and keeps
27
+ * its other protections (per app, per principal, per code). The former
28
+ * `ip ?? 'unknown'` key put every such client into ONE shared bucket, so a
29
+ * handful of requests locked everybody out. Skipping loses nothing a spoofer
30
+ * could not already get by rotating forged headers.
31
+ *
32
+ * The first skip of each bucket logs one warning per process so a
33
+ * misconfigured proxy is visible without flooding the log.
34
+ */
35
+
36
+
37
+ /**
38
+ * The key of the per-IP bucket `bucket` for this client: the resolved IP, or
39
+ * `null` when there is none — the caller must then skip the per-IP check
40
+ * (never substitute a shared placeholder). `bucket` only labels the warning.
41
+ */
42
+ declare function perIpLimitKey(ip: string | null | undefined, bucket: string, log: Logger = defaultLog): string | null {
43
+ const key = ip?.trim();
44
+ if (key) return key;
45
+ if (!warnedBuckets.has(bucket)) {
46
+ warnedBuckets.add(bucket);
47
+ log.warn(
48
+ `[rate-limit] no client IP resolved — per-IP limit "${bucket}" skipped (check TRUST_PROXY and the proxy X-Real-IP header)`,
49
+ { event: 'rate_limit_no_client_ip', bucket }
50
+ );
51
+ }
52
+ return null;
53
+ }
54
+
55
+ /**
56
+ * What stored bytes ARE, decided from their first bytes — never from a
57
+ * client's Content-Type or a file name. PURE, shared by the files module
58
+ * (end-user uploads) and app assets (@drobek/apps, NSO-358), so both refuse
59
+ * the same disguised HTML page named `.png`.
60
+ *
61
+ * image/png 89 50 4E 47 0D 0A 1A 0A
62
+ * image/jpeg FF D8 FF
63
+ * image/gif "GIF87a" | "GIF89a"
64
+ * image/webp "RIFF" …… "WEBP"
65
+ * image/avif …… "ftyp" + the "avif" / "avis" brand (major or compatible)
66
+ * image/x-icon 00 00 01 00, at least one image, a zero reserved byte
67
+ * application/pdf "%PDF-"
68
+ * video/mp4 …… "ftyp" + an MP4 brand (isom, mp41, mp42, avc1, M4V …)
69
+ * audio/mp4 …… "ftyp" + the "M4A " / "M4B " brand
70
+ * video/webm 1A 45 DF A3 (EBML) with the "webm" DocType in the header
71
+ * audio/mpeg "ID3" | an MPEG audio Layer III frame sync (FF Ex/Fx)
72
+ * audio/ogg "OggS"
73
+ * audio/wav "RIFF" …… "WAVE"
74
+ * font/woff "wOFF"
75
+ * font/woff2 "wOF2"
76
+ *
77
+ * Other `ftyp` boxes (HEIC, QuickTime) are neither MP4 nor AVIF: their brands
78
+ * are not on the lists, so they sniff as nothing.
79
+ *
80
+ * SVG has no magic bytes: `looksLikeSvg` recognises an `<svg` root after an
81
+ * optional prolog. The caller checks that the whole stream is text.
82
+ */
83
+
84
+ type SniffedType =
85
+ | 'image/png'
86
+ | 'image/jpeg'
87
+ | 'image/gif'
88
+ | 'image/webp'
89
+ | 'image/avif'
90
+ | 'image/x-icon'
91
+ | 'application/pdf'
92
+ | 'video/mp4'
93
+ | 'audio/mp4'
94
+ | 'video/webm'
95
+ | 'audio/mpeg'
96
+ | 'audio/ogg'
97
+ | 'audio/wav'
98
+ | 'font/woff'
99
+ | 'font/woff2';
100
+
101
+ /** The type of some stored bytes from their first bytes (≥ SIGNATURE_HEAD_BYTES when there are that many), or null. */
102
+ declare function sniffSignature(head: Buffer): SniffedType | null {
103
+ if (startsWith(head, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return 'image/png';
104
+ if (startsWith(head, [0xff, 0xd8, 0xff])) return 'image/jpeg';
105
+ if (startsWith(head, 'GIF87a') || startsWith(head, 'GIF89a')) return 'image/gif';
106
+ if (startsWith(head, 'RIFF') && startsWith(head, 'WEBP', 8)) return 'image/webp';
107
+ if (startsWith(head, 'RIFF') && startsWith(head, 'WAVE', 8)) return 'audio/wav';
108
+ if (startsWith(head, '%PDF-')) return 'application/pdf';
109
+ if (startsWith(head, 'ftyp', 4) && head.length >= 12) {
110
+ const { major, compatible } = ftypBrands(head);
111
+ if (AVIF_BRANDS.has(major) || compatible.some((b) => AVIF_BRANDS.has(b))) return 'image/avif';
112
+ if (MP4_BRANDS.has(major)) return 'video/mp4';
113
+ if (M4A_BRANDS.has(major)) return 'audio/mp4';
114
+ return null;
115
+ }
116
+ if (startsWith(head, [0x1a, 0x45, 0xdf, 0xa3])) {
117
+ return head.subarray(4, SIGNATURE_HEAD_BYTES).includes('webm', 0, 'latin1') ? 'video/webm' : null;
118
+ }
119
+ if (startsWith(head, 'OggS')) return 'audio/ogg';
120
+ if (startsWith(head, 'wOFF')) return 'font/woff';
121
+ if (startsWith(head, 'wOF2')) return 'font/woff2';
122
+ if (icoHeader(head)) return 'image/x-icon';
123
+ if (startsWith(head, 'ID3') || mp3FrameSync(head)) return 'audio/mpeg';
124
+ return null;
125
+ }
126
+
127
+ /**
128
+ * Does the (BOM-stripped) head start with an `<svg` root? Whitespace, then any
129
+ * mix of XML declarations / processing instructions (`<?…?>`) and comments
130
+ * (`<!--…-->`) — at most SVG_PROLOG_MAX_ITEMS — and one `<!DOCTYPE svg …>`
131
+ * (with an optional `[…]` internal subset), then `<svg`.
132
+ *
133
+ * A linear scanner on purpose: the regex it replaces backtracked
134
+ * exponentially on repeated `<?xml?>` (NSO-322 R1). Every step moves `i`
135
+ * forward through `indexOf`, so the cost is O(head length). An unterminated
136
+ * item is not an SVG.
137
+ */
138
+ declare function looksLikeSvg(head: string): boolean {
139
+ let i = 0;
140
+ const skipSpace = (): void => {
141
+ while (i < head.length && isXmlSpace(head[i])) i++;
142
+ };
143
+ let doctype = false;
144
+ for (let items = 0; items <= SVG_PROLOG_MAX_ITEMS; items++) {
145
+ skipSpace();
146
+ if (head.startsWith('<?', i)) {
147
+ const end = head.indexOf('?>', i + 2);
148
+ if (end < 0) return false;
149
+ i = end + 2;
150
+ continue;
151
+ }
152
+ if (head.startsWith('<!--', i)) {
153
+ const end = head.indexOf('-->', i + 4);
154
+ if (end < 0) return false;
155
+ i = end + 3;
156
+ continue;
157
+ }
158
+ if (!doctype && matchesAt(DOCTYPE_SVG_RE, head, i)) {
159
+ doctype = true;
160
+ const gt = head.indexOf('>', i);
161
+ const bracket = head.indexOf('[', i);
162
+ if (gt < 0) return false;
163
+ if (bracket < 0 || gt < bracket) {
164
+ i = gt + 1;
165
+ continue;
166
+ }
167
+ // An internal subset: `[` … `]`, optional whitespace, `>`.
168
+ const close = head.indexOf(']', bracket + 1);
169
+ if (close < 0) return false;
170
+ i = close + 1;
171
+ skipSpace();
172
+ if (head[i] !== '>') return false;
173
+ i++;
174
+ continue;
175
+ }
176
+ return matchesAt(SVG_ROOT_RE, head, i);
177
+ }
178
+ return false;
179
+ }
180
+
181
+ /** A C0 control character other than tab, LF, CR (never in a text file drobek accepts). */
182
+ declare function hasControlBytes(chunk: Buffer): boolean {
183
+ for (let i = 0; i < chunk.length; i++) {
184
+ const c = chunk[i];
185
+ if (c < 0x20 && c !== 0x09 && c !== 0x0a && c !== 0x0d) return true;
186
+ }
187
+ return false;
188
+ }
189
+
190
+ /**
191
+ * End-user sign-in providers (NSO-348, contract 1.1): the typed contract of
192
+ * the two slots the built-in `auth` module offers other modules.
193
+ *
194
+ * `auth.provider` — a way to sign in besides the e-mail code (OIDC, SAML, …).
195
+ * The provider only proves an identity `{ issuer, subject, email,
196
+ * emailVerified, name? }`; `auth` keeps everything else — the allowlist, roles,
197
+ * `mod_auth_users`, the session, `drobek.auth`, `<LoginGate>`.
198
+ * `auth.signedIn` — an observer told about every successful sign-in (e.g.
199
+ * a CRM sync); its failure is logged and never blocks the sign-in.
200
+ *
201
+ * A module contributes one of each at most:
202
+ *
203
+ * export default defineModule({
204
+ * name: 'oidc', …,
205
+ * requires: ['auth'],
206
+ * contributes: { 'auth.provider': defineAuthProvider({ id: 'oidc', label: 'Company SSO', … }) },
207
+ * });
208
+ *
209
+ * The flow (docs/MODULES.md "Auth providers"): the app calls
210
+ * `drobek.auth.signIn(id)` → `POST /__drobek/v1/auth/begin` on the app host →
211
+ * `begin()` answers the IdP URL (state, nonce and a PKCE S256 challenge made
212
+ * by `auth`) → the IdP redirects to the ONE callback on the dashboard host,
213
+ * `/__drobek/auth/callback/<id>` (the `redirectUri`) → `callback()` answers
214
+ * the verified identity → a one-time handoff code carries it back to the app
215
+ * host, which sets the host-only session cookie.
216
+ *
217
+ * Providers are operator-installed server code (the module trust model);
218
+ * the types here are what `auth` guarantees them and asks of them.
219
+ */
220
+
221
+ /** Provider ids: lowercase letters and digits, 2–16 characters (a URL segment, a config key, a secret prefix). */
222
+ declare const AUTH_PROVIDER_ID_RE: RegExp;
223
+ /** The provider id of the e-mail code (in `mod_auth_users.provider` and sessions) — no contribution may take it. */
224
+ declare const EMAIL_PROVIDER_ID = "email";
225
+ /** The keys `auth` adds to every provider's config entry — a provider's configSchema may not declare them. */
226
+ declare const AUTH_RESERVED_CONFIG_KEYS: readonly ["enabled", "relinkByEmail"];
227
+ /** The identity a provider proved (the IdP's verified answer — never a value the browser sent). */
228
+ interface AuthIdentity {
229
+ /**
230
+ * The authority that asserted `subject`, as the provider VERIFIED it: OIDC —
231
+ * the `iss` of the validated ID token (the configured or discovered issuer,
232
+ * env fallbacks included); SAML — the assertion's Issuer. `auth` binds a
233
+ * person to (provider, issuer, subject) (OIDC Core §5.7): the same subject
234
+ * from another issuer is another person. Never a constant or a value the
235
+ * browser sent — max 2048 characters.
236
+ */
237
+ issuer: string;
238
+ /** The IdP's stable, unique id of the person at `issuer` (OIDC `sub`, SAML NameID) — max 255 characters. */
239
+ subject: string;
240
+ /** Their e-mail address (normalized to lower case by `auth`). */
241
+ email: string;
242
+ /**
243
+ * The IdP vouches for the address. `auth` refuses a sign-in with an
244
+ * unverified address (`email_not_verified`): the allowlist, `adminEmails`
245
+ * and the workspace-editor rule all decide by the address.
246
+ */
247
+ emailVerified: boolean;
248
+ /** A display name (optional, max 200 characters; passed to `auth.signedIn` observers, not stored). */
249
+ name?: string;
250
+ }
251
+ /**
252
+ * A secret a provider uses, declared under the `auth` module (per app,
253
+ * entered in the dashboard only). `name` starts with `<ID>_` (upper case),
254
+ * e.g. `OIDC_CLIENT_SECRET`.
255
+ */
256
+ interface AuthProviderSecretDoc extends ModuleSecretDoc {
257
+ /**
258
+ * The operator's env var used when the app has no value of its own
259
+ * (`AUTH_<ID>_…`, e.g. `AUTH_OIDC_CLIENT_SECRET`) — one IdP for the whole
260
+ * self-hosted server.
261
+ */
262
+ env?: string;
263
+ }
264
+ /** The provider's secrets for ONE app: its declared names only (the app's value, else the declared env fallback). */
265
+ interface AuthProviderSecrets {
266
+ get(name: string): Promise<string | null>;
267
+ }
268
+ interface AuthProviderInput<Config> {
269
+ app: HookApp;
270
+ /** The app's config of THIS provider (`auth.providers.<id>` without the keys auth adds), as its configSchema parsed it. */
271
+ config: Config;
272
+ secrets: AuthProviderSecrets;
273
+ /**
274
+ * The operator's `AUTH_<ID>_*` env vars (only those) — a provider's env
275
+ * fallback for config it may take from the server instead of the app.
276
+ */
277
+ env: Readonly<Record<string, string>>;
278
+ /**
279
+ * `<dashboard origin>/__drobek/auth/callback/<id>` — the ONE redirect URI
280
+ * to register at the IdP (the same for every app of the server).
281
+ */
282
+ redirectUri: string;
283
+ /** Opaque, signed and single-use: send it to the IdP (OIDC `state`, SAML `RelayState`), it comes back to the callback. */
284
+ state: string;
285
+ /** Random per sign-in (OIDC `nonce`): check the IdP's answer carries it. */
286
+ nonce: string;
287
+ log: Logger;
288
+ }
289
+ interface AuthProviderBeginInput<Config = unknown> extends AuthProviderInput<Config> {
290
+ /** PKCE: `BASE64URL(SHA-256(code_verifier))` — the verifier stays on the server. */
291
+ codeChallenge: string;
292
+ codeChallengeMethod: 'S256';
293
+ }
294
+ /** Where the browser goes to sign in: the IdP's authorization URL (a SAML provider: its HTTP-Redirect binding). */
295
+ interface AuthProviderBeginResult {
296
+ /** https (http only outside production). */
297
+ url: string;
298
+ }
299
+ interface AuthProviderCallbackInput<Config = unknown> extends AuthProviderInput<Config> {
300
+ /**
301
+ * The callback's query parameters (first value of each). `auth` found the
302
+ * sign-in by its `state` (the `state` or `RelayState` parameter / form
303
+ * field) and consumed it before calling the provider.
304
+ */
305
+ query: Record<string, string>;
306
+ /** The form fields of a POST callback (`application/x-www-form-urlencoded`, e.g. a SAML response), else null. */
307
+ body: Record<string, string> | null;
308
+ /** The PKCE verifier of this sign-in (send it with the token request). */
309
+ codeVerifier: string;
310
+ }
311
+ /**
312
+ * A sign-in provider (the `auth.provider` slot). `begin` and `callback` are
313
+ * called unbound (`provider.begin(input)` on the parsed contribution): do
314
+ * not rely on `this`. A throw is logged by `auth` (the error's name only)
315
+ * and answers the user `provider_error` — never the IdP's details. Each
316
+ * call is cut off after 15 s.
317
+ */
318
+ interface AuthProvider<Config = any> {
319
+ /** `AUTH_PROVIDER_ID_RE`, not `email`; unique within the slot. The `:provider` of the callback URL and the key of `auth.providers`. */
320
+ id: string;
321
+ /** What `<LoginGate>` shows: "Continue with <label>" (1–40 characters). */
322
+ label: string;
323
+ /**
324
+ * The per-app config of the provider (`auth.providers.<id>`), a zod OBJECT
325
+ * schema (`z.object` / `z.strictObject`) without the keys `auth` adds:
326
+ * `enabled: boolean` and `relinkByEmail?: boolean`. While disabled, every
327
+ * field is optional; enabling the provider validates the whole schema.
328
+ */
329
+ configSchema: ZodType<Config>;
330
+ /** Defaults merged under the app's config (optional; must pass `configSchema.partial()`). */
331
+ configDefaults?: Partial<Config>;
332
+ /**
333
+ * Config keys that decide WHO can sign in (e.g. `issuer`, `clientId`):
334
+ * changing one while the provider is enabled — and enabling it — waits for
335
+ * the app owner's confirmation. Their values and the operator's non-secret
336
+ * `AUTH_<ID>_*` env vars make the provider's connection: a change of either
337
+ * refuses the sign-ins in flight and ends the provider's sessions.
338
+ */
339
+ identityFields?: string[];
340
+ secrets?: AuthProviderSecretDoc[];
341
+ begin(input: AuthProviderBeginInput<Config>): Promise<AuthProviderBeginResult>;
342
+ callback(input: AuthProviderCallbackInput<Config>): Promise<AuthIdentity>;
343
+ }
344
+ /** What an `auth.signedIn` observer gets after a successful sign-in (e-mail code or provider). */
345
+ interface AuthSignInEvent {
346
+ app: HookApp;
347
+ /** The signed-in user with the role they got (and the provider's display name, if any). */
348
+ user: EndUser & {
349
+ name?: string;
350
+ };
351
+ /** `email` (the e-mail code) or the provider id. */
352
+ provider: string;
353
+ /** The first sign-in of this user to the app. */
354
+ isNew: boolean;
355
+ db: DB;
356
+ log: Logger;
357
+ }
358
+ /** An observer of successful sign-ins (the `auth.signedIn` slot): run after the session exists, cut off after 5 s, errors logged. */
359
+ interface AuthSignedInObserver {
360
+ /** Names the observer in logs (unique within the slot). */
361
+ id: string;
362
+ onSignIn(event: AuthSignInEvent): Promise<void> | void;
363
+ }
364
+ /** Type a provider contribution (identity at run time). */
365
+ declare function defineAuthProvider<Config>(provider: AuthProvider<Config>): AuthProvider<Config>;
366
+ /** Type a sign-in observer contribution (identity at run time). */
367
+ declare function defineSignInObserver(observer: AuthSignedInObserver): AuthSignedInObserver;
368
+ /** The `auth.provider` slot's schema (the contribution as `auth` gets it; unknown keys kept). */
369
+ declare const authProviderSchema: ZodType<AuthProvider>;
370
+ /** The `auth.signedIn` slot's schema. */
371
+ declare const authSignedInObserverSchema: ZodType<AuthSignedInObserver>;
372
+ /** What `auth` accepts from `callback()` (the address normalized to lower case). */
373
+ declare const authIdentitySchema: z.ZodObject<{
374
+ issuer: z.ZodString;
375
+ subject: z.ZodString;
376
+ email: z.ZodPipe<z.ZodString, z.ZodEmail>;
377
+ emailVerified: z.ZodBoolean;
378
+ name: z.ZodOptional<z.ZodString>;
379
+ }, z.core.$strip>;
380
+
381
+ /**
382
+ * The ONE error shape every module route answers with (§3.5):
383
+ * `{ error, message, details?, hint }` + an HTTP status.
384
+ * `hint` links the agent to the documentation — by default the module's own
385
+ * skill, `skill_info('<module>')`. Handlers throw ModuleError; anything else
386
+ * becomes a 500 `internal_error` without internals.
387
+ */
388
+ type ModuleErrorCode = 'invalid_request' | 'unauthorized' | 'forbidden' | 'not_found' | 'module_not_enabled' | 'method_not_allowed' | 'payload_too_large' | 'unsupported_media_type' | 'rate_limited' | 'limit_exceeded' | 'quota_exceeded' | 'conflict' | 'csrf_rejected' | 'password_required' | 'unavailable' | 'internal_error' | (string & {});
389
+ /** Every code the platform answers module routes with (each has an agent-dx catalogue entry). */
390
+ declare const MODULE_ERROR_CODES: readonly string[];
391
+ /**
392
+ * Every code of the CORE error catalogue (`@drobek/agent-dx`
393
+ * `ERROR_CATALOGUE`, code-shaped entries): the module-route codes above plus
394
+ * the MCP tool, compile and OAuth codes. A module may not declare one of
395
+ * these in `errors`, and a module route may answer any of them. A unit test
396
+ * in @drobek/mcp keeps this list equal to the catalogue.
397
+ */
398
+ declare const CORE_ERROR_CODES: readonly string[];
399
+ interface ModuleErrorBody {
400
+ error: string;
401
+ message: string;
402
+ details?: unknown;
403
+ hint?: string;
404
+ }
405
+ /**
406
+ * Cross-instance brand: the dashboard's Vite SSR runner and the module
407
+ * processes can each hold their own copy of this class, and subclasses
408
+ * (`DataError`, …) rename `name`, so neither `instanceof` nor the name
409
+ * identifies a module error reliably. `Symbol.for` is shared per realm.
410
+ */
411
+ declare const MODULE_ERROR_BRAND: unique symbol;
412
+ declare class ModuleError extends Error {
413
+ readonly [MODULE_ERROR_BRAND]: true;
414
+ readonly code: ModuleErrorCode;
415
+ readonly status: number;
416
+ readonly details?: unknown;
417
+ readonly hint?: string;
418
+ readonly headers: Record<string, string>;
419
+ constructor(code: ModuleErrorCode, message: string, opts?: {
420
+ status?: number;
421
+ details?: unknown;
422
+ hint?: string;
423
+ headers?: Record<string, string>;
424
+ });
425
+ body(defaultHint?: string): ModuleErrorBody;
426
+ }
427
+ /**
428
+ * ModuleError by shape, not by class identity: a third-party module may bundle
429
+ * its own copy of this package, so `instanceof` is not enough.
430
+ */
431
+ declare function isModuleError(err: unknown): err is ModuleError;
432
+ /**
433
+ * NSO-346: an opt-in module (`availability: 'opt-in'`) that is not enabled
434
+ * for the app's workspace — the module route (404), configure_module and the
435
+ * owner's confirm answer this.
436
+ */
437
+ declare function moduleNotEnabled(name: string): ModuleError;
438
+ /** `skill_info('<name>')` — the hint every module error carries by default. */
439
+ declare function skillHint(name?: string): string;
440
+ /** zod issues → `[{ path: 'a.b[0]', message }]` (the agent-facing field paths). */
441
+ declare function issuePaths(issues: ReadonlyArray<{
442
+ path: ReadonlyArray<PropertyKey>;
443
+ message: string;
444
+ }>): {
445
+ path: string;
446
+ message: string;
447
+ }[];
448
+
449
+ /**
450
+ * The ONE rule evaluator every module uses (§5.0) — a pure function with
451
+ * table tests. A rule is a `|`-separated disjunction of principals:
452
+ *
453
+ * public — anyone, signed in or not
454
+ * user — any end user signed in to this app
455
+ * owner — a signed-in user whose id equals the record's owner (`_owner`)
456
+ * admin — a signed-in user with role admin
457
+ * none — nobody (the operation is closed)
458
+ *
459
+ * Outcome: `{ ok: true }`, or 401 (anonymous and a sign-in could help) / 403
460
+ * (signed in but not allowed, or the rule admits nobody). An unknown token
461
+ * makes the rule invalid — `parseRule` reports it; `decideAccess` fails closed
462
+ * (403) on it.
463
+ */
464
+
465
+ declare const RULE_TOKENS: readonly ["public", "user", "owner", "admin", "none"];
466
+ type RuleToken = (typeof RULE_TOKENS)[number];
467
+ /** The tokens of `rule`, or null when it contains an unknown/empty token. */
468
+ declare function parseRule(rule: Rule): RuleToken[] | null;
469
+ declare function isValidRule(rule: unknown): rule is Rule;
470
+ /** Does `rule` let anyone in (`public`)? — the usual confirmRequired trigger. */
471
+ declare function ruleIsPublic(rule: Rule): boolean;
472
+ declare function decideAccess(rule: Rule, principal: Principal, ownerId?: string | null): AccessDecision;
473
+
474
+ declare function mergePatch(target: unknown, patch: unknown): unknown;
475
+ /** Structural equality of two JSON values (key order does not matter). */
476
+ declare function jsonEqual(a: unknown, b: unknown): boolean;
477
+
478
+ declare class Lru<V> {
479
+ readonly maxEntries: number;
480
+ private readonly map;
481
+ constructor(maxEntries: number);
482
+ get(key: string): V | undefined;
483
+ set(key: string, value: V): void;
484
+ clear(): void;
485
+ get size(): number;
486
+ }
487
+ /** JSON with object keys sorted at every level (so key order never changes the key). */
488
+ declare function stableJson(value: unknown): string;
489
+ /** A short content key of a JSON value: sha256 of its stable JSON. */
490
+ declare function jsonKey(value: unknown): string;
491
+
492
+ declare const LIMITS_CACHE_TTL_SEC = 60;
493
+ declare const LIMITS_SIGNATURE_HEADER = "X-Drobek-Signature";
494
+ declare const LIMITS_TIMESTAMP_HEADER = "X-Drobek-Timestamp";
495
+ /**
496
+ * A catalogue entry: a module's limit, or a core limit that may take 0 (= off)
497
+ * and/or has a ceiling (`max`, the opt-in pseudo-limits: 0/1).
498
+ */
499
+ type CatalogueLimit = ModuleLimit & {
500
+ allowZero?: boolean;
501
+ max?: number;
502
+ };
503
+ /** NSO-346: the pseudo-limit that enables an opt-in module for a workspace. */
504
+ declare function moduleEnabledLimitName(module: string): string;
505
+ /**
506
+ * The limits core enforces itself (NSO-329) — same env / provider mechanics
507
+ * as module limits, so a plan can set them per workspace. The SaaS limits
508
+ * provider mirrors this list (docs/MODULES.md "Limits"). DOMAINS_MAX_PER_APP's
509
+ * default must equal @drobek/domains DEFAULT_DOMAINS_MAX_PER_APP (guarded by
510
+ * a test in @drobek/mcp, which depends on both).
511
+ */
512
+ declare const CORE_LIMITS: readonly CatalogueLimit[];
513
+ interface LimitsProvider {
514
+ forWorkspace(workspaceId: string): Promise<Limits>;
515
+ /** The env-level values (no workspace): what skill_info documents. */
516
+ defaults(): Limits;
517
+ /**
518
+ * NSO-346: only the values the limits provider's plan sets for the workspace
519
+ * (validated, known names only) — null without a provider, or while it is
520
+ * unavailable. Tells an explicit plan value from the env default.
521
+ */
522
+ fromPlan?(workspaceId: string): Promise<Limits | null>;
523
+ }
524
+ type RedisLike$1 = {
525
+ get(key: string): Promise<string | null>;
526
+ set(key: string, value: string, mode: 'EX', seconds: number): Promise<unknown>;
527
+ };
528
+ type FetchLike = (url: string, init: {
529
+ headers: Record<string, string>;
530
+ signal: AbortSignal;
531
+ }) => Promise<{
532
+ ok: boolean;
533
+ status: number;
534
+ json(): Promise<unknown>;
535
+ }>;
536
+ interface LimitsProviderOptions {
537
+ catalogue: readonly CatalogueLimit[];
538
+ env?: NodeJS.ProcessEnv;
539
+ redis?: () => RedisLike$1;
540
+ fetch?: FetchLike;
541
+ log?: Logger;
542
+ now?: () => number;
543
+ }
544
+ /** HMAC-SHA256 signature of one provider request (exported for the provider side + tests). */
545
+ declare function signLimitsRequest(secret: string, timestampSec: number, path: string): string;
546
+ /** Startup check: LIMITS_PROVIDER_URL needs an http(s) URL and a strong secret. */
547
+ declare function limitsProviderConfigError(env?: NodeJS.ProcessEnv): string | null;
548
+ declare function createLimitsProvider(opts: LimitsProviderOptions): LimitsProvider;
549
+
550
+ declare const END_USER_COOKIE = "__Host-drobek_eu";
551
+ declare const END_USER_COOKIE_INSECURE = "drobek_eu";
552
+ declare const END_USER_TOKEN_RE: RegExp;
553
+ /** Session lifetime: 30 days, rolling. */
554
+ declare const END_USER_SESSION_TTL_SEC: number;
555
+ /** `__Host-` + Secure end-user cookies: production, and any https apps origin. */
556
+ declare function endUserCookiesSecure(env?: NodeJS.ProcessEnv): boolean;
557
+ declare function endUserCookieName(secure: boolean): string;
558
+ /**
559
+ * The end-user Set-Cookie value: host-only (no Domain), Path=/, HttpOnly,
560
+ * SameSite=Lax; `__Host-` + Secure when `secure`. `clear` expires it.
561
+ */
562
+ declare function endUserCookieHeader(token: string, opts: {
563
+ maxAgeSec: number;
564
+ clear?: boolean;
565
+ }, secure: boolean): string;
566
+ declare function endUserSessionKey(appId: string, token: string): string;
567
+ declare function endUserEpochKey(appId: string): string;
568
+ interface EndUserSession {
569
+ id: string;
570
+ email: string;
571
+ role: 'user' | 'admin';
572
+ /** The app's epoch when the session was issued. */
573
+ epoch: number;
574
+ /** How it signed in (`email` or an auth provider id); absent = `email`. */
575
+ provider?: string;
576
+ /** A provider session's connection fingerprint (see EndUser.connection). */
577
+ connection?: string;
578
+ }
579
+ /** The Redis subset the session helpers use (ioredis-compatible). */
580
+ interface EndUserRedis {
581
+ mget(...keys: string[]): Promise<(string | null)[]>;
582
+ get(key: string): Promise<string | null>;
583
+ set(key: string, value: string, mode: 'EX', seconds: number): Promise<unknown>;
584
+ del(...keys: string[]): Promise<number>;
585
+ incr(key: string): Promise<number>;
586
+ }
587
+ /** The end-user token of a Cookie header, or null (never a malformed value). */
588
+ declare function readEndUserToken(cookieHeader: string | null, secure: boolean): string | null;
589
+ declare function parseEndUserSession(raw: string | null): EndUserSession | null;
590
+ /** The live session of `token` on `appId`: present, well-formed and of the current epoch — else null. */
591
+ declare function loadEndUserSession(redis: Pick<EndUserRedis, 'mget'>, appId: string, token: string): Promise<EndUserSession | null>;
592
+ /** Start a session for a verified end user of `appId` (current epoch, 30 days) → the token. */
593
+ declare function createEndUserSession(redis: Pick<EndUserRedis, 'get' | 'set'>, appId: string, user: {
594
+ id: string;
595
+ email: string;
596
+ role: 'user' | 'admin';
597
+ provider?: string;
598
+ connection?: string;
599
+ }): Promise<string>;
600
+ /** Roll a live session forward another 30 days (and store a changed role). */
601
+ declare function renewEndUserSession(redis: Pick<EndUserRedis, 'set'>, appId: string, token: string, session: EndUserSession): Promise<void>;
602
+ declare function destroyEndUserSession(redis: Pick<EndUserRedis, 'del'>, appId: string, token: string): Promise<void>;
603
+ /** Sign every end user of `appId` out (PHY-76 #9) → the new epoch. */
604
+ declare function revokeEndUserSessions(redis: Pick<EndUserRedis, 'incr'>, appId: string): Promise<number>;
605
+ /** Resolves the caller of one request on one app. */
606
+ type PrincipalResolver = (input: {
607
+ app: HookApp;
608
+ cookieHeader: string | null;
609
+ }) => Promise<Principal>;
610
+ /** Who the user of a live session is NOW (null → the session ends). See EndUserAuthority. */
611
+ type CurrentEndUser = (app: HookApp, user: EndUser) => Promise<EndUser | null>;
612
+ /**
613
+ * The default resolver: end-user cookie → a live session of THIS app (current
614
+ * epoch) → `current(app, user)` → user with the CURRENT role. Anything
615
+ * missing, malformed, foreign, revoked or unreadable → anon (fail closed); no
616
+ * `current` (no module owns sessions) → anon. `current` answering null ends
617
+ * the session (deleted from Redis); `current` throwing → anon for this
618
+ * request only.
619
+ */
620
+ declare function cookiePrincipalResolver(opts: {
621
+ redis: () => Pick<EndUserRedis, 'mget' | 'del'>;
622
+ secure: boolean;
623
+ current: CurrentEndUser | null;
624
+ }): PrincipalResolver;
625
+
626
+ declare const SECRET_NAME_RE: RegExp;
627
+ declare const SECRET_MAX_BYTES: number;
628
+ declare class SecretStoreError extends Error {
629
+ constructor(message: string);
630
+ }
631
+ /** Store (or rotate) one secret value. Dashboard-only; validates name + size. */
632
+ declare function setModuleSecret(input: {
633
+ appId: string;
634
+ module: string;
635
+ name: string;
636
+ value: string;
637
+ env?: NodeJS.ProcessEnv;
638
+ }): Promise<void>;
639
+ declare function deleteModuleSecret(appId: string, module: string, name: string): Promise<boolean>;
640
+ /** The plaintext (in memory only), or null when unset. Throws on a wrong/rotated KEK (fail closed). */
641
+ declare function getModuleSecret(appId: string, module: string, name: string, env?: NodeJS.ProcessEnv): Promise<string | null>;
642
+ /**
643
+ * When each of `names` was last set for this app + module (name → updated_at;
644
+ * unset names are absent) — for the dashboard's secrets form. Selects the name
645
+ * and the timestamp only: the ciphertext never leaves the database here.
646
+ */
647
+ declare function secretsStatus(appId: string, module: string, names: string[]): Promise<Map<string, Date>>;
648
+ /** Which of `names` are set for this app + module — names only, never values. */
649
+ declare function secretsSet(appId: string, module: string, names: string[]): Promise<Set<string>>;
650
+
651
+ /** A change held for the owner's confirmation (confirmRequired). */
652
+ interface PendingChange {
653
+ /** The RFC 7396 merge patch the agent sent — applied on top of the config at confirm time. */
654
+ patch: Record<string, unknown>;
655
+ /** What needs confirming, verbatim from the module's confirmRequired. */
656
+ changes: string[];
657
+ proposed_at: string;
658
+ /** The dashboard user whose agent proposed it. */
659
+ proposed_by: string | null;
660
+ /** Who may confirm it (NSO-322 H3; missing on older rows = `editor`). */
661
+ confirm_role?: ConfirmRole;
662
+ }
663
+ interface ConfigRow {
664
+ config: Record<string, unknown>;
665
+ pending: PendingChange | null;
666
+ updatedAt: Date | null;
667
+ }
668
+ type Tx = Parameters<Parameters<DB['transaction']>[0]>[0];
669
+ type Executor = DB | Tx;
670
+ declare function readConfigRow(appId: string, module: string, executor?: Executor): Promise<ConfigRow>;
671
+
672
+ /**
673
+ * The e-mail to an app's owners when an agent's configure_module leaves a
674
+ * change waiting for their confirmation (M2-02, NSO-291). Sent by the runtime
675
+ * through the normal module e-mail path (`{ appOwners: true }`, the mail
676
+ * authority = the `email` module, the operator-wide budgets), at most ONE per
677
+ * app per PENDING_MAIL_WINDOW_MS; each message lists EVERYTHING that is
678
+ * waiting for the app at that moment, so a burst of proposals is aggregated
679
+ * into one e-mail. The dashboard banner is the always-on signal.
680
+ */
681
+ /** At most one pending-change e-mail per app per hour. */
682
+ declare const PENDING_MAIL_WINDOW_MS: number;
683
+ /** The rate-limit key of the per-app pending-change e-mail (`drobek:rl:` + this). */
684
+ declare function pendingMailKey(appId: string): string;
685
+ interface PendingMailModule {
686
+ module: string;
687
+ changes: string[];
688
+ /** The dashboard page where the owner reviews the module's pending change. */
689
+ confirmUrl: string;
690
+ }
691
+ /** Subject + plain text of the pending-change e-mail (the layout escapes the text). */
692
+ declare function pendingMail(input: {
693
+ appName: string;
694
+ modules: PendingMailModule[];
695
+ }): {
696
+ subject: string;
697
+ text: string;
698
+ };
699
+
700
+ /** The platform skill (installed into the agent) — never listed by skill_info. */
701
+ declare const PLATFORM_SKILL_NAME = "drobek";
702
+ interface SkillEntry {
703
+ name: string;
704
+ kind: 'module' | 'general';
705
+ useWhen: string;
706
+ markdown: string;
707
+ module?: AnyModule;
708
+ }
709
+ /** Split `---\nkey: value\n---\n` frontmatter off a SKILL.md. */
710
+ declare function parseSkillMarkdown(text: string): {
711
+ meta: Record<string, string>;
712
+ body: string;
713
+ };
714
+ /**
715
+ * Where the general skills live: `DROBEK_SKILLS_DIR`, else `<cwd>/skills` (the
716
+ * production image copies the repo's `skills/` to `/app/skills`), else
717
+ * `<cwd>/../../skills` (the dev server runs in `apps/server`). null = none.
718
+ */
719
+ declare function generalSkillsDir(env?: NodeJS.ProcessEnv, cwd?: string): string | null;
720
+ /** Read every `<dir>/<name>/SKILL.md` except the platform skill. */
721
+ declare function loadGeneralSkills(dir: string | null, log?: Logger): SkillEntry[];
722
+ /**
723
+ * Backend packages an agent reaches for out of habit → the skill that does the
724
+ * job on drobek. An `unresolved_import` of one of them gets
725
+ * `hint: "skill_info('<skill>')"` (or `skill_info()` when this server has no
726
+ * such skill), so the agent learns the platform way instead of fighting the
727
+ * import map.
728
+ */
729
+ declare const BACKEND_IMPORT_SKILLS: ReadonlyArray<readonly [prefix: string, skill: string]>;
730
+ /** The skill for a backend-ish import specifier, or null. */
731
+ declare function skillForImport(specifier: string): string | null;
732
+
733
+ /**
734
+ * `ctx.email.send()` recipients (M1-01, M1-02, M1-04) — shared by the runtime
735
+ * and the test context so both resolve exactly the same way. A module never
736
+ * names an arbitrary address: it points at owner-confirmed config, at the
737
+ * signed-in end user, at the app's owners (verified drobek accounts), or
738
+ * (auth only) at the address being signed in with.
739
+ */
740
+
741
+ declare const EMAIL_RE: RegExp;
742
+ /** Longest message text a module may send (characters). */
743
+ declare const MAX_EMAIL_TEXT = 20000;
744
+ /** One header line: control characters (CR/LF/TAB, C0, DEL, U+2028/9) → a space, trimmed, capped. */
745
+ declare function sanitizeSubject(subject: unknown): string;
746
+ /** The message text, capped at MAX_EMAIL_TEXT characters (plain text; the layout escapes it). */
747
+ declare function capEmailText(text: unknown): string;
748
+ /** The recipient references of a message (one or several). */
749
+ declare function recipientRefs(to: EmailMessage['to']): EmailRecipient[];
750
+ /** A sign-in code, or a notification. */
751
+ declare function emailKind(to: EmailMessage['to']): EmailKind;
752
+ interface RecipientSources {
753
+ principal: Principal;
754
+ /** The sending module's effective config for this app. */
755
+ config: unknown;
756
+ /** The app's owners' addresses (only called when a message asks for them). */
757
+ owners?: () => Promise<string[]>;
758
+ }
759
+ /**
760
+ * The addresses a message goes to ([] = nobody), lowercased and de-duplicated.
761
+ * Throws `unauthorized` for `{ principal }` without a signed-in user.
762
+ */
763
+ declare function resolveRecipients(to: EmailMessage['to'], src: RecipientSources): Promise<string[]>;
764
+
765
+ /**
766
+ * `multipart/form-data` with TEXT fields only (M1-04) — what a plain
767
+ * `fetch(url, { body: new FormData(form) })` sends. The result is a plain
768
+ * object `{ name: value }`; a repeated name becomes an array of its values.
769
+ * A part with a `filename` (a file) is refused: modules take files through
770
+ * the files module, never inside another request.
771
+ *
772
+ * The body is already size-capped by the router before it gets here.
773
+ *
774
+ * `streamMultipartFile` is the other half: ONE file part, streamed (the
775
+ * `bodyTypes: ['file']` routes, i.e. the files module).
776
+ */
777
+
778
+ /** The `boundary` parameter of a multipart Content-Type, or null. */
779
+ declare function multipartBoundary(contentType: string | null): string | null;
780
+ /** Parse a text-only multipart body into `{ name: value | value[] }`. */
781
+ declare function parseMultipart(body: Buffer, contentType: string | null): Record<string, string | string[]>;
782
+ /** Bytes before the first boundary + all part headers + the text fields before the file. */
783
+ declare const MAX_FILE_HEAD_BYTES: number;
784
+ /**
785
+ * Parse a `multipart/form-data` body that carries ONE file part WITHOUT
786
+ * buffering the file: the part headers (and any text fields before the file,
787
+ * at most 64 KiB together) are read up front, then `stream` yields the file's
788
+ * bytes as they arrive. A delimiter split across chunks is handled by holding
789
+ * back the last `boundary.length + 6` bytes. After the file only the closing
790
+ * delimiter may follow (a second part → `invalid_request` from the stream).
791
+ * Leaving the stream early — or finishing it — returns the source iterator,
792
+ * which lets the adapter discard the rest of the request.
793
+ */
794
+ declare function streamMultipartFile(source: AsyncIterable<Buffer>, contentType: string | null): Promise<UploadedFile>;
795
+
796
+ /**
797
+ * ModuleRouter (M1-01): the route table of one module and the request
798
+ * pipeline every module route goes through, in this order —
799
+ *
800
+ * 1. match method + path (404 / 405);
801
+ * 2. CSRF guard for mutations: an `Origin`, when present, must be the app
802
+ * host itself (`null` and foreign origins → 403); with the default
803
+ * `csrf: 'sdk-header'` also `X-Drobek-SDK: 1`;
804
+ * 3. the caller (principal) and this app's config → the route `rule`
805
+ * (401 / 403);
806
+ * 4. rate limit (429 `rate_limited`, Retry-After);
807
+ * 5. body: JSON (or, when the route accepts it, text-only
808
+ * multipart/form-data, or the raw bytes), size-capped, then the route's
809
+ * zod schema; query too (400 `invalid_request` with
810
+ * `details: [{ path, message }]`) — a `bodyTypes: ['file']` route
811
+ * instead streams its one file through `req.file()` (the unread rest is
812
+ * discarded after the handler);
813
+ * 6. the handler → JSON (or `respond(...)`), `Cache-Control: no-store`.
814
+ *
815
+ * Every failure answers the uniform `{ error, message, details?, hint }`.
816
+ */
817
+
818
+ declare const DEFAULT_MAX_BODY_BYTES: number;
819
+ declare const SDK_HEADER = "x-drobek-sdk";
820
+ /** What the pipeline needs from the HTTP request (framework-free). */
821
+ interface PipelineRequest {
822
+ method: string;
823
+ path: string;
824
+ query: string;
825
+ header(name: string): string | null;
826
+ /** Every header, lower-cased names (absent → ModuleRequest.headers() answers {}). */
827
+ headers?(): Record<string, string>;
828
+ clientIp: string | null;
829
+ /** The raw body up to `limit` bytes; 'too_large' past it. */
830
+ readBody(limit: number): Promise<Buffer | 'too_large' | null>;
831
+ /**
832
+ * The raw body as a stream, uncapped (`bodyTypes: ['file']` routes — the
833
+ * handler caps it). Call it at most once. `return()` abandons the rest: the
834
+ * adapter discards it without buffering. Adapters without it fall back to
835
+ * `readBody` (tests).
836
+ */
837
+ bodyStream?(): AsyncIterableIterator<Buffer>;
838
+ }
839
+ interface PipelineResult {
840
+ status: number;
841
+ headers: Record<string, string>;
842
+ /** A Node Readable is streamed by the adapter (and destroyed unread for HEAD). */
843
+ body: Buffer | string | Readable | null;
844
+ }
845
+ /** A Node Readable (a streamed response body)? */
846
+ declare function isReadable(v: unknown): v is Readable;
847
+
848
+ /**
849
+ * The streamed body of a CSV export (NSO-323 M5): CSV lines in, CRLF-terminated
850
+ * text chunks of about `chunkChars` out — so an export (the data module's
851
+ * `export.csv` on the app host, the dashboard's Data tab) holds one chunk in
852
+ * memory, never the whole file. `Readable.from(csvChunks(lines))` is a module
853
+ * route's streamed response body.
854
+ */
855
+ declare function csvChunks(lines: AsyncIterable<string>, chunkChars?: number): AsyncGenerator<string>;
856
+
857
+ /** Names a module may not take (they are path segments of `/__drobek/…`). */
858
+ declare const RESERVED_MODULE_NAMES: Set<string>;
859
+ declare class ModuleLoadError extends Error {
860
+ constructor(message: string);
861
+ }
862
+ /** `DROBEK_MODULES` → trimmed, de-duplicated entries (order kept). */
863
+ declare function parseModuleList(raw: string | undefined): string[];
864
+ /** The package an entry of DROBEK_MODULES refers to (rules 2 + 3 above). */
865
+ declare function packageNameFor(entry: string): string;
866
+ interface ResolveOptions {
867
+ /** Directory whose package.json the third-party modules are dependencies of. */
868
+ root?: string;
869
+ /** Test seam: import by specifier. */
870
+ importer?: (specifier: string) => Promise<unknown>;
871
+ /** Start-up warnings (a module without `contract`, an unused DROBEK_MODULE_<NAME>_DEFAULTS). */
872
+ log?: Logger;
873
+ /** The operator's modules directory (default: DROBEK_MODULES_DIR, else /data/modules). */
874
+ modulesDir?: string;
875
+ }
876
+ /** Where a module was loaded from: the modules directory or the server's own dependencies. */
877
+ type ModuleSource = 'builtin' | 'dir';
878
+ interface ModuleOrigin {
879
+ source: ModuleSource;
880
+ /** The install prefix `<DROBEK_MODULES_DIR>/<name>` of a dir module (null: builtin). Logs only — never served. */
881
+ path: string | null;
882
+ }
883
+ /** One module's contribution to a slot, as the slot's schema parsed it. */
884
+ interface SlotContribution {
885
+ /** The contributing module. */
886
+ module: string;
887
+ value: unknown;
888
+ }
889
+ /**
890
+ * Every `contributes` of the active modules, checked against the slots they
891
+ * target and grouped by slot (each list in module order; every declared slot
892
+ * present, [] without contributions). Refuses the start on a contribution to
893
+ * a slot no active module declares, one that fails the slot's schema, and two
894
+ * contributions with the same value of the slot's `unique` key.
895
+ */
896
+ declare function collectContributions(modules: AnyModule[]): Map<string, SlotContribution[]>;
897
+ /**
898
+ * A slot host's module as composed from the contributions (`compose`): the
899
+ * parts it returns replace the declared ones (validated like a declared
900
+ * module: a zod configSchema, defaults that pass it, UPPER_SNAKE unique
901
+ * secret names). A module without `compose` is returned as is. Throws
902
+ * ModuleLoadError naming the module.
903
+ */
904
+ declare function composeModule(m: AnyModule, contributions: <T = unknown>(slot: string) => T[]): AnyModule;
905
+ /** An error code may be declared by one active module only (a clash refuses the start). */
906
+ declare function checkErrorCodes(modules: AnyModule[]): void;
907
+ /** The env var that overrides a module's config defaults: `DROBEK_MODULE_<NAME>_DEFAULTS`. */
908
+ declare function moduleDefaultsEnvName(name: string): string;
909
+ /**
910
+ * The config defaults of `m` on this server: its `configDefaults` with the
911
+ * operator's `DROBEK_MODULE_<NAME>_DEFAULTS` (a JSON merge patch) applied —
912
+ * validated by the module's configSchema (else ModuleLoadError with the issue
913
+ * paths).
914
+ */
915
+ declare function effectiveConfigDefaults(m: AnyModule, env?: NodeJS.ProcessEnv): unknown;
916
+ /**
917
+ * Every rule ACROSS the active modules (one owner per authority, `requires`,
918
+ * limit names, error codes, slots and contributions), the slot hosts'
919
+ * `compose`, then the operator's config-defaults overrides. Returns the
920
+ * modules composed and with their effective `configDefaults` (a module with
921
+ * neither is returned as is), in the same order. Throws ModuleLoadError.
922
+ */
923
+ declare function checkModuleSet(modules: AnyModule[], env?: NodeJS.ProcessEnv, log?: Logger): AnyModule[];
924
+ /**
925
+ * The one active module that owns app e-mail (`mail`), or null. Two would
926
+ * apply two policies to one message: refused at start.
927
+ */
928
+ declare function mailAuthorityOf(modules: AnyModule[]): AnyModule | null;
929
+ /**
930
+ * The one active module that stores the app's records (`records`), or null.
931
+ * query_data and the dashboard's data browser must read ONE store: two are
932
+ * refused at start.
933
+ */
934
+ declare function recordsAuthorityOf(modules: AnyModule[]): AnyModule | null;
935
+ /** The one active module that stores form submissions (`submissions`), or null (two refuse the start). */
936
+ declare function submissionsAuthorityOf(modules: AnyModule[]): AnyModule | null;
937
+ /** The one active module that stores end-user uploads (`files`), or null (two refuse the start). */
938
+ declare function filesAuthorityOf(modules: AnyModule[]): AnyModule | null;
939
+ /** Every module's `requires` must be active too (a clear start error names what to add). */
940
+ declare function checkRequires(modules: AnyModule[]): void;
941
+ /**
942
+ * The one active module that owns end-user sessions (`endUsers`), or null.
943
+ * Two owners would disagree about who is signed in: refused at start.
944
+ */
945
+ declare function endUserAuthorityOf(modules: AnyModule[]): AnyModule | null;
946
+ /** The active modules and where each came from (by module name). */
947
+ interface LoadedModules {
948
+ modules: AnyModule[];
949
+ origins: Record<string, ModuleOrigin>;
950
+ }
951
+ /**
952
+ * Resolve + validate every DROBEK_MODULES entry (no duplicates, a short name
953
+ * loads a module of that name; a dir module also passes checkDirModule), then
954
+ * check the set (`checkModuleSet`). Returns the modules in DROBEK_MODULES
955
+ * order, with their effective config defaults, and their origins.
956
+ */
957
+ declare function loadModuleSet(env?: NodeJS.ProcessEnv, opts?: ResolveOptions): Promise<LoadedModules>;
958
+ /** `loadModuleSet` without the origins: the active modules in DROBEK_MODULES order. */
959
+ declare function loadModules(env?: NodeJS.ProcessEnv, opts?: ResolveOptions): Promise<AnyModule[]>;
960
+
961
+ /** `DROBEK_MODULES_DIR` when unset (the `modules_data` volume in the image). */
962
+ declare const DEFAULT_MODULES_DIR = "/data/modules";
963
+
964
+ type RateLimiter = (key: string, max: number, windowMs: number) => Promise<RateLimitResult>;
965
+ /** One outgoing message to ONE address (recipients never see each other). */
966
+ interface TransportMessage extends MailEnvelope {
967
+ to: string;
968
+ subject: string;
969
+ text: string;
970
+ }
971
+ interface EmailTransport {
972
+ send(message: TransportMessage): Promise<void>;
973
+ }
974
+ interface RuntimeDeps {
975
+ env: NodeJS.ProcessEnv;
976
+ log: Logger;
977
+ db: () => DB;
978
+ limits: LimitsProvider;
979
+ principal: PrincipalResolver;
980
+ rateLimit: RateLimiter;
981
+ email: EmailTransport;
982
+ /** The operator-wide hourly cap on module e-mail (auto-pause). */
983
+ mailGuard: MailGuard;
984
+ /**
985
+ * Count one response of a MATCHED route of an active module (M1-07 — get_logs
986
+ * `requests`; never a 429, an unknown route or a wrong method). Best-effort:
987
+ * never awaited by the response, errors dropped.
988
+ */
989
+ requestStats?: (appId: string, module: string, status: number) => Promise<void> | void;
990
+ }
991
+ type RedisLike = ReturnType<typeof getRedis>;
992
+ /** Fixed-window counter in Redis (atomic INCR + PEXPIRE), `drobek:rl:` keys. */
993
+ declare function redisRateLimiter(redis: () => Pick<RedisLike, 'incr' | 'pexpire' | 'ttl'>): RateLimiter;
994
+ /** In-process fixed-window counter (tests; `now` is the clock seam). */
995
+ declare function memoryRateLimiter(now?: () => number): RateLimiter & {
996
+ reset(): void;
997
+ };
998
+ /** Plain-text mail through the operator's transport (@drobek/email: SMTP or Resend per EMAIL_TRANSPORT — the transport of the login codes too). */
999
+ declare function smtpEmailTransport(log: Logger, env?: NodeJS.ProcessEnv): EmailTransport;
1000
+ /** The addresses of an app's owners: the editors and workspace-admins of its workspace. */
1001
+ declare function appOwnerEmails(db: DB, workspaceId: string): Promise<string[]>;
1002
+ /** What serving hands over for a `/__drobek/*` request. */
1003
+ interface PlatformRequest extends PipelineRequest {
1004
+ }
1005
+ /** The app behind the host (resolved + visibility-gated by @drobek/serving). */
1006
+ interface PlatformApp {
1007
+ id: string;
1008
+ slug: string;
1009
+ workspaceId: string;
1010
+ }
1011
+ interface SkillListItem {
1012
+ name: string;
1013
+ use_when: string;
1014
+ /** NSO-346: only on an opt-in module's skill — it is active only for the workspaces it is enabled for. */
1015
+ availability?: 'opt-in';
1016
+ /** NSO-346: skill_info() with an app, on an opt-in module's skill: active for the app's workspace. */
1017
+ enabled_for_workspace?: boolean;
1018
+ }
1019
+ /** NSO-346: what decides that an opt-in module is on or off for a workspace. */
1020
+ type WorkspaceModuleSource = 'dashboard' | 'plan' | 'env';
1021
+ /** NSO-346: one opt-in module for one workspace (the dashboard's Workspace → Modules). */
1022
+ interface WorkspaceModuleState {
1023
+ name: string;
1024
+ version: string;
1025
+ use_when: string;
1026
+ /** Active for the workspace now. */
1027
+ enabled: boolean;
1028
+ /**
1029
+ * What decides it: the limits provider's plan (`MODULE_ENABLED_<NAME>`, 1 or
1030
+ * 0), the operator's env (`MODULE_ENABLED_<NAME>=1`), or the super-admin's
1031
+ * switch; null = nothing enables it.
1032
+ */
1033
+ source: WorkspaceModuleSource | null;
1034
+ /** The super-admin's switch (a `workspace_modules` row) — a plan value overrides it. */
1035
+ dashboard: {
1036
+ enabled: boolean;
1037
+ enabled_by: string | null;
1038
+ enabled_at: string | null;
1039
+ };
1040
+ }
1041
+ interface SkillInfo {
1042
+ name: string;
1043
+ kind: 'module' | 'general';
1044
+ use_when: string;
1045
+ content: string;
1046
+ sdk?: {
1047
+ import: string;
1048
+ types: string;
1049
+ inline?: {
1050
+ import: string;
1051
+ types: string;
1052
+ };
1053
+ };
1054
+ config?: {
1055
+ schema: unknown;
1056
+ defaults: unknown;
1057
+ confirm_required: string;
1058
+ };
1059
+ limits?: {
1060
+ name: string;
1061
+ value: number;
1062
+ meaning: string;
1063
+ }[];
1064
+ secrets?: {
1065
+ name: string;
1066
+ description: string;
1067
+ required: boolean;
1068
+ }[];
1069
+ /** A module's own error codes (the core ones are in the catalogue of /llms-full.txt); [] when it declares none. */
1070
+ errors?: ModuleErrorDoc[];
1071
+ /** Who the module is for: every workspace (`default`) or the workspaces it is enabled for (`opt-in`). */
1072
+ availability?: ModuleAvailability;
1073
+ /** A module's own semver, where it was loaded from, the contract range it declares (null: none) and the modules it requires. */
1074
+ version?: string;
1075
+ source?: ModuleSource;
1076
+ contract?: string | null;
1077
+ requires?: string[];
1078
+ /** The extension points the module offers (with who contributes) and its own contributions to other modules' slots. */
1079
+ slots?: ModuleFacts['slots'];
1080
+ contributes?: ModuleFacts['contributes'];
1081
+ /** NSO-346: skill_info with an app: whether this opt-in module is active for the app's workspace. */
1082
+ enabled_for_workspace?: boolean;
1083
+ }
1084
+ /**
1085
+ * The operator-facing facts of one active module, app-independent (NSO-347):
1086
+ * the dashboard's workspace Modules page and the module page's "About", and
1087
+ * the same fields in `skill_info(name)`. Never a path on disk, never a
1088
+ * secret, never an app's config.
1089
+ */
1090
+ interface ModuleFacts {
1091
+ name: string;
1092
+ version: string;
1093
+ source: ModuleSource;
1094
+ /** The contract range the module declares (`contract`), null when it declares none. */
1095
+ contract: string | null;
1096
+ availability: ModuleAvailability;
1097
+ requires: string[];
1098
+ /** Its slots: name, what a contribution does, the unique key, and the active modules contributing (with their unique value). */
1099
+ slots: {
1100
+ name: string;
1101
+ description: string;
1102
+ unique: string | null;
1103
+ contributions: {
1104
+ module: string;
1105
+ key: string | null;
1106
+ }[];
1107
+ }[];
1108
+ /** Its contributions to other modules' slots: the slot, the host module and the contribution's unique value (null without one). */
1109
+ contributes: {
1110
+ slot: string;
1111
+ host: string;
1112
+ key: string | null;
1113
+ }[];
1114
+ /** The limits it declares with the server's values (env or the module default; a workspace's plan may differ: workspaceLimits). */
1115
+ limits: {
1116
+ name: string;
1117
+ default: number;
1118
+ meaning: string;
1119
+ }[];
1120
+ /** Its own error codes (beyond the core catalogue). */
1121
+ errors: ModuleErrorDoc[];
1122
+ /** The dedicated dashboard editor its config declares it fits (`dashboard.editor`), null for the generic form. */
1123
+ editor: ModuleDashboardEditor | null;
1124
+ }
1125
+ /** One module's own error codes (a section of the error catalogue). */
1126
+ interface ModuleErrorSection {
1127
+ module: string;
1128
+ errors: ModuleErrorDoc[];
1129
+ }
1130
+ interface AppModuleState {
1131
+ /** NSO-346: active for the app's workspace (always true for a default module). */
1132
+ enabled: boolean;
1133
+ configured: boolean;
1134
+ config: unknown;
1135
+ pending: boolean;
1136
+ pending_confirmation?: string[];
1137
+ /** Only a workspace admin can confirm the pending change (absent: any editor). */
1138
+ confirm_role?: 'admin';
1139
+ confirm_url?: string;
1140
+ secrets?: {
1141
+ name: string;
1142
+ hasSecret: boolean;
1143
+ }[];
1144
+ /** The module's `appInfo` (secret-free), when it declares one. */
1145
+ info?: Record<string, unknown>;
1146
+ }
1147
+ interface ConfigureInput {
1148
+ app: {
1149
+ id: string;
1150
+ slug: string;
1151
+ workspaceId: string;
1152
+ workspaceSlug: string;
1153
+ };
1154
+ module: string;
1155
+ patch: unknown;
1156
+ /** The dashboard user whose agent calls (audit + pending.proposed_by). */
1157
+ actorUserId: string;
1158
+ /**
1159
+ * Who changes the config: `mcp` (default — configure_module, audit actor
1160
+ * `agent`, the owners get the pending-change e-mail) or `web` (the owner's
1161
+ * own dashboard form, audit actor `user`, no e-mail: they are looking at it).
1162
+ */
1163
+ surface?: 'mcp' | 'web';
1164
+ }
1165
+ interface ConfigureResult {
1166
+ module: string;
1167
+ applied: boolean;
1168
+ /** The effective config now in force. */
1169
+ config: unknown;
1170
+ /** Changes waiting for the owner ([] when nothing waits). */
1171
+ pending_confirmation: string[];
1172
+ /** Only a workspace admin can confirm the pending change (absent: any editor). */
1173
+ confirm_role?: 'admin';
1174
+ confirm_url?: string;
1175
+ secrets_missing?: string[];
1176
+ unchanged?: true;
1177
+ /** The module's `appInfo` for the config now in force (secret-free), when it declares one. */
1178
+ info?: Record<string, unknown>;
1179
+ }
1180
+ /** The records store of one app (the module that declares `records`, bound to the app's config). */
1181
+ interface BoundRecords {
1182
+ /** The module that stores the records (e.g. `data`). */
1183
+ module: string;
1184
+ collections(): Promise<RecordsCollection[]>;
1185
+ query(query: RecordsQuery): Promise<RecordsPage>;
1186
+ get(collection: string, id: string): Promise<Record<string, unknown> | null>;
1187
+ remove(collection: string, id: string): Promise<boolean>;
1188
+ csv(query: Omit<RecordsQuery, 'limit' | 'cursor'>): AsyncIterable<string>;
1189
+ /** Replace a record's fields (owner edit); null when it does not exist. `unavailable` when the module cannot. */
1190
+ update(collection: string, id: string, fields: Record<string, unknown>): Promise<Record<string, unknown> | null>;
1191
+ /** All-or-nothing CSV import (see RecordsAuthority.importCsv). */
1192
+ importCsv(collection: string, csv: string): Promise<{
1193
+ imported: number;
1194
+ }>;
1195
+ /**
1196
+ * Delete a collection: its records and its declaration in the module's
1197
+ * config, in ONE transaction under the config lock; audited
1198
+ * `data.collection_delete` (actor user).
1199
+ */
1200
+ dropCollection(collection: string, actorUserId: string): Promise<{
1201
+ records: number;
1202
+ }>;
1203
+ /** Collections with records but no declaration (orphans); [] when the module cannot tell. */
1204
+ orphans(): Promise<{
1205
+ name: string;
1206
+ records: number;
1207
+ }[]>;
1208
+ /**
1209
+ * Purge the records of an orphan collection, under the config lock (so it
1210
+ * cannot be declared meanwhile); audited `data.collection.purge` (actor user).
1211
+ */
1212
+ purgeOrphan(collection: string, actorUserId: string): Promise<{
1213
+ records: number;
1214
+ }>;
1215
+ }
1216
+ /** The end users of one app (the module that declares `endUsers`, bound to the app's config). */
1217
+ interface BoundEndUsers {
1218
+ module: string;
1219
+ list(query: EndUserListQuery): Promise<EndUserPage>;
1220
+ /** Change a role; a config change is applied under the config lock and audited `end_users.role` (actor user). */
1221
+ setRole(id: string, role: 'user' | 'admin', actorUserId: string): Promise<EndUserRecord>;
1222
+ setDisabled(id: string, disabled: boolean): Promise<EndUserRecord | null>;
1223
+ }
1224
+ /** The form submissions of one app (the module that declares `submissions`). */
1225
+ interface BoundSubmissions {
1226
+ module: string;
1227
+ forms(): Promise<{
1228
+ name: string;
1229
+ submissions: number;
1230
+ }[]>;
1231
+ list(query: SubmissionsQuery): Promise<SubmissionsPage>;
1232
+ csv(query: Omit<SubmissionsQuery, 'limit' | 'cursor'>): AsyncIterable<string>;
1233
+ remove(id: string): Promise<boolean>;
1234
+ }
1235
+ /** The end-user uploads of one app (the module that declares `files`). */
1236
+ interface BoundFiles {
1237
+ module: string;
1238
+ list(query: {
1239
+ limit?: number;
1240
+ cursor?: string | null;
1241
+ }): Promise<OwnerFilesPage>;
1242
+ open(id: string): Promise<{
1243
+ file: OwnerFile;
1244
+ stream: Readable;
1245
+ } | null>;
1246
+ remove(id: string): Promise<boolean>;
1247
+ }
1248
+ /** A pending change as the dashboard shows it (M2-02). */
1249
+ interface PendingView {
1250
+ /** What needs confirming, verbatim from the module's confirmRequired. */
1251
+ changes: string[];
1252
+ proposed_at: string;
1253
+ proposed_by: string | null;
1254
+ /** Who may confirm it: any editor, or only a workspace admin (NSO-322 H3). */
1255
+ confirm_role: ConfirmRole;
1256
+ /** The effective config once confirmed (null when it no longer validates). */
1257
+ after: unknown;
1258
+ /** Why confirming would fail now (the pending change no longer fits the config). */
1259
+ invalid?: {
1260
+ path: string;
1261
+ message: string;
1262
+ }[];
1263
+ }
1264
+ /** One module of one app, for the owner's dashboard (never a secret value). */
1265
+ interface ModuleDashboardView {
1266
+ name: string;
1267
+ version: string;
1268
+ use_when: string;
1269
+ /** The config's JSON Schema (zod → JSON Schema, input side), null when not representable. */
1270
+ schema: unknown;
1271
+ defaults: unknown;
1272
+ /** What was set (sparse merge patch over the defaults). */
1273
+ stored: Record<string, unknown>;
1274
+ /** The effective config in force. */
1275
+ config: unknown;
1276
+ pending: PendingView | null;
1277
+ /** Declared secrets: names, docs and whether/when they are set — never a value. */
1278
+ secrets: {
1279
+ name: string;
1280
+ description: string;
1281
+ required: boolean;
1282
+ hasSecret: boolean;
1283
+ updated_at: string | null;
1284
+ }[];
1285
+ /** The operations the module's rules cover (module.rules.ops). */
1286
+ ops: Record<string, string>;
1287
+ /** Whether some changes of this module wait for the owner (it declares confirmRequired). */
1288
+ confirms: boolean;
1289
+ /** The module's secret-free appInfo. */
1290
+ info?: Record<string, unknown>;
1291
+ /** Who the module is for (`default`: every workspace; `opt-in`: the workspaces it is enabled for). */
1292
+ availability: ModuleAvailability;
1293
+ /** The dedicated config editor the module declares (`dashboard.editor`), or null for the generic form. */
1294
+ editor: ModuleDashboardEditor | null;
1295
+ /** NSO-347 — the module's facts for the page's "About this module": where it came from, its contract range, requires, slots, contributions and error codes. */
1296
+ source: ModuleSource;
1297
+ contract: string | null;
1298
+ requires: string[];
1299
+ slots: ModuleFacts['slots'];
1300
+ contributes: ModuleFacts['contributes'];
1301
+ errors: ModuleErrorDoc[];
1302
+ /** NSO-346: active for the app's workspace (an opt-in module may not be — the page then shows no form). */
1303
+ enabled: boolean;
1304
+ }
1305
+ interface DecisionInput {
1306
+ app: {
1307
+ id: string;
1308
+ slug: string;
1309
+ workspaceId: string;
1310
+ };
1311
+ module: string;
1312
+ userId: string;
1313
+ /**
1314
+ * The decider's role in the app's workspace: `admin` = workspace admin or
1315
+ * super-admin. Default `editor` — a change that needs an admin is refused.
1316
+ */
1317
+ role?: ConfirmRole;
1318
+ }
1319
+ /** The dashboard page where the owner confirms a module change (M2-02 serves it). */
1320
+ declare function confirmUrl(env: NodeJS.ProcessEnv, workspaceSlug: string, appSlug: string, module: string): string;
1321
+ /** One active module as /healthz, /api/version and the start log show it — never a path on disk. */
1322
+ interface ModuleSummary {
1323
+ name: string;
1324
+ version: string;
1325
+ source: ModuleSource;
1326
+ /** The contract range the module declares (null: none). */
1327
+ contract: string | null;
1328
+ }
1329
+ declare class ModuleRuntime {
1330
+ readonly modules: AnyModule[];
1331
+ readonly skills: SkillEntry[];
1332
+ readonly sdk: SdkBundle;
1333
+ readonly deps: RuntimeDeps;
1334
+ private readonly routes;
1335
+ private readonly byName;
1336
+ /** Slot name → the contributions to it, in module order (checked at load). */
1337
+ private readonly slotContributions;
1338
+ /** Module → the error codes its routes may answer (the core catalogue + its own `errors`). */
1339
+ private readonly errorCodes;
1340
+ /**
1341
+ * Effective configs by (module, content of the stored config) — NSO-322 H1:
1342
+ * every module request used to re-run configSchema.safeParse (for data: an
1343
+ * ajv compile per collection). Keyed on the stored JSON itself, so a
1344
+ * configure / confirm (or a write by another process) is a new key and can
1345
+ * never serve a stale config.
1346
+ */
1347
+ private readonly configMemo;
1348
+ /** Module name → where it was loaded from (absent: builtin). */
1349
+ private readonly origins;
1350
+ constructor(input: {
1351
+ modules: AnyModule[];
1352
+ skills: SkillEntry[];
1353
+ sdk: SdkBundle;
1354
+ deps: RuntimeDeps;
1355
+ origins?: Record<string, ModuleOrigin>;
1356
+ });
1357
+ get(name: string): AnyModule | undefined;
1358
+ /**
1359
+ * Where the active module `name` was loaded from — the single place that
1360
+ * answers it (summary, moduleFacts, skill_info): `dir` when the
1361
+ * DROBEK_MODULES_DIR loader found it in the operator's directory (its
1362
+ * ModuleOrigin), `builtin` otherwise. Never a path on disk.
1363
+ */
1364
+ sourceOf(name: string): ModuleSource;
1365
+ /** The active modules (name, version, source, contract) in DROBEK_MODULES order — for /healthz and /api/version. */
1366
+ summary(): ModuleSummary[];
1367
+ /**
1368
+ * The contributions of the active modules to `slot`, in DROBEK_MODULES
1369
+ * order, as the slot's schema parsed them ([] when nobody contributes or no
1370
+ * active module declares the slot). With `enabled` (a workspace's
1371
+ * enabledModules()) only those of the modules in it (NSO-360).
1372
+ */
1373
+ contributions<T = unknown>(slot: string, enabled?: ReadonlySet<string>): T[];
1374
+ /** The services a hook (or a request context) gets; `enabled` scopes the contributions to a workspace's modules. */
1375
+ services(enabled?: ReadonlySet<string>): ModuleServices;
1376
+ /** The default (not opt-in) active modules: what is on when no workspace is known. */
1377
+ private defaultModules;
1378
+ /** The active modules' own error codes, one section per module that declares any (DROBEK_MODULES order). */
1379
+ errorCatalogue(): ModuleErrorSection[];
1380
+ /** The facts of one active module (see ModuleFacts), or null when no such module is active. */
1381
+ moduleFacts(name: string): ModuleFacts | null;
1382
+ /** The facts of every active module, in DROBEK_MODULES order. */
1383
+ moduleFactsList(): ModuleFacts[];
1384
+ /** The active modules declared `availability: 'opt-in'`. */
1385
+ private optInModules;
1386
+ /**
1387
+ * Whether and why each opt-in module is on for `workspaceId`: the plan
1388
+ * (limits provider, cached 60 s like every limit) wins in both directions,
1389
+ * then the env value 1, then the super-admin's `workspace_modules` row
1390
+ * (a primary-key read, so a toggle applies at once).
1391
+ */
1392
+ private optInStates;
1393
+ /** NSO-346: is module `name` active for `workspaceId`? A default module always is; an unknown one never. */
1394
+ isEnabled(workspaceId: string, name: string): Promise<boolean>;
1395
+ /**
1396
+ * NSO-346: the names of the active modules that are on for `workspaceId`
1397
+ * (every default module + the enabled opt-in ones). No I/O when the server
1398
+ * has no opt-in module. Compute it once per request and pass it on.
1399
+ */
1400
+ enabledModules(workspaceId: string): Promise<ReadonlySet<string>>;
1401
+ /** NSO-346: every opt-in module with its state for `workspaceId` (the dashboard's Workspace → Modules). */
1402
+ workspaceModules(workspaceId: string): Promise<WorkspaceModuleState[]>;
1403
+ /**
1404
+ * NSO-346: a super-admin turns an opt-in module on or off for a workspace
1405
+ * (the dashboard's switch — the caller has checked super-admin). Audited
1406
+ * `module.workspace_enable` / `module.workspace_disable` (meta: module) when
1407
+ * it changes anything. A plan value (`MODULE_ENABLED_<NAME>`) still wins.
1408
+ */
1409
+ setWorkspaceModule(input: {
1410
+ workspaceId: string;
1411
+ module: string;
1412
+ enabled: boolean;
1413
+ actorUserId: string;
1414
+ }): Promise<{
1415
+ changed: boolean;
1416
+ }>;
1417
+ /**
1418
+ * Who the user of a live session of `app` is NOW, according to the module
1419
+ * that owns end-user sessions (its `endUsers.current` with this app's
1420
+ * effective config and the contributions of the modules on for its
1421
+ * workspace) — null when no active module owns sessions, it is off for the
1422
+ * workspace, or the user may not be signed in any more.
1423
+ */
1424
+ currentEndUser(app: HookApp, user: EndUser): Promise<EndUser | null>;
1425
+ /**
1426
+ * The IdP callback of the end-user sign-in providers
1427
+ * (`/__drobek/auth/callback/:provider` on the dashboard host): hands the
1428
+ * request to the `endUsers` authority's `callback` with services that
1429
+ * know no app yet — a callback-scoped rate limiter, the server's default
1430
+ * limits, the default modules' contributions and `app(id)`, which the
1431
+ * authority calls once its own signed state named the app (it carries the
1432
+ * contributions of the app's workspace). No authority / no callback → 404
1433
+ * page; a throw → a generic 500 page (logged without the error's text).
1434
+ */
1435
+ endUserCallback(input: Omit<EndUserCallbackInput, 'services'>): Promise<EndUserCallbackResult>;
1436
+ /** A live app (not deleted, not taken down) as the end-user authority sees it in a callback, or null. */
1437
+ private callbackApp;
1438
+ /**
1439
+ * The app's records store (the module that declares `records`, e.g. data),
1440
+ * bound to the app's effective config — null when no active module stores
1441
+ * records. For the OWNER's view (query_data, the dashboard): the caller has
1442
+ * authorized a drobek account for the app already.
1443
+ */
1444
+ records(app: HookApp): Promise<BoundRecords | null>;
1445
+ /**
1446
+ * The effective limits of one workspace (NSO-329): the env defaults, or the
1447
+ * limits provider's plan — CORE_LIMITS (APPS_MAX_PER_WORKSPACE,
1448
+ * DOMAINS_MAX_PER_APP) and every module limit. For core callers: create_app
1449
+ * and the dashboard's custom domains.
1450
+ */
1451
+ workspaceLimits(workspaceId: string): Promise<Limits>;
1452
+ /** The OwnerView of `app` for an owner-facing authority (limits of the app's workspace, loaded once). */
1453
+ private ownerView;
1454
+ /**
1455
+ * An OWNER's change of module `m`'s config for `app` (the dashboard, never
1456
+ * an agent): under the config lock, `fn` gets the effective config and the
1457
+ * transaction, does its own writes in it and returns a merge patch (or
1458
+ * null); the patched config must pass configSchema. No confirmation: the
1459
+ * owner is the one who confirms. A pending agent change stays pending.
1460
+ */
1461
+ private ownerConfigChange;
1462
+ /** The app's end users (the module that declares `endUsers`), or null. */
1463
+ endUsers(app: HookApp): Promise<BoundEndUsers | null>;
1464
+ /** The app's form submissions (the module that declares `submissions`), or null. */
1465
+ submissions(app: HookApp): Promise<BoundSubmissions | null>;
1466
+ /** The app's end-user uploads (the module that declares `files`), or null. */
1467
+ files(app: HookApp): Promise<BoundFiles | null>;
1468
+ /**
1469
+ * The skills list (skill_info(), create_app, get_app). Without `enabled`:
1470
+ * every skill, an opt-in module's marked `availability: 'opt-in'`. With the
1471
+ * app workspace's `enabled` set (enabledModules): the opt-in modules that
1472
+ * are off for it are left out (NSO-346).
1473
+ */
1474
+ skillList(enabled?: ReadonlySet<string>): SkillListItem[];
1475
+ /** One skill's documentation, or null (the caller answers not_found + the list). */
1476
+ skillInfo(name: string): SkillInfo | null;
1477
+ /**
1478
+ * The hint for a compile message: backend imports point at the skill that
1479
+ * replaces them — only when that skill is on the server and, given the app
1480
+ * workspace's `enabled` set, its module is active there (NSO-346).
1481
+ */
1482
+ compileHint(msg: {
1483
+ code?: string;
1484
+ specifier?: string;
1485
+ }, enabled?: ReadonlySet<string>): string | undefined;
1486
+ /** The config's JSON Schema for forms (the INPUT side: defaulted keys are optional), or null. */
1487
+ configJsonSchema(m: AnyModule): unknown;
1488
+ /** The modules of `appId` with a change waiting for the owner (active modules only). */
1489
+ pendingSummary(appId: string): Promise<{
1490
+ module: string;
1491
+ changes: string[];
1492
+ }[]>;
1493
+ /**
1494
+ * One module of one app for the owner's dashboard (M2-02): schema, defaults,
1495
+ * stored + effective config, the pending change with its effective result,
1496
+ * the declared secrets with hasSecret / updated_at (NEVER a value), the rule
1497
+ * operations and the module's secret-free appInfo.
1498
+ */
1499
+ moduleView(app: HookApp, name: string): Promise<ModuleDashboardView>;
1500
+ /**
1501
+ * The effective config of `module` for a stored (sparse) config. Memoized
1502
+ * by content (configMemo); every caller gets its own copy, so a handler that
1503
+ * mutates `ctx.config` cannot change what the next request sees. A stored
1504
+ * config that fails configSchema is served through the module's
1505
+ * `salvageConfig` when it has one, else as the defaults.
1506
+ */
1507
+ effectiveConfig(m: AnyModule, stored: Record<string, unknown>): unknown;
1508
+ /**
1509
+ * The secret-free `appInfo` of module `m` for `app` (undefined when the
1510
+ * module has none, or it failed — logged, never fatal).
1511
+ */
1512
+ private appInfo;
1513
+ /**
1514
+ * get_app's `modules`. Pass the app (not only its id) to include each
1515
+ * module's `info` (it needs the app's workspace). `enabled` = the app
1516
+ * workspace's enabledModules() when the caller has it already.
1517
+ */
1518
+ appModules(app: string | HookApp, confirmLink?: (module: string) => string, enabled?: ReadonlySet<string>): Promise<Record<string, AppModuleState>>;
1519
+ /** The workspace of an app id ('' when it does not exist: then only default modules are on). */
1520
+ private workspaceOf;
1521
+ private requireModule;
1522
+ private validateConfig;
1523
+ private missingSecrets;
1524
+ /** configure_module: validate a partial config; apply it, or hold it for the owner. */
1525
+ configure(input: ConfigureInput): Promise<ConfigureResult>;
1526
+ /**
1527
+ * E-mail the app's owners that changes wait for their confirmation (M2-02):
1528
+ * through the module e-mail path (`{ appOwners: true }`, the mail authority
1529
+ * — the `email` module — and the operator-wide budgets), at most once per
1530
+ * app per hour (PENDING_MAIL_WINDOW_MS), listing every module that waits.
1531
+ * Never fails the configure call: without a mail authority nothing is sent
1532
+ * (the dashboard banner shows it), and any refusal is logged.
1533
+ */
1534
+ private notifyPendingOwners;
1535
+ /** The owner confirms the pending change (dashboard): apply it on top of the current config. */
1536
+ confirm(input: DecisionInput): Promise<{
1537
+ module: string;
1538
+ config: unknown;
1539
+ confirmed: string[];
1540
+ }>;
1541
+ /** The owner rejects the pending change (dashboard): drop it, config unchanged. */
1542
+ reject(input: DecisionInput): Promise<{
1543
+ module: string;
1544
+ config: unknown;
1545
+ rejected: string[];
1546
+ }>;
1547
+ runHook(hook: 'onAppCreate' | 'onAppDelete', app: HookApp): Promise<void>;
1548
+ runHook(hook: 'onPublish', app: HookApp & {
1549
+ version: number;
1550
+ }): Promise<void>;
1551
+ /**
1552
+ * `ctx.email.send` of module `m` for `app`: refuse `{ signInAddress }` from
1553
+ * any module but the sign-in provider (`endUsers`), resolve the allowed recipients,
1554
+ * refuse while module e-mail is paused, let the mail authority (the `email`
1555
+ * module) apply the app's policy and envelope, count against the
1556
+ * operator-wide hourly budget of the message's class (sign-in codes vs
1557
+ * notifications, plus the app's and its workspace's shares — mail-guard.ts),
1558
+ * send one message per address, audit.
1559
+ */
1560
+ private sendEmail;
1561
+ /** Answer one `/__drobek/*` request of `app` (never throws). */
1562
+ handle(req: PlatformRequest, app: PlatformApp): Promise<PipelineResult>;
1563
+ /** get_logs `requests`: one response of a matched route of an active module (fire-and-forget). */
1564
+ private countRequest;
1565
+ private dispatch;
1566
+ private serveSdk;
1567
+ private serveBeaconScript;
1568
+ /** A platform script: `?v=<hash>` → immutable, otherwise revalidated by ETag. */
1569
+ private serveScript;
1570
+ private context;
1571
+ }
1572
+ interface LoadRuntimeOptions extends ResolveOptions {
1573
+ env?: NodeJS.ProcessEnv;
1574
+ log?: Logger;
1575
+ /** Use these modules instead of resolving DROBEK_MODULES (tests). */
1576
+ modules?: AnyModule[];
1577
+ /** Where the given `modules` came from (tests; absent: builtin). Ignored without `modules`. */
1578
+ origins?: Record<string, ModuleOrigin>;
1579
+ /** Directory of the general skills (default: generalSkillsDir()). null = none. */
1580
+ skillsDir?: string | null;
1581
+ /** Apply a module's migrations (default: runJournalMigrations against DATABASE_URL). */
1582
+ migrate?: (folder: string, table: string) => Promise<void>;
1583
+ deps?: Partial<RuntimeDeps>;
1584
+ }
1585
+ declare function moduleJournalTable(name: string): string;
1586
+ /** Load the active modules, apply their migrations, compose the SDK, collect the skills. */
1587
+ declare function loadModuleRuntime(opts?: LoadRuntimeOptions): Promise<ModuleRuntime>;
1588
+ /**
1589
+ * The process-wide runtime: loaded once from the environment on first use
1590
+ * (the server entry awaits it at boot, so a bad DROBEK_MODULES stops the
1591
+ * start). Shared through globalThis with the dev server's Vite-loaded copy.
1592
+ */
1593
+ declare function moduleRuntime(opts?: LoadRuntimeOptions): Promise<ModuleRuntime>;
1594
+ /**
1595
+ * The active modules (name, version, source, contract) for `/healthz` and
1596
+ * `/api/version` — never a path. The server entry loads the runtime at boot,
1597
+ * so this only misses in tooling: then [].
1598
+ */
1599
+ declare function activeModules(): Promise<ModuleSummary[]>;
1600
+ /** Tests: install a runtime (or null to reset). */
1601
+ declare function setModuleRuntimeForTests(runtime: ModuleRuntime | null): void;
1602
+
1603
+ export { AUTH_PROVIDER_ID_RE, AUTH_RESERVED_CONFIG_KEYS, AccessDecision, AnyModule, BACKEND_IMPORT_SKILLS, CORE_ERROR_CODES, CORE_LIMITS, ConfirmRole, DB, DEFAULT_MAX_BODY_BYTES, DEFAULT_MODULES_DIR, EMAIL_PROVIDER_ID, EMAIL_RE, END_USER_COOKIE, END_USER_COOKIE_INSECURE, END_USER_SESSION_TTL_SEC, END_USER_TOKEN_RE, EmailKind, EmailMessage, EmailRecipient, EndUser, EndUserCallbackInput, EndUserCallbackResult, EndUserListQuery, EndUserPage, EndUserRecord, HookApp, LIMITS_CACHE_TTL_SEC, LIMITS_SIGNATURE_HEADER, LIMITS_TIMESTAMP_HEADER, Limits, Logger, Lru, MAX_EMAIL_TEXT, MAX_FILE_HEAD_BYTES, MODULE_ERROR_CODES, MailEnvelope, MailGuard, ModuleAvailability, ModuleDashboardEditor, ModuleError, ModuleErrorDoc, ModuleLimit, ModuleLoadError, ModuleRuntime, ModuleSecretDoc, ModuleServices, OwnerFile, OwnerFilesPage, PENDING_MAIL_WINDOW_MS, PLATFORM_SKILL_NAME, Principal, RESERVED_MODULE_NAMES, RULE_TOKENS, RateLimitResult, RecordsCollection, RecordsPage, RecordsQuery, Rule, SDK_HEADER, SECRET_MAX_BYTES, SECRET_NAME_RE, SdkBundle, SecretStoreError, SubmissionsPage, SubmissionsQuery, UploadedFile, activeModules, appOwnerEmails, authIdentitySchema, authProviderSchema, authSignedInObserverSchema, capEmailText, checkErrorCodes, checkModuleSet, checkRequires, collectContributions, composeModule, confirmUrl, cookiePrincipalResolver, createEndUserSession, createLimitsProvider, csvChunks, decideAccess, defineAuthProvider, defineSignInObserver, deleteModuleSecret, destroyEndUserSession, effectiveConfigDefaults, emailKind, endUserAuthorityOf, endUserCookieHeader, endUserCookieName, endUserCookiesSecure, endUserEpochKey, endUserSessionKey, filesAuthorityOf, generalSkillsDir, getModuleSecret, hasControlBytes, isModuleError, isReadable, isValidRule, issuePaths, jsonEqual, jsonKey, limitsProviderConfigError, loadEndUserSession, loadGeneralSkills, loadModuleRuntime, loadModuleSet, loadModules, looksLikeSvg, mailAuthorityOf, memoryRateLimiter, mergePatch, moduleDefaultsEnvName, moduleEnabledLimitName, moduleJournalTable, moduleNotEnabled, moduleRuntime, multipartBoundary, packageNameFor, parseEndUserSession, parseModuleList, parseMultipart, parseRule, parseSkillMarkdown, pendingMail, pendingMailKey, perIpLimitKey, readConfigRow, readEndUserToken, recipientRefs, recordsAuthorityOf, redisRateLimiter, renewEndUserSession, resolveRecipients, revokeEndUserSessions, ruleIsPublic, sanitizeSubject, secretsSet, secretsStatus, setModuleRuntimeForTests, setModuleSecret, signLimitsRequest, skillForImport, skillHint, smtpEmailTransport, sniffSignature, stableJson, streamMultipartFile, submissionsAuthorityOf };
1604
+ export type { AppModuleState, AuthIdentity, AuthProvider, AuthProviderBeginInput, AuthProviderBeginResult, AuthProviderCallbackInput, AuthProviderSecretDoc, AuthProviderSecrets, AuthSignInEvent, AuthSignedInObserver, BoundEndUsers, BoundFiles, BoundRecords, BoundSubmissions, ConfigRow, ConfigureInput, ConfigureResult, CurrentEndUser, DecisionInput, EmailTransport, EndUserRedis, EndUserSession, LimitsProvider, LoadRuntimeOptions, LoadedModules, ModuleDashboardView, ModuleErrorBody, ModuleErrorCode, ModuleErrorSection, ModuleFacts, ModuleOrigin, ModuleSource, ModuleSummary, PendingChange, PendingMailModule, PendingView, PipelineRequest, PipelineResult, PlatformApp, PlatformRequest, PrincipalResolver, RateLimiter, RecipientSources, RuntimeDeps, SkillEntry, SkillInfo, SkillListItem, SlotContribution, SniffedType, TransportMessage, WorkspaceModuleSource, WorkspaceModuleState };