@kahitsan/plugin-sdk 0.2.0 → 0.3.0-staging.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,499 @@
1
+ // The dev-stub TOOLKIT host (Vision §5.1/§5.2): the kernel core in dev mode. It
2
+ // serves the SAME backend contract the REAL host shell calls — so the unmodified
3
+ // vinxi UI renders the plugin in the real Sidebar/PageShell/theme/plugin-renderer —
4
+ // but with the private extensions swapped for dev stubs: auth → a synthetic identity
5
+ // directory (auto-login, no login screen), data → the SQLite dev plugins.
6
+ //
7
+ // The directory ships DEFAULTS a plugin developer can play with immediately — 3 users
8
+ // and 3 workspaces — and a "view-as" control (§5.1) lets them switch the active workspace,
9
+ // user, and role INDEPENDENTLY at runtime (including custom roles they define) to see how
10
+ // their plugin's permission gating responds, with zero login/seeding. The active view is
11
+ // forwarded to each plugin as `x-kserp-dev-identity`, so the role's grants gate the
12
+ // plugin's actions and the workspace scopes its data, live.
13
+ //
14
+ // Run it on the API port the vinxi UI proxies `/api` to; launch the dev plugins
15
+ // separately and pass their registry in KSERP_DEV_PLUGINS. Bun-only. Never in prod.
16
+
17
+ import { readFileSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ import sdkPkg from "../../package.json";
20
+ import { handleDevAsset } from "./dev-assets.js";
21
+
22
+ const SDK_VERSION: string = sdkPkg.version;
23
+
24
+ interface DevPlugin {
25
+ name: string;
26
+ port: number;
27
+ basePath: string;
28
+ uiRouteBase: string;
29
+ label: string;
30
+ version: string;
31
+ nav: unknown;
32
+ /** The plugin's declared permission keys (from its manifest) — drives role gating. */
33
+ permissions: string[];
34
+ }
35
+
36
+ const PORT = Number(process.env.KSERP_DEV_KERNEL_PORT || process.env.API_PORT || "4061");
37
+ // The shared internal secret for cross-plugin RPC: plugins verify the inbound
38
+ // `x-kserp-internal` header against their KSERP_INTERNAL_SECRET, so the dev-kernel must
39
+ // forward the SAME value the plugins were launched with (else peer calls are Forbidden).
40
+ const INTERNAL_SECRET = process.env.KSERP_INTERNAL_SECRET || "dev-stub-internal-secret";
41
+ // Cache-bust the remote bundles per dev-kernel boot: prod versions the URL, but in dev
42
+ // the URL is stable and the browser would otherwise serve a stale remote.js (e.g. one
43
+ // cached while a port was briefly mapped to the wrong plugin). New boot → fresh fetch.
44
+ const BOOT_NONCE = String(process.env.KSERP_DEV_BOOT_NONCE || Date.now());
45
+
46
+ // The registry only needs each plugin's `dir` (+ the `port` it's served on); EVERYTHING
47
+ // else — name, routes, nav, and especially the PERMISSIONS the custom-role builder offers
48
+ // — is read live from that plugin's `plugin.manifest.json`, so the toolkit always reflects
49
+ // the actual plugin. Explicit registry fields still override (for a plugin without a dir).
50
+ interface DevPluginInput extends Partial<DevPlugin> {
51
+ dir?: string;
52
+ }
53
+ function loadPlugins(): DevPlugin[] {
54
+ const input: DevPluginInput[] = JSON.parse(process.env.KSERP_DEV_PLUGINS || "[]");
55
+ return input.map((p) => {
56
+ let m: Partial<DevPlugin> = {};
57
+ if (p.dir) {
58
+ try {
59
+ m = JSON.parse(
60
+ readFileSync(join(p.dir, "plugin.manifest.json"), "utf8"),
61
+ ) as Partial<DevPlugin>;
62
+ } catch {
63
+ console.warn(`[dev-kernel] could not read manifest at ${p.dir}/plugin.manifest.json`);
64
+ }
65
+ }
66
+ const name = p.name ?? m.name ?? "plugin";
67
+ return {
68
+ name,
69
+ port: Number(p.port),
70
+ basePath: p.basePath ?? m.basePath ?? `/api/${name}`,
71
+ uiRouteBase: p.uiRouteBase ?? m.uiRouteBase ?? name,
72
+ label: p.label ?? m.label ?? name,
73
+ version: p.version ?? m.version ?? "0.0.0",
74
+ nav: p.nav ?? m.nav ?? null,
75
+ permissions: p.permissions ?? m.permissions ?? [],
76
+ };
77
+ });
78
+ }
79
+ const PLUGINS: DevPlugin[] = loadPlugins();
80
+
81
+ // ── the synthetic identity directory (the auth extension, stubbed) ──
82
+ // A plugin developer builds for WORKSPACES, so the toolkit exposes ONLY workspace roles —
83
+ // never the confidential platform roles (superuser/member). The baseline is workspace
84
+ // "admin"; there's a read-only "viewer"; and the dev can CREATE custom roles at runtime
85
+ // (granting a chosen subset of the plugin's permissions), exactly like a real workspace
86
+ // defining "accountant"/"staff". Workspace, user, and role are INDEPENDENT dimensions the
87
+ // dev switches freely (a dev affordance — not something a real workspace-admin could do).
88
+ interface DevUser {
89
+ id: string;
90
+ name: string;
91
+ email: string;
92
+ }
93
+ interface DevWorkspace {
94
+ id: number;
95
+ name: string;
96
+ slug: string;
97
+ }
98
+
99
+ const USERS: readonly DevUser[] = [
100
+ { id: "dev-user-1", name: "Alex Santos", email: "alex@localhost" },
101
+ { id: "dev-user-2", name: "Bea Cruz", email: "bea@localhost" },
102
+ { id: "dev-user-3", name: "Caloy Reyes", email: "caloy@localhost" },
103
+ ];
104
+ const WORKSPACES: readonly DevWorkspace[] = [
105
+ { id: 1, name: "Acme Trading", slug: "acme" },
106
+ { id: 2, name: "Sunrise Cafe", slug: "sunrise" },
107
+ { id: 3, name: "Metro Hardware", slug: "metro" },
108
+ ];
109
+ // Built-in workspace roles. "admin" has full access (bypass); "viewer" is read-only.
110
+ const BUILTIN_ROLES = ["admin", "viewer"];
111
+ // Custom roles the dev creates at runtime: name → the plugin permissions it grants.
112
+ const customRoles: { name: string; permissions: string[] }[] = [];
113
+ const roleNames = (): string[] => [...BUILTIN_ROLES, ...customRoles.map((r) => r.name)];
114
+
115
+ // The active "view-as" selection — workspace, user, and role are INDEPENDENT (switching
116
+ // one never resets the others). Mutated by POST /api/_dev/view-as, read by every shell
117
+ // endpoint + forwarded to plugins. Process-global (a single local dev session).
118
+ const view = { workspaceId: WORKSPACES[0].id, userId: USERS[0].id, role: "admin" };
119
+ const currentUser = (): DevUser => USERS.find((u) => u.id === view.userId) ?? USERS[0];
120
+ const currentWorkspace = (): DevWorkspace =>
121
+ WORKSPACES.find((w) => w.id === view.workspaceId) ?? WORKSPACES[0];
122
+
123
+ // The plugin permissions a custom role can grant (and the read-ish subset "viewer" gets).
124
+ const allPluginPermissions = (): string[] => [
125
+ ...new Set(PLUGINS.flatMap((p) => p.permissions ?? [])),
126
+ ];
127
+ const READ_PERM = /(?:^|[._-])(view|read|list|get|show|index|export)(?:$|[._-])/i;
128
+ const DESTRUCTIVE_PERM = /(?:^|[._-])(delete|remove|destroy|purge|archive)(?:$|[._-])/i;
129
+ // Per-role grant overrides edited live via the Share matrix (PUT /role-permissions). An
130
+ // override REPLACES the role's computed default, so a matrix edit drives the plugin's gating
131
+ // immediately — the dev customizes a role and watches the plugin respond, like a real ws.
132
+ const roleGrantOverrides = new Map<string, Set<string>>();
133
+
134
+ function grantsForRoleDefault(role: string): { bypass: boolean; permissions: string[] } {
135
+ if (role === "admin") return { bypass: true, permissions: [] };
136
+ if (role === "viewer")
137
+ return { bypass: false, permissions: allPluginPermissions().filter((k) => READ_PERM.test(k)) };
138
+ const custom = customRoles.find((r) => r.name === role);
139
+ return { bypass: false, permissions: custom ? custom.permissions : [] };
140
+ }
141
+
142
+ // The role's effective permission set as the Share matrix shows it (override wins; admin's
143
+ // bypass expands to "everything checked").
144
+ function effectivePermSet(role: string): Set<string> {
145
+ const ov = roleGrantOverrides.get(role);
146
+ if (ov) return ov;
147
+ const g = grantsForRoleDefault(role);
148
+ return new Set(g.bypass ? allPluginPermissions() : g.permissions);
149
+ }
150
+
151
+ function grantsForRole(role: string): { bypass: boolean; permissions: string[] } {
152
+ if (roleGrantOverrides.has(role))
153
+ return { bypass: false, permissions: [...effectivePermSet(role)] };
154
+ return grantsForRoleDefault(role);
155
+ }
156
+
157
+ // The kernel's role→permission matrix contract — what the gear's PluginSettingsModal "Share"
158
+ // tab reads (GET) and writes (PUT). The SAME shape the real host serves, so the UNMODIFIED
159
+ // kernel modal works in the toolkit: customize a role's grants and the plugin's gating follows.
160
+ function rolePermissionsResponse() {
161
+ const roles = roleNames().map((code, i) => ({
162
+ code,
163
+ label: code.charAt(0).toUpperCase() + code.slice(1),
164
+ description: null,
165
+ sort_order: i,
166
+ }));
167
+ const permissions = allPluginPermissions().map((code, i) => {
168
+ const [module, action = ""] = code.split(".");
169
+ return {
170
+ code,
171
+ module,
172
+ action,
173
+ label: action ? action.charAt(0).toUpperCase() + action.slice(1) : code,
174
+ description: null,
175
+ sort_order: i,
176
+ is_destructive: DESTRUCTIVE_PERM.test(code),
177
+ };
178
+ });
179
+ const grants: { role_code: string; permission_code: string; allowed: boolean }[] = [];
180
+ for (const r of roles) {
181
+ const eff = effectivePermSet(r.code);
182
+ for (const p of permissions)
183
+ grants.push({ role_code: r.code, permission_code: p.code, allowed: eff.has(p.code) });
184
+ }
185
+ return { workspace_id: view.workspaceId, roles, permissions, grants };
186
+ }
187
+
188
+ async function handleRolePermissions(req: Request): Promise<Response> {
189
+ if (req.method === "GET") return json(rolePermissionsResponse());
190
+ if (req.method === "PUT") {
191
+ const body = (await req.json().catch(() => ({}))) as {
192
+ grants?: { role_code: string; permission_code: string; allowed: boolean }[];
193
+ };
194
+ for (const g of body.grants ?? []) {
195
+ let ov = roleGrantOverrides.get(g.role_code);
196
+ if (!ov) {
197
+ ov = new Set(effectivePermSet(g.role_code)); // seed from current effective, then edit
198
+ roleGrantOverrides.set(g.role_code, ov);
199
+ }
200
+ if (g.allowed) ov.add(g.permission_code);
201
+ else ov.delete(g.permission_code);
202
+ }
203
+ return json({ ok: true });
204
+ }
205
+ return json({ error: "method not allowed" }, 405);
206
+ }
207
+
208
+ function json(body: unknown, status = 200): Response {
209
+ return Response.json(body, { status });
210
+ }
211
+
212
+ function meResponse() {
213
+ const u = currentUser();
214
+ const w = currentWorkspace();
215
+ // The dev is always a regular platform user (never the confidential superuser); the
216
+ // active workspace role is whatever they selected. All 3 workspaces are listed so the
217
+ // existing switcher dropdown can move between them.
218
+ const workspaces = WORKSPACES.map((ws) => ({
219
+ ws_id: ws.id,
220
+ ws_name: ws.name,
221
+ ws_slug: ws.slug,
222
+ ws_role: view.role,
223
+ }));
224
+ return {
225
+ // username set so the prod "pick a username" banner stays out of the toolkit.
226
+ user: { id: u.id, name: u.name, email: u.email, role: "user", is_active: true, username: u.id },
227
+ workspaces,
228
+ workspace: { ws_id: w.id, ws_name: w.name, ws_slug: w.slug, ws_role: view.role },
229
+ // devMode + sdkVersion tell the host shell it's the toolkit: strip the prod chrome,
230
+ // show the SDK build, swap the dev (blue) icon (§5.1).
231
+ devMode: true,
232
+ sdkVersion: SDK_VERSION,
233
+ sdkPackage: sdkPkg.name,
234
+ };
235
+ }
236
+
237
+ function permissionsResponse() {
238
+ // Role-driven so the dev can test role-sensitive permissions: switching the active role
239
+ // changes exactly which of the plugin's permission-gated actions show. Never superuser.
240
+ const { bypass, permissions } = grantsForRole(view.role);
241
+ return {
242
+ permissions,
243
+ effectivePermissions: permissions,
244
+ bypass,
245
+ isSuperUser: false,
246
+ isAdmin: view.role === "admin",
247
+ };
248
+ }
249
+
250
+ /** The active view, as the header the prod kernel would sign — the plugin's dev
251
+ * middleware reads it so the active role scopes the plugin per-request. */
252
+ function devIdentityHeader(): string {
253
+ const u = currentUser();
254
+ return JSON.stringify({
255
+ userId: u.id,
256
+ workspaceId: view.workspaceId,
257
+ role: "user",
258
+ wsRole: view.role,
259
+ name: u.name,
260
+ email: u.email,
261
+ });
262
+ }
263
+
264
+ async function proxyToPlugin(p: DevPlugin, path: string, req: Request): Promise<Response> {
265
+ try {
266
+ const headers = new Headers(req.headers);
267
+ headers.set("x-kserp-dev-identity", devIdentityHeader());
268
+ const upstream = await fetch(`http://127.0.0.1:${p.port}${path}`, {
269
+ method: req.method,
270
+ headers,
271
+ body: req.method === "GET" || req.method === "HEAD" ? undefined : await req.text(),
272
+ });
273
+ return new Response(upstream.body, { status: upstream.status, headers: upstream.headers });
274
+ } catch {
275
+ // A peer/plugin that's down degrades to a 503 the UI shows — never crashes.
276
+ return json({ error: `plugin "${p.name}" unreachable` }, 503);
277
+ }
278
+ }
279
+
280
+ // The plugin-ui envelope the real PluginUiProvider expects.
281
+ function pluginsUi() {
282
+ return {
283
+ plugins: PLUGINS.map((p) => ({
284
+ name: p.name,
285
+ basePath: p.basePath,
286
+ uiRouteBase: p.uiRouteBase,
287
+ // The plugin renderer matches the URL's first segment against p.routeBase; the
288
+ // Sidebar links to p.href.
289
+ routeBase: p.uiRouteBase,
290
+ href: `/${p.uiRouteBase}`,
291
+ label: p.label,
292
+ version: p.version,
293
+ // The host loads this remote bundle; we proxy it to the plugin's own /_ui.
294
+ // ?v=<boot> busts any stale browser-cached bundle from a prior dev session.
295
+ remoteEntry: `${p.basePath}/_ui/remote.js?v=${BOOT_NONCE}`,
296
+ // The plugin's view permission — gates the nav row (a role without it hides the row,
297
+ // so gating is visible live) AND lets the sidebar resolve the plugin "module" so the
298
+ // per-row dev settings gear renders (navModuleOf needs a requiredPermission).
299
+ navPermission:
300
+ (p.permissions ?? []).find((perm) => perm === `${p.uiRouteBase}.view`) ??
301
+ (p.permissions ?? []).find((perm) => perm.endsWith(".view")) ??
302
+ `${p.uiRouteBase}.view`,
303
+ nav: p.nav ?? { label: p.label, section: "plugins", icon: "box" },
304
+ })),
305
+ routeSettings: {},
306
+ theme: {},
307
+ };
308
+ }
309
+
310
+ /** Shell read-channels + the dev-control endpoints, all reflecting the active view.
311
+ * Returns null for a path this doesn't own (caller then tries RPC / the plugin proxy). */
312
+ function shellEndpoint(path: string): Response | null {
313
+ if (path === "/api/auth/get-session" || path.startsWith("/api/auth/")) {
314
+ const u = currentUser();
315
+ // The dev is always a regular platform user (never the confidential superuser).
316
+ return json({
317
+ user: { id: u.id, name: u.name, email: u.email, role: "user", username: u.id },
318
+ session: { userId: u.id },
319
+ });
320
+ }
321
+ if (path === "/api/me") return json(meResponse());
322
+ if (path === "/api/capabilities") return json({ capabilities: {} });
323
+ if (path === "/api/permissions/me") return json(permissionsResponse());
324
+ if (path === "/api/permissions") return json({ permissions: [] });
325
+ if (path === "/api/plugins/ui") return json(pluginsUi());
326
+ if (path === "/api/plugins/ui/nav-config") return json({ navConfig: null });
327
+ if (path.startsWith("/api/plugins/ui/route-settings")) return json({});
328
+ if (path === "/api/theme/me" || path === "/api/theme/workspace-default") return json({});
329
+ // The view-as directory the dev controls read (users + roles + the plugin permissions
330
+ // a custom role can grant).
331
+ if (path === "/api/_dev/info")
332
+ return json({
333
+ sdkVersion: SDK_VERSION,
334
+ sdkPackage: sdkPkg.name,
335
+ pluginPermissions: allPluginPermissions(),
336
+ // Per-plugin permissions + route segment, so the toolkit can scope the role
337
+ // builder to the plugin whose page the dev is on (route-aware permission tuning)
338
+ // and link straight to that plugin's §9 flow (`/<routeBase>.spec`).
339
+ plugins: PLUGINS.map((p) => ({
340
+ name: p.name,
341
+ routeBase: p.uiRouteBase,
342
+ permissions: p.permissions ?? [],
343
+ })),
344
+ roles: roleNames(),
345
+ builtinRoles: BUILTIN_ROLES,
346
+ customRoles,
347
+ users: USERS,
348
+ workspaces: WORKSPACES,
349
+ view,
350
+ });
351
+ return null;
352
+ }
353
+
354
+ async function handleViewAs(req: Request): Promise<Response> {
355
+ const body = (await req.json().catch(() => ({}))) as {
356
+ userId?: string;
357
+ role?: string;
358
+ workspaceId?: number;
359
+ };
360
+ // Workspace, user, and role are INDEPENDENT — setting one never resets the others.
361
+ if (body.workspaceId && WORKSPACES.some((w) => w.id === body.workspaceId))
362
+ view.workspaceId = body.workspaceId;
363
+ if (body.userId && USERS.some((u) => u.id === body.userId)) view.userId = body.userId;
364
+ if (body.role && roleNames().includes(body.role)) view.role = body.role;
365
+ return json({ ok: true, view });
366
+ }
367
+
368
+ // Create (or update) a custom workspace role from a chosen subset of the loaded plugins'
369
+ // permissions — the toolkit equivalent of a workspace defining "accountant"/"staff" — and
370
+ // make it the active role so the dev immediately sees its gating.
371
+ async function handleCreateRole(req: Request): Promise<Response> {
372
+ const body = (await req.json().catch(() => ({}))) as { name?: string; permissions?: string[] };
373
+ const name = String(body.name ?? "")
374
+ .trim()
375
+ .toLowerCase();
376
+ if (!name || BUILTIN_ROLES.includes(name))
377
+ return json({ error: "invalid or reserved role name" }, 400);
378
+ const valid = allPluginPermissions();
379
+ const permissions = (Array.isArray(body.permissions) ? body.permissions : []).filter((p) =>
380
+ valid.includes(p),
381
+ );
382
+ const existing = customRoles.find((r) => r.name === name);
383
+ if (existing) existing.permissions = permissions;
384
+ else customRoles.push({ name, permissions });
385
+ view.role = name;
386
+ return json({ ok: true, roles: roleNames(), view });
387
+ }
388
+
389
+ /** /api/plugins/graph for the toolkit — each dev plugin as a node, with the §9
390
+ * flows it declares (fetched from its /__meta/flows). Falls back gracefully to an
391
+ * empty `flows` (the host then derives a flow from `permissions`). */
392
+ async function pluginsGraph(): Promise<Response> {
393
+ const plugins = await Promise.all(
394
+ PLUGINS.map(async (p) => {
395
+ let flows: unknown[] = [];
396
+ try {
397
+ const r = await fetch(`http://127.0.0.1:${p.port}/__meta/flows`, {
398
+ signal: AbortSignal.timeout(800),
399
+ });
400
+ if (r.ok) {
401
+ const body = (await r.json()) as unknown;
402
+ if (Array.isArray(body)) flows = body;
403
+ }
404
+ } catch {
405
+ // plugin not serving flows (older SDK) — empty → permission-derived fallback
406
+ }
407
+ return {
408
+ name: p.name,
409
+ key: p.uiRouteBase,
410
+ label: p.label,
411
+ tier: null,
412
+ emits: [],
413
+ permissions: p.permissions,
414
+ flows,
415
+ };
416
+ }),
417
+ );
418
+ return json({ plugins, edges: [] });
419
+ }
420
+
421
+ async function handleRpc(req: Request): Promise<Response> {
422
+ const body = (await req.json()) as { target: string; method: string; args: unknown };
423
+ const target = PLUGINS.find((x) => x.name === body.target);
424
+ if (!target)
425
+ return json({ error: `plugin "${body.target}" is not loaded in this dev session` }, 503);
426
+ return proxyToPlugin(
427
+ target,
428
+ `/_internal/services/${encodeURIComponent(body.method)}`,
429
+ new Request(req.url, {
430
+ method: "POST",
431
+ headers: { "content-type": "application/json", "x-kserp-internal": INTERNAL_SECRET },
432
+ body: JSON.stringify(body.args ?? {}),
433
+ }),
434
+ );
435
+ }
436
+
437
+ // The dev-control + kernel-contract routes the view-as / settings-modal UI calls. Returns
438
+ // null when `path` isn't one, so the main handler falls through to the shell stub / proxy.
439
+ function handleControlRoute(path: string, req: Request): Response | Promise<Response> | null {
440
+ if (path === "/api/_dev/view-as" && req.method === "POST") return handleViewAs(req);
441
+ if (path === "/api/_dev/roles" && req.method === "POST") return handleCreateRole(req);
442
+ // The settings-modal flow graph: the dev plugins as nodes + the §9 flows each serves at
443
+ // /__meta/flows, so an author previews their declared flow in the toolkit as in prod.
444
+ if (path === "/api/plugins/graph") return pluginsGraph();
445
+ // The Share tab's role→permission matrix (GET) + live edits (PUT). `:id` is the active
446
+ // workspace; the dev session is single-workspace so the id is informational.
447
+ if (/^\/api\/workspaces\/[^/]+\/role-permissions$/.test(path)) return handleRolePermissions(req);
448
+ return null;
449
+ }
450
+
451
+ Bun.serve({
452
+ port: PORT,
453
+ idleTimeout: 30,
454
+ async fetch(req) {
455
+ const url = new URL(req.url);
456
+ const path = url.pathname;
457
+
458
+ const ctrl = handleControlRoute(path, req);
459
+ if (ctrl) return ctrl;
460
+
461
+ const stub = shellEndpoint(path);
462
+ if (stub) return stub;
463
+
464
+ // ── cross-plugin RPC mediation (IP1): an unloaded peer → 503, which the SDK's
465
+ // tryCallPlugin degrades to null (the contract a dev can test between plugins). ──
466
+ if (path === "/_internal/rpc" && req.method === "POST") return handleRpc(req);
467
+
468
+ // ── api.assets (A1) in the toolkit: a dev plugin relays upload/presign/delete here;
469
+ // answered against local MinIO + an in-memory ledger, clamped to the active view's
470
+ // workspace + owner (the dev equivalent of the prod token clamp). ──
471
+ if (path.startsWith("/_internal/assets/")) {
472
+ const r = await handleDevAsset(path, req, {
473
+ workspaceId: view.workspaceId,
474
+ ownerId: view.userId,
475
+ });
476
+ if (r) return r;
477
+ }
478
+
479
+ // ── plugin proxy: <basePath>/* (incl. the /_ui bundle) → the dev plugin. Match by
480
+ // basePath, NOT by name — a plugin's basePath segment need not equal its name
481
+ // (e.g. the timesheets plugin serves `/api/time-entries`), so matching the first
482
+ // path segment against the name silently 404s those plugins' bundles + routes. ──
483
+ if (path.startsWith("/api/")) {
484
+ const p = PLUGINS.find((x) => path === x.basePath || path.startsWith(x.basePath + "/"));
485
+ if (p) {
486
+ const rest = path.slice(p.basePath.length);
487
+ return proxyToPlugin(p, (rest || "/") + url.search, req);
488
+ }
489
+ }
490
+
491
+ // Unknown API call → empty 200 (a missing read-channel must not break the shell).
492
+ if (path.startsWith("/api/")) return json({});
493
+ return new Response("dev-kernel (toolkit mode)", { status: 404 });
494
+ },
495
+ });
496
+
497
+ console.log(
498
+ `[dev-kernel] toolkit mode on :${PORT} — ${sdkPkg.name}@${SDK_VERSION}, ${USERS.length} users, ${WORKSPACES.length} workspaces, ${BUILTIN_ROLES.length}+ roles, ${PLUGINS.length} plugin(s): ${PLUGINS.map((p) => p.name).join(", ")}`,
499
+ );
@@ -0,0 +1,61 @@
1
+ // Dev-host Bun PRELOAD (Vision §5.1). Runs before a plugin's `server/main.ts` in a
2
+ // `bun --preload` launch and installs the two dev hooks `createPluginServer` reads:
3
+ //
4
+ // • a bun:sqlite pool (the local engine — Postgres in prod, SQLite in dev), and
5
+ // • a synthetic logged-in admin (auto-login: no kernel, no signed token).
6
+ //
7
+ // The plugin's own code is byte-identical — it just calls `createPluginServer(...)`
8
+ // and, finding `globalThis.__KSERP_DEV__` set, runs on SQLite as the synthetic admin.
9
+ // Bun-only (imports bun:sqlite); never on the prod path.
10
+
11
+ import { readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { makeSqlitePool } from "./sqlite-pool.js";
14
+
15
+ const pluginDir = process.env.KSERP_DEV_PLUGIN_DIR;
16
+ if (!pluginDir) {
17
+ throw new Error("[dev-stub] KSERP_DEV_PLUGIN_DIR not set (the preload needs the plugin root)");
18
+ }
19
+
20
+ // Read the manifest so the synthetic admin is granted exactly this plugin's declared
21
+ // permissions — so `requirePermission('<key>.view'|…)` passes for every route the
22
+ // plugin gates, without a real role/grant table.
23
+ interface Manifest {
24
+ name: string;
25
+ permissions?: string[];
26
+ schemas?: string[];
27
+ }
28
+ const manifest = JSON.parse(
29
+ readFileSync(join(pluginDir, "plugin.manifest.json"), "utf8"),
30
+ ) as Manifest;
31
+
32
+ // One SQLite file per plugin (mirrors prod's schema-per-plugin isolation; cross-plugin
33
+ // reads go over the mediated RPC, never a shared DB). In-memory unless a dir is given.
34
+ const sqliteFile = process.env.KSERP_SQLITE_FILE || ":memory:";
35
+
36
+ const workspaceId = Number(process.env.KSERP_DEV_WORKSPACE_ID || "1");
37
+ const userId = "dev-admin";
38
+
39
+ (globalThis as { __KSERP_DEV__?: unknown }).__KSERP_DEV__ = {
40
+ poolFactory: () =>
41
+ makeSqlitePool(
42
+ sqliteFile,
43
+ { workspaceId, userId, wsRole: "Admin", userName: "Dev Admin" },
44
+ manifest.schemas ?? [],
45
+ ),
46
+ identity: {
47
+ userId,
48
+ workspaceId,
49
+ role: "superuser",
50
+ wsRole: "Admin",
51
+ // The dev admin can do everything THIS plugin declares.
52
+ permissions: manifest.permissions ?? [],
53
+ email: "dev@localhost",
54
+ name: "Dev Admin",
55
+ },
56
+ };
57
+
58
+ console.log(
59
+ `[dev-stub] ${manifest.name}: bun:sqlite (${sqliteFile}) + synthetic admin ` +
60
+ `(${(manifest.permissions ?? []).length} perms) installed`,
61
+ );