@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 +15 -0
- package/dist/index.d.ts +376 -0
- package/dist/index.js +681 -0
- package/package.json +54 -0
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
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|