@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/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 };
@@ -0,0 +1 @@
1
+ export * from "jotai";
package/dist/state.mjs ADDED
@@ -0,0 +1,2 @@
1
+ export * from "jotai";
2
+ export {};
@@ -0,0 +1,2 @@
1
+ import { MockAddonContextResult, MockAddonServerContextOptions, createMockAddonServerContext } from "./index.mjs";
2
+ export { MockAddonContextResult, MockAddonServerContextOptions, createMockAddonServerContext };
@@ -0,0 +1,2 @@
1
+ import { s as createMockAddonServerContext } from "./src.mjs";
2
+ export { createMockAddonServerContext };
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 { t as AddonManifest } from "./contracts.mjs";
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 to configure Jchat addon manifest generation, bundle backend scripts, copy
6
- * migrations, declarative seed data, and zip production build artifacts.
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
- interface JchatAddonPluginOptions {
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
- declare function createAddonServerBuildArguments(entryPath: string, outputPath: string, developmentBuild: boolean): string[];
20
- declare function signAddonManifest(manifest: AddonManifest, files: readonly {
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
- declare function requireAddonSigningKey(signingPrivateKey: string | undefined, developmentBuild: boolean): string | undefined;
25
- //#endregion
26
- export { JchatAddonPluginOptions, createAddonServerBuildArguments, jchatAddon, requireAddonSigningKey, signAddonManifest };
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