@jchat/addon-sdk 1.0.0-alpha.3 → 1.0.0-alpha.31
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/CHANGELOG.md +248 -0
- package/LICENSE +18 -196
- package/README.md +384 -20
- package/dist/cli.d.mts +54 -0
- package/dist/cli.mjs +154 -0
- package/dist/contracts.d.mts +2 -130
- package/dist/contracts.mjs +64 -0
- package/dist/contracts2.d.mts +282 -0
- package/dist/diagnostics.d.mts +55 -9
- package/dist/diagnostics.mjs +67 -2
- package/dist/i18n-validator.d.mts +23 -0
- package/dist/i18n-validator.mjs +95 -0
- package/dist/i18n.mjs +169 -0
- package/dist/index.d.mts +668 -90
- package/dist/index.mjs +5 -105
- package/dist/signing.d.mts +2 -0
- package/dist/signing.mjs +136 -0
- package/dist/signing2.d.mts +76 -0
- package/dist/src.mjs +493 -0
- package/dist/state.d.mts +1 -0
- package/dist/state.mjs +2 -0
- package/dist/testing.d.mts +2 -0
- package/dist/testing.mjs +2 -0
- package/dist/ui.d.mts +48 -0
- package/dist/ui.mjs +39 -0
- package/dist/vite.d.mts +64 -10
- package/dist/vite.mjs +231 -38
- package/package.json +125 -23
- package/skills/create-jchat-addon/SKILL.md +140 -0
- package/skills/jchat-addon-development/SKILL.md +260 -0
- package/src/tailwind.css +1 -0
- package/tsconfig.addon.json +20 -0
package/dist/src.mjs
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
import "./i18n.mjs";
|
|
2
|
+
import { AbilityBuilder, createMongoAbility, subject } from "@casl/ability";
|
|
3
|
+
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
4
|
+
//#region package.json
|
|
5
|
+
var version = "1.0.0-alpha.31";
|
|
6
|
+
//#endregion
|
|
7
|
+
//#region src/permissions.ts
|
|
8
|
+
/**
|
|
9
|
+
* Standard forbidden exception thrown when an actor lacks required permissions or fails ownership
|
|
10
|
+
* checks.
|
|
11
|
+
*/
|
|
12
|
+
var ForbiddenError = class extends Error {
|
|
13
|
+
status = 403;
|
|
14
|
+
code = "FORBIDDEN";
|
|
15
|
+
/**
|
|
16
|
+
* Initializes a new ForbiddenError instance.
|
|
17
|
+
*
|
|
18
|
+
* @param message - User-friendly error message detailing the authorization failure.
|
|
19
|
+
*/
|
|
20
|
+
constructor(message = "Forbidden") {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = "FORBIDDEN";
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Checks whether an actor holds a specific permission with wildcard support.
|
|
27
|
+
*
|
|
28
|
+
* Evaluation rules:
|
|
29
|
+
*
|
|
30
|
+
* 1. Exact match (`'addon.pollwise.close.own'` matches `'addon.pollwise.close.own'`).
|
|
31
|
+
* 2. Global wildcard (`'*'` grants all actions).
|
|
32
|
+
* 3. Segment wildcards (`'addon.pollwise.*'` grants `'addon.pollwise.close.own'`).
|
|
33
|
+
*
|
|
34
|
+
* @param userPermissions - Array of permissions possessed by the user.
|
|
35
|
+
* @param required - Permission string required for the requested action.
|
|
36
|
+
* @returns `true` if the user is granted the required permission, `false` otherwise.
|
|
37
|
+
*/
|
|
38
|
+
function hasPermission(userPermissions, required) {
|
|
39
|
+
if (userPermissions.includes(required)) return true;
|
|
40
|
+
if (userPermissions.includes("*")) return true;
|
|
41
|
+
const parts = required.split(".");
|
|
42
|
+
for (let i = parts.length - 1; i > 0; i--) {
|
|
43
|
+
const wildcard = parts.slice(0, i).join(".") + ".*";
|
|
44
|
+
if (userPermissions.includes(wildcard)) return true;
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Evaluates Attribute-Based Access Control (ABAC) ownership rules to prevent Broken Object-Level
|
|
50
|
+
* Authorization (BOLA / IDOR) vulnerabilities.
|
|
51
|
+
*
|
|
52
|
+
* Resolution flow:
|
|
53
|
+
*
|
|
54
|
+
* 1. **Site Owner Bypass**: If `isOwner === true` or holding `'*'`, access is granted unconditionally.
|
|
55
|
+
* 2. **Author Ownership**: If `actorId === ownerId` and actor holds `ownPermission`, grants `"own"`.
|
|
56
|
+
* 3. **Moderator Override**: If actor holds `anyPermission`, grants `"any"`.
|
|
57
|
+
* 4. **Denial**: Throws an error generated by `errorFactory` (defaults to {@link ForbiddenError}).
|
|
58
|
+
*
|
|
59
|
+
* @param input - Ownership parameters, actor credentials, and required permissions.
|
|
60
|
+
* @returns The resolved {@link ResourceOwnershipScope} (`"own"` or `"any"`).
|
|
61
|
+
* @throws {ForbiddenError} When the actor lacks required ownership or moderator privileges.
|
|
62
|
+
*/
|
|
63
|
+
function authorizeResourceOwnership({ actorId, actorPermissions, ownerId, ownPermission, anyPermission, resourceLabel = "resource", errorFactory = (msg) => new ForbiddenError(msg), isOwner: actorIsOwner = false }) {
|
|
64
|
+
const isResourceOwner = actorId === ownerId;
|
|
65
|
+
if (actorIsOwner || hasPermission(actorPermissions, "*")) return isResourceOwner ? "own" : "any";
|
|
66
|
+
if (isResourceOwner && hasPermission(actorPermissions, ownPermission)) return "own";
|
|
67
|
+
if (hasPermission(actorPermissions, anyPermission)) return "any";
|
|
68
|
+
if (isResourceOwner) throw errorFactory(`Missing required permission to modify this ${resourceLabel}`);
|
|
69
|
+
throw errorFactory(`Missing required permission to modify another user's ${resourceLabel}`);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Creates a CASL Ability instance for an addon actor with automated permission convention mapping
|
|
73
|
+
* and optional custom rule extensions.
|
|
74
|
+
*
|
|
75
|
+
* Convention mapping:
|
|
76
|
+
*
|
|
77
|
+
* - Global `'*'` or `isOwner: true` $\rightarrow$ `can('manage', 'all')`
|
|
78
|
+
* - `*.any` (e.g. `'addon.pollwise.close.any'`) $\rightarrow$ `can('close', 'all')`
|
|
79
|
+
* - `*.own` (e.g. `'addon.pollwise.close.own'`) $\rightarrow$ `can('close', 'all', { userId: actor.id
|
|
80
|
+
* })`
|
|
81
|
+
*
|
|
82
|
+
* @param actor - Authenticated request user context.
|
|
83
|
+
* @param defineRules - Optional callback to register custom CASL ability rules.
|
|
84
|
+
* @returns A frozen CASL {@link AddonAbility} instance.
|
|
85
|
+
*/
|
|
86
|
+
function createAddonAbility(actor, defineRules) {
|
|
87
|
+
const builder = new AbilityBuilder(createMongoAbility);
|
|
88
|
+
const perms = actor?.permissions ?? [];
|
|
89
|
+
if (actor?.isOwner || perms.includes("*")) {
|
|
90
|
+
builder.can("manage", "all");
|
|
91
|
+
return builder.build();
|
|
92
|
+
}
|
|
93
|
+
for (const perm of perms) if (perm.endsWith(".any")) {
|
|
94
|
+
const parts = perm.split(".");
|
|
95
|
+
const action = parts[parts.length - 2] || "manage";
|
|
96
|
+
builder.can(action, "all");
|
|
97
|
+
} else if (perm.endsWith(".own") && actor?.id) {
|
|
98
|
+
const parts = perm.split(".");
|
|
99
|
+
const action = parts[parts.length - 2] || "manage";
|
|
100
|
+
builder.can(action, "all", { userId: actor.id });
|
|
101
|
+
}
|
|
102
|
+
if (defineRules) defineRules(builder);
|
|
103
|
+
return builder.build();
|
|
104
|
+
}
|
|
105
|
+
//#endregion
|
|
106
|
+
//#region src/query.ts
|
|
107
|
+
/**
|
|
108
|
+
* Perform a declarative, cached GET request using the host's shared React Query instance.
|
|
109
|
+
* Automatically injects authentication headers and prefixes addon paths.
|
|
110
|
+
*
|
|
111
|
+
* @example
|
|
112
|
+
* ;```tsx
|
|
113
|
+
* const { data, isLoading } = useAddonQuery<{ polls: Poll[] }>({
|
|
114
|
+
* path: "/polls",
|
|
115
|
+
* queryKey: [siteId]
|
|
116
|
+
* })
|
|
117
|
+
* ```
|
|
118
|
+
*/
|
|
119
|
+
function useAddonQuery(options) {
|
|
120
|
+
const sdk = useAddonSDK();
|
|
121
|
+
const { path, queryKey = [], queryFn, ...queryOptions } = options;
|
|
122
|
+
const effectiveKey = path !== void 0 ? [path, ...queryKey] : queryKey;
|
|
123
|
+
const effectiveFn = queryFn ?? (() => {
|
|
124
|
+
if (!path) throw new Error("useAddonQuery requires either a `path` or a custom `queryFn`");
|
|
125
|
+
return sdk.api.get(path);
|
|
126
|
+
});
|
|
127
|
+
return useQuery({
|
|
128
|
+
...queryOptions,
|
|
129
|
+
queryKey: effectiveKey,
|
|
130
|
+
queryFn: effectiveFn
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Perform a paginated or infinite scrolling request using the host's shared React Query instance.
|
|
135
|
+
*
|
|
136
|
+
* @example
|
|
137
|
+
* ;```tsx
|
|
138
|
+
* const { data, fetchNextPage, hasNextPage } = useAddonInfiniteQuery<GifResponse, Error, number>({
|
|
139
|
+
* path: (page) => `/trending?offset=${page * 20}`,
|
|
140
|
+
* initialPageParam: 0,
|
|
141
|
+
* getNextPageParam: (lastPage, allPages) => (lastPage.hasMore ? allPages.length : undefined)
|
|
142
|
+
* })
|
|
143
|
+
* ```
|
|
144
|
+
*/
|
|
145
|
+
function useAddonInfiniteQuery(options) {
|
|
146
|
+
const sdk = useAddonSDK();
|
|
147
|
+
const { path, queryKey = [], queryFn, ...infiniteOptions } = options;
|
|
148
|
+
const effectiveKey = [typeof path === "string" ? path : "infinite", ...queryKey];
|
|
149
|
+
const effectiveFn = queryFn ?? (async (context) => {
|
|
150
|
+
const pageParam = context.pageParam;
|
|
151
|
+
const resolvedPath = typeof path === "function" ? path(pageParam) : path;
|
|
152
|
+
if (!resolvedPath) throw new Error("useAddonInfiniteQuery requires either a `path` or a custom `queryFn`");
|
|
153
|
+
return sdk.api.get(resolvedPath);
|
|
154
|
+
});
|
|
155
|
+
return useInfiniteQuery({
|
|
156
|
+
...infiniteOptions,
|
|
157
|
+
queryKey: effectiveKey,
|
|
158
|
+
queryFn: effectiveFn
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Perform a declarative mutation using the host's shared React Query instance. Automatically
|
|
163
|
+
* executes the request and can invalidate specified query keys upon success.
|
|
164
|
+
*
|
|
165
|
+
* @example
|
|
166
|
+
* ;```tsx
|
|
167
|
+
* const { mutate, isPending } = useAddonMutation<Poll, CreatePollInput>({
|
|
168
|
+
* path: "/polls",
|
|
169
|
+
* method: "POST",
|
|
170
|
+
* invalidateQueries: [["/polls"]],
|
|
171
|
+
* onSuccess: () => sdk.toast("Poll created!", "success")
|
|
172
|
+
* })
|
|
173
|
+
* ```
|
|
174
|
+
*/
|
|
175
|
+
function useAddonMutation(options) {
|
|
176
|
+
const sdk = useAddonSDK();
|
|
177
|
+
const queryClient = useQueryClient();
|
|
178
|
+
const { path, method = "POST", invalidateQueries = [], mutationFn, onSuccess, ...mutationOptions } = options;
|
|
179
|
+
const effectiveFn = mutationFn ?? (async (variables) => {
|
|
180
|
+
const resolvedPath = typeof path === "function" ? path(variables) : path ?? "";
|
|
181
|
+
if (!resolvedPath) throw new Error("useAddonMutation requires a valid 'path' or 'mutationFn'.");
|
|
182
|
+
if (method === "PUT") return sdk.api.put(resolvedPath, variables);
|
|
183
|
+
if (method === "DELETE") return sdk.api.delete(resolvedPath);
|
|
184
|
+
return sdk.api.post(resolvedPath, variables);
|
|
185
|
+
});
|
|
186
|
+
return useMutation({
|
|
187
|
+
...mutationOptions,
|
|
188
|
+
mutationFn: effectiveFn,
|
|
189
|
+
onSuccess: async (...args) => {
|
|
190
|
+
if (invalidateQueries.length > 0) await Promise.all(invalidateQueries.map((key) => queryClient.invalidateQueries({ queryKey: key })));
|
|
191
|
+
if (onSuccess) await onSuccess(...args);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Ergonomic hook for loading and updating site configuration for an addon. Handles fetching,
|
|
197
|
+
* caching, pending states, and automatic query cache invalidation.
|
|
198
|
+
*/
|
|
199
|
+
function useAddonSettings(siteId) {
|
|
200
|
+
const sdk = useAddonSDK();
|
|
201
|
+
const query = useAddonQuery({
|
|
202
|
+
queryKey: ["settings", siteId],
|
|
203
|
+
queryFn: () => sdk.settings.get()
|
|
204
|
+
});
|
|
205
|
+
const mutation = useAddonMutation({
|
|
206
|
+
mutationFn: (settings) => sdk.settings.save(settings),
|
|
207
|
+
invalidateQueries: [["settings", siteId]]
|
|
208
|
+
});
|
|
209
|
+
return {
|
|
210
|
+
settings: query.data ?? null,
|
|
211
|
+
isLoading: query.isLoading,
|
|
212
|
+
isSaving: mutation.isPending,
|
|
213
|
+
error: query.error ?? mutation.error,
|
|
214
|
+
saveSettings: mutation.mutate,
|
|
215
|
+
saveSettingsAsync: mutation.mutateAsync,
|
|
216
|
+
refetch: query.refetch
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
//#endregion
|
|
220
|
+
//#region src/testing.ts
|
|
221
|
+
/**
|
|
222
|
+
* Creates an in-memory, zero-dependency {@link AddonServerContext} mock for testing addon server
|
|
223
|
+
* routes, lifecycle handlers, and pipeline hooks offline in CI without requiring live database
|
|
224
|
+
* connections or Redis.
|
|
225
|
+
*
|
|
226
|
+
* Provides complete mock implementations of:
|
|
227
|
+
*
|
|
228
|
+
* - **Key-Value & Set Cache**: Supports `get`, `set` (with TTL simulation), `del`, `incr`, `expire`,
|
|
229
|
+
* `ttl`, `setAdd`, `setRemove`, and `setMembers` with automatic tenant/addon namespacing.
|
|
230
|
+
* - **Sliding-Window Rate Limiting**: In-memory timestamp-bucket rate limiting via `consume`.
|
|
231
|
+
* - **Event Capturing**: Records emitted events in {@link MockAddonContextResult.emittedEvents} with
|
|
232
|
+
* canonical `addon:<addonId>:<event>` prefixing.
|
|
233
|
+
* - **Pipeline Hook Registration**: Inspects registered hook stages and handlers.
|
|
234
|
+
* - **Fine-Grained ABAC & Ownership**: Enforces CASL `can` attribute queries and BOLA protection via
|
|
235
|
+
* `authorizeOwnership`.
|
|
236
|
+
* - **License Entitlement**: Deterministic commercial entitlement verification.
|
|
237
|
+
*
|
|
238
|
+
* @param addonId - Unique identifier of the addon under test.
|
|
239
|
+
* @param options - Optional mock configuration parameters.
|
|
240
|
+
* @returns A {@link MockAddonContextResult} containing the mock context and inspection fixtures.
|
|
241
|
+
*/
|
|
242
|
+
function createMockAddonServerContext(addonId, options = {}) {
|
|
243
|
+
const { config = {}, user = {}, isEntitled = true } = options;
|
|
244
|
+
const emittedEvents = [];
|
|
245
|
+
const registeredHooks = [];
|
|
246
|
+
const cacheEntries = /* @__PURE__ */ new Map();
|
|
247
|
+
const cacheSets = /* @__PURE__ */ new Map();
|
|
248
|
+
const cacheTtld = /* @__PURE__ */ new Map();
|
|
249
|
+
const rateLimitTimestamps = /* @__PURE__ */ new Map();
|
|
250
|
+
const prefix = `addon:${addonId}:`;
|
|
251
|
+
const cache = {
|
|
252
|
+
async get(key) {
|
|
253
|
+
const fullKey = prefix + key;
|
|
254
|
+
const exp = cacheTtld.get(fullKey);
|
|
255
|
+
if (exp && Date.now() > exp) {
|
|
256
|
+
cacheEntries.delete(fullKey);
|
|
257
|
+
cacheTtld.delete(fullKey);
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
const val = cacheEntries.get(fullKey);
|
|
261
|
+
return val !== void 0 ? val : null;
|
|
262
|
+
},
|
|
263
|
+
async set(key, value, ttlSeconds) {
|
|
264
|
+
const fullKey = prefix + key;
|
|
265
|
+
cacheEntries.set(fullKey, value);
|
|
266
|
+
if (ttlSeconds && ttlSeconds > 0) cacheTtld.set(fullKey, Date.now() + ttlSeconds * 1e3);
|
|
267
|
+
else cacheTtld.delete(fullKey);
|
|
268
|
+
},
|
|
269
|
+
async del(...keys) {
|
|
270
|
+
let count = 0;
|
|
271
|
+
for (const k of keys) {
|
|
272
|
+
const fullKey = prefix + k;
|
|
273
|
+
if (cacheEntries.delete(fullKey)) count++;
|
|
274
|
+
cacheTtld.delete(fullKey);
|
|
275
|
+
cacheSets.delete(fullKey);
|
|
276
|
+
}
|
|
277
|
+
return count;
|
|
278
|
+
},
|
|
279
|
+
async incr(key) {
|
|
280
|
+
const fullKey = prefix + key;
|
|
281
|
+
const next = (Number(cacheEntries.get(fullKey)) || 0) + 1;
|
|
282
|
+
cacheEntries.set(fullKey, next);
|
|
283
|
+
return next;
|
|
284
|
+
},
|
|
285
|
+
async expire(key, ttlSeconds) {
|
|
286
|
+
const fullKey = prefix + key;
|
|
287
|
+
if (!cacheEntries.has(fullKey) && !cacheSets.has(fullKey)) return false;
|
|
288
|
+
cacheTtld.set(fullKey, Date.now() + ttlSeconds * 1e3);
|
|
289
|
+
return true;
|
|
290
|
+
},
|
|
291
|
+
async ttl(key) {
|
|
292
|
+
const fullKey = prefix + key;
|
|
293
|
+
if (!cacheEntries.has(fullKey) && !cacheSets.has(fullKey)) return -2;
|
|
294
|
+
const exp = cacheTtld.get(fullKey);
|
|
295
|
+
if (!exp) return -1;
|
|
296
|
+
const remaining = Math.ceil((exp - Date.now()) / 1e3);
|
|
297
|
+
return remaining > 0 ? remaining : -2;
|
|
298
|
+
},
|
|
299
|
+
async setAdd(key, member) {
|
|
300
|
+
const fullKey = prefix + key;
|
|
301
|
+
let set = cacheSets.get(fullKey);
|
|
302
|
+
if (!set) {
|
|
303
|
+
set = /* @__PURE__ */ new Set();
|
|
304
|
+
cacheSets.set(fullKey, set);
|
|
305
|
+
}
|
|
306
|
+
const initialSize = set.size;
|
|
307
|
+
set.add(member);
|
|
308
|
+
return set.size > initialSize ? 1 : 0;
|
|
309
|
+
},
|
|
310
|
+
async setRemove(key, member) {
|
|
311
|
+
const fullKey = prefix + key;
|
|
312
|
+
const set = cacheSets.get(fullKey);
|
|
313
|
+
if (!set) return 0;
|
|
314
|
+
return set.delete(member) ? 1 : 0;
|
|
315
|
+
},
|
|
316
|
+
async setMembers(key) {
|
|
317
|
+
const fullKey = prefix + key;
|
|
318
|
+
const set = cacheSets.get(fullKey);
|
|
319
|
+
return set ? Array.from(set) : [];
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
const rateLimiter = { async consume(key, opts) {
|
|
323
|
+
const fullKey = `addon:${addonId}:ratelimit:${key}`;
|
|
324
|
+
const now = Date.now();
|
|
325
|
+
const windowStart = now - opts.windowMs;
|
|
326
|
+
const valid = (rateLimitTimestamps.get(fullKey) ?? []).filter((t) => t > windowStart);
|
|
327
|
+
if (valid.length >= opts.limit) {
|
|
328
|
+
rateLimitTimestamps.set(fullKey, valid);
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
valid.push(now);
|
|
332
|
+
rateLimitTimestamps.set(fullKey, valid);
|
|
333
|
+
return true;
|
|
334
|
+
} };
|
|
335
|
+
const defaultUser = {
|
|
336
|
+
id: user.id ?? "mock-user-1",
|
|
337
|
+
name: user.name ?? "Mock User",
|
|
338
|
+
displayName: user.displayName ?? "Mock User",
|
|
339
|
+
avatar: user.avatar ?? "https://example.com/avatar.png",
|
|
340
|
+
gender: user.gender ?? "neutral"
|
|
341
|
+
};
|
|
342
|
+
return {
|
|
343
|
+
context: {
|
|
344
|
+
t: {},
|
|
345
|
+
fetch: async (input, init) => globalThis.fetch(input, init),
|
|
346
|
+
db: {},
|
|
347
|
+
cache,
|
|
348
|
+
rateLimiter,
|
|
349
|
+
emit: async (event, payload, target) => {
|
|
350
|
+
emittedEvents.push({
|
|
351
|
+
event: `addon:${addonId}:${event}`,
|
|
352
|
+
payload,
|
|
353
|
+
target
|
|
354
|
+
});
|
|
355
|
+
},
|
|
356
|
+
pipeline: { register: (hooks) => {
|
|
357
|
+
registeredHooks.push(hooks);
|
|
358
|
+
} },
|
|
359
|
+
getConfig: async (_targetSiteId) => config,
|
|
360
|
+
getUser: async (userId) => userId === defaultUser.id ? defaultUser : null,
|
|
361
|
+
findUserByName: async (name) => name === defaultUser.name ? defaultUser : null,
|
|
362
|
+
getUsers: async (userIds) => {
|
|
363
|
+
const map = /* @__PURE__ */ new Map();
|
|
364
|
+
for (const id of userIds) if (id === defaultUser.id) map.set(id, defaultUser);
|
|
365
|
+
return map;
|
|
366
|
+
},
|
|
367
|
+
hasPermission: (reqUser, permission) => {
|
|
368
|
+
return hasPermission(reqUser?.permissions ?? [], permission);
|
|
369
|
+
},
|
|
370
|
+
authorizeOwnership: ({ actor, ownerId, ownPermission, anyPermission, resourceLabel }) => {
|
|
371
|
+
return authorizeResourceOwnership({
|
|
372
|
+
actorId: actor.id,
|
|
373
|
+
actorPermissions: actor.permissions ?? [],
|
|
374
|
+
ownerId,
|
|
375
|
+
ownPermission,
|
|
376
|
+
anyPermission,
|
|
377
|
+
resourceLabel
|
|
378
|
+
});
|
|
379
|
+
},
|
|
380
|
+
can: (actor, action, subjectName, attributes) => {
|
|
381
|
+
if (!actor) return false;
|
|
382
|
+
return createAddonAbility(actor).can(action, attributes ? subject(subjectName, attributes) : subjectName);
|
|
383
|
+
},
|
|
384
|
+
verifyEntitlement: (expectedAddonId) => {
|
|
385
|
+
if (expectedAddonId && expectedAddonId !== addonId) return false;
|
|
386
|
+
return isEntitled;
|
|
387
|
+
}
|
|
388
|
+
},
|
|
389
|
+
emittedEvents,
|
|
390
|
+
registeredHooks,
|
|
391
|
+
cacheEntries,
|
|
392
|
+
cacheSets
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
//#endregion
|
|
396
|
+
//#region src/index.ts
|
|
397
|
+
/**
|
|
398
|
+
* @jchat/addon-sdk
|
|
399
|
+
*
|
|
400
|
+
* Lightweight SDK for Jchat addon developers. Provides TypeScript types for addon props, manifest
|
|
401
|
+
* schema, and host API access. This package is always EXTERNALISED from addon bundles — the host
|
|
402
|
+
* app provides the runtime implementation.
|
|
403
|
+
*/
|
|
404
|
+
/** Standard error thrown or handled when an addon message pipeline hook aborts sending. */
|
|
405
|
+
var AddonPipelineAbortError = class extends Error {
|
|
406
|
+
code;
|
|
407
|
+
reason;
|
|
408
|
+
constructor(error) {
|
|
409
|
+
super(error.reason || error.code);
|
|
410
|
+
this.name = "AddonPipelineAbortError";
|
|
411
|
+
this.code = error.code;
|
|
412
|
+
this.reason = error.reason;
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
/** Define a manifest with full validation-friendly typing while preserving literal values. */
|
|
416
|
+
function defineAddonManifest(manifest) {
|
|
417
|
+
return manifest;
|
|
418
|
+
}
|
|
419
|
+
/** Define a typed server factory without widening its addon database interface. */
|
|
420
|
+
function defineAddonServer(factory) {
|
|
421
|
+
return factory;
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Access the host application's SDK. Provides authenticated API access, toast notifications, and
|
|
425
|
+
* navigation.
|
|
426
|
+
*
|
|
427
|
+
* This function is a shell; the real implementation is provided by the Jchat host environment at
|
|
428
|
+
* runtime.
|
|
429
|
+
*
|
|
430
|
+
* @example
|
|
431
|
+
* ;```tsx
|
|
432
|
+
* import { useAddonSDK } from "@jchat/addon-sdk"
|
|
433
|
+
*
|
|
434
|
+
* export default function MyAddon({ mountPoint }: AddonProps) {
|
|
435
|
+
* const sdk = useAddonSDK()
|
|
436
|
+
*
|
|
437
|
+
* const handleClick = async () => {
|
|
438
|
+
* const data = await sdk.api.get("/trending")
|
|
439
|
+
* sdk.toast("Fetched trending posts!", "success")
|
|
440
|
+
* }
|
|
441
|
+
* }
|
|
442
|
+
* ```
|
|
443
|
+
*/
|
|
444
|
+
function useAddonSDK() {
|
|
445
|
+
const hostUseAddonSDK = typeof window !== "undefined" ? window.__JCHAT_EXTERNALS__?.JchatAddonSDK?.useAddonSDK : void 0;
|
|
446
|
+
if (hostUseAddonSDK && hostUseAddonSDK !== useAddonSDK) return hostUseAddonSDK();
|
|
447
|
+
throw new Error("AddonSDK must be used within the Jchat host environment.");
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Get the version of the addon SDK. This is useful for checking compatibility with the host
|
|
451
|
+
* application.
|
|
452
|
+
*
|
|
453
|
+
* @example
|
|
454
|
+
* ;```tsx
|
|
455
|
+
* import { getAddonSdkVersion } from "@jchat/addon-sdk"
|
|
456
|
+
*
|
|
457
|
+
* console.log(getAddonSdkVersion())
|
|
458
|
+
* ```
|
|
459
|
+
*
|
|
460
|
+
* @returns
|
|
461
|
+
*/
|
|
462
|
+
function getAddonSdkVersion() {
|
|
463
|
+
return version;
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Verifies purchase entitlement and identity integrity for commercial addons. Throws an error if
|
|
467
|
+
* the addon fails entitlement verification or if its manifest ID has been tampered with to
|
|
468
|
+
* impersonate another addon.
|
|
469
|
+
*
|
|
470
|
+
* @example
|
|
471
|
+
* ;```ts
|
|
472
|
+
* export default ({ verifyEntitlement, db, t }: AddonServerContext, app: AddonApp) => {
|
|
473
|
+
* verifyAddonEntitlement("tubescanpro", verifyEntitlement)
|
|
474
|
+
* return app.get("/trending", async () => { ... })
|
|
475
|
+
* }
|
|
476
|
+
* ```
|
|
477
|
+
*
|
|
478
|
+
* @param addonId - The canonical addon ID defined by the publisher (e.g. 'tubescanpro')
|
|
479
|
+
* @param target - Either the `verifyEntitlement` function from context or the `AddonServerContext`
|
|
480
|
+
* object itself
|
|
481
|
+
* @returns True if valid
|
|
482
|
+
* @throws Error if entitlement verification fails
|
|
483
|
+
*/
|
|
484
|
+
function verifyAddonEntitlement(addonId, target) {
|
|
485
|
+
const verifyFn = typeof target === "function" ? target : target?.verifyEntitlement;
|
|
486
|
+
if (verifyFn) {
|
|
487
|
+
if (!verifyFn(addonId)) throw new Error(`addon_entitlement_verification_failed:${addonId}`);
|
|
488
|
+
return true;
|
|
489
|
+
}
|
|
490
|
+
return true;
|
|
491
|
+
}
|
|
492
|
+
//#endregion
|
|
493
|
+
export { createMongoAbility as _, useAddonSDK as a, useAddonInfiniteQuery as c, useAddonSettings as d, useQueryClient as f, createAddonAbility as g, authorizeResourceOwnership as h, getAddonSdkVersion as i, useAddonMutation as l, ForbiddenError as m, defineAddonManifest as n, verifyAddonEntitlement as o, AbilityBuilder as p, defineAddonServer as r, createMockAddonServerContext as s, AddonPipelineAbortError as t, useAddonQuery as u, hasPermission as v, subject as y };
|
package/dist/state.d.mts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "jotai";
|
package/dist/state.mjs
ADDED
package/dist/testing.mjs
ADDED
package/dist/ui.d.mts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { _ as AddonUser, d as AddonRequestUser } from "./contracts2.mjs";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import ui_default from "@jchat/ui";
|
|
4
|
+
import { Can } from "@casl/react";
|
|
5
|
+
export * from "@jchat/ui";
|
|
6
|
+
//#region src/ui.d.ts
|
|
7
|
+
export interface AddonCanProps {
|
|
8
|
+
/** The current user attempting the action */
|
|
9
|
+
actor?: (Partial<AddonRequestUser | AddonUser> & {
|
|
10
|
+
permissions?: string[];
|
|
11
|
+
isOwner?: boolean;
|
|
12
|
+
}) | null;
|
|
13
|
+
/** The owner ID of the target resource, if evaluating an ownership-restricted action */
|
|
14
|
+
ownerId?: string;
|
|
15
|
+
/** Permission required if actor is the resource owner (e.g. 'addon.pollwise.close.own') */
|
|
16
|
+
ownPermission?: string;
|
|
17
|
+
/** Permission required if actor is NOT the resource owner (e.g. 'addon.pollwise.close.any') */
|
|
18
|
+
anyPermission?: string;
|
|
19
|
+
/** Simple permission string when not evaluating ownership (e.g. 'addon.pollwise.create') */
|
|
20
|
+
permission?: string;
|
|
21
|
+
/** Fallback content rendered when permission is denied (defaults to null) */
|
|
22
|
+
fallback?: React.ReactNode;
|
|
23
|
+
/** Content rendered when access is granted */
|
|
24
|
+
children: React.ReactNode;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Declarative component for conditional UI rendering based on RBAC/ABAC permissions and resource
|
|
28
|
+
* ownership.
|
|
29
|
+
*
|
|
30
|
+
* @param actor The current user attempting the action
|
|
31
|
+
* @param ownerId The owner ID of the target resource, if evaluating an ownership-restricted action
|
|
32
|
+
* @param ownPermission Permission required if actor is the resource owner (e.g.
|
|
33
|
+
* 'addon.pollwise.close.own')
|
|
34
|
+
* @param anyPermission Permission required if actor is NOT the resource owner (e.g.
|
|
35
|
+
* 'addon.pollwise.close.any')
|
|
36
|
+
* @param permission Simple permission string when not evaluating ownership (e.g.
|
|
37
|
+
* 'addon.pollwise.create')
|
|
38
|
+
* @param fallback Fallback content rendered when permission is denied (defaults to null)
|
|
39
|
+
* @param children Content rendered when access is granted
|
|
40
|
+
*/
|
|
41
|
+
export declare function AddonCan({ actor, ownerId, ownPermission, anyPermission, permission, fallback, children }: AddonCanProps): React.ReactElement | null;
|
|
42
|
+
/** Standard loading spinner for addon settings views. */
|
|
43
|
+
export declare function AddonSettingsLoading({ message, className }: {
|
|
44
|
+
message?: string;
|
|
45
|
+
className?: string;
|
|
46
|
+
}): React.ReactElement;
|
|
47
|
+
//#endregion
|
|
48
|
+
export { Can, ui_default as default };
|
package/dist/ui.mjs
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { v as hasPermission } from "./src.mjs";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import ui_default from "@jchat/ui";
|
|
4
|
+
import { Can } from "@casl/react";
|
|
5
|
+
export * from "@jchat/ui";
|
|
6
|
+
//#region src/ui.ts
|
|
7
|
+
/**
|
|
8
|
+
* Declarative component for conditional UI rendering based on RBAC/ABAC permissions and resource
|
|
9
|
+
* ownership.
|
|
10
|
+
*
|
|
11
|
+
* @param actor The current user attempting the action
|
|
12
|
+
* @param ownerId The owner ID of the target resource, if evaluating an ownership-restricted action
|
|
13
|
+
* @param ownPermission Permission required if actor is the resource owner (e.g.
|
|
14
|
+
* 'addon.pollwise.close.own')
|
|
15
|
+
* @param anyPermission Permission required if actor is NOT the resource owner (e.g.
|
|
16
|
+
* 'addon.pollwise.close.any')
|
|
17
|
+
* @param permission Simple permission string when not evaluating ownership (e.g.
|
|
18
|
+
* 'addon.pollwise.create')
|
|
19
|
+
* @param fallback Fallback content rendered when permission is denied (defaults to null)
|
|
20
|
+
* @param children Content rendered when access is granted
|
|
21
|
+
*/
|
|
22
|
+
function AddonCan({ actor, ownerId, ownPermission, anyPermission, permission, fallback = null, children }) {
|
|
23
|
+
if (!actor) return fallback;
|
|
24
|
+
const perms = actor.permissions ?? [];
|
|
25
|
+
if (actor.isOwner || hasPermission(perms, "*")) return children;
|
|
26
|
+
if (permission && !hasPermission(perms, permission)) return fallback;
|
|
27
|
+
if (ownPermission || anyPermission) {
|
|
28
|
+
if (Boolean(actor.id && ownerId && actor.id === ownerId) && ownPermission && hasPermission(perms, ownPermission)) return children;
|
|
29
|
+
if (anyPermission && hasPermission(perms, anyPermission)) return children;
|
|
30
|
+
return fallback;
|
|
31
|
+
}
|
|
32
|
+
return children;
|
|
33
|
+
}
|
|
34
|
+
/** Standard loading spinner for addon settings views. */
|
|
35
|
+
function AddonSettingsLoading({ message = "Loading configuration...", className }) {
|
|
36
|
+
return React.createElement("div", { className: "flex items-center justify-center p-8 text-sm text-muted-foreground" + (className ? ` ${className}` : "") }, React.createElement("span", { className: "me-2 inline-block size-4 animate-spin rounded-full border-2 border-primary border-t-transparent" }), message);
|
|
37
|
+
}
|
|
38
|
+
//#endregion
|
|
39
|
+
export { AddonCan, AddonSettingsLoading, Can, ui_default as default };
|
package/dist/vite.d.mts
CHANGED
|
@@ -1,14 +1,44 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { l as AddonManifest } from "./contracts2.mjs";
|
|
2
2
|
import { Plugin } from "vite";
|
|
3
3
|
//#region src/vite.d.ts
|
|
4
4
|
/**
|
|
5
|
-
* Vite plugin
|
|
6
|
-
*
|
|
5
|
+
* Vite plugin for building JChat sandboxed addon extensions.
|
|
6
|
+
*
|
|
7
|
+
* Configures automatic manifest generation (`manifest.json`), bundles server-side API scripts
|
|
8
|
+
* (`src/api.ts`), embeds scoped Tailwind CSS into the frontend bundle, packages database migrations
|
|
9
|
+
* and seed files, copies documentation (`README.md`), signs database-bearing addon manifests, and
|
|
10
|
+
* zips production build artifacts.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ;```ts
|
|
14
|
+
* import { jchatAddon } from "@jchat/addon-sdk/vite"
|
|
15
|
+
* import manifest from "./src/manifest"
|
|
16
|
+
* import { defineConfig } from "vite"
|
|
17
|
+
*
|
|
18
|
+
* export default defineConfig({
|
|
19
|
+
* plugins: [jchatAddon({ manifest })]
|
|
20
|
+
* })
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* @param options - Configuration options for the JChat addon Vite plugin.
|
|
24
|
+
* @returns A Vite {@link Plugin} instance configured for addon compilation.
|
|
7
25
|
*/
|
|
8
|
-
declare function jchatAddon(options: JchatAddonPluginOptions): Plugin;
|
|
9
|
-
|
|
26
|
+
export declare function jchatAddon(options: JchatAddonPluginOptions): Plugin;
|
|
27
|
+
/** Configuration options for the {@link jchatAddon} Vite plugin. */
|
|
28
|
+
export interface JchatAddonPluginOptions {
|
|
29
|
+
/** The base manifest specification for the addon. */
|
|
10
30
|
manifest: AddonManifest;
|
|
31
|
+
/** Explicitly declare development mode to permit unsigned database-bearing builds. */
|
|
32
|
+
development?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Whether to package the build output into a `.zip` archive upon build completion. Defaults to
|
|
35
|
+
* `true` during production builds and `false` during development builds.
|
|
36
|
+
*/
|
|
11
37
|
zip?: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Optional RSA private key string used to cryptographically sign database-bearing addons. If
|
|
40
|
+
* omitted, falls back to the `JCHAT_ADDON_SIGNING_PRIVATE_KEY` environment variable.
|
|
41
|
+
*/
|
|
12
42
|
signingPrivateKey?: string;
|
|
13
43
|
/**
|
|
14
44
|
* Embed Tailwind CSS output into the JS bundle and scope it to the addon's DOM container.
|
|
@@ -16,11 +46,35 @@ interface JchatAddonPluginOptions {
|
|
|
16
46
|
*/
|
|
17
47
|
css?: boolean;
|
|
18
48
|
}
|
|
19
|
-
|
|
20
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Generates CLI argument parameters for bundling server-side Elysia API scripts (`src/api.ts`) via
|
|
51
|
+
* Bun.
|
|
52
|
+
*
|
|
53
|
+
* @param entryPath - Absolute file path to the server entry script (`src/api.ts`).
|
|
54
|
+
* @param outputPath - Absolute destination file path for the bundled output (`build/api.js`).
|
|
55
|
+
* @param developmentBuild - Indicates whether to enable external sourcemaps or minification.
|
|
56
|
+
* @returns Array of command line arguments for running `bun build`.
|
|
57
|
+
*/
|
|
58
|
+
export declare function createAddonServerBuildArguments(entryPath: string, outputPath: string, developmentBuild: boolean): string[];
|
|
59
|
+
/**
|
|
60
|
+
* Cryptographically signs an addon manifest using RSA-SHA256 over all archive file contents.
|
|
61
|
+
*
|
|
62
|
+
* @param manifest - Unsigned or base addon manifest.
|
|
63
|
+
* @param files - List of archive file paths and raw contents to digest.
|
|
64
|
+
* @param privateKey - RSA private key in PEM format.
|
|
65
|
+
* @returns Updated {@link AddonManifest} containing an RSA signature block.
|
|
66
|
+
*/
|
|
67
|
+
export declare function signAddonManifest(manifest: AddonManifest, files: readonly {
|
|
21
68
|
path: string;
|
|
22
69
|
contents: Buffer;
|
|
23
70
|
}[], privateKey: string): AddonManifest;
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
71
|
+
/**
|
|
72
|
+
* Resolves the required RSA signing key for database-bearing addons.
|
|
73
|
+
*
|
|
74
|
+
* @param signingPrivateKey - Provided signing key or undefined.
|
|
75
|
+
* @param developmentBuild - Whether the current build is targeting development mode.
|
|
76
|
+
* @returns Resolved signing key string or undefined (in dev builds).
|
|
77
|
+
* @throws {@link Error} If building a database-bearing addon in production without a signing key.
|
|
78
|
+
*/
|
|
79
|
+
export declare function requireAddonSigningKey(signingPrivateKey: string | undefined, developmentBuild: boolean): string | undefined;
|
|
80
|
+
//#endregion
|