@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.
- package/LICENSE +661 -0
- package/README.md +53 -0
- package/dist/chunk-WHKPW5MP.js +6611 -0
- package/dist/index.d.ts +1604 -0
- package/dist/index.js +373 -0
- package/dist/mail-guard.d-BKJhsVA2.d.ts +2097 -0
- package/dist/migrations/core/0000_dusty_scarecrow.sql +84 -0
- package/dist/migrations/core/0001_busy_orphan.sql +69 -0
- package/dist/migrations/core/0002_odd_alex_power.sql +17 -0
- package/dist/migrations/core/0003_nosy_shatterstar.sql +26 -0
- package/dist/migrations/core/0004_nice_ben_parker.sql +27 -0
- package/dist/migrations/core/0005_panoramic_bill_hollister.sql +4 -0
- package/dist/migrations/core/0006_outgoing_ricochet.sql +29 -0
- package/dist/migrations/core/0007_app_versions.sql +95 -0
- package/dist/migrations/core/0008_user_bound_tokens.sql +42 -0
- package/dist/migrations/core/0009_app_name.sql +1 -0
- package/dist/migrations/core/0010_apps_origin.sql +12 -0
- package/dist/migrations/core/0011_modules.sql +32 -0
- package/dist/migrations/core/0012_data_module_tables.sql +9 -0
- package/dist/migrations/core/0014_get_logs.sql +26 -0
- package/dist/migrations/core/0016_apps_slug_release.sql +8 -0
- package/dist/migrations/core/0018_custom_domains.sql +22 -0
- package/dist/migrations/core/0021_abuse_reports.sql +23 -0
- package/dist/migrations/core/0022_gallery.sql +20 -0
- package/dist/migrations/core/0023_workspace_modules.sql +13 -0
- package/dist/migrations/core/0024_app_assets.sql +18 -0
- package/dist/migrations/core/0025_app_asset_snapshots.sql +28 -0
- package/dist/migrations/core/0026_publish_approval.sql +13 -0
- package/dist/migrations/core/0027_publish_block.sql +6 -0
- package/dist/migrations/core/meta/_journal.json +167 -0
- package/dist/testing.d.ts +269 -0
- package/dist/testing.js +1569 -0
- 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 };
|