@ensera/plugin-backend 1.0.0

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/README.md ADDED
@@ -0,0 +1,15 @@
1
+ # @ensera/plugin-backend
2
+
3
+ Backend runtime SDK for Ensera plugins.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @ensera/plugin-backend express
9
+ ```
10
+
11
+ ## Purpose
12
+
13
+ - verifies Ensera runtime JWTs
14
+ - provides instance-scoped backend helpers
15
+ - stays separate from the scaffold template packages
@@ -0,0 +1,376 @@
1
+ import { Request, Response, NextFunction } from 'express';
2
+
3
+ /**
4
+ * Plugin scope extracted from JWT token
5
+ */
6
+ type PluginScope = {
7
+ userId: string;
8
+ workspaceId: string;
9
+ spaceId: string;
10
+ instanceId: string;
11
+ featureSlug: string;
12
+ tabViewId?: string;
13
+ taskScopeId?: string;
14
+ roles?: string[];
15
+ perms?: string[];
16
+ tokenId?: string;
17
+ issuedAt?: number;
18
+ expiresAt?: number;
19
+ issuer?: string;
20
+ audience?: string;
21
+ };
22
+ /**
23
+ * Plugin authentication middleware options
24
+ */
25
+ type PluginAuthOptions = {
26
+ /**
27
+ * Enforce that tokens must match a specific featureSlug.
28
+ * If mismatched -> 403 PLUGIN_FEATURE_MISMATCH
29
+ */
30
+ featureSlug?: string;
31
+ /**
32
+ * One or more secrets to verify HS256 tokens.
33
+ * Provide multiple for rotation: [new, old]
34
+ */
35
+ secrets: string[];
36
+ /**
37
+ * Validate iss/aud if provided.
38
+ */
39
+ issuer?: string;
40
+ audience?: string;
41
+ /**
42
+ * Clock tolerance in seconds (default 30)
43
+ */
44
+ clockToleranceSec?: number;
45
+ /**
46
+ * Allow token from this header instead of Authorization.
47
+ * Default: none
48
+ */
49
+ tokenHeader?: string;
50
+ /**
51
+ * DEV ONLY: When true and no Bearer token is present,
52
+ * injects a mock PluginScope instead of returning 401.
53
+ * NEVER enable in production.
54
+ */
55
+ devMode?: boolean;
56
+ /**
57
+ * DEV ONLY: Override default mock scope values.
58
+ * Only used when devMode is true.
59
+ */
60
+ devScope?: Partial<PluginScope>;
61
+ };
62
+ declare global {
63
+ namespace Express {
64
+ interface Request {
65
+ plugin?: PluginScope;
66
+ }
67
+ }
68
+ }
69
+ /**
70
+ * JSON-serializable value types
71
+ */
72
+ type JsonValue = null | boolean | number | string | JsonValue[] | {
73
+ [key: string]: JsonValue;
74
+ };
75
+ type JsonObject = Record<string, JsonValue>;
76
+ /**
77
+ * Notification System - Action attached to a notification
78
+ */
79
+ interface NotificationAction {
80
+ label: string;
81
+ actionId: string;
82
+ payload?: JsonValue;
83
+ }
84
+ /**
85
+ * Notification System - Notification payload sent from backend
86
+ */
87
+ interface BackendNotification {
88
+ userId: string;
89
+ type: string;
90
+ title: string;
91
+ message: string;
92
+ metadata?: JsonValue;
93
+ priority?: "low" | "normal" | "high" | "urgent";
94
+ link?: string;
95
+ actions?: NotificationAction[];
96
+ }
97
+ /**
98
+ * Notification System - Options for sending notifications
99
+ */
100
+ interface NotificationOptions {
101
+ /** Skip if user has disabled notifications for this feature */
102
+ respectPreferences?: boolean;
103
+ /** Only send via specific channels */
104
+ channels?: Array<"email" | "push" | "inApp">;
105
+ /** Schedule for later */
106
+ scheduledAt?: Date | string;
107
+ }
108
+ /**
109
+ * Notification System - Response from Core API
110
+ */
111
+ interface NotificationPublishResponse {
112
+ success: boolean;
113
+ id?: string;
114
+ sent?: boolean;
115
+ emailSent?: boolean;
116
+ pushSent?: boolean;
117
+ error?: string;
118
+ reason?: string;
119
+ }
120
+ /**
121
+ * Notification System - Bulk send response
122
+ */
123
+ interface NotificationBulkResponse {
124
+ success: boolean;
125
+ count: number;
126
+ errors?: Array<{
127
+ index: number;
128
+ userId: string;
129
+ error: string;
130
+ }>;
131
+ }
132
+ /**
133
+ * Notification System - Cancel response
134
+ */
135
+ interface NotificationCancelResponse {
136
+ success: boolean;
137
+ cancelled: number;
138
+ error?: string;
139
+ }
140
+ /**
141
+ * Notification System - Type configuration in package.json
142
+ */
143
+ interface NotificationTypeConfig {
144
+ id: string;
145
+ label: string;
146
+ description?: string;
147
+ defaultEnabled?: boolean;
148
+ }
149
+ /**
150
+ * Notification System - Feature capabilities
151
+ */
152
+ interface NotificationCapabilities {
153
+ enabled: boolean;
154
+ types: NotificationTypeConfig[];
155
+ }
156
+ /**
157
+ * Publisher configuration
158
+ */
159
+ interface NotificationPublisherConfig {
160
+ /** Feature slug/ID */
161
+ featureSlug: string;
162
+ /** Core API base URL (e.g., "http://localhost:3000") */
163
+ coreApiUrl: string;
164
+ /** Service-to-service authentication token */
165
+ serviceToken: string;
166
+ /** Timeout in ms (default 5000) */
167
+ timeoutMs?: number;
168
+ }
169
+ /**
170
+ * Internal: Notification payload sent to Core API
171
+ */
172
+ interface NotificationApiPayload extends BackendNotification {
173
+ appId: string;
174
+ options?: NotificationOptions;
175
+ timestamp: string;
176
+ }
177
+ /**
178
+ * Internal: Bulk notification payload
179
+ */
180
+ interface NotificationBulkApiPayload {
181
+ notifications: NotificationApiPayload[];
182
+ }
183
+
184
+ type PluginErrorResponse = {
185
+ message: string;
186
+ code: string;
187
+ requestId?: string;
188
+ };
189
+ declare class PluginError extends Error {
190
+ readonly status: number;
191
+ readonly code: string;
192
+ readonly requestId?: string;
193
+ constructor(args: {
194
+ status: number;
195
+ message: string;
196
+ code: string;
197
+ requestId?: string;
198
+ });
199
+ toJSON(): PluginErrorResponse;
200
+ }
201
+
202
+ declare function pluginAuth(options: PluginAuthOptions): (req: Request, _res: Response, next: NextFunction) => void;
203
+
204
+ declare function assertPluginScope(req: Request): PluginScope;
205
+ declare function getPluginScope(req: Request): PluginScope | undefined;
206
+ declare function requireFeature(featureSlug: string): (req: Request, _res: Response, next: NextFunction) => void;
207
+ declare function requirePermission(perm: string): (req: Request, _res: Response, next: NextFunction) => void;
208
+ declare function requireTaskScope(): (req: Request, _res: Response, next: NextFunction) => void;
209
+ declare function requireTabView(): (req: Request, _res: Response, next: NextFunction) => void;
210
+
211
+ declare function pluginErrorHandler(): (err: unknown, req: Request, res: Response, _next: NextFunction) => void;
212
+
213
+ type InstanceOnlyHandler = (instanceId: string, req: Omit<Request, "plugin">, res: Response, next: NextFunction) => unknown | Promise<unknown>;
214
+ /**
215
+ * Resolves the effective instance ID from the plugin scope.
216
+ *
217
+ * Priority:
218
+ * 1. scope.taskScopeId — present when inside a synced Tab View
219
+ * 2. scope.instanceId — normal standalone instance
220
+ */
221
+ declare function resolveEffectiveInstanceId(scope: {
222
+ taskScopeId?: string;
223
+ instanceId: string;
224
+ }): string;
225
+ declare function withInstanceOnly(handler: InstanceOnlyHandler): (req: Request, res: Response, next: NextFunction) => Promise<void>;
226
+
227
+ type Options = {
228
+ keys?: string[];
229
+ locations?: ("body" | "query" | "params")[];
230
+ };
231
+ declare function forbidClientInstanceId(options?: Options): (req: Request, _res: Response, next: NextFunction) => void;
232
+
233
+ /**
234
+ * Create a notification publisher for feature backend
235
+ *
236
+ * @example
237
+ * ```typescript
238
+ * const notifier = createNotificationPublisher({
239
+ * featureSlug: "task-manager",
240
+ * coreApiUrl: process.env.CORE_API_URL,
241
+ * serviceToken: process.env.SERVICE_TOKEN,
242
+ * });
243
+ *
244
+ * await notifier.publish({
245
+ * userId: "user-123",
246
+ * type: "task.assigned",
247
+ * title: "New Task Assigned",
248
+ * message: "You were assigned to: Fix login bug",
249
+ * link: "/tasks/456",
250
+ * });
251
+ * ```
252
+ */
253
+ declare function createNotificationPublisher(config: NotificationPublisherConfig): {
254
+ publish: (notification: BackendNotification, options?: NotificationOptions) => Promise<NotificationPublishResponse>;
255
+ publishBulk: (notifications: BackendNotification[], options?: NotificationOptions) => Promise<NotificationBulkResponse>;
256
+ publishToUsers: (userIds: string[], notification: Omit<BackendNotification, "userId">, options?: NotificationOptions) => Promise<NotificationBulkResponse>;
257
+ schedule: (notification: BackendNotification, scheduledAt: Date | string, options?: Omit<NotificationOptions, "scheduledAt">) => Promise<NotificationPublishResponse>;
258
+ cancel: (metadata: Record<string, any>) => Promise<NotificationCancelResponse>;
259
+ };
260
+ type NotificationPublisher = ReturnType<typeof createNotificationPublisher>;
261
+
262
+ /**
263
+ * Plugin context with both userId and instanceId
264
+ */
265
+ interface PluginContext {
266
+ instanceId: string;
267
+ userId: string;
268
+ workspaceId?: string;
269
+ }
270
+ /**
271
+ * Request with plugin context
272
+ */
273
+ interface RequestWithContext extends Request {
274
+ pluginContext?: PluginContext;
275
+ }
276
+ /**
277
+ * Handler that receives both instanceId and userId
278
+ */
279
+ type UserAndInstanceHandler = (context: PluginContext, req: Omit<Request, "plugin">, res: Response, next: NextFunction) => unknown | Promise<unknown>;
280
+ /**
281
+ * Middleware that extracts BOTH userId and instanceId from plugin scope
282
+ *
283
+ * Similar to withInstanceOnly but also captures userId before hiding req.plugin
284
+ */
285
+ declare function withUserAndInstance(handler: UserAndInstanceHandler): (req: Request, res: Response, next: NextFunction) => Promise<void>;
286
+
287
+ /**
288
+ * Handler that receives the effective instanceId AND full scope
289
+ */
290
+ type InstanceScopeHandler = (instanceId: string, scope: PluginScope, req: Omit<Request, "plugin">, res: Response, next: NextFunction) => unknown | Promise<unknown>;
291
+ declare function withInstanceScope(handler: InstanceScopeHandler): (req: Request, res: Response, next: NextFunction) => Promise<void>;
292
+
293
+ /**
294
+ * Date and time utilities for notification scheduling
295
+ */
296
+ interface ScheduleOptions {
297
+ /** Date as string or Date object */
298
+ date: string | Date;
299
+ /** Time in HH:mm or HH:mm:ss format */
300
+ time?: string | null;
301
+ /** Minutes to subtract from the calculated time (for reminders) */
302
+ minutesBefore?: number;
303
+ /** Default hour if no time provided (0-23) */
304
+ defaultHour?: number;
305
+ /** Default minute if no time provided (0-59) */
306
+ defaultMinute?: number;
307
+ }
308
+ /**
309
+ * Calculate a scheduled time from date, time, and offset
310
+ *
311
+ * @param options - Schedule options
312
+ * @returns Date object or null if invalid/past
313
+ *
314
+ * @example
315
+ * ```typescript
316
+ * // Schedule for specific date and time
317
+ * const scheduleTime = calculateScheduleTime({
318
+ * date: "2026-03-15",
319
+ * time: "14:30",
320
+ * });
321
+ *
322
+ * // Schedule 30 minutes before due time
323
+ * const reminderTime = calculateScheduleTime({
324
+ * date: task.dueDate,
325
+ * time: task.dueTime,
326
+ * minutesBefore: 30,
327
+ * });
328
+ * ```
329
+ */
330
+ declare function calculateScheduleTime(options: ScheduleOptions): Date | null;
331
+ /**
332
+ * Format 24-hour time string to 12-hour AM/PM format
333
+ *
334
+ * @param time - Time string in HH:mm or HH:mm:ss format
335
+ * @returns Formatted time string or empty string if invalid
336
+ *
337
+ * @example
338
+ * ```typescript
339
+ * formatTime("14:30"); // "2:30 PM"
340
+ * formatTime("09:00"); // "9:00 AM"
341
+ * formatTime("00:30"); // "12:30 AM"
342
+ * ```
343
+ */
344
+ declare function formatTime(time: string | null | undefined): string;
345
+ /**
346
+ * Check if a date is in the past
347
+ *
348
+ * @param date - Date as string or Date object
349
+ * @returns true if the date is in the past
350
+ */
351
+ declare function isPastDate(date: Date | string): boolean;
352
+ /**
353
+ * Normalize date to YYYY-MM-DD string format
354
+ *
355
+ * @param date - Date as string or Date object
356
+ * @returns Date string in YYYY-MM-DD format or null if invalid
357
+ *
358
+ * @example
359
+ * ```typescript
360
+ * toDateString(new Date("2026-03-15")); // "2026-03-15"
361
+ * toDateString("2026-03-15T14:30:00"); // "2026-03-15"
362
+ * ```
363
+ */
364
+ declare function toDateString(date: string | Date | null | undefined): string | null;
365
+ /**
366
+ * Build a complete DateTime from separate date and time values
367
+ *
368
+ * @param dateValue - Date as string or Date object
369
+ * @param timeValue - Time in HH:mm or HH:mm:ss format
370
+ * @param defaultHour - Default hour if no time provided
371
+ * @param defaultMinute - Default minute if no time provided
372
+ * @returns Date object or null if invalid
373
+ */
374
+ declare function buildDateTime(dateValue: string | Date | null | undefined, timeValue: string | null | undefined, defaultHour?: number, defaultMinute?: number): Date | null;
375
+
376
+ export { type BackendNotification, 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, createNotificationPublisher, forbidClientInstanceId, formatTime, getPluginScope, isPastDate, pluginAuth, pluginErrorHandler, requireFeature, requirePermission, requireTabView, requireTaskScope, resolveEffectiveInstanceId, toDateString, withInstanceOnly, withInstanceScope, withUserAndInstance };
package/dist/index.js ADDED
@@ -0,0 +1,681 @@
1
+ // src/errors.ts
2
+ var PluginError = class extends Error {
3
+ status;
4
+ code;
5
+ requestId;
6
+ constructor(args) {
7
+ super(args.message);
8
+ Object.setPrototypeOf(this, new.target.prototype);
9
+ this.name = "PluginError";
10
+ this.status = args.status;
11
+ this.code = args.code;
12
+ this.requestId = args.requestId;
13
+ }
14
+ toJSON() {
15
+ return { message: this.message, code: this.code, requestId: this.requestId };
16
+ }
17
+ };
18
+
19
+ // src/auth.ts
20
+ import jwt from "jsonwebtoken";
21
+ function readBearerToken(req, tokenHeader) {
22
+ if (tokenHeader) {
23
+ const v = req.header(tokenHeader);
24
+ if (v && v.trim()) return v.trim();
25
+ }
26
+ const auth = req.header("authorization") ?? req.header("Authorization");
27
+ if (!auth) return null;
28
+ const m = auth.match(/^Bearer\s+(.+)$/i);
29
+ return m ? m[1].trim() : null;
30
+ }
31
+ function pickClaim(obj, key) {
32
+ const v = obj?.[key];
33
+ return typeof v === "string" && v.length > 0 ? v : void 0;
34
+ }
35
+ function normalizeScope(payload) {
36
+ const userId = pickClaim(payload, "sub");
37
+ const workspaceId = pickClaim(payload, "workspaceId");
38
+ const spaceId = pickClaim(payload, "spaceId");
39
+ const instanceId = pickClaim(payload, "instanceId");
40
+ const featureSlug = pickClaim(payload, "featureSlug");
41
+ const tabViewId = pickClaim(payload, "tabViewId");
42
+ const taskScopeId = pickClaim(payload, "taskScopeId");
43
+ if (!userId || !workspaceId || !spaceId || !instanceId || !featureSlug) {
44
+ throw new PluginError({
45
+ status: 401,
46
+ message: "Invalid or expired plugin token",
47
+ code: "PLUGIN_AUTH_FAILED"
48
+ });
49
+ }
50
+ return {
51
+ userId,
52
+ workspaceId,
53
+ spaceId,
54
+ instanceId,
55
+ featureSlug,
56
+ tabViewId,
57
+ taskScopeId,
58
+ roles: Array.isArray(payload.roles) ? payload.roles : void 0,
59
+ perms: Array.isArray(payload.perms) ? payload.perms : void 0,
60
+ tokenId: pickClaim(payload, "jti"),
61
+ issuedAt: typeof payload.iat === "number" ? payload.iat : void 0,
62
+ expiresAt: typeof payload.exp === "number" ? payload.exp : void 0,
63
+ issuer: pickClaim(payload, "iss"),
64
+ audience: pickClaim(payload, "aud")
65
+ };
66
+ }
67
+ function pluginAuth(options) {
68
+ if (!options.devMode && !options?.secrets?.length) {
69
+ throw new Error(
70
+ "pluginAuth: options.secrets is required (provide at least 1 secret)"
71
+ );
72
+ }
73
+ if (options.devMode) {
74
+ console.warn(
75
+ "[plugin-backend] WARNING: devMode is ON \u2014 auth will be bypassed for tokenless requests. Do not use in production."
76
+ );
77
+ }
78
+ const clockToleranceSec = options.clockToleranceSec ?? 30;
79
+ return function pluginAuthMiddleware(req, _res, next) {
80
+ const requestId = req.header("x-request-id") ?? void 0;
81
+ if (requestId) _res.setHeader("x-request-id", requestId);
82
+ try {
83
+ const token = readBearerToken(req, options.tokenHeader);
84
+ if (!token && options.devMode) {
85
+ req.plugin = {
86
+ userId: "dev-user",
87
+ workspaceId: "dev-workspace",
88
+ spaceId: "dev-space",
89
+ instanceId: "dev-instance",
90
+ featureSlug: options.featureSlug ?? "unknown",
91
+ ...options.devScope
92
+ };
93
+ return next();
94
+ }
95
+ if (!token) {
96
+ throw new PluginError({
97
+ status: 401,
98
+ message: "Invalid or expired plugin token",
99
+ code: "PLUGIN_AUTH_FAILED",
100
+ requestId
101
+ });
102
+ }
103
+ let lastErr = null;
104
+ let decoded = null;
105
+ for (const secret of options.secrets) {
106
+ try {
107
+ if (token.length > 1e4) throw "auth failed";
108
+ decoded = jwt.verify(token, secret, {
109
+ algorithms: ["HS256"],
110
+ issuer: options.issuer,
111
+ audience: options.audience,
112
+ clockTolerance: clockToleranceSec
113
+ });
114
+ lastErr = null;
115
+ break;
116
+ } catch (e) {
117
+ lastErr = e;
118
+ }
119
+ }
120
+ if (!decoded || typeof decoded !== "object") {
121
+ throw new PluginError({
122
+ status: 401,
123
+ message: "Invalid or expired plugin token",
124
+ code: "PLUGIN_AUTH_FAILED",
125
+ requestId
126
+ });
127
+ }
128
+ const scope = normalizeScope(decoded);
129
+ if (options.featureSlug && scope.featureSlug !== options.featureSlug) {
130
+ throw new PluginError({
131
+ status: 403,
132
+ message: "Token featureSlug mismatch",
133
+ code: "PLUGIN_FEATURE_MISMATCH",
134
+ requestId
135
+ });
136
+ }
137
+ req.plugin = scope;
138
+ return next();
139
+ } catch (err) {
140
+ return next(err);
141
+ }
142
+ };
143
+ }
144
+
145
+ // src/guards.ts
146
+ function assertPluginScope(req) {
147
+ if (!req.plugin) {
148
+ throw new PluginError({
149
+ status: 500,
150
+ message: "Plugin scope missing on request (did you forget app.use(pluginAuth())?)",
151
+ code: "PLUGIN_SCOPE_MISSING"
152
+ });
153
+ }
154
+ return req.plugin;
155
+ }
156
+ function getPluginScope(req) {
157
+ return req.plugin;
158
+ }
159
+ function requireFeature(featureSlug) {
160
+ return (req, _res, next) => {
161
+ try {
162
+ const p = assertPluginScope(req);
163
+ if (p.featureSlug !== featureSlug) {
164
+ throw new PluginError({
165
+ status: 403,
166
+ message: "Forbidden for this plugin",
167
+ code: "FORBIDDEN"
168
+ });
169
+ }
170
+ next();
171
+ } catch (e) {
172
+ next(e);
173
+ }
174
+ };
175
+ }
176
+ function requirePermission(perm) {
177
+ return (req, _res, next) => {
178
+ try {
179
+ const p = assertPluginScope(req);
180
+ if (!p.perms?.includes(perm)) {
181
+ throw new PluginError({
182
+ status: 403,
183
+ message: "Missing permission",
184
+ code: "FORBIDDEN"
185
+ });
186
+ }
187
+ next();
188
+ } catch (e) {
189
+ next(e);
190
+ }
191
+ };
192
+ }
193
+ function requireTaskScope() {
194
+ return (req, _res, next) => {
195
+ try {
196
+ const p = assertPluginScope(req);
197
+ if (!p.taskScopeId) {
198
+ throw new PluginError({
199
+ status: 400,
200
+ message: "taskScopeId missing. This endpoint requires a task-scoped context (open inside a synced tab or use a default task scope).",
201
+ code: "TASK_SCOPE_MISSING"
202
+ });
203
+ }
204
+ next();
205
+ } catch (e) {
206
+ next(e);
207
+ }
208
+ };
209
+ }
210
+ function requireTabView() {
211
+ return (req, _res, next) => {
212
+ try {
213
+ const p = assertPluginScope(req);
214
+ if (!p.tabViewId) {
215
+ throw new PluginError({
216
+ status: 400,
217
+ message: "tabViewId missing. This endpoint requires a tab context.",
218
+ code: "TAB_VIEW_MISSING"
219
+ });
220
+ }
221
+ next();
222
+ } catch (e) {
223
+ next(e);
224
+ }
225
+ };
226
+ }
227
+
228
+ // src/errorHandler.ts
229
+ function pluginErrorHandler() {
230
+ return (err, req, res, _next) => {
231
+ const requestId = req.header("x-request-id") ?? void 0;
232
+ if (err instanceof PluginError) {
233
+ res.status(err.status).json(err.toJSON());
234
+ return;
235
+ }
236
+ if (process.env.NODE_ENV !== "production") {
237
+ console.error("[plugin-backend] unhandled error", { requestId, err });
238
+ }
239
+ res.status(500).json({
240
+ message: "Internal server error",
241
+ code: "INTERNAL_ERROR",
242
+ requestId
243
+ });
244
+ };
245
+ }
246
+
247
+ // src/withInstance.ts
248
+ function resolveEffectiveInstanceId(scope) {
249
+ const effective = scope.taskScopeId?.trim() || scope.instanceId;
250
+ if (!effective || !effective.trim()) {
251
+ throw new PluginError({
252
+ status: 500,
253
+ code: "INSTANCE_SCOPE_MISSING",
254
+ message: "Instance scope missing. All plugin backend routes must be instance-scoped."
255
+ });
256
+ }
257
+ return effective;
258
+ }
259
+ function withInstanceOnly(handler) {
260
+ return async function instanceOnlyWrapped(req, res, next) {
261
+ try {
262
+ const scope = assertPluginScope(req);
263
+ const instanceId = resolveEffectiveInstanceId(scope);
264
+ const safeReq = req;
265
+ safeReq.plugin = void 0;
266
+ await handler(instanceId, safeReq, res, next);
267
+ } catch (e) {
268
+ next(e);
269
+ }
270
+ };
271
+ }
272
+
273
+ // src/forbidClientInstanceId.ts
274
+ function forbidClientInstanceId(options = {}) {
275
+ const keys = options.keys ?? ["instanceId", "instance_id"];
276
+ const locations = options.locations ?? ["body", "query", "params"];
277
+ return (req, _res, next) => {
278
+ const targets = [];
279
+ if (locations.includes("body")) targets.push(req.body);
280
+ if (locations.includes("query")) targets.push(req.query);
281
+ if (locations.includes("params")) targets.push(req.params);
282
+ for (const t of targets) {
283
+ if (!t || typeof t !== "object") continue;
284
+ for (const k of keys) {
285
+ if (k in t) {
286
+ return next(
287
+ new PluginError({
288
+ status: 400,
289
+ code: "INSTANCE_ID_NOT_ALLOWED",
290
+ message: "Do not send instanceId from the client. Instance scope is derived from the runtime token."
291
+ })
292
+ );
293
+ }
294
+ }
295
+ }
296
+ next();
297
+ };
298
+ }
299
+
300
+ // src/notify.ts
301
+ function createNotificationPublisher(config) {
302
+ const { featureSlug, coreApiUrl, serviceToken, timeoutMs = 5e3 } = config;
303
+ if (!featureSlug?.trim()) {
304
+ throw new Error("NotificationPublisher: featureSlug is required");
305
+ }
306
+ if (!coreApiUrl?.trim()) {
307
+ throw new Error("NotificationPublisher: coreApiUrl is required");
308
+ }
309
+ if (!serviceToken?.trim()) {
310
+ throw new Error("NotificationPublisher: serviceToken is required");
311
+ }
312
+ const baseUrl = coreApiUrl.replace(/\/+$/, "");
313
+ async function fetchWithTimeout(url, options) {
314
+ const controller = new AbortController();
315
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
316
+ try {
317
+ const response = await fetch(url, {
318
+ ...options,
319
+ signal: controller.signal
320
+ });
321
+ clearTimeout(timeout);
322
+ return response;
323
+ } catch (error) {
324
+ clearTimeout(timeout);
325
+ if (error.name === "AbortError") {
326
+ throw new Error(`Request timeout after ${timeoutMs}ms`);
327
+ }
328
+ throw error;
329
+ }
330
+ }
331
+ 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;
337
+ const payload = {
338
+ ...notification,
339
+ featureSlug,
340
+ options: processedOptions
341
+ };
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
+ try {
350
+ const response = await fetchWithTimeout(url, {
351
+ method: "POST",
352
+ headers: {
353
+ "Content-Type": "application/json",
354
+ Authorization: `Bearer ${serviceToken}`,
355
+ "X-Feature-Slug": featureSlug
356
+ },
357
+ body: JSON.stringify(payload)
358
+ });
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
+ });
368
+ if (!response.ok) {
369
+ return {
370
+ success: false,
371
+ error: data.error || `HTTP ${response.status}`
372
+ };
373
+ }
374
+ return data;
375
+ } catch (error) {
376
+ console.error("[NotificationPublisher] Error:", error);
377
+ return {
378
+ success: false,
379
+ error: error.message || "Failed to publish notification"
380
+ };
381
+ }
382
+ }
383
+ async function publishBulk(notifications, options) {
384
+ const url = `${baseUrl}/api/notifications/bulk`;
385
+ const payload = {
386
+ notifications,
387
+ featureSlug,
388
+ options
389
+ };
390
+ try {
391
+ const response = await fetchWithTimeout(url, {
392
+ method: "POST",
393
+ headers: {
394
+ "Content-Type": "application/json",
395
+ Authorization: `Bearer ${serviceToken}`,
396
+ "X-Feature-Slug": featureSlug
397
+ },
398
+ body: JSON.stringify(payload)
399
+ });
400
+ const data = await response.json();
401
+ if (!response.ok) {
402
+ return {
403
+ success: false,
404
+ count: 0,
405
+ errors: [
406
+ {
407
+ index: 0,
408
+ userId: "",
409
+ error: data.error || `HTTP ${response.status}`
410
+ }
411
+ ]
412
+ };
413
+ }
414
+ return data;
415
+ } catch (error) {
416
+ return {
417
+ success: false,
418
+ count: 0,
419
+ errors: [{ index: 0, userId: "", error: error.message }]
420
+ };
421
+ }
422
+ }
423
+ async function publishToUsers(userIds, notification, options) {
424
+ const notifications = userIds.map((userId) => ({
425
+ ...notification,
426
+ userId
427
+ }));
428
+ return publishBulk(notifications, options);
429
+ }
430
+ async function schedule(notification, scheduledAt, options) {
431
+ return publish(notification, {
432
+ ...options,
433
+ scheduledAt
434
+ });
435
+ }
436
+ 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
+ });
446
+ try {
447
+ const response = await fetchWithTimeout(url, {
448
+ 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
463
+ });
464
+ if (!response.ok) {
465
+ return {
466
+ success: false,
467
+ cancelled: 0,
468
+ error: data.error || `HTTP ${response.status}`
469
+ };
470
+ }
471
+ return {
472
+ success: true,
473
+ cancelled: data.cancelled || 0
474
+ };
475
+ } catch (error) {
476
+ console.error("[NotificationPublisher] Cancel error:", error);
477
+ return {
478
+ success: false,
479
+ cancelled: 0,
480
+ error: error.message || "Failed to cancel notifications"
481
+ };
482
+ }
483
+ }
484
+ return {
485
+ publish,
486
+ publishBulk,
487
+ publishToUsers,
488
+ schedule,
489
+ cancel
490
+ };
491
+ }
492
+
493
+ // src/withUserAndInstance.ts
494
+ function withUserAndInstance(handler) {
495
+ return async function userAndInstanceWrapped(req, res, next) {
496
+ try {
497
+ const scope = req.plugin;
498
+ if (!scope) {
499
+ throw new PluginError({
500
+ status: 500,
501
+ code: "PLUGIN_SCOPE_MISSING",
502
+ message: "Plugin scope missing. Ensure pluginAuth middleware runs first."
503
+ });
504
+ }
505
+ const instanceId = scope.instanceId;
506
+ const userId = scope.userId;
507
+ if (!instanceId) {
508
+ throw new PluginError({
509
+ status: 500,
510
+ code: "INSTANCE_SCOPE_MISSING",
511
+ message: "Instance scope missing. All plugin routes must be instance-scoped."
512
+ });
513
+ }
514
+ if (!userId) {
515
+ throw new PluginError({
516
+ status: 400,
517
+ code: "USER_ID_MISSING",
518
+ message: "User ID missing from plugin scope."
519
+ });
520
+ }
521
+ const context = {
522
+ instanceId,
523
+ userId,
524
+ workspaceId: scope.workspaceId
525
+ };
526
+ const safeReq = req;
527
+ safeReq.plugin = void 0;
528
+ await handler(context, safeReq, res, next);
529
+ } catch (e) {
530
+ next(e);
531
+ }
532
+ };
533
+ }
534
+
535
+ // src/withInstanceScope.ts
536
+ function withInstanceScope(handler) {
537
+ return async function instanceScopeWrapped(req, res, next) {
538
+ try {
539
+ const scope = assertPluginScope(req);
540
+ const instanceId = resolveEffectiveInstanceId(scope);
541
+ const safeReq = req;
542
+ safeReq.plugin = void 0;
543
+ await handler(instanceId, scope, safeReq, res, next);
544
+ } catch (e) {
545
+ next(e);
546
+ }
547
+ };
548
+ }
549
+
550
+ // src/dateUtils.ts
551
+ function calculateScheduleTime(options) {
552
+ const {
553
+ date,
554
+ time,
555
+ minutesBefore = 0,
556
+ defaultHour = 9,
557
+ defaultMinute = 0
558
+ } = options;
559
+ let baseDate;
560
+ if (date instanceof Date) {
561
+ if (isNaN(date.getTime())) return null;
562
+ baseDate = new Date(date);
563
+ } else if (typeof date === "string") {
564
+ const parsed = new Date(date);
565
+ if (isNaN(parsed.getTime())) return null;
566
+ baseDate = parsed;
567
+ } else {
568
+ return null;
569
+ }
570
+ if (time && typeof time === "string") {
571
+ const timeParts = time.split(":");
572
+ const hours = parseInt(timeParts[0], 10);
573
+ const minutes = parseInt(timeParts[1], 10);
574
+ const seconds = parseInt(timeParts[2], 10) || 0;
575
+ if (!isNaN(hours) && !isNaN(minutes)) {
576
+ baseDate.setHours(hours, minutes, seconds, 0);
577
+ } else {
578
+ baseDate.setHours(defaultHour, defaultMinute, 0, 0);
579
+ }
580
+ } else {
581
+ baseDate.setHours(defaultHour, defaultMinute, 0, 0);
582
+ }
583
+ if (minutesBefore > 0) {
584
+ baseDate.setMinutes(baseDate.getMinutes() - minutesBefore);
585
+ }
586
+ if (baseDate <= /* @__PURE__ */ new Date()) {
587
+ return null;
588
+ }
589
+ return baseDate;
590
+ }
591
+ function formatTime(time) {
592
+ if (!time) return "";
593
+ const parts = time.split(":");
594
+ if (parts.length < 2) return "";
595
+ const h = parseInt(parts[0], 10);
596
+ const minutes = parts[1];
597
+ if (isNaN(h)) return "";
598
+ const ampm = h >= 12 ? "PM" : "AM";
599
+ const hour12 = h % 12 || 12;
600
+ return `${hour12}:${minutes} ${ampm}`;
601
+ }
602
+ function isPastDate(date) {
603
+ const dateObj = date instanceof Date ? date : new Date(date);
604
+ if (isNaN(dateObj.getTime())) return false;
605
+ return dateObj <= /* @__PURE__ */ new Date();
606
+ }
607
+ function toDateString(date) {
608
+ if (!date) return null;
609
+ if (date instanceof Date) {
610
+ if (isNaN(date.getTime())) return null;
611
+ const yyyy = date.getFullYear();
612
+ const mm = String(date.getMonth() + 1).padStart(2, "0");
613
+ const dd = String(date.getDate()).padStart(2, "0");
614
+ return `${yyyy}-${mm}-${dd}`;
615
+ }
616
+ if (typeof date === "string") {
617
+ const trimmed = date.trim();
618
+ if (!trimmed) return null;
619
+ if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) return trimmed;
620
+ const d = new Date(trimmed);
621
+ if (!isNaN(d.getTime())) {
622
+ const yyyy = d.getFullYear();
623
+ const mm = String(d.getMonth() + 1).padStart(2, "0");
624
+ const dd = String(d.getDate()).padStart(2, "0");
625
+ return `${yyyy}-${mm}-${dd}`;
626
+ }
627
+ }
628
+ return null;
629
+ }
630
+ function buildDateTime(dateValue, timeValue, defaultHour = 9, defaultMinute = 0) {
631
+ if (!dateValue) return null;
632
+ let result;
633
+ if (dateValue instanceof Date) {
634
+ if (isNaN(dateValue.getTime())) return null;
635
+ result = new Date(dateValue);
636
+ } else if (typeof dateValue === "string") {
637
+ const dateStr = toDateString(dateValue);
638
+ if (!dateStr) return null;
639
+ const [year, month, day] = dateStr.split("-").map(Number);
640
+ result = new Date(year, month - 1, day);
641
+ } else {
642
+ return null;
643
+ }
644
+ if (timeValue && typeof timeValue === "string") {
645
+ const timeParts = timeValue.split(":");
646
+ const hours = parseInt(timeParts[0], 10);
647
+ const minutes = parseInt(timeParts[1], 10);
648
+ const seconds = parseInt(timeParts[2], 10) || 0;
649
+ if (!isNaN(hours) && !isNaN(minutes)) {
650
+ result.setHours(hours, minutes, seconds, 0);
651
+ } else {
652
+ result.setHours(defaultHour, defaultMinute, 0, 0);
653
+ }
654
+ } else {
655
+ result.setHours(defaultHour, defaultMinute, 0, 0);
656
+ }
657
+ if (isNaN(result.getTime())) return null;
658
+ return result;
659
+ }
660
+ export {
661
+ PluginError,
662
+ assertPluginScope,
663
+ buildDateTime,
664
+ calculateScheduleTime,
665
+ createNotificationPublisher,
666
+ forbidClientInstanceId,
667
+ formatTime,
668
+ getPluginScope,
669
+ isPastDate,
670
+ pluginAuth,
671
+ pluginErrorHandler,
672
+ requireFeature,
673
+ requirePermission,
674
+ requireTabView,
675
+ requireTaskScope,
676
+ resolveEffectiveInstanceId,
677
+ toDateString,
678
+ withInstanceOnly,
679
+ withInstanceScope,
680
+ withUserAndInstance
681
+ };
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@ensera/plugin-backend",
3
+ "version": "1.0.0",
4
+ "description": "Runtime backend SDK for Ensera plugins.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "files": [
10
+ "dist",
11
+ "README.md"
12
+ ],
13
+ "sideEffects": false,
14
+ "scripts": {
15
+ "build": "tsup src/index.ts --format esm --dts --clean --target es2022 --outDir dist",
16
+ "dev": "tsup src/index.ts --format esm --dts --watch --target es2022 --outDir dist",
17
+ "typecheck": "tsc -p tsconfig.json --noEmit",
18
+ "prepublishOnly": "npm run build && npm run typecheck"
19
+ },
20
+ "keywords": [
21
+ "ensera",
22
+ "backend",
23
+ "sdk",
24
+ "plugin",
25
+ "express"
26
+ ],
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "dependencies": {
31
+ "jsonwebtoken": "^9.0.0"
32
+ },
33
+ "devDependencies": {
34
+ "@types/express": "^4.17.0",
35
+ "@types/jsonwebtoken": "^9.0.0",
36
+ "tsup": "^8.0.0",
37
+ "typescript": "^5.0.0",
38
+ "jsonwebtoken": "^9.0.0"
39
+ },
40
+ "engines": {
41
+ "node": ">=18"
42
+ },
43
+ "peerDependencies": {
44
+ "express": "^4.18.0",
45
+ "jsonwebtoken": "^9.0.0"
46
+ },
47
+ "exports": {
48
+ ".": {
49
+ "types": "./dist/index.d.ts",
50
+ "import": "./dist/index.js"
51
+ },
52
+ "./package.json": "./package.json"
53
+ }
54
+ }