@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,2097 @@
1
+ import { Readable } from 'node:stream';
2
+ import { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
3
+ import { ZodType } from 'zod';
4
+
5
+ /**
6
+ * drobek core schema — day-one set (M0 walking skeleton).
7
+ *
8
+ * Identity + tenancy + apps and their immutable versions (M0-02, NSO-281),
9
+ * plus the tables each later unit added (oauth_*, upstreams, audit_log,
10
+ * app_errors, app_daily_stats, app_compiles, module_request_stats,
11
+ * module_configs, module_secrets, workspace_modules, abuse_reports,
12
+ * app_assets, app_version_assets). Platform
13
+ * modules own their tables (`mod_<name>_*`, their own migration journals).
14
+ *
15
+ * Hard constraints encoded here:
16
+ * - App file bytes live IN Postgres (`blobs.bytes`, content-addressed by
17
+ * sha256, deduplicated across versions and apps). Apps are small source
18
+ * trees (≤ 5 MiB per version, enforced by @drobek/compile), so one database
19
+ * is the whole state — no disk volume to back up separately.
20
+ * - A version is immutable; publish/restore only move `apps.published_version_id`
21
+ * or add a new version.
22
+ * - PHY-101: soft-delete tombstone (`deleted_at`) on apps.
23
+ * - super-admin is a GLOBAL env flag (SUPERADMIN_EMAIL), NOT a membership
24
+ * role — hence memberships only knows workspace-admin/editor/viewer.
25
+ */
26
+
27
+
28
+ /** Postgres `bytea` ↔ Node `Buffer`. */
29
+ declare const bytea = customType<{ data: Buffer; driverData: Buffer | Uint8Array }>({
30
+ dataType: () => 'bytea',
31
+ fromDriver: (v) => (Buffer.isBuffer(v) ? v : Buffer.from(v)),
32
+ });
33
+
34
+ /**
35
+ * Who performed an audited action (PHY-85). `agent` = an MCP tool call made on
36
+ * behalf of a connected coding agent (OAuth token); `user` = a human dashboard/
37
+ * web session action. Derived SERVER-SIDE at the call site — never from client
38
+ * input — so attribution is not spoofable. Defaults to `user` so the existing
39
+ * deploy/rollback rows migrate additively without loss. `end_user` (M1-01) = a
40
+ * signed-in end user of an app acting through a platform module on the apps
41
+ * origin (`ctx.audit`); such rows carry no `actor_user_id` (end users are not
42
+ * drobek users).
43
+ */
44
+ declare const auditActorKindEnum = pgEnum('audit_actor_kind', ['user', 'agent', 'end_user']);
45
+
46
+ // ── Enums ────────────────────────────────────────────────────────────────────
47
+
48
+ declare const workspaceKindEnum = pgEnum('workspace_kind', ['personal', 'team']);
49
+
50
+ declare const membershipRoleEnum = pgEnum('membership_role', [
51
+ 'workspace-admin',
52
+ 'editor',
53
+ 'viewer',
54
+ ]);
55
+
56
+ /**
57
+ * App visibility gate, checked on the app host BEFORE any file is read
58
+ * (M0-06): `public` or `password`. The former `team` value is gone — app hosts
59
+ * never read the dashboard session, so "members only" cannot be enforced
60
+ * there (migration 0010 turns `team` apps into `password` apps).
61
+ */
62
+ declare const appVisibilityEnum = pgEnum('app_visibility', ['public', 'password']);
63
+
64
+ declare const appStatusEnum = pgEnum('app_status', ['live', 'hibernated']);
65
+
66
+ /**
67
+ * Result of compiling a version (@drobek/compile). `pending` = stored but not
68
+ * compiled yet; only an `ok` version can be published.
69
+ */
70
+ declare const compileStatusEnum = pgEnum('compile_status', ['pending', 'ok', 'error']);
71
+
72
+ /** `source` = written by the agent; `built` = compiler output (served first). */
73
+ declare const versionFileKindEnum = pgEnum('version_file_kind', ['source', 'built']);
74
+
75
+ // ── Identity ─────────────────────────────────────────────────────────────────
76
+
77
+ declare const users = pgTable('users', {
78
+ id: text('id')
79
+ .primaryKey()
80
+ .$defaultFn(() => createId()),
81
+ email: text('email').notNull().unique(),
82
+ /** Google OIDC subject (`sub`); links the OAuth account to this user (U3). */
83
+ googleSub: text('google_sub').unique(),
84
+ createdAt: timestamp('created_at').notNull().defaultNow(),
85
+ });
86
+
87
+ // ── Tenancy ──────────────────────────────────────────────────────────────────
88
+
89
+ declare const workspaces = pgTable('workspaces', {
90
+ id: text('id')
91
+ .primaryKey()
92
+ .$defaultFn(() => createId()),
93
+ kind: workspaceKindEnum('kind').notNull(),
94
+ slug: text('slug').notNull().unique(),
95
+ name: text('name').notNull(),
96
+ createdAt: timestamp('created_at').notNull().defaultNow(),
97
+ /** NSO-366: PUBLISH_APPROVAL=approval — a super-admin allowed this workspace to publish (null = not approved). */
98
+ publishApprovedAt: timestamp('publish_approved_at', { withTimezone: true }),
99
+ publishApprovedBy: text('publish_approved_by').references((): AnyPgColumn => users.id, { onDelete: 'set null' }),
100
+ /** NSO-366: the last approval request e-mailed to the operator (dedupe: one per 24 h until decided). */
101
+ publishApprovalRequestedAt: timestamp('publish_approval_requested_at', { withTimezone: true }),
102
+ publishApprovalRequestedBy: text('publish_approval_requested_by').references((): AnyPgColumn => users.id, {
103
+ onDelete: 'set null',
104
+ }),
105
+ /** NSO-366: a super-admin turned publishing off for this workspace (refused in every PUBLISH_APPROVAL mode; null = not blocked). */
106
+ publishBlockedAt: timestamp('publish_blocked_at', { withTimezone: true }),
107
+ publishBlockedBy: text('publish_blocked_by').references((): AnyPgColumn => users.id, { onDelete: 'set null' }),
108
+ });
109
+
110
+ declare const memberships = pgTable(
111
+ 'memberships',
112
+ {
113
+ userId: text('user_id')
114
+ .notNull()
115
+ .references(() => users.id, { onDelete: 'cascade' }),
116
+ workspaceId: text('workspace_id')
117
+ .notNull()
118
+ .references(() => workspaces.id, { onDelete: 'cascade' }),
119
+ role: membershipRoleEnum('role').notNull(),
120
+ createdAt: timestamp('created_at').notNull().defaultNow(),
121
+ },
122
+ (t) => [primaryKey({ columns: [t.userId, t.workspaceId] })]
123
+ );
124
+
125
+ // ── Apps & versions ──────────────────────────────────────────────────────────
126
+
127
+ declare const apps = pgTable(
128
+ 'apps',
129
+ {
130
+ id: text('id')
131
+ .primaryKey()
132
+ .$defaultFn(() => createId()),
133
+ workspaceId: text('workspace_id')
134
+ .notNull()
135
+ .references(() => workspaces.id),
136
+ /** GLOBALLY unique — it is the app's host label `<slug>.<APPS_DOMAIN>`. */
137
+ slug: text('slug').notNull(),
138
+ /** Human-readable name given at create_app (null for pre-M0-05 apps → show the slug). */
139
+ name: text('name'),
140
+ /** The version served on the production host; publish/rollback move it. */
141
+ publishedVersionId: text('published_version_id').references(
142
+ (): AnyPgColumn => appVersions.id,
143
+ { onDelete: 'set null' }
144
+ ),
145
+ visibility: appVisibilityEnum('visibility').notNull().default('public'),
146
+ /** Only set when visibility = 'password'. */
147
+ passwordHash: text('password_hash'),
148
+ /**
149
+ * CSP `frame-ancestors` override for the app's hosts (M0-06), e.g.
150
+ * `https://intranet.example.com`. Null → `'none'` (no embedding). Edited
151
+ * in the dashboard later (M2); validated before it reaches a header.
152
+ */
153
+ frameAncestors: text('frame_ancestors'),
154
+ status: appStatusEnum('status').notNull().default('live'),
155
+ createdAt: timestamp('created_at').notNull().defaultNow(),
156
+ /**
157
+ * Soft delete (PHY-101; dashboard delete NSO-288): a deleted app is
158
+ * invisible everywhere (dashboard, MCP, app hosts). It keeps its slug for
159
+ * 30 days; then @drobek/apps renames it to the tombstone
160
+ * `<slug>~deleted-<id>` so a new app can take the slug.
161
+ */
162
+ deletedAt: timestamp('deleted_at'),
163
+ /**
164
+ * Super-admin takedown (M4-02, NSO-293): the reason CATEGORY
165
+ * (`phishing` | `malware` | `spam` | `copyright` | `illegal` | `other`,
166
+ * validated by @drobek/apps). Non-null = locked: every host of the app
167
+ * answers 451, and writes / publish / restore / module config are refused
168
+ * with `app_locked_by_admin`. Only a super-admin restore clears it.
169
+ */
170
+ lockedReason: text('locked_reason'),
171
+ /**
172
+ * When the production host last started serving a version (NSO-340):
173
+ * every publish sets it, unpublish / takedown clear it. The public
174
+ * gallery lists the newest first.
175
+ */
176
+ publishedAt: timestamp('published_at'),
177
+ /**
178
+ * NSO-340: the owner's opt-in to the public gallery — changed in the
179
+ * dashboard by an editor+ of a PUBLISHED app, never through MCP.
180
+ * Unpublish / takedown turn it off. The gallery also filters at query
181
+ * time (published, not taken down, not deleted, not hidden).
182
+ */
183
+ galleryListed: boolean('gallery_listed').notNull().default(false),
184
+ /** NSO-340: the gallery's one-line public description (plain text, ≤ 160 chars). */
185
+ galleryDescription: text('gallery_description'),
186
+ /** NSO-340: a super-admin hid the gallery entry (non-null = hidden, whatever the owner sets). */
187
+ galleryHiddenAt: timestamp('gallery_hidden_at'),
188
+ },
189
+ (t) => [
190
+ uniqueIndex('apps_slug_uq').on(t.slug),
191
+ // The public gallery page reads listed apps newest-published first (NSO-340).
192
+ index('apps_gallery_idx').on(t.publishedAt.desc(), t.slug.desc()).where(sql`${t.galleryListed}`),
193
+ index('apps_workspace_idx').on(t.workspaceId),
194
+ // The slug-release sweep reads deleted apps only.
195
+ index('apps_deleted_at_idx').on(t.deletedAt).where(sql`${t.deletedAt} IS NOT NULL`),
196
+ // Grammar mirrored by @drobek/apps validateAppSlug (which adds reserved
197
+ // words). A deleted app may carry its released-slug tombstone instead (0016).
198
+ check(
199
+ 'apps_slug_format',
200
+ sql`(${t.slug} ~ '^[a-z0-9]+(-[a-z0-9]+)*$' AND char_length(${t.slug}) BETWEEN 3 AND 40) OR (${t.deletedAt} IS NOT NULL AND ${t.slug} ~ '^[a-z0-9]+(-[a-z0-9]+)*~deleted-[a-z0-9]+$')`
201
+ ),
202
+ ]
203
+ );
204
+
205
+ /** Content-addressed file bytes, shared by every version (and app) that uses them. */
206
+ declare const blobs = pgTable('blobs', {
207
+ sha256: text('sha256').primaryKey(),
208
+ bytes: bytea('bytes').notNull(),
209
+ size: integer('size').notNull(),
210
+ /** Refreshed whenever a new version references the blob (GC grace period). */
211
+ createdAt: timestamp('created_at').notNull().defaultNow(),
212
+ });
213
+
214
+ /** One immutable snapshot of an app's files; `number` counts up per app from 1. */
215
+ declare const appVersions = pgTable(
216
+ 'app_versions',
217
+ {
218
+ id: text('id')
219
+ .primaryKey()
220
+ .$defaultFn(() => createId()),
221
+ appId: text('app_id')
222
+ .notNull()
223
+ .references(() => apps.id),
224
+ number: integer('number').notNull(),
225
+ createdByUserId: text('created_by_user_id').references(() => users.id),
226
+ /** agent (MCP) vs user (dashboard) — server-derived, like audit_log. */
227
+ actorKind: auditActorKindEnum('actor_kind').notNull(),
228
+ /** The agent's one-line "why" for this change (shown in the history). */
229
+ reasoning: text('reasoning'),
230
+ compileStatus: compileStatusEnum('compile_status').notNull().default('pending'),
231
+ /** @drobek/compile messages when compile_status = 'error'. */
232
+ compileErrors: jsonb('compile_errors'),
233
+ /**
234
+ * NSO-362: set when a publish froze the app's assets for this version
235
+ * (its `app_version_assets` rows — possibly none); null = never frozen,
236
+ * or the snapshot was pruned.
237
+ */
238
+ assetsFrozenAt: timestamp('assets_frozen_at'),
239
+ createdAt: timestamp('created_at').notNull().defaultNow(),
240
+ },
241
+ (t) => [uniqueIndex('app_versions_app_number_uq').on(t.appId, t.number)]
242
+ );
243
+
244
+ /** A version's file list: path → blob. A path may exist once per kind. */
245
+ declare const versionFiles = pgTable(
246
+ 'version_files',
247
+ {
248
+ versionId: text('version_id')
249
+ .notNull()
250
+ .references(() => appVersions.id, { onDelete: 'cascade' }),
251
+ path: text('path').notNull(),
252
+ sha256: text('sha256')
253
+ .notNull()
254
+ .references(() => blobs.sha256),
255
+ size: integer('size').notNull(),
256
+ kind: versionFileKindEnum('kind').notNull(),
257
+ },
258
+ (t) => [
259
+ primaryKey({ columns: [t.versionId, t.kind, t.path] }),
260
+ index('version_files_sha256_idx').on(t.sha256),
261
+ ]
262
+ );
263
+
264
+ /**
265
+ * Append-only audit trail (U6/PHY-57; governance v1 PHY-85; TECHNICAL_DESIGN
266
+ * §1). Every security-relevant workspace mutation (deploy.activate,
267
+ * deploy.rollback, app.create, member.invite/accept/role_change, …) writes one
268
+ * immutable row. APPEND-ONLY: rows are never updated and never deleted except by
269
+ * the age-based retention prune (@drobek/audit).
270
+ *
271
+ * Attribution (PHY-85): `actor_user_id` is the acting user (nullable for system
272
+ * actions); `actor_kind` records whether that action came from a connected AGENT
273
+ * (an MCP tool call) or a human USER (dashboard/web) — both derived server-side.
274
+ *
275
+ * Subject: `subject_type` names the kind of thing acted on (app | member | …)
276
+ * and `target` holds its STABLE id (the app slug, the member user id, …). It is
277
+ * deliberately plain text with NO foreign key, so an audit row SURVIVES the
278
+ * deletion/tombstoning of its subject app/workspace (governance must outlive the
279
+ * resource). `meta` carries structured, secret-free, PII-free context only.
280
+ *
281
+ * `workspace_id` keeps the FK (audit is always read within a live workspace and
282
+ * workspaces are not deleted in v1); the subject app id is the one that must
283
+ * survive deletion, hence its text-not-FK treatment above.
284
+ */
285
+ declare const auditLog = pgTable(
286
+ 'audit_log',
287
+ {
288
+ id: text('id')
289
+ .primaryKey()
290
+ .$defaultFn(() => createId()),
291
+ workspaceId: text('workspace_id')
292
+ .notNull()
293
+ .references(() => workspaces.id),
294
+ actorUserId: text('actor_user_id').references(() => users.id),
295
+ /** agent (MCP tool) vs user (dashboard/web) — server-derived, not spoofable. */
296
+ actorKind: auditActorKindEnum('actor_kind').notNull().default('user'),
297
+ action: text('action').notNull(),
298
+ /** The KIND of subject acted on: 'app' | 'member' | … (nullable, forward-open). */
299
+ subjectType: text('subject_type'),
300
+ /** The subject's stable id (app slug, member user id, …) — text, NO FK. */
301
+ target: text('target'),
302
+ meta: jsonb('meta'),
303
+ createdAt: timestamp('created_at').notNull().defaultNow(),
304
+ },
305
+ (t) => [
306
+ // The Activity view reads by workspace, newest-first — index it.
307
+ index('audit_log_workspace_created_idx').on(t.workspaceId, t.createdAt),
308
+ ]
309
+ );
310
+
311
+ // ── MCP OAuth 2.1 Authorization Server (U5, PHY-71/PHY-53) ────────────────────
312
+ //
313
+ // drobek's web app is the OAuth 2.1 Authorization Server; mcp-server is the
314
+ // protected Resource Server. All opaque tokens/codes are stored SHA-256-hashed
315
+ // at rest (never the raw secret). PKCE S256 is mandatory. Tokens are
316
+ // bound to a USER (M0-04, NSO-282) — not to a workspace: every MCP tool call
317
+ // re-resolves the caller's membership in the workspace it targets — and carry
318
+ // the granted scope (`read` / `write` / `publish`) plus the RFC 8707
319
+ // `audience` the Resource Server validates. See @drobek/oauth.
320
+
321
+ /**
322
+ * Public PKCE clients — no client_secret. `source` = `dcr` for a Dynamic Client
323
+ * Registration row (random hex client_id) or `cimd` for a Client ID Metadata
324
+ * Document client (client_id = the https URL of its metadata; the row is an
325
+ * upserted mirror of the fetched document so codes/tokens can reference it).
326
+ * `last_used_at` is stamped when the user approves a grant for the client —
327
+ * a DCR row that never got one counts toward the unused-client cap.
328
+ */
329
+ declare const oauthClients = pgTable('oauth_clients', {
330
+ id: text('id')
331
+ .primaryKey()
332
+ .$defaultFn(() => createId()),
333
+ clientId: text('client_id').notNull().unique(),
334
+ clientName: text('client_name').notNull(),
335
+ /** Exact-match set — the authorize redirect_uri must equal one of these. */
336
+ redirectUris: text('redirect_uris').array().notNull(),
337
+ tokenEndpointAuthMethod: text('token_endpoint_auth_method')
338
+ .notNull()
339
+ .default('none'),
340
+ source: text('source').notNull().default('dcr'),
341
+ lastUsedAt: timestamp('last_used_at'),
342
+ createdAt: timestamp('created_at').notNull().defaultNow(),
343
+ });
344
+
345
+ /** Single-use PKCE authorization codes (~5 min TTL); consumed atomically. */
346
+ declare const oauthAuthorizationCodes = pgTable('oauth_authorization_codes', {
347
+ id: text('id')
348
+ .primaryKey()
349
+ .$defaultFn(() => createId()),
350
+ codeHash: text('code_hash').notNull().unique(),
351
+ /** Public client_id string the code was issued to (FK → oauth_clients). */
352
+ clientId: text('client_id')
353
+ .notNull()
354
+ .references(() => oauthClients.clientId, { onDelete: 'cascade' }),
355
+ userId: text('user_id')
356
+ .notNull()
357
+ .references(() => users.id, { onDelete: 'cascade' }),
358
+ redirectUri: text('redirect_uri').notNull(),
359
+ codeChallenge: text('code_challenge').notNull(),
360
+ codeChallengeMethod: text('code_challenge_method').notNull(),
361
+ scope: text('scope').notNull(),
362
+ /** RFC 8707 requested resource → becomes the access token audience. */
363
+ resource: text('resource').notNull(),
364
+ used: boolean('used').notNull().default(false),
365
+ expiresAt: timestamp('expires_at').notNull(),
366
+ createdAt: timestamp('created_at').notNull().defaultNow(),
367
+ });
368
+
369
+ /** Opaque access tokens (~1 h TTL), audience-bound (RFC 8707). */
370
+ declare const oauthAccessTokens = pgTable('oauth_access_tokens', {
371
+ id: text('id')
372
+ .primaryKey()
373
+ .$defaultFn(() => createId()),
374
+ tokenHash: text('token_hash').notNull().unique(),
375
+ userId: text('user_id')
376
+ .notNull()
377
+ .references(() => users.id, { onDelete: 'cascade' }),
378
+ oauthClientId: text('oauth_client_id').references(() => oauthClients.id, {
379
+ onDelete: 'set null',
380
+ }),
381
+ scope: text('scope').notNull(),
382
+ audience: text('audience').notNull(),
383
+ expiresAt: timestamp('expires_at').notNull(),
384
+ createdAt: timestamp('created_at').notNull().defaultNow(),
385
+ revokedAt: timestamp('revoked_at'),
386
+ });
387
+
388
+ /**
389
+ * Opaque refresh tokens (~30 day TTL) with ROTATION + reuse detection. On use:
390
+ * `used_at` is stamped and `rotated_to` points at the freshly-issued successor.
391
+ * Presenting a token whose `used_at` is already set is REUSE → the whole
392
+ * rotated_to lineage is invalidated and the grant's access tokens revoked.
393
+ */
394
+ declare const oauthRefreshTokens = pgTable('oauth_refresh_tokens', {
395
+ id: text('id')
396
+ .primaryKey()
397
+ .$defaultFn(() => createId()),
398
+ tokenHash: text('token_hash').notNull().unique(),
399
+ userId: text('user_id')
400
+ .notNull()
401
+ .references(() => users.id, { onDelete: 'cascade' }),
402
+ oauthClientId: text('oauth_client_id').references(() => oauthClients.id, {
403
+ onDelete: 'set null',
404
+ }),
405
+ scope: text('scope').notNull(),
406
+ audience: text('audience').notNull(),
407
+ /** Self-FK: the successor token minted when this one was rotated. */
408
+ rotatedTo: text('rotated_to').references(
409
+ (): AnyPgColumn => oauthRefreshTokens.id
410
+ ),
411
+ usedAt: timestamp('used_at'),
412
+ expiresAt: timestamp('expires_at').notNull(),
413
+ createdAt: timestamp('created_at').notNull().defaultNow(),
414
+ });
415
+
416
+ /**
417
+ * Personal API keys (M0-04, NSO-282): `drk_` + 32 base64url chars, an
418
+ * alternative Bearer for the same MCP Resource Server path (the prefix tells
419
+ * them apart). Bound to a user like an OAuth token, same scope vocabulary
420
+ * (`scopes` is space-delimited), no audience. Only the SHA-256 of the key is
421
+ * stored; the raw key is shown once at creation. `last_used_at` is refreshed
422
+ * at most once a minute; a set `revoked_at` rejects the key.
423
+ */
424
+ declare const apiKeys = pgTable('api_keys', {
425
+ id: text('id')
426
+ .primaryKey()
427
+ .$defaultFn(() => createId()),
428
+ userId: text('user_id')
429
+ .notNull()
430
+ .references(() => users.id, { onDelete: 'cascade' }),
431
+ name: text('name').notNull(),
432
+ keyHash: text('key_hash').notNull().unique(),
433
+ scopes: text('scopes').notNull(),
434
+ lastUsedAt: timestamp('last_used_at'),
435
+ revokedAt: timestamp('revoked_at'),
436
+ createdAt: timestamp('created_at').notNull().defaultNow(),
437
+ });
438
+
439
+ // ── Agent loop v1 — error beacon + serving signals (PHY-123, PHY-92 slice) ─────
440
+ //
441
+ // The observe half of the deploy→observe→fix loop. `app_errors` is a per-app
442
+ // RING BUFFER (capped count + age, oldest evicted by @drobek/insights on insert)
443
+ // of SANITIZED client errors ingested by the PUBLIC, UNAUTHENTICATED beacon —
444
+ // only message/stack/url/ua are stored, PII/secret-redacted + truncated; NEVER
445
+ // cookies/tokens. `app_daily_stats` is the durable per-app/day serving-signal
446
+ // roll-up (request volume / 5xx / 404s-by-path), fed from Redis hot counters by
447
+ // a light flush. Both are read-only to the app_errors/app_logs MCP tools + the
448
+ // dashboard Overview panels (viewer+; stored text is escaped on render).
449
+
450
+ declare const appErrorTypeEnum = pgEnum('app_error_type', [
451
+ 'error',
452
+ 'unhandledrejection',
453
+ ]);
454
+
455
+ declare const appErrors = pgTable(
456
+ 'app_errors',
457
+ {
458
+ id: text('id')
459
+ .primaryKey()
460
+ .$defaultFn(() => createId()),
461
+ appId: text('app_id')
462
+ .notNull()
463
+ .references(() => apps.id),
464
+ type: appErrorTypeEnum('type').notNull(),
465
+ /** Sanitized (redacted + truncated) error message. */
466
+ message: text('message').notNull(),
467
+ /** Sanitized stack, when the browser supplied one. */
468
+ stack: text('stack'),
469
+ /** Page URL the error fired on (sanitized). */
470
+ url: text('url').notNull(),
471
+ /** User-agent (sanitized), when present. */
472
+ ua: text('ua'),
473
+ /** Client-supplied event time (validated epoch-ms → timestamp); may skew. */
474
+ ts: timestamp('ts'),
475
+ /** sha256(message + stack head) — groups identical errors with counts. */
476
+ dedupKey: text('dedup_key').notNull(),
477
+ /** Server ingest time — the authoritative ordering + retention field. */
478
+ createdAt: timestamp('created_at').notNull().defaultNow(),
479
+ },
480
+ (t) => [index('app_errors_app_created_idx').on(t.appId, t.createdAt)]
481
+ );
482
+
483
+ /**
484
+ * Per-app, per-UTC-day serving signals (PHY-123). `day` is a `YYYY-MM-DD` string
485
+ * (deterministic bucket, no tz math). `path_404_counts` is `{ path: count }`;
486
+ * `__other__` absorbs paths past the per-day cardinality cap. Upserted from the
487
+ * Redis hot counters on the read path (unique on (app_id, day)).
488
+ */
489
+ declare const appDailyStats = pgTable(
490
+ 'app_daily_stats',
491
+ {
492
+ appId: text('app_id')
493
+ .notNull()
494
+ .references(() => apps.id),
495
+ day: text('day').notNull(),
496
+ path404Counts: jsonb('path_404_counts')
497
+ .$type<Record<string, number>>()
498
+ .notNull()
499
+ .default({}),
500
+ count5xx: integer('count_5xx').notNull().default(0),
501
+ requestCount: integer('request_count').notNull().default(0),
502
+ updatedAt: timestamp('updated_at').notNull().defaultNow(),
503
+ },
504
+ (t) => [primaryKey({ columns: [t.appId, t.day] })]
505
+ );
506
+
507
+ // ── get_logs (M1-07, NSO-290) — compile history + module request stats ────────
508
+ //
509
+ // `app_compiles` is a per-app history of every compile a write ran (create_app,
510
+ // write_files — ok, failed or refused), newest kept: 30 days / the last 200
511
+ // rows per app, pruned by @drobek/insights on insert. `module_request_stats`
512
+ // counts the responses of `/__drobek/v1/<module>/…` per app, module, status
513
+ // class (2xx…5xx) and UTC day, upserted by the module runtime; kept 30 days.
514
+ // Both are read by the MCP `get_logs` tool (compile / requests).
515
+
516
+ declare const appCompiles = pgTable(
517
+ 'app_compiles',
518
+ {
519
+ id: text('id')
520
+ .primaryKey()
521
+ .$defaultFn(() => createId()),
522
+ appId: text('app_id')
523
+ .notNull()
524
+ .references(() => apps.id, { onDelete: 'cascade' }),
525
+ /** The version the compile produced; null when the write was refused (nothing stored). */
526
+ versionNumber: integer('version_number'),
527
+ ok: boolean('ok').notNull(),
528
+ /** `[{ code, file, line, column, text }]` — capped by @drobek/insights. */
529
+ errors: jsonb('errors').$type<unknown[]>().notNull().default([]),
530
+ warningCount: integer('warning_count').notNull().default(0),
531
+ durationMs: integer('duration_ms').notNull().default(0),
532
+ /** `create_app` | `write_files`. */
533
+ trigger: text('trigger').notNull(),
534
+ createdAt: timestamp('created_at').notNull().defaultNow(),
535
+ },
536
+ (t) => [index('app_compiles_app_created_idx').on(t.appId, t.createdAt)]
537
+ );
538
+
539
+ declare const moduleRequestStats = pgTable(
540
+ 'module_request_stats',
541
+ {
542
+ appId: text('app_id')
543
+ .notNull()
544
+ .references(() => apps.id, { onDelete: 'cascade' }),
545
+ module: text('module').notNull(),
546
+ /** `2xx` | `3xx` | `4xx` | `5xx`. */
547
+ statusClass: text('status_class').notNull(),
548
+ /** UTC `YYYY-MM-DD`. */
549
+ day: text('day').notNull(),
550
+ count: integer('count').notNull().default(0),
551
+ },
552
+ (t) => [primaryKey({ columns: [t.appId, t.module, t.statusClass, t.day] })]
553
+ );
554
+
555
+ // ── BFF proxy v1 — authed-member gateway to a backend (PHY-59, U12 slice) ──────
556
+ //
557
+ // A static app reaches a backend WITHOUT holding its secret: drobek is the
558
+ // controlled gateway. A workspace-admin REGISTERS an upstream (a pinned base_url
559
+ // + an allow-list of methods and path prefixes + an auth mode) and stores the
560
+ // upstream secret AES-256-GCM envelope-encrypted (a random per-secret DEK wrapped
561
+ // by the KEK env DROBEK_MASTER_KEY). At forward time drobek decrypts the secret
562
+ // IN-MEMORY, injects it as the configured auth header, and forwards to the
563
+ // SSRF-guarded upstream. In v1 the caller is an AUTHENTICATED workspace MEMBER
564
+ // (drobek_session) — the anonymous public-app-visitor path is DEFERRED to U11
565
+ // end-user auth (Referer is spoofable and is NOT a caller-auth boundary), so
566
+ // `allowed_app_ids` is STORED for U11 but is NOT the v1 caller-auth.
567
+
568
+ /** MVP upstream auth modes. HMAC + OpenAPI validation are deferred (PHY-59 v1). */
569
+ declare const upstreamAuthTypeEnum = pgEnum('upstream_auth_type', [
570
+ 'none',
571
+ 'bearer',
572
+ 'header',
573
+ ]);
574
+
575
+ declare const upstreams = pgTable(
576
+ 'upstreams',
577
+ {
578
+ id: text('id')
579
+ .primaryKey()
580
+ .$defaultFn(() => createId()),
581
+ workspaceId: text('workspace_id')
582
+ .notNull()
583
+ .references(() => workspaces.id, { onDelete: 'cascade' }),
584
+ name: text('name').notNull(),
585
+ /** PINNED origin+base path; validated http(s) + non-private at registration. */
586
+ baseUrl: text('base_url').notNull(),
587
+ /** Allow-list — a forwarded method MUST be one of these (else 405). */
588
+ allowedMethods: text('allowed_methods').array().notNull(),
589
+ /** Allow-list — the normalized subpath MUST start with one of these (else 403). */
590
+ allowedPathPrefixes: text('allowed_path_prefixes').array().notNull(),
591
+ authType: upstreamAuthTypeEnum('auth_type').notNull().default('none'),
592
+ /** Header name to inject the secret under when auth_type = 'header'. */
593
+ authHeaderName: text('auth_header_name'),
594
+ /**
595
+ * STORED for U11 hosted-app end-user auth (which app may call this upstream
596
+ * anonymously) — NOT the v1 caller-auth (v1 = an authed workspace member).
597
+ */
598
+ allowedAppIds: text('allowed_app_ids').array().notNull().default([]),
599
+ createdBy: text('created_by').references(() => users.id),
600
+ createdAt: timestamp('created_at').notNull().defaultNow(),
601
+ },
602
+ (t) => [uniqueIndex('upstreams_workspace_name_uq').on(t.workspaceId, t.name)]
603
+ );
604
+
605
+ /**
606
+ * The upstream's injected secret, AES-256-GCM ENVELOPE-encrypted. A random
607
+ * per-secret DEK encrypts the secret; the DEK is WRAPPED by the KEK
608
+ * (DROBEK_MASTER_KEY). `kek_id` records which KEK wrapped it (enables rotation +
609
+ * fails closed on a wrong/rotated key — the GCM auth tag verify fails → config
610
+ * error, never a leak). The plaintext is NEVER stored, logged, or returned.
611
+ */
612
+ declare const upstreamSecrets = pgTable('upstream_secrets', {
613
+ upstreamId: text('upstream_id')
614
+ .primaryKey()
615
+ .references(() => upstreams.id, { onDelete: 'cascade' }),
616
+ /** base64 AES-256-GCM ciphertext of the secret (under the DEK). */
617
+ ciphertext: text('ciphertext').notNull(),
618
+ /** base64 12-byte IV for the secret ciphertext. */
619
+ iv: text('iv').notNull(),
620
+ /** base64 GCM auth tag for the secret ciphertext. */
621
+ authTag: text('auth_tag').notNull(),
622
+ /** The DEK wrapped by the KEK: base64(iv).base64(tag).base64(wrappedDek). */
623
+ wrappedDek: text('wrapped_dek').notNull(),
624
+ /** Stable, non-secret id of the KEK that wrapped the DEK (rotation). */
625
+ kekId: text('kek_id').notNull(),
626
+ createdAt: timestamp('created_at').notNull().defaultNow(),
627
+ });
628
+
629
+ // ── Platform modules (M1-01, NSO-287) ────────────────────────────────────────
630
+
631
+ /**
632
+ * Per-app configuration of one platform module (`@drobek/modules`).
633
+ *
634
+ * `config` holds what the agent / owner SET (a sparse JSON object — the
635
+ * module's `configDefaults` fill the rest when it is read, so a module upgrade
636
+ * can change a default without rewriting rows). `pending` holds ONE change
637
+ * waiting for the owner's confirmation in the dashboard (`confirmRequired`):
638
+ * `{ patch, changes, proposed_at, proposed_by }` — the RFC 7396 merge patch
639
+ * the agent sent, applied on top of the then-current config when confirmed.
640
+ * Never contains secret values (module secrets live in `module_secrets`).
641
+ */
642
+ declare const moduleConfigs = pgTable(
643
+ 'module_configs',
644
+ {
645
+ appId: text('app_id')
646
+ .notNull()
647
+ .references(() => apps.id, { onDelete: 'cascade' }),
648
+ module: text('module').notNull(),
649
+ config: jsonb('config').notNull().default({}),
650
+ pending: jsonb('pending'),
651
+ updatedAt: timestamp('updated_at').notNull().defaultNow(),
652
+ },
653
+ (t) => [primaryKey({ columns: [t.appId, t.module] })]
654
+ );
655
+
656
+ /**
657
+ * A module secret of one app (e.g. an API key a module injects server-side),
658
+ * AES-256-GCM ENVELOPE-encrypted exactly like `upstream_secrets` (random DEK
659
+ * wrapped by the KEK from DROBEK_MASTER_KEY, `kek_id` for rotation). Values are
660
+ * written only from the dashboard (never over MCP), read only inside a module
661
+ * handler (`ctx.secrets.get`) and never returned by any API — agents see
662
+ * `hasSecret` only.
663
+ */
664
+ declare const moduleSecrets = pgTable(
665
+ 'module_secrets',
666
+ {
667
+ appId: text('app_id')
668
+ .notNull()
669
+ .references(() => apps.id, { onDelete: 'cascade' }),
670
+ module: text('module').notNull(),
671
+ name: text('name').notNull(),
672
+ ciphertext: text('ciphertext').notNull(),
673
+ iv: text('iv').notNull(),
674
+ authTag: text('auth_tag').notNull(),
675
+ wrappedDek: text('wrapped_dek').notNull(),
676
+ kekId: text('kek_id').notNull(),
677
+ createdAt: timestamp('created_at').notNull().defaultNow(),
678
+ updatedAt: timestamp('updated_at').notNull().defaultNow(),
679
+ },
680
+ (t) => [primaryKey({ columns: [t.appId, t.module, t.name] })]
681
+ );
682
+
683
+ /**
684
+ * NSO-346: an opt-in platform module (`availability: 'opt-in'`) a super-admin
685
+ * enabled for one workspace in the dashboard. A default module never has a
686
+ * row (it is on everywhere). The limits provider's `MODULE_ENABLED_<NAME>`
687
+ * (plan) overrides the row in both directions; `enabled_by` turns null when
688
+ * the user row goes away.
689
+ */
690
+ declare const workspaceModules = pgTable(
691
+ 'workspace_modules',
692
+ {
693
+ workspaceId: text('workspace_id')
694
+ .notNull()
695
+ .references(() => workspaces.id, { onDelete: 'cascade' }),
696
+ module: text('module').notNull(),
697
+ enabledBy: text('enabled_by').references(() => users.id, { onDelete: 'set null' }),
698
+ enabledAt: timestamp('enabled_at', { withTimezone: true }).notNull().defaultNow(),
699
+ },
700
+ (t) => [primaryKey({ columns: [t.workspaceId, t.module] })]
701
+ );
702
+
703
+ // ── Custom domains (M3-01, NSO-292) ──────────────────────────────────────────
704
+ //
705
+ // A hostname an owner attached to an app. It serves the app's PUBLISHED
706
+ // version once VERIFIED: `TXT _drobek.<hostname> = drobek-verify=<token>`
707
+ // (proves control of the name) AND `<hostname> CNAME <slug>.<APPS_DOMAIN>`
708
+ // (or the same addresses — apex ALIAS/flattening). Caddy's on-demand `ask`
709
+ // answers 200 for verified rows only. A hostname is unique per app, and at
710
+ // most ONE row per hostname can be verified instance-wide — an unverified
711
+ // claim never blocks the real owner (anti-squatting: whoever proves DNS wins).
712
+ // `is_primary` (≤ 1 per app, verified only) = redirect `<slug>.<APPS_DOMAIN>`
713
+ // here with a 302. `cert_state`: `none` | `requested` (the ask said 200 — Caddy
714
+ // is obtaining / holds a certificate). Deleting a row does not revoke Caddy's
715
+ // certificate; it expires on its own (docs/SELF-HOSTING.md).
716
+
717
+ declare const domains = pgTable(
718
+ 'domains',
719
+ {
720
+ id: text('id')
721
+ .primaryKey()
722
+ .$defaultFn(() => createId()),
723
+ appId: text('app_id')
724
+ .notNull()
725
+ .references(() => apps.id, { onDelete: 'cascade' }),
726
+ /** Lower-case ASCII (IDNA) hostname, no trailing dot. */
727
+ hostname: text('hostname').notNull(),
728
+ /** The `drobek-verify=<token>` TXT value's token (random, not a secret). */
729
+ verificationToken: text('verification_token').notNull(),
730
+ verifiedAt: timestamp('verified_at'),
731
+ lastCheckAt: timestamp('last_check_at'),
732
+ /** The last check's human-readable problem (null when it passed). */
733
+ lastError: text('last_error'),
734
+ certState: text('cert_state').notNull().default('none'),
735
+ isPrimary: boolean('is_primary').notNull().default(false),
736
+ createdAt: timestamp('created_at').notNull().defaultNow(),
737
+ },
738
+ (t) => [
739
+ uniqueIndex('domains_app_hostname_uq').on(t.appId, t.hostname),
740
+ uniqueIndex('domains_verified_hostname_uq').on(t.hostname).where(sql`${t.verifiedAt} IS NOT NULL`),
741
+ uniqueIndex('domains_primary_uq').on(t.appId).where(sql`${t.isPrimary}`),
742
+ index('domains_hostname_idx').on(t.hostname),
743
+ ]
744
+ );
745
+
746
+ // ── Abuse reports (M4-02, NSO-293) ───────────────────────────────────────────
747
+ //
748
+ // The moderation queue: a report from the public form on the dashboard origin
749
+ // (`/report?host=`, no login, rate-limited per IP) or a flag raised by the
750
+ // publish heuristic (`reason = 'heuristic'`, never a block). `app_id` is the
751
+ // app behind `host` when it resolved (set null if the app row goes away — the
752
+ // report stays); `ip_hash` is a keyed hash of the reporter's IP (never the
753
+ // address). A super-admin resolves a report from the queue (takedown, or
754
+ // just "mark resolved").
755
+
756
+ declare const abuseReportStatusEnum = pgEnum('abuse_report_status', ['open', 'resolved']);
757
+
758
+ declare const abuseReports = pgTable(
759
+ 'abuse_reports',
760
+ {
761
+ id: text('id')
762
+ .primaryKey()
763
+ .$defaultFn(() => createId()),
764
+ appId: text('app_id').references(() => apps.id, { onDelete: 'set null' }),
765
+ /** The host the report names, normalized (lower-case, no scheme/path). */
766
+ host: text('host').notNull(),
767
+ /** `phishing` | `malware` | `spam` | `copyright` | `illegal` | `other` | `heuristic`. */
768
+ reason: text('reason').notNull(),
769
+ /** Free text from the reporter (≤ 2 000 chars) or the heuristic's finding. */
770
+ details: text('details').notNull().default(''),
771
+ reporterEmail: text('reporter_email'),
772
+ ipHash: text('ip_hash'),
773
+ status: abuseReportStatusEnum('status').notNull().default('open'),
774
+ createdAt: timestamp('created_at').notNull().defaultNow(),
775
+ resolvedAt: timestamp('resolved_at'),
776
+ resolvedBy: text('resolved_by').references(() => users.id, { onDelete: 'set null' }),
777
+ },
778
+ (t) => [
779
+ index('abuse_reports_status_created_idx').on(t.status, t.createdAt),
780
+ index('abuse_reports_app_idx').on(t.appId),
781
+ ]
782
+ );
783
+
784
+ // ── App assets (NSO-358, NSO-362) ────────────────────────────────────────────
785
+ //
786
+ // Binary files an app serves at `/<path>` (images, video, audio, fonts) —
787
+ // the same URL space as its files, where an app file at the same path wins.
788
+ // Uploaded outside the LLM through a one-time upload URL or the dashboard,
789
+ // typed from their bytes. The bytes live on disk
790
+ // (`ASSETS_DIR/<app_id>/<storage_key>`, not in Postgres — up to
791
+ // APP_ASSET_MAX_BYTES each), content-addressed: an upload is stored under its
792
+ // sha256 (rows from before NSO-362 keep their random key) and a file is never
793
+ // rewritten, so several rows may share one.
794
+ //
795
+ // `app_assets` is the DRAFT: what uploads, replacements and deletes change,
796
+ // and what the preview host serves. `app_version_assets` is the set a publish
797
+ // FROZE for a version (NSO-362): the production host and custom domains serve
798
+ // only the live published version's rows, so an asset change reaches the
799
+ // public URL only with a publish. A soft-deleted app's rows and files, and
800
+ // files no row references, are removed by the assets sweep.
801
+
802
+ declare const appAssets = pgTable(
803
+ 'app_assets',
804
+ {
805
+ appId: text('app_id')
806
+ .notNull()
807
+ .references(() => apps.id, { onDelete: 'cascade' }),
808
+ /** A relative path of 1–4 segments with an allowed extension (`film.mp4`, `img/s1.jpg`); served at `/<name>`. */
809
+ name: text('name').notNull(),
810
+ /** The type sniffed from the bytes (served as Content-Type, with nosniff). */
811
+ contentType: text('content_type').notNull(),
812
+ size: bigint('size', { mode: 'number' }).notNull(),
813
+ /** Content hash — the strong ETag. */
814
+ sha256: text('sha256').notNull(),
815
+ /** The file name under `ASSETS_DIR/<app_id>/` (the sha256; a random key for rows from before NSO-362). */
816
+ storageKey: text('storage_key').notNull(),
817
+ createdByUserId: text('created_by_user_id').references(() => users.id, { onDelete: 'set null' }),
818
+ createdAt: timestamp('created_at').notNull().defaultNow(),
819
+ updatedAt: timestamp('updated_at').notNull().defaultNow(),
820
+ },
821
+ (t) => [primaryKey({ columns: [t.appId, t.name] })]
822
+ );
823
+
824
+ /** NSO-362: the assets a publish froze for one version (see the section comment). */
825
+ declare const appVersionAssets = pgTable(
826
+ 'app_version_assets',
827
+ {
828
+ versionId: text('version_id')
829
+ .notNull()
830
+ .references(() => appVersions.id, { onDelete: 'cascade' }),
831
+ appId: text('app_id')
832
+ .notNull()
833
+ .references(() => apps.id, { onDelete: 'cascade' }),
834
+ name: text('name').notNull(),
835
+ contentType: text('content_type').notNull(),
836
+ size: bigint('size', { mode: 'number' }).notNull(),
837
+ sha256: text('sha256').notNull(),
838
+ storageKey: text('storage_key').notNull(),
839
+ /** The draft row's upload time (Last-Modified). */
840
+ updatedAt: timestamp('updated_at').notNull().defaultNow(),
841
+ },
842
+ (t) => [primaryKey({ columns: [t.versionId, t.name] }), index('app_version_assets_app_idx').on(t.appId)]
843
+ );
844
+
845
+ declare const schema_abuseReportStatusEnum: typeof abuseReportStatusEnum;
846
+ declare const schema_abuseReports: typeof abuseReports;
847
+ declare const schema_apiKeys: typeof apiKeys;
848
+ declare const schema_appAssets: typeof appAssets;
849
+ declare const schema_appCompiles: typeof appCompiles;
850
+ declare const schema_appDailyStats: typeof appDailyStats;
851
+ declare const schema_appErrorTypeEnum: typeof appErrorTypeEnum;
852
+ declare const schema_appErrors: typeof appErrors;
853
+ declare const schema_appStatusEnum: typeof appStatusEnum;
854
+ declare const schema_appVersionAssets: typeof appVersionAssets;
855
+ declare const schema_appVersions: typeof appVersions;
856
+ declare const schema_appVisibilityEnum: typeof appVisibilityEnum;
857
+ declare const schema_apps: typeof apps;
858
+ declare const schema_auditActorKindEnum: typeof auditActorKindEnum;
859
+ declare const schema_auditLog: typeof auditLog;
860
+ declare const schema_blobs: typeof blobs;
861
+ declare const schema_bytea: typeof bytea;
862
+ declare const schema_compileStatusEnum: typeof compileStatusEnum;
863
+ declare const schema_domains: typeof domains;
864
+ declare const schema_membershipRoleEnum: typeof membershipRoleEnum;
865
+ declare const schema_memberships: typeof memberships;
866
+ declare const schema_moduleConfigs: typeof moduleConfigs;
867
+ declare const schema_moduleRequestStats: typeof moduleRequestStats;
868
+ declare const schema_moduleSecrets: typeof moduleSecrets;
869
+ declare const schema_oauthAccessTokens: typeof oauthAccessTokens;
870
+ declare const schema_oauthAuthorizationCodes: typeof oauthAuthorizationCodes;
871
+ declare const schema_oauthClients: typeof oauthClients;
872
+ declare const schema_oauthRefreshTokens: typeof oauthRefreshTokens;
873
+ declare const schema_upstreamAuthTypeEnum: typeof upstreamAuthTypeEnum;
874
+ declare const schema_upstreamSecrets: typeof upstreamSecrets;
875
+ declare const schema_upstreams: typeof upstreams;
876
+ declare const schema_users: typeof users;
877
+ declare const schema_versionFileKindEnum: typeof versionFileKindEnum;
878
+ declare const schema_versionFiles: typeof versionFiles;
879
+ declare const schema_workspaceKindEnum: typeof workspaceKindEnum;
880
+ declare const schema_workspaceModules: typeof workspaceModules;
881
+ declare const schema_workspaces: typeof workspaces;
882
+ declare namespace schema {
883
+ export {
884
+ schema_abuseReportStatusEnum as abuseReportStatusEnum,
885
+ schema_abuseReports as abuseReports,
886
+ schema_apiKeys as apiKeys,
887
+ schema_appAssets as appAssets,
888
+ schema_appCompiles as appCompiles,
889
+ schema_appDailyStats as appDailyStats,
890
+ schema_appErrorTypeEnum as appErrorTypeEnum,
891
+ schema_appErrors as appErrors,
892
+ schema_appStatusEnum as appStatusEnum,
893
+ schema_appVersionAssets as appVersionAssets,
894
+ schema_appVersions as appVersions,
895
+ schema_appVisibilityEnum as appVisibilityEnum,
896
+ schema_apps as apps,
897
+ schema_auditActorKindEnum as auditActorKindEnum,
898
+ schema_auditLog as auditLog,
899
+ schema_blobs as blobs,
900
+ schema_bytea as bytea,
901
+ schema_compileStatusEnum as compileStatusEnum,
902
+ schema_domains as domains,
903
+ schema_membershipRoleEnum as membershipRoleEnum,
904
+ schema_memberships as memberships,
905
+ schema_moduleConfigs as moduleConfigs,
906
+ schema_moduleRequestStats as moduleRequestStats,
907
+ schema_moduleSecrets as moduleSecrets,
908
+ schema_oauthAccessTokens as oauthAccessTokens,
909
+ schema_oauthAuthorizationCodes as oauthAuthorizationCodes,
910
+ schema_oauthClients as oauthClients,
911
+ schema_oauthRefreshTokens as oauthRefreshTokens,
912
+ schema_upstreamAuthTypeEnum as upstreamAuthTypeEnum,
913
+ schema_upstreamSecrets as upstreamSecrets,
914
+ schema_upstreams as upstreams,
915
+ schema_users as users,
916
+ schema_versionFileKindEnum as versionFileKindEnum,
917
+ schema_versionFiles as versionFiles,
918
+ schema_workspaceKindEnum as workspaceKindEnum,
919
+ schema_workspaceModules as workspaceModules,
920
+ schema_workspaces as workspaces,
921
+ };
922
+ }
923
+
924
+ type DB = PostgresJsDatabase<typeof schema>;
925
+
926
+ /**
927
+ * Logger interface stub (core stays vendor-free — §15 ARCHITECTURE.md).
928
+ * drobek-web plugs Sentry/pino behind this; core + self-host default to
929
+ * the console logger.
930
+ */
931
+ type LogMeta = Record<string, unknown>;
932
+
933
+ interface Logger {
934
+ debug(message: string, meta?: LogMeta): void;
935
+ info(message: string, meta?: LogMeta): void;
936
+ warn(message: string, meta?: LogMeta): void;
937
+ error(message: string, meta?: LogMeta): void;
938
+ }
939
+
940
+ /**
941
+ * The public TypeScript module contract of drobek (M1-01, NSO-287) — semver
942
+ * 1.x. A platform module is an npm package (a built-in under `modules/<name>`,
943
+ * a third-party one named `drobek-module-<name>`) whose default export is the
944
+ * result of `defineModule()`. The OPERATOR installs modules and lists them in
945
+ * `DROBEK_MODULES`; they are trusted server-side dependencies of the same
946
+ * class as Express. An app author (or agent) never uploads one — the server
947
+ * still never runs app code.
948
+ *
949
+ * A module contributes: routes on every app host under
950
+ * `/__drobek/v1/<name>/…` (ModuleRouter), a piece of the browser SDK
951
+ * (`drobek.<name>` in `/__drobek/sdk.js`), a per-app configuration validated by
952
+ * `configSchema` (set by agents through `configure_module`, risky changes held
953
+ * for the owner's confirmation by `confirmRequired`), the names of the secrets
954
+ * it can use (values only ever entered in the dashboard), limits, and a SKILL:
955
+ * the agent-facing documentation `skill_info` returns.
956
+ *
957
+ * Contract 1.1 adds: `contract` (the contract versions a module works
958
+ * with), `errors` (its own error codes), typed `slots` other modules
959
+ * contribute to (`contributes`, read with `services.contributions()`),
960
+ * `availability`, `dashboard.editor` and `hooks.onAppDelete` — all
961
+ * optional, so a 1.0 module loads unchanged.
962
+ *
963
+ * Everything a handler needs arrives in a per-request, APP-SCOPED
964
+ * ModuleContext: the caller (principal from the `drobek_eu` end-user cookie),
965
+ * the rule evaluator, limits, a rate limiter, this app's secrets for this
966
+ * module, audit, the database and e-mail. A module never reads cookies itself
967
+ * and never sees another app's id.
968
+ */
969
+
970
+ /**
971
+ * The contract version this package implements (the `DrobekModule` shape).
972
+ * A module states the versions it works with in `contract` (a semver range,
973
+ * e.g. `'^1.1'`); the server refuses to start a module whose range this
974
+ * version does not satisfy.
975
+ */
976
+ declare const MODULE_CONTRACT_VERSION = "1.1.0";
977
+ /** Module names: lowercase, URL-, JS-property- and env-safe. */
978
+ declare const MODULE_NAME_RE: RegExp;
979
+ /** Error codes a module declares in `errors`: lowercase snake case, 3–41 characters. */
980
+ declare const MODULE_ERROR_CODE_RE: RegExp;
981
+ /** Slot names: `<host module name>.<camelCase name>`, e.g. `auth.provider`. */
982
+ declare const SLOT_NAME_RE: RegExp;
983
+ /**
984
+ * Who is calling a module route, resolved by core from the host-only
985
+ * `drobek_eu` end-user session cookie of the app host (§5.0). The platform
986
+ * module `auth` signs end users in (e-mail code):
987
+ * - `anon` — no (valid) session;
988
+ * - `user` — an end user signed in to THIS app; `role: 'admin'` marks the
989
+ * app's administrators (the auth config's `adminEmails`, and the editors
990
+ * of the app's workspace signing in with their own e-mail).
991
+ * The dashboard session is never read on an app host.
992
+ */
993
+ type Principal = {
994
+ kind: 'anon';
995
+ } | {
996
+ kind: 'user';
997
+ id: string;
998
+ email: string;
999
+ role: 'user' | 'admin';
1000
+ };
1001
+ /** A signed-in end user (the `user` principal without its tag). */
1002
+ interface EndUser {
1003
+ id: string;
1004
+ email: string;
1005
+ role: 'user' | 'admin';
1006
+ /**
1007
+ * How the session signed in: `email` (the e-mail code) or an auth
1008
+ * provider's id (NSO-348). Absent = `email` (a session from before
1009
+ * providers existed). Never part of the principal a module sees.
1010
+ */
1011
+ provider?: string;
1012
+ /**
1013
+ * A provider session's connection: the end-user authority's fingerprint of
1014
+ * the provider's identity config when the session began (`auth` ends the
1015
+ * session once it differs). Never part of the principal a module sees.
1016
+ */
1017
+ connection?: string;
1018
+ }
1019
+ /**
1020
+ * An access rule: a `|`-separated disjunction of principals —
1021
+ * `public | user | owner | admin | none` (e.g. `"owner|admin"`). `owner`
1022
+ * matches a signed-in user whose id equals the record's owner.
1023
+ */
1024
+ type Rule = string;
1025
+ type AccessDecision = {
1026
+ ok: true;
1027
+ } | {
1028
+ ok: false;
1029
+ status: 401 | 403;
1030
+ };
1031
+ /** The operations a module exposes to rules, for the dashboard's rule editor. */
1032
+ interface RuleSurface {
1033
+ /** operation → one-line meaning, e.g. `{ read: 'List and get records' }`. */
1034
+ ops: Record<string, string>;
1035
+ }
1036
+ /**
1037
+ * The agent-facing skill (returned by `skill_info('<name>')`). Short
1038
+ * Markdown, target ≤ 150 lines: when to use → minimal working code → the exact
1039
+ * SDK calls and their types → limits and server-enforced rules → common
1040
+ * errors and fixes. No marketing.
1041
+ */
1042
+ interface ModuleSkill {
1043
+ /**
1044
+ * ONE sentence starting with the situation, listed by `skill_info()`,
1045
+ * create_app and get_app — e.g. "the user submits something and you want to
1046
+ * store it or get it by e-mail".
1047
+ */
1048
+ useWhen: string;
1049
+ /** The skill body (Markdown). */
1050
+ markdown: string;
1051
+ }
1052
+ /** A limit the module enforces — its env name is the operator's knob (§5.7). */
1053
+ interface ModuleLimit {
1054
+ /** Env var name, e.g. `FORMS_PER_APP_PER_DAY` (UPPER_SNAKE). */
1055
+ env: string;
1056
+ /** Default when neither the env nor the limits provider sets it. */
1057
+ default: number;
1058
+ meaning: string;
1059
+ }
1060
+ /** A secret the module can use (the value is entered in the dashboard only). */
1061
+ interface ModuleSecretDoc {
1062
+ /** UPPER_SNAKE name, e.g. `OPENAI_API_KEY`. */
1063
+ name: string;
1064
+ description: string;
1065
+ /** true → configure_module reports it in `secrets_missing` until it is set. */
1066
+ required?: boolean;
1067
+ }
1068
+ /** The browser part of a module. */
1069
+ interface ModuleSdk {
1070
+ /**
1071
+ * Absolute path (or `file:` URL) of an ES module whose DEFAULT export is
1072
+ * `(core: SdkCore) => Api` (SdkCore from `@drobek/sdk`). Bundled into
1073
+ * `/__drobek/sdk.js` as `drobek.<name>` by esbuild at server start; https://
1074
+ * imports stay external, anything else is bundled.
1075
+ */
1076
+ entry: string;
1077
+ /**
1078
+ * TypeScript declarations for `drobek.<name>`: MUST declare an
1079
+ * `interface Api` (plus any helper types). Wrapped in
1080
+ * `declare namespace <name> { … }` in `/__drobek/sdk.d.ts`.
1081
+ */
1082
+ types: string;
1083
+ /**
1084
+ * Optional source module an app imports as `drobek/<name>` (e.g. React
1085
+ * components such as the auth module's `<LoginGate>`). Unlike `entry` it is
1086
+ * NOT in `/__drobek/sdk.js`: the compiler builds it INTO the app bundle, so
1087
+ * its bare imports (`react`, …) resolve through the app's own `drobek.json`
1088
+ * — the app and the component share one React — and `drobek` resolves to
1089
+ * the SDK. One self-contained `.ts`/`.tsx` file (no relative imports),
1090
+ * read from the operator's disk at server start.
1091
+ */
1092
+ inline?: {
1093
+ /** Absolute path (or `file:` URL) of the `.ts`/`.tsx` source. */
1094
+ entry: string;
1095
+ /** Its declarations (shown by skill_info and in `/__drobek/sdk.d.ts`). */
1096
+ types: string;
1097
+ };
1098
+ }
1099
+ /** Module-owned tables: a drizzle migrations folder with its own journal. */
1100
+ interface ModuleMigrations {
1101
+ /** Absolute path (or `file:` URL) of the drizzle migrations folder. */
1102
+ folder: string;
1103
+ }
1104
+ /** Which app a hook runs for. */
1105
+ interface HookApp {
1106
+ id: string;
1107
+ slug: string;
1108
+ workspaceId: string;
1109
+ }
1110
+ interface ModuleHooks {
1111
+ /** After create_app stored version 1 (best effort — a failure is logged). */
1112
+ onAppCreate?: (app: HookApp, services: ModuleServices) => Promise<void> | void;
1113
+ /** After a version was published (MCP publish or the dashboard). */
1114
+ onPublish?: (app: HookApp & {
1115
+ version: number;
1116
+ }, services: ModuleServices) => Promise<void> | void;
1117
+ /**
1118
+ * After the app was soft-deleted (the dashboard's delete; best effort — a
1119
+ * failure is logged). For clean-up outside the database cascade, e.g. data
1120
+ * the module keeps in another system.
1121
+ */
1122
+ onAppDelete?: (app: HookApp, services: ModuleServices) => Promise<void> | void;
1123
+ }
1124
+ /**
1125
+ * One error code a module's routes answer with (`{ error: code, … }`),
1126
+ * documented for agents: `skill_info('<name>').errors` and the module's
1127
+ * section of the error catalogue in `/llms-full.txt`. A route may answer only
1128
+ * the core codes (`CORE_ERROR_CODES`) and the codes its own module declares
1129
+ * here — any other code becomes `500 internal_error` (logged).
1130
+ */
1131
+ interface ModuleErrorDoc {
1132
+ /** `MODULE_ERROR_CODE_RE`; unique across the core catalogue and every active module. */
1133
+ code: string;
1134
+ /** What happened, for the agent (one or two sentences). */
1135
+ meaning: string;
1136
+ /** What to do about it. */
1137
+ fix: string;
1138
+ }
1139
+ /**
1140
+ * A typed extension point a module (the HOST) offers other modules: each
1141
+ * active module may contribute one value to it (`contributes`), validated
1142
+ * by `schema` at server start. The host reads them with
1143
+ * `services.contributions(slot)`, in `DROBEK_MODULES` order — at run time
1144
+ * only those of modules that are on for the app's workspace (an opt-in
1145
+ * module switched off there contributes nothing); `compose` sees them all.
1146
+ */
1147
+ interface ModuleSlot<T = unknown> {
1148
+ /** Validates every contribution; the host gets the parsed value. */
1149
+ schema: ZodType<T>;
1150
+ /**
1151
+ * A key of the contribution whose value must be unique within the slot
1152
+ * (e.g. `id`): two contributions with the same value refuse the start.
1153
+ */
1154
+ unique?: string;
1155
+ /** What a contribution does, for module authors and the dashboard. */
1156
+ description: string;
1157
+ }
1158
+ /**
1159
+ * Who the module is for: `default` — every workspace of the server (the
1160
+ * behaviour without the field); `opt-in` — the workspaces it is enabled for.
1161
+ * The value is validated and reported (skill_info, the dashboard's module
1162
+ * view); core does not restrict a module by it.
1163
+ */
1164
+ type ModuleAvailability = 'default' | 'opt-in';
1165
+ /** The dedicated dashboard editors a module's config can declare it fits. */
1166
+ type ModuleDashboardEditor = 'collections' | 'upstreams';
1167
+ /** How the dashboard presents the module. */
1168
+ interface ModuleDashboard {
1169
+ /**
1170
+ * The dedicated dashboard editor this module's config fits (a capability
1171
+ * declaration, validated at start and reported in the dashboard's module
1172
+ * view): `collections` (a `collections` config shaped like the built-in
1173
+ * `data`'s) or `upstreams` (an `upstreams` config shaped like `proxy`'s).
1174
+ */
1175
+ editor?: ModuleDashboardEditor;
1176
+ }
1177
+ /**
1178
+ * The module that OWNS end-user sessions (the built-in `auth`): core asks it
1179
+ * about the user of every live session before any module route sees a
1180
+ * principal, so a user who was disabled, deleted or removed from the
1181
+ * allowlist is anonymous — and their session deleted — on the very next
1182
+ * request to ANY module, and a role follows the app's config at once. At most
1183
+ * one active module may declare it; without one, no session is honoured.
1184
+ */
1185
+ interface EndUserAuthority<Config = unknown> {
1186
+ /**
1187
+ * The user of a live session of `app` as they are NOW (their current role),
1188
+ * or null: not allowed any more → core ends the session. Called once per
1189
+ * module request that carries a session; keep it to indexed lookups. A throw
1190
+ * makes that request anonymous (fail closed) without ending the session.
1191
+ */
1192
+ current(input: {
1193
+ app: HookApp;
1194
+ user: EndUser;
1195
+ config: Config;
1196
+ db: DB;
1197
+ log: Logger;
1198
+ /** The slot contributions of the modules that are on for the app's workspace (a disabled opt-in module contributes nothing). */
1199
+ contributions<T = unknown>(slot: string): T[];
1200
+ }): Promise<EndUser | null>;
1201
+ /** The app's end users, newest first (`search` = a case-insensitive part of the address). */
1202
+ list?(view: OwnerView<Config>, query: EndUserListQuery): Promise<EndUserPage>;
1203
+ /**
1204
+ * Make a user `user` or `admin`. When the role follows the app's config,
1205
+ * return the merge patch of THIS module's config that gives it (core applies
1206
+ * it under the config lock, in the same transaction as `view.db`, without a
1207
+ * confirmation — the owner is the one who confirms). The change applies to
1208
+ * the next module request (core asks `current` on every one). A role that
1209
+ * cannot be changed (e.g. a workspace editor is always admin) → ModuleError
1210
+ * `conflict`.
1211
+ */
1212
+ setRole?(view: OwnerView<Config>, id: string, role: 'user' | 'admin'): Promise<{
1213
+ user: EndUserRecord;
1214
+ configPatch: Record<string, unknown> | null;
1215
+ }>;
1216
+ /** Block (true) or unblock a user; a blocked user is anonymous — and signed out — on the next request. */
1217
+ setDisabled?(view: OwnerView<Config>, id: string, disabled: boolean): Promise<EndUserRecord | null>;
1218
+ /**
1219
+ * The IdP callback of the end-user sign-in providers (NSO-348): core routes
1220
+ * `GET|POST /__drobek/auth/callback/:provider` on the DASHBOARD host here —
1221
+ * the one redirect URI an IdP client registers for every app. No dashboard
1222
+ * session is read and no origin check applies (the IdP redirects or posts
1223
+ * the browser here): the authority must authenticate the request by its
1224
+ * own signed, single-use state, find the app from that state (never from
1225
+ * the request) and answer a redirect to the app host or a page. A throw is
1226
+ * logged and answers a generic error page.
1227
+ */
1228
+ callback?(input: EndUserCallbackInput<Config>): Promise<EndUserCallbackResult>;
1229
+ }
1230
+ /** One app as the end-user authority sees it in a sign-in callback (once it found the app in its own state). */
1231
+ interface EndUserCallbackApp<Config = unknown> {
1232
+ app: HookApp;
1233
+ /** The authority module's effective config for the app. */
1234
+ config: Config;
1235
+ /** Limits of the app's workspace. */
1236
+ limits(): Promise<Limits>;
1237
+ /** This app's secrets of the authority module (declared names only), plaintext in memory. */
1238
+ secrets: {
1239
+ get(name: string): Promise<string | null>;
1240
+ };
1241
+ /** Append an audit row for this app (actor: the anonymous visitor; action prefixed with the module name). */
1242
+ audit(action: string, meta?: Record<string, unknown>): Promise<void>;
1243
+ /** The slot contributions of the modules that are on for the app's workspace (a disabled opt-in module contributes nothing). */
1244
+ contributions<T = unknown>(slot: string): T[];
1245
+ }
1246
+ /** What the end-user authority's `callback` gets. */
1247
+ interface EndUserCallbackInput<Config = unknown> {
1248
+ /** The `:provider` path segment as the request sent it (unvalidated). */
1249
+ provider: string;
1250
+ method: 'GET' | 'POST';
1251
+ /** Query parameters (first value of each). */
1252
+ query: Record<string, string>;
1253
+ /** The form fields of a POST (`application/x-www-form-urlencoded`, ≤ 256 KiB), else null. */
1254
+ body: Record<string, string> | null;
1255
+ clientIp: string | null;
1256
+ services: ModuleServices & {
1257
+ /** Fixed-window counter namespaced to the module's callback (no app is known yet). */
1258
+ rateLimit(bucket: string, key: string, max: number, windowMs: number): Promise<RateLimitResult>;
1259
+ /** The server's default limits (env / catalogue defaults — no workspace is known yet). */
1260
+ limits(): Limits;
1261
+ /** A live app by id (not deleted, not taken down) with the authority's config for it, or null. */
1262
+ app(appId: string): Promise<EndUserCallbackApp<Config> | null>;
1263
+ };
1264
+ }
1265
+ /** The callback's answer: send the browser on (to the app host), or show a page on the dashboard host. */
1266
+ type EndUserCallbackResult = {
1267
+ kind: 'redirect';
1268
+ location: string;
1269
+ } | {
1270
+ kind: 'page';
1271
+ status: number;
1272
+ title: string;
1273
+ message: string;
1274
+ link?: {
1275
+ href: string;
1276
+ label: string;
1277
+ };
1278
+ };
1279
+ /** One end user as the owner sees them. */
1280
+ interface EndUserRecord {
1281
+ id: string;
1282
+ email: string;
1283
+ /** The role they have NOW (what `current` would answer). */
1284
+ role: 'user' | 'admin';
1285
+ /** Why they are admin: `workspace` (an editor of the app's workspace — fixed), `config` (the module's config). */
1286
+ roleSource: 'workspace' | 'config' | null;
1287
+ /** `active`, `disabled` by the owner, or `not_allowed` any more by the config (signed out on their next request). */
1288
+ status: 'active' | 'disabled' | 'not_allowed';
1289
+ /** How the user signs in: `email` (the e-mail code) or the auth provider their account is linked to. */
1290
+ provider?: string;
1291
+ created_at: string;
1292
+ last_sign_in_at: string | null;
1293
+ }
1294
+ interface EndUserListQuery {
1295
+ search?: string;
1296
+ limit?: number;
1297
+ cursor?: string | null;
1298
+ }
1299
+ interface EndUserPage {
1300
+ users: EndUserRecord[];
1301
+ /** Users matching the search (all pages). */
1302
+ total: number;
1303
+ next_cursor: string | null;
1304
+ }
1305
+ /** What an e-mail is for (the module that owns app e-mail treats them differently). */
1306
+ type EmailKind =
1307
+ /** A one-time sign-in code (`{ signInAddress }`) — the auth module. */
1308
+ 'sign_in'
1309
+ /** Everything else: form notifications, notifyAdmins, a message to the signed-in user. */
1310
+ | 'notification';
1311
+ /** What core hands the mail authority for every message of any module of an app. */
1312
+ interface MailPrepareInput<Config = unknown> {
1313
+ app: HookApp;
1314
+ /** The module that sends (e.g. `forms`). */
1315
+ module: string;
1316
+ kind: EmailKind;
1317
+ /** How many addresses the message resolved to (≥ 1). */
1318
+ recipients: number;
1319
+ /** The MAIL module's own effective config for this app. */
1320
+ config: Config;
1321
+ /** Limits of the app's workspace. */
1322
+ limits: Limits;
1323
+ /** Fixed-window counter namespaced to the MAIL module + this app. */
1324
+ rateLimit(bucket: string, key: string, max: number, windowMs: number): Promise<RateLimitResult>;
1325
+ log: Logger;
1326
+ }
1327
+ /** Envelope details the mail authority adds to a message. */
1328
+ interface MailEnvelope {
1329
+ /** Display name of the sender (the address is always the operator's EMAIL_FROM). */
1330
+ fromName?: string;
1331
+ /** A Reply-To address. */
1332
+ replyTo?: string;
1333
+ }
1334
+ /**
1335
+ * The module that owns app e-mail (the built-in `email`): core calls
1336
+ * `prepare` for EVERY `ctx.email.send` of any module, after the recipients
1337
+ * resolved and before anything is sent. It enforces the per-app policy (a
1338
+ * `limit_exceeded` ModuleError refuses the message) and returns the
1339
+ * envelope. At most one active module may declare it. Without one, only
1340
+ * sign-in codes can be sent; any other message is `unavailable`.
1341
+ */
1342
+ interface MailAuthority<Config = unknown> {
1343
+ prepare(input: MailPrepareInput<Config>): Promise<MailEnvelope>;
1344
+ }
1345
+ /** What `confirmRequired` gets besides the two configs. */
1346
+ interface ConfirmContext {
1347
+ app: HookApp;
1348
+ /** Read-only use: the configure transaction (the config row is locked). */
1349
+ db: DB;
1350
+ }
1351
+ /**
1352
+ * Who may confirm a pending change (NSO-322 H3): `editor` (the default —
1353
+ * editors, workspace admins, super-admins) or `admin` (workspace admins and
1354
+ * super-admins only), e.g. a change that spends a secret an admin registered.
1355
+ */
1356
+ type ConfirmRole = 'editor' | 'admin';
1357
+ /** One change that waits for the owner: its text, or the text + who may confirm it. */
1358
+ type ConfirmItem = string | {
1359
+ change: string;
1360
+ confirmRole?: ConfirmRole;
1361
+ };
1362
+ /** The texts of `items` and the role their confirmation needs (the highest any item asks for). */
1363
+ declare function normalizeConfirmItems(items: readonly unknown[]): {
1364
+ changes: string[];
1365
+ role: ConfirmRole;
1366
+ };
1367
+ /** What `onConfirmed` gets: the confirm transaction and who confirmed. */
1368
+ interface ConfirmedContext {
1369
+ app: HookApp;
1370
+ /** The confirm transaction (the config row is locked): writes commit with the confirmation. */
1371
+ db: DB;
1372
+ /** The dashboard user who confirmed. */
1373
+ userId: string;
1374
+ /** Their confirming role (`admin` = workspace admin or super-admin). */
1375
+ role: ConfirmRole;
1376
+ /**
1377
+ * Append an audit row for this app IN the confirm transaction (actor: the
1378
+ * confirming user; the action is prefixed with the module name, e.g.
1379
+ * `collection.purge` → `data.collection.purge`). `meta`: ids and counts only.
1380
+ */
1381
+ audit(action: string, meta?: Record<string, unknown>): Promise<void>;
1382
+ }
1383
+ /**
1384
+ * One app as a module's OWNER-facing authority sees it (records, end users,
1385
+ * submissions, files): core authorized a drobek account for the app first.
1386
+ * `config` is the module's effective config for the app, `db` the database —
1387
+ * or the transaction core runs the call in (a config change) — and `limits`
1388
+ * the app's workspace limits.
1389
+ */
1390
+ interface OwnerView<Config = unknown> {
1391
+ app: HookApp;
1392
+ config: Config;
1393
+ db: DB;
1394
+ log: Logger;
1395
+ limits(): Promise<Limits>;
1396
+ }
1397
+ /** The app a records call is about, with the records module's effective config for it. */
1398
+ type RecordsView<Config = unknown> = OwnerView<Config>;
1399
+ /** One collection as the owner sees it. */
1400
+ interface RecordsCollection {
1401
+ name: string;
1402
+ /** operation → rule, e.g. `{ read: 'public', create: 'admin', … }`. */
1403
+ rules: Record<string, string>;
1404
+ /** The collection's JSON Schema, or null (schemaless). */
1405
+ schema: unknown;
1406
+ /** Display columns from the schema: required properties first. [] without a schema. */
1407
+ columns: {
1408
+ key: string;
1409
+ required: boolean;
1410
+ }[];
1411
+ /** Stored records. */
1412
+ records: number;
1413
+ }
1414
+ interface RecordsQuery {
1415
+ collection: string;
1416
+ /** The records filter (`{ field: value }` or `{ field: { op: value } }`, see the module's skill). */
1417
+ filter?: unknown;
1418
+ /** A sort field (a schema property or `_id` / `_created_at` / `_updated_at`). */
1419
+ sort?: string;
1420
+ dir?: 'asc' | 'desc';
1421
+ limit?: number;
1422
+ cursor?: string | null;
1423
+ }
1424
+ /** A page of records: every record is `{ _id, _owner, _created_at, _updated_at, …fields }`. */
1425
+ interface RecordsPage {
1426
+ collection: RecordsCollection;
1427
+ records: Record<string, unknown>[];
1428
+ /** Records matching the filter (all pages). */
1429
+ total: number;
1430
+ next_cursor: string | null;
1431
+ }
1432
+ /**
1433
+ * The module that stores records (the built-in `data`) answers the OWNER's
1434
+ * questions about an app's data, bypassing the end-user rules: core calls it
1435
+ * only after it authorized a drobek account for the app (MCP membership, the
1436
+ * dashboard's workspace role). Unknown collection → ModuleError `not_found`;
1437
+ * a bad filter/sort → `invalid_request`.
1438
+ */
1439
+ interface RecordsAuthority<Config = unknown> {
1440
+ collections(view: RecordsView<Config>): Promise<RecordsCollection[]>;
1441
+ query(view: RecordsView<Config>, query: RecordsQuery): Promise<RecordsPage>;
1442
+ get(view: RecordsView<Config>, collection: string, id: string): Promise<Record<string, unknown> | null>;
1443
+ /** Delete one record (the dashboard, editor+); false when it did not exist. */
1444
+ remove(view: RecordsView<Config>, collection: string, id: string): Promise<boolean>;
1445
+ /** The CSV export of a collection (filter + sort applied): the header line, then one line per record (no line breaks). */
1446
+ csv(view: RecordsView<Config>, query: Omit<RecordsQuery, 'limit' | 'cursor'>): AsyncIterable<string>;
1447
+ /**
1448
+ * Replace a record's own fields (`_…` keys are ignored), validated like any
1449
+ * write (schema, the per-record and per-app quotas); `_owner` and
1450
+ * `_created_at` stay. null when the record does not exist. A bad record →
1451
+ * ModuleError `validation_failed` (details: the fields).
1452
+ */
1453
+ update?(view: RecordsView<Config>, collection: string, id: string, fields: Record<string, unknown>): Promise<Record<string, unknown> | null>;
1454
+ /**
1455
+ * Import CSV text into a collection as new records (no owner): the header
1456
+ * names the fields. All or nothing — ONE transaction; too many rows
1457
+ * (`RECORDS_IMPORT_MAX_ROWS`) is refused before anything is parsed further,
1458
+ * and the first invalid row is reported with its line number
1459
+ * (ModuleError `validation_failed`, `details.line`) with nothing stored.
1460
+ */
1461
+ importCsv?(view: RecordsView<Config>, collection: string, csv: string): Promise<{
1462
+ imported: number;
1463
+ }>;
1464
+ /**
1465
+ * Delete a collection: its records now (in `view.db`, core's config
1466
+ * transaction), and return the merge patch of the module's config that
1467
+ * removes its declaration (core writes it in the same transaction).
1468
+ */
1469
+ dropCollection?(view: RecordsView<Config>, collection: string): Promise<{
1470
+ records: number;
1471
+ configPatch: Record<string, unknown>;
1472
+ }>;
1473
+ /**
1474
+ * Collections that hold records but are not declared in the config any
1475
+ * more (orphans — e.g. a write that landed while its collection was being
1476
+ * removed), with their record counts. They still count towards the quotas.
1477
+ */
1478
+ orphans?(view: RecordsView<Config>): Promise<{
1479
+ name: string;
1480
+ records: number;
1481
+ }[]>;
1482
+ /**
1483
+ * Delete the records of an ORPHAN collection (in `view.db`, core's config
1484
+ * transaction). A collection the config declares → ModuleError `conflict`
1485
+ * (the owner deletes a declared one with dropCollection).
1486
+ */
1487
+ purgeOrphan?(view: RecordsView<Config>, collection: string): Promise<{
1488
+ records: number;
1489
+ }>;
1490
+ }
1491
+ /** The most rows (without the header) one CSV import may carry. */
1492
+ declare const RECORDS_IMPORT_MAX_ROWS = 5000;
1493
+ interface SubmissionsQuery {
1494
+ /** One form, or every form of the app. */
1495
+ form?: string;
1496
+ /** Submitted at or after (ISO timestamp). */
1497
+ from?: string;
1498
+ /** Submitted before (ISO timestamp, exclusive). */
1499
+ to?: string;
1500
+ limit?: number;
1501
+ cursor?: string | null;
1502
+ }
1503
+ /** A stored submission as the owner sees it. */
1504
+ interface OwnerSubmission {
1505
+ id: string;
1506
+ form: string;
1507
+ created_at: string;
1508
+ data: Record<string, unknown>;
1509
+ user_id: string | null;
1510
+ notified: boolean;
1511
+ }
1512
+ interface SubmissionsPage {
1513
+ submissions: OwnerSubmission[];
1514
+ /** Submissions matching the filter (all pages). */
1515
+ total: number;
1516
+ next_cursor: string | null;
1517
+ }
1518
+ /**
1519
+ * The module that stores form submissions (the built-in `forms`) answers the
1520
+ * OWNER (the dashboard Forms tab): core calls it only after it authorized a
1521
+ * drobek account for the app. Bad filter/cursor → ModuleError `invalid_request`.
1522
+ */
1523
+ interface SubmissionsAuthority<Config = unknown> {
1524
+ /** The app's forms (declared or with stored submissions) and their submission counts. */
1525
+ forms(view: OwnerView<Config>): Promise<{
1526
+ name: string;
1527
+ submissions: number;
1528
+ }[]>;
1529
+ list(view: OwnerView<Config>, query: SubmissionsQuery): Promise<SubmissionsPage>;
1530
+ /** The CSV export (filter applied, newest first, capped): header line first, one line per submission. */
1531
+ csv(view: OwnerView<Config>, query: Omit<SubmissionsQuery, 'limit' | 'cursor'>): AsyncIterable<string>;
1532
+ /** Delete one submission; false when it did not exist. */
1533
+ remove(view: OwnerView<Config>, id: string): Promise<boolean>;
1534
+ }
1535
+ /** A stored upload as the owner sees it. */
1536
+ interface OwnerFile {
1537
+ id: string;
1538
+ name: string;
1539
+ /** The sniffed type (never the client's), e.g. `image/png`. */
1540
+ type: string;
1541
+ size: number;
1542
+ /** The uploader's end-user id, or null. */
1543
+ owner: string | null;
1544
+ created_at: string;
1545
+ }
1546
+ interface OwnerFilesPage {
1547
+ files: OwnerFile[];
1548
+ next_cursor: string | null;
1549
+ used_bytes: number;
1550
+ quota_bytes: number;
1551
+ }
1552
+ /**
1553
+ * The module that stores end-user uploads (the built-in `files`) answers the
1554
+ * OWNER (the dashboard Uploads tab): list, the bytes (for a preview /
1555
+ * download core serves with `nosniff`), delete (the module's own rules for
1556
+ * the stored bytes, e.g. content shared by another app stays).
1557
+ */
1558
+ interface FilesAuthority<Config = unknown> {
1559
+ list(view: OwnerView<Config>, query: {
1560
+ limit?: number;
1561
+ cursor?: string | null;
1562
+ }): Promise<OwnerFilesPage>;
1563
+ /** The file and its bytes, or null. The caller reads the stream once. */
1564
+ open(view: OwnerView<Config>, id: string): Promise<{
1565
+ file: OwnerFile;
1566
+ stream: Readable;
1567
+ } | null>;
1568
+ remove(view: OwnerView<Config>, id: string): Promise<boolean>;
1569
+ }
1570
+ /** One app as a module sees it outside a request: its effective config + services. */
1571
+ interface ModuleAppView<Config = unknown> {
1572
+ app: HookApp;
1573
+ config: Config;
1574
+ db: DB;
1575
+ log: Logger;
1576
+ }
1577
+ interface DrobekModule<Config = unknown> {
1578
+ /** `/__drobek/v1/<name>`, `drobek.<name>`, the config key and the skill name. */
1579
+ name: string;
1580
+ /** The module's own semver. */
1581
+ version: string;
1582
+ /**
1583
+ * The contract versions this module works with: a semver range matched
1584
+ * against `MODULE_CONTRACT_VERSION` (e.g. `'^1.1'`). Not satisfied → the
1585
+ * server refuses to start; missing → a start-up warning.
1586
+ */
1587
+ contract?: string;
1588
+ skill: ModuleSkill;
1589
+ /**
1590
+ * Per-app configuration (zod). Validates every configure_module call and
1591
+ * the dashboard form (JSON Schema via `z.toJSONSchema`). Keep secrets OUT.
1592
+ */
1593
+ configSchema: ZodType<Config>;
1594
+ /** The configuration of an app nobody configured (must pass configSchema). */
1595
+ configDefaults: Config;
1596
+ /**
1597
+ * Optional: the usable part of a STORED config that no longer passes
1598
+ * configSchema as a whole (a legacy import, a hand edit, a lowered cap).
1599
+ * Gets the merged config (defaults + stored) and returns the config to
1600
+ * serve with plus one line per part it dropped or could not fix (logged
1601
+ * once per stored content), or null to fall back to `configDefaults` — the
1602
+ * behaviour without it. configure_module still validates the WHOLE config,
1603
+ * so the next change has to repair it. E.g. data keeps every valid
1604
+ * collection instead of answering 404 for all of them.
1605
+ */
1606
+ salvageConfig?(merged: unknown): {
1607
+ config: Config;
1608
+ issues: string[];
1609
+ } | null;
1610
+ /**
1611
+ * The changes between two VALID configs that need the owner's confirmation
1612
+ * in the dashboard — e.g. an operation opened to `public`, a new e-mail
1613
+ * recipient. Non-empty → configure_module stores the change as pending.
1614
+ * Each string is shown to the owner and the agent verbatim. `context` names
1615
+ * the app and gives read access to the database (inside the configure
1616
+ * transaction), for rules that depend on stored data — e.g. removing the
1617
+ * schema of a collection that holds records. An item may be
1618
+ * `{ change, confirmRole: 'admin' }`: only a workspace admin can confirm
1619
+ * the pending change then (editors may still reject it).
1620
+ */
1621
+ confirmRequired?(before: Config, after: Config, context: ConfirmContext): ConfirmItem[] | Promise<ConfirmItem[]>;
1622
+ /**
1623
+ * Runs INSIDE the confirm transaction once the owner confirmed a pending
1624
+ * change (`before` / `after` = the effective configs). A throw rolls the
1625
+ * confirmation back. E.g. proxy records the app on the upstream's allow-list.
1626
+ */
1627
+ onConfirmed?(before: Config, after: Config, context: ConfirmedContext): void | Promise<void>;
1628
+ secrets?: ModuleSecretDoc[];
1629
+ rules?: RuleSurface;
1630
+ limits?: ModuleLimit[];
1631
+ /** Register the HTTP routes (called once at startup). */
1632
+ routes?(r: ModuleRouter<Config>): void;
1633
+ sdk?: ModuleSdk;
1634
+ migrations?: ModuleMigrations;
1635
+ hooks?: ModuleHooks;
1636
+ /** Only the module that creates end-user sessions (auth). */
1637
+ endUsers?: EndUserAuthority<Config>;
1638
+ /** Only the module that owns app e-mail (email): per-app limits + the envelope of every module e-mail. */
1639
+ mail?: MailAuthority<Config>;
1640
+ /**
1641
+ * Only the module that stores the app's records (data): the owner's
1642
+ * read-mostly view for core — MCP `query_data` and the dashboard's data
1643
+ * browser. Never called for an app host request.
1644
+ */
1645
+ records?: RecordsAuthority<Config>;
1646
+ /** Only the module that stores form submissions (forms): the owner's view for the dashboard. */
1647
+ submissions?: SubmissionsAuthority<Config>;
1648
+ /** Only the module that stores end-user uploads (files): the owner's view for the dashboard. */
1649
+ files?: FilesAuthority<Config>;
1650
+ /**
1651
+ * Secret-free facts about this module's state for ONE app, shown to the
1652
+ * app's agents: get_app's `modules.<name>.info` and configure_module's
1653
+ * `info` (after the change). E.g. the proxy module lists the workspace
1654
+ * upstreams the config points at with `hasSecret` — NEVER a secret value,
1655
+ * never another app's data. A throw is logged and the `info` left out.
1656
+ */
1657
+ appInfo?(view: ModuleAppView<Config>): Promise<Record<string, unknown>> | Record<string, unknown>;
1658
+ /**
1659
+ * Other modules this one needs (by name), e.g. `forms` requires `email`.
1660
+ * The server refuses to start when one of them is not in DROBEK_MODULES.
1661
+ */
1662
+ requires?: string[];
1663
+ /**
1664
+ * The module's own error codes (beyond the core catalogue) with their
1665
+ * meaning and fix — see ModuleErrorDoc.
1666
+ */
1667
+ errors?: ModuleErrorDoc[];
1668
+ /**
1669
+ * Extension points this module offers other modules, by slot name
1670
+ * (`<this module's name>.<name>`, e.g. `auth.provider`) — see ModuleSlot.
1671
+ */
1672
+ slots?: Record<string, ModuleSlot>;
1673
+ /**
1674
+ * This module's contribution to other modules' slots, by slot name
1675
+ * (e.g. `{ 'auth.provider': { id: 'oidc', … } }`). The slot must belong to
1676
+ * an active module and the value must pass its schema.
1677
+ */
1678
+ contributes?: Record<string, unknown>;
1679
+ /**
1680
+ * Optional, for a slot HOST whose config or secrets depend on what other
1681
+ * modules contribute (e.g. `auth` adds `providers.<id>` and the providers'
1682
+ * secrets for each `auth.provider` contribution). Called once at start,
1683
+ * after the contributions were checked and before
1684
+ * `DROBEK_MODULE_<NAME>_DEFAULTS` is applied; the parts it returns replace
1685
+ * the declared ones everywhere (configure_module, the dashboard, skill_info,
1686
+ * the test kit). The composed `configDefaults` must pass the composed
1687
+ * `configSchema`. A throw refuses the start.
1688
+ */
1689
+ compose?(input: ModuleComposeInput): ComposedModuleParts<Config>;
1690
+ /** Who the module is for (default `default`: every workspace) — see ModuleAvailability. */
1691
+ availability?: ModuleAvailability;
1692
+ /** How the dashboard presents the module. */
1693
+ dashboard?: ModuleDashboard;
1694
+ }
1695
+ /** A module of any config type (what the registry holds). */
1696
+ type AnyModule = DrobekModule<any>;
1697
+ /** What a slot host's `compose` gets at start: the checked contributions to every slot. */
1698
+ interface ModuleComposeInput {
1699
+ contributions<T = unknown>(slot: string): T[];
1700
+ }
1701
+ /** The parts of a module its `compose` may replace (the rest of the module stays as declared). */
1702
+ type ComposedModuleParts<Config = unknown> = Partial<Pick<DrobekModule<Config>, 'configSchema' | 'configDefaults' | 'salvageConfig' | 'confirmRequired' | 'secrets'>>;
1703
+ /** Declare a module (typed identity + a brand the registry checks). */
1704
+ declare function defineModule<Config>(module: DrobekModule<Config>): DrobekModule<Config>;
1705
+ /** Was `value` produced by defineModule()? */
1706
+ declare function isDefinedModule(value: unknown): value is DrobekModule;
1707
+ /** Limits for one workspace: env name → value (env default, or the limits provider). */
1708
+ type Limits = Readonly<Record<string, number>>;
1709
+ interface RateLimitResult {
1710
+ ok: boolean;
1711
+ /** Calls counted in the current window, this one included. */
1712
+ count: number;
1713
+ /** Seconds until the window resets (a hint for Retry-After). */
1714
+ retryAfterSec: number;
1715
+ }
1716
+ /** Who an e-mail may go to — never an arbitrary address (§5.4). */
1717
+ type EmailRecipient =
1718
+ /** The addresses at this dotted path of THIS module's app config (owner-confirmed). */
1719
+ {
1720
+ config: string;
1721
+ }
1722
+ /** The signed-in end user making the request (their verified e-mail). */
1723
+ | {
1724
+ principal: true;
1725
+ }
1726
+ /**
1727
+ * The app's owners: the editors and workspace-admins of the app's workspace
1728
+ * (verified drobek accounts; membership is managed by people in the
1729
+ * dashboard, never by an agent).
1730
+ */
1731
+ | {
1732
+ appOwners: true;
1733
+ }
1734
+ /**
1735
+ * The ONE address someone is signing in with — the one-time code of the
1736
+ * auth module, sent only after the address passed the app's owner-confirmed
1737
+ * allowlist and the sign-in rate limits. Not for anything else (and never
1738
+ * combined with another recipient). Only the module that owns end-user
1739
+ * sessions (`endUsers`) may use it; any other module gets `forbidden`.
1740
+ */
1741
+ | {
1742
+ signInAddress: string;
1743
+ };
1744
+ interface EmailMessage {
1745
+ /** One recipient reference or several (de-duplicated; every address gets its own message). */
1746
+ to: EmailRecipient | EmailRecipient[];
1747
+ /** One line: control characters (CR/LF, …) become spaces, max 200 characters. */
1748
+ subject: string;
1749
+ /** Plain text (max 20 000 characters); the server wraps it in the drobek layout (escaped). */
1750
+ text: string;
1751
+ }
1752
+ /** App-independent services (hooks, startup). */
1753
+ interface ModuleServices {
1754
+ db: DB;
1755
+ log: Logger;
1756
+ /**
1757
+ * The contributions of the active modules to `slot` (a slot THIS module
1758
+ * declares, or any other active module's), in `DROBEK_MODULES` order, as
1759
+ * the slot's schema parsed them; [] when nobody contributes. In a route,
1760
+ * `endUsers.current` and the create / publish hooks: only the modules that
1761
+ * are on for the app's workspace (an opt-in module switched off there
1762
+ * contributes nothing); with no app known (the end-user callback before
1763
+ * `app()`): only the default modules; in `onAppDelete`: every active
1764
+ * module. Type it with the slot's value type:
1765
+ * `contributions<Provider>('auth.provider')`.
1766
+ */
1767
+ contributions<T = unknown>(slot: string): T[];
1768
+ }
1769
+ /** Everything a route handler gets — scoped to ONE app and ONE module. */
1770
+ interface ModuleContext<Config = unknown> extends ModuleServices {
1771
+ app: HookApp;
1772
+ module: string;
1773
+ principal: Principal;
1774
+ /** This app's effective config (defaults + what was set). */
1775
+ config: Config;
1776
+ rules: {
1777
+ /** Evaluate `rule` for the caller; `ownerId` = the record's owner (for `owner`). */
1778
+ decide(rule: Rule, ownerId?: string | null): AccessDecision;
1779
+ };
1780
+ /** Limits of this app's workspace (env defaults or the limits provider). */
1781
+ limits(): Promise<Limits>;
1782
+ /** Fixed-window counter, namespaced to this module + app: `bucket` × `key`. */
1783
+ rateLimit(bucket: string, key: string, max: number, windowMs: number): Promise<RateLimitResult>;
1784
+ secrets: {
1785
+ /** The plaintext of this app's secret `name` for this module (in memory only), or null. */
1786
+ get(name: string): Promise<string | null>;
1787
+ };
1788
+ /** Append an audit row for this app (actor derived by the server). */
1789
+ audit(action: string, meta?: Record<string, unknown>): Promise<void>;
1790
+ email: {
1791
+ /**
1792
+ * Send to allowed recipients only (never an arbitrary address). Resolves
1793
+ * `{ sent }` (0 when no address resolved). Rejects with a ModuleError:
1794
+ * `limit_exceeded` (the app's e-mail limits), `unavailable` (e-mail of
1795
+ * this class — sign-in codes or notifications — is paused by the
1796
+ * operator-wide hourly budget, the app used its hourly share of
1797
+ * notifications, or no mail module is active).
1798
+ */
1799
+ send(message: EmailMessage): Promise<{
1800
+ sent: number;
1801
+ }>;
1802
+ /**
1803
+ * Sign-in codes (`{ signInAddress }`) this app may send per hour under the
1804
+ * operator-wide budget (EMAIL_SIGNIN_APP_HOURLY_SHARE of the sign-in
1805
+ * budget) — a module's own per-app cap must not exceed it (NSO-322 H2).
1806
+ * Undefined when core runs no e-mail guard (tests).
1807
+ */
1808
+ signInShare?: number;
1809
+ };
1810
+ }
1811
+ interface ModuleRequest<Body = unknown, Query = Record<string, string>> {
1812
+ method: string;
1813
+ /** Path below `/__drobek/v1/<module>`, always starting with `/`. */
1814
+ path: string;
1815
+ /**
1816
+ * `:name` segments of the route pattern (percent-decoded). A trailing `*`
1817
+ * segment captures the rest of the path in `params['*']` — RAW
1818
+ * (percent-encoded, no leading slash, '' when nothing follows).
1819
+ */
1820
+ params: Record<string, string>;
1821
+ /** Query parameters (validated when the route declares `query`). */
1822
+ query: Query;
1823
+ /** The raw query string, without `?` ('' when none) — repeated keys and encoding intact. */
1824
+ rawQuery: string;
1825
+ /** Parsed JSON body (validated when the route declares `body`). */
1826
+ body: Body;
1827
+ header(name: string): string | null;
1828
+ /** Every request header (lower-cased names; repeated ones joined with `, `). */
1829
+ headers(): Record<string, string>;
1830
+ clientIp: string | null;
1831
+ /**
1832
+ * The ONE file of a `bodyTypes: ['file']` route (multipart/form-data),
1833
+ * streamed — never buffered by the router. Rejects with a ModuleError
1834
+ * (`unsupported_media_type`, `invalid_request`) when the body is not such a
1835
+ * multipart body; throws on any other route. Call it once.
1836
+ */
1837
+ file(): Promise<UploadedFile>;
1838
+ }
1839
+ /** The file part of a multipart upload (`req.file()`). Everything but `stream` is client-supplied: never trust it. */
1840
+ interface UploadedFile {
1841
+ /** The multipart field name of the file part. */
1842
+ field: string;
1843
+ /** The client's file name ('' when none) — untrusted. */
1844
+ filename: string;
1845
+ /** The Content-Type the client declared for the part, or null — untrusted (sniff the bytes). */
1846
+ declaredType: string | null;
1847
+ /** Text fields sent BEFORE the file part (a field after it is refused). */
1848
+ fields: Record<string, string>;
1849
+ /**
1850
+ * The file's bytes as they arrive. Read it once. The route caps the size
1851
+ * itself: stop early by leaving the loop (`break`/`throw`) — the rest of the
1852
+ * request is then discarded without being buffered. Iteration throws a
1853
+ * ModuleError `invalid_request` on a malformed body (no closing boundary, a
1854
+ * second part) and an Error when the client aborts.
1855
+ */
1856
+ stream: AsyncIterable<Buffer>;
1857
+ }
1858
+ /** A non-JSON-200 answer: `respond(status, body, headers)`. */
1859
+ interface ModuleResponse {
1860
+ readonly __drobekResponse: true;
1861
+ status: number;
1862
+ /** JSON-serialisable value, a string/Buffer sent as-is, or a Node `Readable` streamed as-is (e.g. a file). */
1863
+ body: unknown;
1864
+ headers: Record<string, string>;
1865
+ }
1866
+ declare function respond(status: number, body?: unknown, headers?: Record<string, string>): ModuleResponse;
1867
+ type RouteHandler<Config, Body, Query> = (req: ModuleRequest<Body, Query>, ctx: ModuleContext<Config>) => Promise<unknown> | unknown;
1868
+ interface RouteRateLimit {
1869
+ /** Bucket name (namespaced by core to the module + app). */
1870
+ bucket: string;
1871
+ /** Max calls per window: a number, or the env name of one of the module's limits. */
1872
+ max: number | string;
1873
+ windowMs: number;
1874
+ /**
1875
+ * What the counter keys on (default `ip`; `principal` = the signed-in user,
1876
+ * the IP for an anonymous caller). A request without a resolved client IP
1877
+ * skips an IP-keyed limit — no shared bucket (NSO-328).
1878
+ */
1879
+ per?: 'ip' | 'app' | 'principal';
1880
+ }
1881
+ interface RouteOptions<Config = unknown, Body = unknown, Query = Record<string, string>> {
1882
+ /** Validates the JSON body (400 `invalid_request` with field paths otherwise). */
1883
+ body?: ZodType<Body>;
1884
+ /** Validates the query parameters (strings). */
1885
+ query?: ZodType<Query>;
1886
+ /** Access rule for the caller — fixed, or derived from the app's config. */
1887
+ rule?: Rule | ((config: Config) => Rule);
1888
+ rateLimit?: RouteRateLimit;
1889
+ /** Max request body (default 32 KiB). */
1890
+ maxBodyBytes?: number;
1891
+ /**
1892
+ * Accepted body formats (default `['json']`). `multipart` =
1893
+ * `multipart/form-data` with text fields only: the body becomes
1894
+ * `{ name: value }` (a repeated name → an array of values); a file part is
1895
+ * refused (415). `raw` = any content type, unparsed: the body is the
1896
+ * `Buffer` (undefined when empty) and `body` validation is skipped — for
1897
+ * pass-through routes (the proxy). Anything else is
1898
+ * `415 unsupported_media_type`.
1899
+ * `file` = `multipart/form-data` carrying ONE file: the router does not read
1900
+ * the body (and `maxBodyBytes` does not apply) — the handler streams it with
1901
+ * `req.file()` and MUST cap its size itself. Exclusive: a `file` route takes
1902
+ * no JSON body.
1903
+ */
1904
+ bodyTypes?: Array<'json' | 'multipart' | 'raw' | 'file'>;
1905
+ /**
1906
+ * CSRF guard for mutating methods (POST/PUT/PATCH/DELETE). Always: an
1907
+ * `Origin` header, when present, must be the app host itself. Default
1908
+ * `sdk-header` additionally requires `X-Drobek-SDK: 1` (the SDK sends it; a
1909
+ * cross-site form or fetch cannot without a CORS preflight the apps origin
1910
+ * never grants). `same-origin` drops the header requirement — only for
1911
+ * endpoints a browser calls without custom headers (e.g. navigator.sendBeacon).
1912
+ */
1913
+ csrf?: 'sdk-header' | 'same-origin';
1914
+ }
1915
+ interface ModuleRouter<Config = unknown> {
1916
+ get<Q = Record<string, string>>(path: string, opts: RouteOptions<Config, undefined, Q>, handler: RouteHandler<Config, undefined, Q>): void;
1917
+ get(path: string, handler: RouteHandler<Config, undefined, Record<string, string>>): void;
1918
+ post<B = unknown, Q = Record<string, string>>(path: string, opts: RouteOptions<Config, B, Q>, handler: RouteHandler<Config, B, Q>): void;
1919
+ post(path: string, handler: RouteHandler<Config, unknown, Record<string, string>>): void;
1920
+ put<B = unknown, Q = Record<string, string>>(path: string, opts: RouteOptions<Config, B, Q>, handler: RouteHandler<Config, B, Q>): void;
1921
+ put(path: string, handler: RouteHandler<Config, unknown, Record<string, string>>): void;
1922
+ patch<B = unknown, Q = Record<string, string>>(path: string, opts: RouteOptions<Config, B, Q>, handler: RouteHandler<Config, B, Q>): void;
1923
+ patch(path: string, handler: RouteHandler<Config, unknown, Record<string, string>>): void;
1924
+ delete<Q = Record<string, string>>(path: string, opts: RouteOptions<Config, undefined, Q>, handler: RouteHandler<Config, undefined, Q>): void;
1925
+ delete(path: string, handler: RouteHandler<Config, undefined, Record<string, string>>): void;
1926
+ }
1927
+
1928
+ declare const SDK_PATH = "/__drobek/sdk.js";
1929
+ declare const SDK_TYPES_PATH = "/__drobek/sdk.d.ts";
1930
+ /** The browser error beacon script (M1-07). */
1931
+ declare const BEACON_SCRIPT_PATH = "/__drobek/beacon.js";
1932
+ /** The bundled beacon script: `url` = `/__drobek/beacon.js?v=<hash>` (what the compiler imports). */
1933
+ interface BeaconScript {
1934
+ js: Buffer;
1935
+ hash: string;
1936
+ url: string;
1937
+ }
1938
+ interface SdkBundle {
1939
+ js: Buffer;
1940
+ dts: string;
1941
+ hash: string;
1942
+ /** `/__drobek/sdk.js?v=<hash>` */
1943
+ url: string;
1944
+ /** The active modules it contains, in order. */
1945
+ modules: string[];
1946
+ /**
1947
+ * `drobek/<module>` → the source of that module's `sdk.inline` (read at
1948
+ * start). The compiler builds these INTO an app that imports them, with the
1949
+ * app's own import map (M1-02) — they are not part of `js`.
1950
+ */
1951
+ inline: Record<string, string>;
1952
+ /** The error beacon script every compiled app imports (M1-07). */
1953
+ beacon: BeaconScript;
1954
+ }
1955
+ /** The import specifier of a module's inline SDK source. */
1956
+ declare function inlineSpecifier(module: string): string;
1957
+ /** Bundle the beacon script (esbuild, in memory, minified — it loads on every app page). */
1958
+ declare function buildBeaconScript(entry?: string): Promise<BeaconScript>;
1959
+ declare function sdkDeclarations(modules: AnyModule[]): string;
1960
+ /** Bundle the SDK for `modules` (esbuild, in memory). Throws on a broken module entry. */
1961
+ declare function buildSdk(modules: AnyModule[], coreEntry?: string, beaconEntry?: string): Promise<SdkBundle>;
1962
+
1963
+ /**
1964
+ * The operator-wide brake on module e-mail (M1-04, §6 "Spam"; NSO-320): every
1965
+ * address any platform module sends to counts against an hourly budget of
1966
+ * `EMAIL_GLOBAL_HOURLY_MAX` recipients (all apps together), split in two
1967
+ * classes so a flood of notifications can never lock end users out:
1968
+ *
1969
+ * - `sign_in` — the auth module's one-time codes (`{ signInAddress }`): a
1970
+ * reserved share, `EMAIL_SIGNIN_HOURLY_MAX` (default 20 % of the global
1971
+ * cap, at least 50, never more than half of it), and ONE app may use at
1972
+ * most `EMAIL_SIGNIN_APP_HOURLY_SHARE` percent of it (default 25, at least
1973
+ * 10 codes) — so one app can never pause sign-in for all (NSO-322 H2);
1974
+ * - `notification` — everything else (form notifications, notifyAdmins,
1975
+ * mail to the signed-in user): the rest of the global cap, and ONE app
1976
+ * may use at most `EMAIL_APP_HOURLY_SHARE` percent of it (default 25).
1977
+ *
1978
+ * On top of the per-app shares, ONE workspace (all its apps together) may use
1979
+ * at most `EMAIL_WORKSPACE_HOURLY_SHARE` percent of each class (default 50,
1980
+ * never less than one app's share) — so a workspace with four apps cannot
1981
+ * take a whole class and pause it for every other workspace (NSO-323 M4).
1982
+ * Both shares must pass.
1983
+ *
1984
+ * The two class budgets add up to the global cap, so the operator's mailbox
1985
+ * sees at most `EMAIL_GLOBAL_HOURLY_MAX` module recipients per budget window.
1986
+ * Past a class budget, THAT class pauses for exactly
1987
+ * `EMAIL_GLOBAL_PAUSE_MINUTES` and an ALERT line for the super-admin goes to
1988
+ * the log (`event: email_global_pause`, with the `class`): the mailbox is
1989
+ * protected before the SMTP provider suspends it. The pause is a FIXED window
1990
+ * (NSO-327): tripping it also resets the class counter, so the first message
1991
+ * after the pause starts a fresh hourly budget instead of re-tripping the
1992
+ * pause until the old hour ends — the per-app and per-workspace shares stay
1993
+ * hourly, so the apps that filled the class stay refused until THEIR hour
1994
+ * ends. The other class keeps working. Past its
1995
+ * share of a class, one app's (or one workspace's) messages of that class are
1996
+ * refused until its hourly window ends; other apps (workspaces) continue. Pattern of the dashboard's
1997
+ * `OTP_GLOBAL_HOURLY_MAX` auto-pause (@drobek/auth otp-guard).
1998
+ *
1999
+ * These are operator knobs (env only): the limits provider does not override
2000
+ * them, because they protect the operator's mailbox, not a workspace's plan.
2001
+ * The dashboard login codes and workspace invites keep their own guard.
2002
+ *
2003
+ * Redis keys: `drobek:rl:mail:<class>` (the hourly class counters),
2004
+ * `drobek:rl:mail:app:<app_id>` (one app's notification counter),
2005
+ * `drobek:rl:mail:app:<app_id>:sign_in` (one app's sign-in counter),
2006
+ * `drobek:rl:mail:ws:<workspace_id>` / `drobek:rl:mail:ws:<workspace_id>:sign_in`
2007
+ * (one workspace's counters) and
2008
+ * `drobek:mail:paused:<class>` (the pause; delete it to resume early). Any
2009
+ * Redis error is FAIL-CLOSED: no e-mail is sent.
2010
+ */
2011
+
2012
+ /** The budget a message counts against (= its EmailKind). */
2013
+ type MailClass = EmailKind;
2014
+ declare const MAIL_COUNTER_KEYS: Readonly<Record<MailClass, string>>;
2015
+ declare const MAIL_PAUSE_KEYS: Readonly<Record<MailClass, string>>;
2016
+ /** One app's counter of a class (the per-app share). */
2017
+ declare function mailAppCounterKey(appId: string, cls?: MailClass): string;
2018
+ interface MailGuardConfig {
2019
+ /** Addresses per hour across every app and both classes (EMAIL_GLOBAL_HOURLY_MAX, default 500). */
2020
+ hourlyMax: number;
2021
+ /** Pause after a class budget is hit, minutes (EMAIL_GLOBAL_PAUSE_MINUTES, default 15). */
2022
+ pauseMinutes: number;
2023
+ /** Sign-in codes per hour (EMAIL_SIGNIN_HOURLY_MAX); unset = the default reserved share. */
2024
+ signInHourlyMax?: number;
2025
+ /** Percent of the notification budget one app may use per hour (EMAIL_APP_HOURLY_SHARE, default 25). */
2026
+ appSharePercent?: number;
2027
+ /** Percent of the sign-in budget one app may use per hour (EMAIL_SIGNIN_APP_HOURLY_SHARE, default 25). */
2028
+ signInAppSharePercent?: number;
2029
+ /** Percent of each class budget one workspace (all its apps) may use per hour (EMAIL_WORKSPACE_HOURLY_SHARE, default 50). */
2030
+ workspaceSharePercent?: number;
2031
+ }
2032
+ /** The effective hourly budgets (recipients) derived from the config. */
2033
+ interface MailBudgets {
2034
+ global: number;
2035
+ sign_in: number;
2036
+ notification: number;
2037
+ /** Notifications per app per hour. */
2038
+ perApp: number;
2039
+ /** Sign-in codes per app per hour. */
2040
+ perAppSignIn: number;
2041
+ /** Notifications per workspace per hour (all its apps). */
2042
+ perWorkspace: number;
2043
+ /** Sign-in codes per workspace per hour (all its apps). */
2044
+ perWorkspaceSignIn: number;
2045
+ }
2046
+ /**
2047
+ * sign_in = EMAIL_SIGNIN_HOURLY_MAX, or by default min(max(50, ⌈20 % × G⌉),
2048
+ * ⌊G / 2⌋); an explicit value is capped at G − 1. notification = G − sign_in
2049
+ * (at least 1). perApp = ⌊notification × share / 100⌋ (at least 1).
2050
+ * perAppSignIn = ⌊sign_in × sign-in share / 100⌋, at least
2051
+ * MIN_SIGN_IN_APP_SHARE, never more than sign_in. perWorkspace /
2052
+ * perWorkspaceSignIn = ⌊class × workspace share / 100⌋, never less than the
2053
+ * app share of that class, never more than the class.
2054
+ */
2055
+ declare function mailBudgets(c: MailGuardConfig): MailBudgets;
2056
+ declare function mailGuardConfigFromEnv(env?: NodeJS.ProcessEnv): MailGuardConfig;
2057
+ /** Which message the guard is asked about (logged; never an address). */
2058
+ interface MailGuardMeta {
2059
+ app_id: string;
2060
+ /** The app's workspace (its apps share the per-workspace budget). */
2061
+ workspace_id: string;
2062
+ module: string;
2063
+ /** `sign_in` counts against the sign-in budget; anything else is a notification. */
2064
+ kind: EmailKind;
2065
+ }
2066
+ interface MailGuard {
2067
+ /** The hourly budgets it enforces (modules clamp their own per-app caps to them). */
2068
+ readonly budgets?: MailBudgets;
2069
+ /** Refuse (ModuleError `unavailable`, 503) while the message's class — or its app's or workspace's share — is used up. */
2070
+ assertOpen(meta: MailGuardMeta): Promise<void>;
2071
+ /** Count `recipients` against the app's share, its workspace's share and the class budget; past it: pause (fixed window, class counter reset), ALERT, refuse. */
2072
+ admit(recipients: number, meta: MailGuardMeta): Promise<void>;
2073
+ }
2074
+ interface MailGuardRedis {
2075
+ get(key: string): Promise<string | null>;
2076
+ pttl(key: string): Promise<number>;
2077
+ incrby(key: string, n: number): Promise<number>;
2078
+ pexpire(key: string, ms: number): Promise<number>;
2079
+ set(key: string, value: string, px: 'PX', ms: number): Promise<unknown>;
2080
+ }
2081
+ /** The production guard (Redis). */
2082
+ declare function redisMailGuard(opts: {
2083
+ redis: () => MailGuardRedis;
2084
+ config: MailGuardConfig;
2085
+ log: Logger;
2086
+ }): MailGuard;
2087
+ /** An in-process Redis stand-in with the commands the guard uses (`now` is the clock seam). */
2088
+ declare function memoryMailGuardRedis(now?: () => number): MailGuardRedis & {
2089
+ clear(): void;
2090
+ };
2091
+ /** An in-process guard (tests; `now` is the clock seam) — the Redis guard over an in-memory store. */
2092
+ declare function memoryMailGuard(config: MailGuardConfig, log: Logger, now?: () => number): MailGuard & {
2093
+ reset(): void;
2094
+ };
2095
+
2096
+ export { BEACON_SCRIPT_PATH as B, MAIL_COUNTER_KEYS as T, MAIL_PAUSE_KEYS as V, MODULE_CONTRACT_VERSION as W, MODULE_ERROR_CODE_RE as X, MODULE_NAME_RE as Y, memoryMailGuard as aA, memoryMailGuardRedis as aB, normalizeConfirmItems as aC, redisMailGuard as aD, respond as aE, sdkDeclarations as aF, RECORDS_IMPORT_MAX_ROWS as ai, SDK_PATH as ao, SDK_TYPES_PATH as ap, SLOT_NAME_RE as aq, buildBeaconScript as as, buildSdk as at, defineModule as au, inlineSpecifier as av, isDefinedModule as aw, mailAppCounterKey as ax, mailBudgets as ay, mailGuardConfigFromEnv as az };
2097
+ export type { MailClass as $, AccessDecision as A, ConfirmRole as C, DB as D, EndUser as E, ConfirmContext as F, ConfirmItem as G, HookApp as H, ConfirmedContext as I, DrobekModule as J, EndUserAuthority as K, Logger as L, ModuleSecretDoc as M, EndUserCallbackApp as N, OwnerFilesPage as O, Principal as P, FilesAuthority as Q, Rule as R, SubmissionsQuery as S, UploadedFile as U, MailAuthority as Z, MailBudgets as _, ModuleLimit as a, MailGuardConfig as a0, MailGuardMeta as a1, MailGuardRedis as a2, MailPrepareInput as a3, ModuleAppView as a4, ModuleComposeInput as a5, ModuleContext as a6, ModuleDashboard as a7, ModuleHooks as a8, ModuleMigrations as a9, ModuleRequest as aa, ModuleResponse as ab, ModuleRouter as ac, ModuleSdk as ad, ModuleSkill as ae, ModuleSlot as af, OwnerSubmission as ag, OwnerView as ah, RecordsAuthority as aj, RecordsView as ak, RouteHandler as al, RouteOptions as am, RouteRateLimit as an, SubmissionsAuthority as ar, Limits as b, AnyModule as c, EmailMessage as d, EmailKind as e, EmailRecipient as f, EndUserListQuery as g, EndUserPage as h, EndUserRecord as i, OwnerFile as j, RecordsCollection as k, RecordsQuery as l, RecordsPage as m, SubmissionsPage as n, MailEnvelope as o, RateLimitResult as p, MailGuard as q, ModuleAvailability as r, ModuleDashboardEditor as s, ModuleErrorDoc as t, SdkBundle as u, ModuleServices as v, EndUserCallbackInput as w, EndUserCallbackResult as x, BeaconScript as y, ComposedModuleParts as z };