@ensera/plugin-backend 1.1.0 → 1.2.1

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/dist/index.d.ts CHANGED
@@ -1,5 +1,51 @@
1
1
  import { Request, Response, NextFunction, Express } from 'express';
2
2
 
3
+ type CoarseCapabilities = {
4
+ /** Can invite / remove workspace members. OWNER/ADMIN only. */
5
+ canInviteMembers: boolean;
6
+ /** Can create, rename, delete, or reorder spaces. OWNER/ADMIN only. */
7
+ canManageSpaces: boolean;
8
+ /** Can install or uninstall workspace-level features. OWNER/ADMIN only. */
9
+ canInstallWorkspaceFeatures: boolean;
10
+ /** Can create feature instances inside spaces. OWNER/ADMIN only. */
11
+ canCreateFeatureInstances: boolean;
12
+ /**
13
+ * Can create new content items (tasks, cards, rows, etc.) inside a feature.
14
+ * False for MEMBER role — they may edit assigned content but cannot create.
15
+ * True for OWNER/ADMIN and for non-MEMBER actors with EDIT+ permission.
16
+ */
17
+ canCreateContent: boolean;
18
+ /**
19
+ * Can delete any content item regardless of ownership.
20
+ * True for OWNER/ADMIN or actors with FULL feature permission.
21
+ */
22
+ canDeleteAnyContent: boolean;
23
+ /**
24
+ * Can delete content items the actor personally created.
25
+ * False for MEMBER role. True for non-MEMBER actors with EDIT+ permission.
26
+ */
27
+ canDeleteOwnContent: boolean;
28
+ /**
29
+ * Can assign content items (tasks, cards) to other workspace members.
30
+ * OWNER/ADMIN only.
31
+ */
32
+ canAssignContent: boolean;
33
+ /**
34
+ * Can manage structural elements (columns, sections, board layout).
35
+ * OWNER/ADMIN or actors with FULL feature permission.
36
+ */
37
+ canManageContentStructure: boolean;
38
+ };
39
+ type CoarseAuthorization = {
40
+ workspaceRole: "OWNER" | "ADMIN" | "MEMBER" | "GUEST" | null;
41
+ /** Effective space-level permission (FULL|EDIT|COMMENT|VIEW|NO_ACCESS). */
42
+ spacePermission: string;
43
+ /** Effective feature-level permission (FULL|EDIT|COMMENT|VIEW|NO_ACCESS). */
44
+ featurePermission: string;
45
+ accessSource: "workspace" | "space" | "feature" | "none";
46
+ capabilities: CoarseCapabilities;
47
+ policyVersion: "v2";
48
+ };
3
49
  /**
4
50
  * Plugin scope extracted from JWT token
5
51
  */
@@ -11,6 +57,11 @@ type PluginScope = {
11
57
  featureSlug: string;
12
58
  tabViewId?: string;
13
59
  taskScopeId?: string;
60
+ /**
61
+ * Coarse authorization resolved by core at token-mint time.
62
+ * Use this for permission checks in route handlers and command handlers.
63
+ */
64
+ authorization?: CoarseAuthorization;
14
65
  roles?: string[];
15
66
  perms?: string[];
16
67
  tokenId?: string;
@@ -86,9 +137,12 @@ interface NotificationAction {
86
137
  */
87
138
  interface BackendNotification {
88
139
  userId: string;
140
+ actorId?: string | null;
89
141
  type: string;
90
142
  title: string;
91
143
  message: string;
144
+ entityType?: string;
145
+ entityId?: string;
92
146
  metadata?: JsonValue;
93
147
  priority?: "low" | "normal" | "high" | "urgent";
94
148
  link?: string;
@@ -114,6 +168,12 @@ interface NotificationPublishResponse {
114
168
  sent?: boolean;
115
169
  emailSent?: boolean;
116
170
  pushSent?: boolean;
171
+ scheduled?: boolean;
172
+ scheduledFor?: string;
173
+ warnings?: string[];
174
+ errorCode?: string;
175
+ hint?: string;
176
+ nextSteps?: string[];
117
177
  error?: string;
118
178
  reason?: string;
119
179
  }
@@ -135,6 +195,9 @@ interface NotificationBulkResponse {
135
195
  interface NotificationCancelResponse {
136
196
  success: boolean;
137
197
  cancelled: number;
198
+ errorCode?: string;
199
+ hint?: string;
200
+ nextSteps?: string[];
138
201
  error?: string;
139
202
  }
140
203
  /**
@@ -163,6 +226,8 @@ interface NotificationPublisherConfig {
163
226
  coreApiUrl: string;
164
227
  /** Service-to-service authentication token */
165
228
  serviceToken: string;
229
+ /** Developer Notification Token for Core-side notification authorization */
230
+ notificationToken?: string;
166
231
  /** Timeout in ms (default 5000) */
167
232
  timeoutMs?: number;
168
233
  }
@@ -381,6 +446,29 @@ type CoreRequestContext = {
381
446
  userId: string;
382
447
  taskScopeId?: string;
383
448
  requestId: string;
449
+ /**
450
+ * Structured coarse authorization resolved by core for this feature instance.
451
+ * Prefer this over the flat legacy fields below for new code.
452
+ */
453
+ authorization?: CoarseAuthorization;
454
+ /**
455
+ * Effective feature-level permission string.
456
+ * Kept for backward compatibility; use authorization.featurePermission instead.
457
+ * Values: "FULL" | "EDIT" | "COMMENT" | "VIEW" | "NO_ACCESS"
458
+ */
459
+ effectiveRole?: string;
460
+ /**
461
+ * Optional feature ownership/publisher attribution resolved by Core.
462
+ * Use this for logs, support diagnostics, or future ownership-aware behavior.
463
+ */
464
+ developer?: {
465
+ ownerUserId?: string | null;
466
+ ownerDeveloperProfileId?: string | null;
467
+ ownerDisplayName?: string | null;
468
+ ownerDeveloperType?: string | null;
469
+ releaseId?: string | null;
470
+ storageMode?: string | null;
471
+ };
384
472
  };
385
473
  type CommandHandler = (input: Record<string, unknown>, context: CoreRequestContext) => Promise<unknown>;
386
474
  type CommandRegistry = {
@@ -391,4 +479,18 @@ type CommandRegistry = {
391
479
  declare function createCommandRegistry(): CommandRegistry;
392
480
  declare function createCoreRouter(app: Express, registry: CommandRegistry): void;
393
481
 
394
- export { type BackendNotification, type CommandHandler, type CommandRegistry, type CoreRequestContext, type JsonObject, type JsonValue, type NotificationAction, type NotificationApiPayload, type NotificationBulkApiPayload, type NotificationBulkResponse, type NotificationCancelResponse, type NotificationCapabilities, type NotificationOptions, type NotificationPublishResponse, type NotificationPublisher, type NotificationPublisherConfig, type NotificationTypeConfig, type PluginAuthOptions, type PluginContext, PluginError, type PluginErrorResponse, type PluginScope, type RequestWithContext, type ScheduleOptions, assertPluginScope, buildDateTime, calculateScheduleTime, createCommandRegistry, createCoreRouter, createNotificationPublisher, forbidClientInstanceId, formatTime, getPluginScope, isPastDate, pluginAuth, pluginErrorHandler, requireFeature, requirePermission, requireTabView, requireTaskScope, resolveEffectiveInstanceId, toDateString, withCoreServiceToken, withInstanceOnly, withInstanceScope, withUserAndInstance };
482
+ type CreateDbOptions = {
483
+ prismaDir?: string;
484
+ coreApiUrl?: string;
485
+ serviceToken?: string;
486
+ managedDbSessionToken?: string;
487
+ runMigrations?: boolean;
488
+ };
489
+ type DbProvisionResult = {
490
+ connectionString: string;
491
+ schemaName: string;
492
+ featureSlug: string;
493
+ };
494
+ declare function provisionDb(options?: CreateDbOptions): Promise<DbProvisionResult>;
495
+
496
+ export { type BackendNotification, type CoarseAuthorization, type CoarseCapabilities, type CommandHandler, type CommandRegistry, type CoreRequestContext, type CreateDbOptions, type DbProvisionResult, type JsonObject, type JsonValue, type NotificationAction, type NotificationApiPayload, type NotificationBulkApiPayload, type NotificationBulkResponse, type NotificationCancelResponse, type NotificationCapabilities, type NotificationOptions, type NotificationPublishResponse, type NotificationPublisher, type NotificationPublisherConfig, type NotificationTypeConfig, type PluginAuthOptions, type PluginContext, PluginError, type PluginErrorResponse, type PluginScope, type RequestWithContext, type ScheduleOptions, assertPluginScope, buildDateTime, calculateScheduleTime, createCommandRegistry, createCoreRouter, createNotificationPublisher, forbidClientInstanceId, formatTime, getPluginScope, isPastDate, pluginAuth, pluginErrorHandler, provisionDb, requireFeature, requirePermission, requireTabView, requireTaskScope, resolveEffectiveInstanceId, toDateString, withCoreServiceToken, withInstanceOnly, withInstanceScope, withUserAndInstance };
package/dist/index.js CHANGED
@@ -32,6 +32,14 @@ function pickClaim(obj, key) {
32
32
  const v = obj?.[key];
33
33
  return typeof v === "string" && v.length > 0 ? v : void 0;
34
34
  }
35
+ function isCoarseAuthorization(value) {
36
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
37
+ return false;
38
+ }
39
+ const candidate = value;
40
+ const capabilities = candidate.capabilities;
41
+ return (candidate.workspaceRole === null || typeof candidate.workspaceRole === "string") && typeof candidate.spacePermission === "string" && typeof candidate.featurePermission === "string" && typeof candidate.accessSource === "string" && !!capabilities && typeof capabilities === "object" && !Array.isArray(capabilities) && typeof capabilities.canInviteMembers === "boolean" && typeof capabilities.canManageSpaces === "boolean" && typeof capabilities.canInstallWorkspaceFeatures === "boolean" && typeof capabilities.canCreateFeatureInstances === "boolean" && candidate.policyVersion === "v2";
42
+ }
35
43
  function normalizeScope(payload) {
36
44
  const userId = pickClaim(payload, "sub");
37
45
  const workspaceId = pickClaim(payload, "workspaceId");
@@ -55,6 +63,7 @@ function normalizeScope(payload) {
55
63
  featureSlug,
56
64
  tabViewId,
57
65
  taskScopeId,
66
+ authorization: isCoarseAuthorization(payload.authorization) ? payload.authorization : void 0,
58
67
  roles: Array.isArray(payload.roles) ? payload.roles : void 0,
59
68
  perms: Array.isArray(payload.perms) ? payload.perms : void 0,
60
69
  tokenId: pickClaim(payload, "jti"),
@@ -298,18 +307,101 @@ function forbidClientInstanceId(options = {}) {
298
307
  }
299
308
 
300
309
  // src/notify.ts
310
+ function isLoopbackHost(hostname) {
311
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]" || hostname === "::ffff:127.0.0.1" || hostname === "0.0.0.0" || hostname === "host.docker.internal";
312
+ }
313
+ function normalizeScheduledOptions(options) {
314
+ if (!options) {
315
+ return void 0;
316
+ }
317
+ return {
318
+ ...options,
319
+ scheduledAt: options.scheduledAt instanceof Date ? options.scheduledAt.toISOString() : options.scheduledAt
320
+ };
321
+ }
301
322
  function createNotificationPublisher(config) {
302
323
  const { featureSlug, coreApiUrl, serviceToken, timeoutMs = 5e3 } = config;
303
324
  if (!featureSlug?.trim()) {
304
- throw new Error("NotificationPublisher: featureSlug is required");
325
+ throw new Error(
326
+ "NotificationPublisher: featureSlug is required for Core-attributed notification sends."
327
+ );
305
328
  }
306
329
  if (!coreApiUrl?.trim()) {
307
- throw new Error("NotificationPublisher: coreApiUrl is required");
330
+ throw new Error(
331
+ "NotificationPublisher: coreApiUrl is required. Set CORE_API_URL before sending notifications."
332
+ );
308
333
  }
309
334
  if (!serviceToken?.trim()) {
310
- throw new Error("NotificationPublisher: serviceToken is required");
335
+ throw new Error(
336
+ "NotificationPublisher: serviceToken is required. Set CORE_SERVICE_TOKEN or SERVICE_TOKEN from the installation Developer Settings page."
337
+ );
338
+ }
339
+ function getNotificationToken() {
340
+ return config.notificationToken?.trim() || process.env.ENSERA_NOTIFICATION_TOKEN?.trim() || process.env.NOTIFICATION_TOKEN?.trim() || process.env.ENSERA_DEVELOPER_NOTIFICATION_TOKEN?.trim() || void 0;
341
+ }
342
+ function getCoreApiUrls() {
343
+ const normalized = coreApiUrl.replace(/\/+$/, "");
344
+ const candidates = [normalized];
345
+ try {
346
+ const fallbackUrl = new URL(normalized);
347
+ if (fallbackUrl.hostname === "host.docker.internal") {
348
+ fallbackUrl.hostname = "localhost";
349
+ candidates.push(fallbackUrl.toString().replace(/\/+$/, ""));
350
+ }
351
+ } catch {
352
+ }
353
+ return [...new Set(candidates)];
354
+ }
355
+ function allowsNotificationCompatibilityBridge() {
356
+ if ((process.env.ENSERA_ALLOW_LEGACY_NOTIFICATION_ACCESS ?? "").trim() === "1") {
357
+ return true;
358
+ }
359
+ if (process.env.NODE_ENV === "production") {
360
+ return false;
361
+ }
362
+ try {
363
+ return isLoopbackHost(new URL(coreApiUrl).hostname);
364
+ } catch {
365
+ return false;
366
+ }
367
+ }
368
+ function buildMissingNotificationTokenError() {
369
+ return `[NOTIFICATION_TOKEN_REQUIRED] Backend notifications for '${featureSlug}' require a Notification Token. Open Developer Settings > Tokens, reveal or rotate the Notification Token, set ENSERA_NOTIFICATION_TOKEN or NOTIFICATION_TOKEN in the backend environment, and retry.`;
370
+ }
371
+ function getNotificationPreflightError() {
372
+ if (getNotificationToken() || allowsNotificationCompatibilityBridge()) {
373
+ return null;
374
+ }
375
+ return buildMissingNotificationTokenError();
376
+ }
377
+ function formatCoreError2(status, data) {
378
+ const code = typeof data?.errorCode === "string" && data.errorCode.trim() ? `[${data.errorCode.trim()}] ` : "";
379
+ const message = typeof data?.error === "string" && data.error.trim() ? data.error.trim() : `HTTP ${status}`;
380
+ const hints = [
381
+ typeof data?.hint === "string" ? data.hint.trim() : void 0,
382
+ ...Array.isArray(data?.nextSteps) ? data.nextSteps.filter(
383
+ (entry) => typeof entry === "string" && entry.trim().length > 0
384
+ ) : []
385
+ ];
386
+ return hints.length > 0 ? `${code}${message} ${hints.join(" ")}` : `${code}${message}`;
387
+ }
388
+ function buildHeaders(notificationToken) {
389
+ return {
390
+ "Content-Type": "application/json",
391
+ Authorization: `Bearer ${serviceToken}`,
392
+ "X-Feature-Slug": featureSlug,
393
+ ...notificationToken ? {
394
+ "X-Ensera-Notification-Token": notificationToken
395
+ } : {}
396
+ };
397
+ }
398
+ async function readJson(response) {
399
+ try {
400
+ return await response.json();
401
+ } catch {
402
+ return null;
403
+ }
311
404
  }
312
- const baseUrl = coreApiUrl.replace(/\/+$/, "");
313
405
  async function fetchWithTimeout(url, options) {
314
406
  const controller = new AbortController();
315
407
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
@@ -328,52 +420,84 @@ function createNotificationPublisher(config) {
328
420
  throw error;
329
421
  }
330
422
  }
423
+ async function requestCore(path2, init) {
424
+ let lastError = null;
425
+ const notificationToken = getNotificationToken();
426
+ for (const baseUrl of getCoreApiUrls()) {
427
+ try {
428
+ return await fetchWithTimeout(`${baseUrl}${path2}`, {
429
+ ...init,
430
+ headers: {
431
+ ...buildHeaders(notificationToken),
432
+ ...init.headers ?? {}
433
+ }
434
+ });
435
+ } catch (error) {
436
+ lastError = error;
437
+ }
438
+ }
439
+ throw lastError instanceof Error ? lastError : new Error("Failed to reach Core notification API");
440
+ }
441
+ function toPublishFailure(status, data) {
442
+ return {
443
+ success: false,
444
+ error: formatCoreError2(status, data),
445
+ errorCode: typeof data?.errorCode === "string" ? data.errorCode : void 0,
446
+ hint: typeof data?.hint === "string" ? data.hint : void 0,
447
+ nextSteps: Array.isArray(data?.nextSteps) ? data.nextSteps.filter(
448
+ (entry) => typeof entry === "string" && entry.trim().length > 0
449
+ ) : void 0
450
+ };
451
+ }
452
+ function toCancelFailure(status, data) {
453
+ return {
454
+ success: false,
455
+ cancelled: 0,
456
+ error: formatCoreError2(status, data),
457
+ errorCode: typeof data?.errorCode === "string" ? data.errorCode : void 0,
458
+ hint: typeof data?.hint === "string" ? data.hint : void 0,
459
+ nextSteps: Array.isArray(data?.nextSteps) ? data.nextSteps.filter(
460
+ (entry) => typeof entry === "string" && entry.trim().length > 0
461
+ ) : void 0
462
+ };
463
+ }
331
464
  async function publish(notification, options) {
332
- const url = `${baseUrl}/api/notifications/publish`;
333
- const processedOptions = options ? {
334
- ...options,
335
- scheduledAt: options.scheduledAt instanceof Date ? options.scheduledAt.toISOString() : options.scheduledAt
336
- } : void 0;
465
+ const preflightError = getNotificationPreflightError();
466
+ if (preflightError) {
467
+ return {
468
+ success: false,
469
+ error: preflightError,
470
+ errorCode: "NOTIFICATION_TOKEN_REQUIRED"
471
+ };
472
+ }
337
473
  const payload = {
338
474
  ...notification,
339
475
  featureSlug,
340
- options: processedOptions
476
+ options: normalizeScheduledOptions(options)
341
477
  };
342
- console.log("[NotificationPublisher] Publishing notification:", {
343
- featureSlug,
344
- userId: notification.userId,
345
- type: notification.type,
346
- scheduledAt: processedOptions?.scheduledAt,
347
- hasScheduledAt: !!processedOptions?.scheduledAt
348
- });
349
478
  try {
350
- const response = await fetchWithTimeout(url, {
479
+ const response = await requestCore("/api/notifications/publish", {
351
480
  method: "POST",
352
- headers: {
353
- "Content-Type": "application/json",
354
- Authorization: `Bearer ${serviceToken}`,
355
- "X-Feature-Slug": featureSlug
356
- },
357
481
  body: JSON.stringify(payload)
358
482
  });
359
- const data = await response.json();
360
- console.log("[NotificationPublisher] Response:", {
361
- ok: response.ok,
362
- status: response.status,
363
- success: data.success,
364
- scheduled: data.scheduled,
365
- scheduledFor: data.scheduledFor,
366
- error: data.error
367
- });
483
+ const data = await readJson(response);
368
484
  if (!response.ok) {
369
- return {
370
- success: false,
371
- error: data.error || `HTTP ${response.status}`
372
- };
485
+ return toPublishFailure(response.status, data);
373
486
  }
374
- return data;
487
+ return {
488
+ success: true,
489
+ id: typeof data?.id === "string" ? data.id : void 0,
490
+ sent: typeof data?.sent === "boolean" ? data.sent : void 0,
491
+ emailSent: typeof data?.emailSent === "boolean" ? data.emailSent : void 0,
492
+ pushSent: typeof data?.pushSent === "boolean" ? data.pushSent : void 0,
493
+ scheduled: typeof data?.scheduled === "boolean" ? data.scheduled : void 0,
494
+ scheduledFor: typeof data?.scheduledFor === "string" ? data.scheduledFor : void 0,
495
+ reason: typeof data?.reason === "string" ? data.reason : void 0,
496
+ warnings: Array.isArray(data?.warnings) ? data.warnings.filter(
497
+ (entry) => typeof entry === "string" && entry.trim().length > 0
498
+ ) : void 0
499
+ };
375
500
  } catch (error) {
376
- console.error("[NotificationPublisher] Error:", error);
377
501
  return {
378
502
  success: false,
379
503
  error: error.message || "Failed to publish notification"
@@ -381,23 +505,31 @@ function createNotificationPublisher(config) {
381
505
  }
382
506
  }
383
507
  async function publishBulk(notifications, options) {
384
- const url = `${baseUrl}/api/notifications/bulk`;
508
+ const preflightError = getNotificationPreflightError();
509
+ if (preflightError) {
510
+ return {
511
+ success: false,
512
+ count: 0,
513
+ errors: [
514
+ {
515
+ index: 0,
516
+ userId: "",
517
+ error: preflightError
518
+ }
519
+ ]
520
+ };
521
+ }
385
522
  const payload = {
386
523
  notifications,
387
524
  featureSlug,
388
- options
525
+ options: normalizeScheduledOptions(options)
389
526
  };
390
527
  try {
391
- const response = await fetchWithTimeout(url, {
528
+ const response = await requestCore("/api/notifications/bulk", {
392
529
  method: "POST",
393
- headers: {
394
- "Content-Type": "application/json",
395
- Authorization: `Bearer ${serviceToken}`,
396
- "X-Feature-Slug": featureSlug
397
- },
398
530
  body: JSON.stringify(payload)
399
531
  });
400
- const data = await response.json();
532
+ const data = await readJson(response);
401
533
  if (!response.ok) {
402
534
  return {
403
535
  success: false,
@@ -406,12 +538,16 @@ function createNotificationPublisher(config) {
406
538
  {
407
539
  index: 0,
408
540
  userId: "",
409
- error: data.error || `HTTP ${response.status}`
541
+ error: formatCoreError2(response.status, data)
410
542
  }
411
543
  ]
412
544
  };
413
545
  }
414
- return data;
546
+ return {
547
+ success: Boolean(data?.success),
548
+ count: typeof data?.count === "number" ? data.count : 0,
549
+ errors: Array.isArray(data?.errors) ? data.errors : void 0
550
+ };
415
551
  } catch (error) {
416
552
  return {
417
553
  success: false,
@@ -434,46 +570,32 @@ function createNotificationPublisher(config) {
434
570
  });
435
571
  }
436
572
  async function cancel(metadata) {
437
- const url = `${baseUrl}/api/notifications/cancel-scheduled`;
438
- const payload = {
439
- featureSlug,
440
- metadata
441
- };
442
- console.log("[NotificationPublisher] Cancelling notifications:", {
443
- featureSlug,
444
- metadata
445
- });
573
+ const preflightError = getNotificationPreflightError();
574
+ if (preflightError) {
575
+ return {
576
+ success: false,
577
+ cancelled: 0,
578
+ error: preflightError,
579
+ errorCode: "NOTIFICATION_TOKEN_REQUIRED"
580
+ };
581
+ }
446
582
  try {
447
- const response = await fetchWithTimeout(url, {
583
+ const response = await requestCore("/api/notifications/cancel-scheduled", {
448
584
  method: "POST",
449
- headers: {
450
- "Content-Type": "application/json",
451
- Authorization: `Bearer ${serviceToken}`,
452
- "X-Feature-Slug": featureSlug
453
- },
454
- body: JSON.stringify(payload)
455
- });
456
- const data = await response.json();
457
- console.log("[NotificationPublisher] Cancel response:", {
458
- ok: response.ok,
459
- status: response.status,
460
- success: data.success,
461
- cancelled: data.cancelled,
462
- error: data.error
585
+ body: JSON.stringify({
586
+ featureSlug,
587
+ metadata
588
+ })
463
589
  });
590
+ const data = await readJson(response);
464
591
  if (!response.ok) {
465
- return {
466
- success: false,
467
- cancelled: 0,
468
- error: data.error || `HTTP ${response.status}`
469
- };
592
+ return toCancelFailure(response.status, data);
470
593
  }
471
594
  return {
472
595
  success: true,
473
- cancelled: data.cancelled || 0
596
+ cancelled: typeof data?.cancelled === "number" ? data.cancelled : 0
474
597
  };
475
598
  } catch (error) {
476
- console.error("[NotificationPublisher] Cancel error:", error);
477
599
  return {
478
600
  success: false,
479
601
  cancelled: 0,
@@ -751,6 +873,130 @@ function createCoreRouter(app, registry) {
751
873
  })
752
874
  );
753
875
  }
876
+
877
+ // src/createDb.ts
878
+ import { execSync } from "child_process";
879
+ import fs from "fs";
880
+ import path from "path";
881
+ function isRecord(value) {
882
+ return typeof value === "object" && value !== null && !Array.isArray(value);
883
+ }
884
+ function getString(value) {
885
+ if (typeof value !== "string") return void 0;
886
+ const trimmed = value.trim();
887
+ return trimmed.length > 0 ? trimmed : void 0;
888
+ }
889
+ function formatCoreError(status, payload) {
890
+ if (!isRecord(payload)) {
891
+ return `Core returned ${status}. Unknown error`;
892
+ }
893
+ const parts = [
894
+ getString(payload.errorCode) ? `[${getString(payload.errorCode)}]` : void 0,
895
+ getString(payload.error) ?? `Core returned ${status}. Unknown error`
896
+ ].filter(
897
+ (entry) => typeof entry === "string" && entry.trim().length > 0
898
+ );
899
+ const hints = [
900
+ getString(payload.hint),
901
+ ...Array.isArray(payload.nextSteps) ? payload.nextSteps.filter(
902
+ (entry) => typeof entry === "string" && entry.trim().length > 0
903
+ ) : []
904
+ ];
905
+ return hints.length > 0 ? `${parts.join(" ")} ${hints.join(" ")}` : parts.join(" ");
906
+ }
907
+ function parseProvisionResult(value) {
908
+ if (!isRecord(value)) {
909
+ throw new Error("provisionDb: Core returned an invalid response body");
910
+ }
911
+ const connectionString = getString(value.connectionString);
912
+ const schemaName = getString(value.schemaName);
913
+ const featureSlug = getString(value.featureSlug);
914
+ if (!connectionString) {
915
+ throw new Error("provisionDb: Core did not return a connectionString");
916
+ }
917
+ if (!schemaName) {
918
+ throw new Error("provisionDb: Core did not return a schemaName");
919
+ }
920
+ if (!featureSlug) {
921
+ throw new Error("provisionDb: Core did not return a featureSlug");
922
+ }
923
+ return {
924
+ connectionString,
925
+ schemaName,
926
+ featureSlug
927
+ };
928
+ }
929
+ async function provisionDb(options = {}) {
930
+ const coreApiUrl = (options.coreApiUrl ?? process.env.CORE_API_URL)?.replace(/\/+$/, "");
931
+ const serviceToken = options.serviceToken ?? process.env.CORE_SERVICE_TOKEN;
932
+ const resolvedServiceToken = serviceToken ?? process.env.SERVICE_TOKEN;
933
+ const managedDbSessionToken = options.managedDbSessionToken ?? process.env.ENSERA_MANAGED_DB_SESSION_TOKEN ?? process.env.MANAGED_DB_SESSION_TOKEN ?? process.env.ENSERA_DEVELOPER_DB_TOKEN;
934
+ if (!coreApiUrl) {
935
+ throw new Error(
936
+ "provisionDb: CORE_API_URL is not set. Add it to your .env file."
937
+ );
938
+ }
939
+ if (!resolvedServiceToken) {
940
+ throw new Error(
941
+ "provisionDb: CORE_SERVICE_TOKEN or SERVICE_TOKEN is not set. Add it to your .env file."
942
+ );
943
+ }
944
+ const response = await fetch(`${coreApiUrl}/api/internal/db/provision`, {
945
+ method: "POST",
946
+ headers: {
947
+ "Content-Type": "application/json",
948
+ Authorization: `Bearer ${resolvedServiceToken}`,
949
+ ...managedDbSessionToken ? {
950
+ "x-ensera-managed-db-session-token": managedDbSessionToken
951
+ } : {}
952
+ }
953
+ });
954
+ let payload = null;
955
+ try {
956
+ payload = await response.json();
957
+ } catch {
958
+ payload = null;
959
+ }
960
+ if (!response.ok) {
961
+ throw new Error(
962
+ `provisionDb: ${formatCoreError(response.status, payload)}`
963
+ );
964
+ }
965
+ const result = parseProvisionResult(payload);
966
+ const shouldMigrate = options.runMigrations !== false;
967
+ if (shouldMigrate) {
968
+ const prismaDir = options.prismaDir ?? path.join(process.cwd(), "prisma");
969
+ const migrationsDir = path.join(prismaDir, "migrations");
970
+ if (fs.existsSync(prismaDir) && fs.existsSync(migrationsDir)) {
971
+ console.log(
972
+ `[ensera-db] Running migrations for schema: ${result.schemaName}`
973
+ );
974
+ try {
975
+ execSync("npx prisma migrate deploy", {
976
+ cwd: path.dirname(prismaDir),
977
+ stdio: "inherit",
978
+ env: {
979
+ ...process.env,
980
+ DATABASE_URL: result.connectionString
981
+ }
982
+ });
983
+ console.log("[ensera-db] Migrations complete");
984
+ } catch (error) {
985
+ const message = error instanceof Error ? error.message : "Unknown migration error";
986
+ throw new Error(`[ensera-db] Migration failed: ${message}`);
987
+ }
988
+ } else if (fs.existsSync(prismaDir)) {
989
+ console.warn(
990
+ `[ensera-db] No prisma migrations found at ${migrationsDir}. Skipping migrations.`
991
+ );
992
+ } else {
993
+ console.warn(
994
+ `[ensera-db] No prisma directory found at ${prismaDir}. Skipping migrations.`
995
+ );
996
+ }
997
+ }
998
+ return result;
999
+ }
754
1000
  export {
755
1001
  PluginError,
756
1002
  assertPluginScope,
@@ -765,6 +1011,7 @@ export {
765
1011
  isPastDate,
766
1012
  pluginAuth,
767
1013
  pluginErrorHandler,
1014
+ provisionDb,
768
1015
  requireFeature,
769
1016
  requirePermission,
770
1017
  requireTabView,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ensera/plugin-backend",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "Runtime backend SDK for Ensera plugins.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",