@jchat/addon-sdk 1.0.0-alpha.27 → 1.0.0-alpha.29

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/signing.mjs CHANGED
@@ -1,20 +1,57 @@
1
1
  import crypto from "node:crypto";
2
2
  import * as path$1 from "node:path";
3
3
  //#region src/signing.ts
4
+ /** Maximum allowable byte size for an addon distribution package archive (25 MB). */
4
5
  const MAX_ADDON_ARCHIVE_BYTES = 26214400;
6
+ /**
7
+ * Regular expression validating canonical kebab-case addon identifiers (e.g. `'soundboard'`,
8
+ * `'dj-drop'`).
9
+ */
5
10
  const ADDON_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
11
+ /** Regular expression validating semver compatibility range expressions. */
6
12
  const COMPATIBILITY_RANGE_PATTERN = /^[0-9A-Za-z*^~<>=|.\-\s]+$/;
13
+ /**
14
+ * Asserts that a file path is a safe relative path, guarding against directory traversal attacks,
15
+ * absolute paths, and non-canonical path separators.
16
+ *
17
+ * @param value - File path string to validate.
18
+ * @param label - Human-friendly field name used for error reporting (e.g. `'Logo path'`, `'Bundle
19
+ * path'`).
20
+ * @throws {Error} When the path is empty, absolute, contains backslashes, or attempts directory
21
+ * traversal (`..`).
22
+ */
7
23
  function assertSafeRelativePath(value, label) {
8
24
  if (!value || path$1.isAbsolute(value) || value.includes("\\") || value.split("/").includes("..")) throw new Error(`${label} must be a safe relative path`);
9
25
  }
26
+ /**
27
+ * Type guard verifying whether a value is a valid semver compatibility range string.
28
+ *
29
+ * @param value - The value to check.
30
+ * @returns `true` if the value is a valid non-empty compatibility range string within 64
31
+ * characters.
32
+ */
10
33
  function isCompatibilityRange(value) {
11
34
  return typeof value === "string" && value.length > 0 && value.length <= 64 && COMPATIBILITY_RANGE_PATTERN.test(value);
12
35
  }
36
+ /**
37
+ * Recursively sorts object keys alphabetically to guarantee deterministic, reproducible JSON
38
+ * serialization for cryptographic signing and digest hashing.
39
+ *
40
+ * @param value - Object, array, or primitive value to normalize.
41
+ * @returns A new structure with sorted keys at all nested levels.
42
+ */
13
43
  function sortValue(value) {
14
44
  if (Array.isArray(value)) return value.map(sortValue);
15
45
  if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).toSorted(([left], [right]) => left.localeCompare(right)).map(([key, child]) => [key, sortValue(child)]));
16
46
  return value;
17
47
  }
48
+ /**
49
+ * Produces a canonical JSON string representation of an {@link AddonManifest} with signature fields
50
+ * blanked and all object keys sorted deterministically.
51
+ *
52
+ * @param manifest - Addon manifest to canonicalize.
53
+ * @returns Normalized JSON string representation.
54
+ */
18
55
  function canonicalManifest(manifest) {
19
56
  const copy = structuredClone(manifest);
20
57
  if (copy.signature) {
@@ -23,6 +60,16 @@ function canonicalManifest(manifest) {
23
60
  }
24
61
  return JSON.stringify(sortValue(copy));
25
62
  }
63
+ /**
64
+ * Computes a deterministic SHA-256 cryptographic digest across an addon archive's contents.
65
+ *
66
+ * Files are processed in lexicographical path order, with manifest signature fields sanitized,
67
+ * producing a tamper-evident package fingerprint.
68
+ *
69
+ * @param manifest - Addon manifest containing metadata and package declarations.
70
+ * @param files - Array of files packaged inside the addon archive.
71
+ * @returns Hex-encoded SHA-256 digest string.
72
+ */
26
73
  function createCanonicalAddonDigest(manifest, files) {
27
74
  const hash = crypto.createHash("sha256");
28
75
  const sortedFiles = [...files].toSorted((left, right) => left.path.localeCompare(right.path));
@@ -34,6 +81,22 @@ function createCanonicalAddonDigest(manifest, files) {
34
81
  }
35
82
  return hash.digest("hex");
36
83
  }
84
+ /**
85
+ * Validates the structural, security, and schema integrity of an {@link AddonManifest}.
86
+ *
87
+ * Enforces:
88
+ *
89
+ * - Valid kebab-case identifier (`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
90
+ * - Non-empty name and version constraints
91
+ * - Host and SDK compatibility range specifications
92
+ * - Distribution model classification (`'free'` or `'commercial'`)
93
+ * - Logo image file format (`.svg`, `.png`, `.webp`) and safe relative path
94
+ * - Safe relative paths for client/server bundles, migrations, and seeds
95
+ * - Canonical permission namespacing prefix (`addon.<id>.*`)
96
+ *
97
+ * @param manifest - Addon manifest object to inspect and validate.
98
+ * @throws {Error} When any validation rule or security constraint is violated.
99
+ */
37
100
  function validateAddonManifest(manifest) {
38
101
  if (!manifest.id || manifest.id.length > 40 || !ADDON_ID_PATTERN.test(manifest.id)) throw new Error("Manifest id must contain only lowercase letters, numbers, and hyphens");
39
102
  if (!manifest.name || manifest.name.length > 120) throw new Error("Manifest name is invalid");
@@ -1,13 +1,76 @@
1
1
  import { l as AddonManifest, r as AddonArchiveFile } from "./contracts2.mjs";
2
2
  //#region src/signing.d.ts
3
+ /** Maximum allowable byte size for an addon distribution package archive (25 MB). */
3
4
  declare const MAX_ADDON_ARCHIVE_BYTES: number;
5
+ /**
6
+ * Regular expression validating canonical kebab-case addon identifiers (e.g. `'soundboard'`,
7
+ * `'dj-drop'`).
8
+ */
4
9
  declare const ADDON_ID_PATTERN: RegExp;
10
+ /** Regular expression validating semver compatibility range expressions. */
5
11
  declare const COMPATIBILITY_RANGE_PATTERN: RegExp;
12
+ /**
13
+ * Asserts that a file path is a safe relative path, guarding against directory traversal attacks,
14
+ * absolute paths, and non-canonical path separators.
15
+ *
16
+ * @param value - File path string to validate.
17
+ * @param label - Human-friendly field name used for error reporting (e.g. `'Logo path'`, `'Bundle
18
+ * path'`).
19
+ * @throws {Error} When the path is empty, absolute, contains backslashes, or attempts directory
20
+ * traversal (`..`).
21
+ */
6
22
  declare function assertSafeRelativePath(value: string, label: string): void;
23
+ /**
24
+ * Type guard verifying whether a value is a valid semver compatibility range string.
25
+ *
26
+ * @param value - The value to check.
27
+ * @returns `true` if the value is a valid non-empty compatibility range string within 64
28
+ * characters.
29
+ */
7
30
  declare function isCompatibilityRange(value: unknown): value is string;
31
+ /**
32
+ * Recursively sorts object keys alphabetically to guarantee deterministic, reproducible JSON
33
+ * serialization for cryptographic signing and digest hashing.
34
+ *
35
+ * @param value - Object, array, or primitive value to normalize.
36
+ * @returns A new structure with sorted keys at all nested levels.
37
+ */
8
38
  declare function sortValue(value: unknown): unknown;
39
+ /**
40
+ * Produces a canonical JSON string representation of an {@link AddonManifest} with signature fields
41
+ * blanked and all object keys sorted deterministically.
42
+ *
43
+ * @param manifest - Addon manifest to canonicalize.
44
+ * @returns Normalized JSON string representation.
45
+ */
9
46
  declare function canonicalManifest(manifest: AddonManifest): string;
47
+ /**
48
+ * Computes a deterministic SHA-256 cryptographic digest across an addon archive's contents.
49
+ *
50
+ * Files are processed in lexicographical path order, with manifest signature fields sanitized,
51
+ * producing a tamper-evident package fingerprint.
52
+ *
53
+ * @param manifest - Addon manifest containing metadata and package declarations.
54
+ * @param files - Array of files packaged inside the addon archive.
55
+ * @returns Hex-encoded SHA-256 digest string.
56
+ */
10
57
  declare function createCanonicalAddonDigest(manifest: AddonManifest, files: readonly AddonArchiveFile[]): string;
58
+ /**
59
+ * Validates the structural, security, and schema integrity of an {@link AddonManifest}.
60
+ *
61
+ * Enforces:
62
+ *
63
+ * - Valid kebab-case identifier (`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
64
+ * - Non-empty name and version constraints
65
+ * - Host and SDK compatibility range specifications
66
+ * - Distribution model classification (`'free'` or `'commercial'`)
67
+ * - Logo image file format (`.svg`, `.png`, `.webp`) and safe relative path
68
+ * - Safe relative paths for client/server bundles, migrations, and seeds
69
+ * - Canonical permission namespacing prefix (`addon.<id>.*`)
70
+ *
71
+ * @param manifest - Addon manifest object to inspect and validate.
72
+ * @throws {Error} When any validation rule or security constraint is violated.
73
+ */
11
74
  declare function validateAddonManifest(manifest: AddonManifest): void;
12
75
  //#endregion
13
76
  export { canonicalManifest as a, sortValue as c, assertSafeRelativePath as i, validateAddonManifest as l, COMPATIBILITY_RANGE_PATTERN as n, createCanonicalAddonDigest as o, MAX_ADDON_ARCHIVE_BYTES as r, isCompatibilityRange as s, ADDON_ID_PATTERN as t };
package/dist/src.mjs ADDED
@@ -0,0 +1,467 @@
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.29";
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
+ //#endregion
196
+ //#region src/testing.ts
197
+ /**
198
+ * Creates an in-memory, zero-dependency {@link AddonServerContext} mock for testing addon server
199
+ * routes, lifecycle handlers, and pipeline hooks offline in CI without requiring live database
200
+ * connections or Redis.
201
+ *
202
+ * Provides complete mock implementations of:
203
+ *
204
+ * - **Key-Value & Set Cache**: Supports `get`, `set` (with TTL simulation), `del`, `incr`, `expire`,
205
+ * `ttl`, `setAdd`, `setRemove`, and `setMembers` with automatic tenant/addon namespacing.
206
+ * - **Sliding-Window Rate Limiting**: In-memory timestamp-bucket rate limiting via `consume`.
207
+ * - **Event Capturing**: Records emitted events in {@link MockAddonContextResult.emittedEvents} with
208
+ * canonical `addon:<addonId>:<event>` prefixing.
209
+ * - **Pipeline Hook Registration**: Inspects registered hook stages and handlers.
210
+ * - **Fine-Grained ABAC & Ownership**: Enforces CASL `can` attribute queries and BOLA protection via
211
+ * `authorizeOwnership`.
212
+ * - **License Entitlement**: Deterministic commercial entitlement verification.
213
+ *
214
+ * @param addonId - Unique identifier of the addon under test.
215
+ * @param options - Optional mock configuration parameters.
216
+ * @returns A {@link MockAddonContextResult} containing the mock context and inspection fixtures.
217
+ */
218
+ function createMockAddonServerContext(addonId, options = {}) {
219
+ const { config = {}, user = {}, isEntitled = true } = options;
220
+ const emittedEvents = [];
221
+ const registeredHooks = [];
222
+ const cacheEntries = /* @__PURE__ */ new Map();
223
+ const cacheSets = /* @__PURE__ */ new Map();
224
+ const cacheTtld = /* @__PURE__ */ new Map();
225
+ const rateLimitTimestamps = /* @__PURE__ */ new Map();
226
+ const prefix = `addon:${addonId}:`;
227
+ const cache = {
228
+ async get(key) {
229
+ const fullKey = prefix + key;
230
+ const exp = cacheTtld.get(fullKey);
231
+ if (exp && Date.now() > exp) {
232
+ cacheEntries.delete(fullKey);
233
+ cacheTtld.delete(fullKey);
234
+ return null;
235
+ }
236
+ const val = cacheEntries.get(fullKey);
237
+ return val !== void 0 ? val : null;
238
+ },
239
+ async set(key, value, ttlSeconds) {
240
+ const fullKey = prefix + key;
241
+ cacheEntries.set(fullKey, value);
242
+ if (ttlSeconds && ttlSeconds > 0) cacheTtld.set(fullKey, Date.now() + ttlSeconds * 1e3);
243
+ else cacheTtld.delete(fullKey);
244
+ },
245
+ async del(...keys) {
246
+ let count = 0;
247
+ for (const k of keys) {
248
+ const fullKey = prefix + k;
249
+ if (cacheEntries.delete(fullKey)) count++;
250
+ cacheTtld.delete(fullKey);
251
+ cacheSets.delete(fullKey);
252
+ }
253
+ return count;
254
+ },
255
+ async incr(key) {
256
+ const fullKey = prefix + key;
257
+ const next = (Number(cacheEntries.get(fullKey)) || 0) + 1;
258
+ cacheEntries.set(fullKey, next);
259
+ return next;
260
+ },
261
+ async expire(key, ttlSeconds) {
262
+ const fullKey = prefix + key;
263
+ if (!cacheEntries.has(fullKey) && !cacheSets.has(fullKey)) return false;
264
+ cacheTtld.set(fullKey, Date.now() + ttlSeconds * 1e3);
265
+ return true;
266
+ },
267
+ async ttl(key) {
268
+ const fullKey = prefix + key;
269
+ if (!cacheEntries.has(fullKey) && !cacheSets.has(fullKey)) return -2;
270
+ const exp = cacheTtld.get(fullKey);
271
+ if (!exp) return -1;
272
+ const remaining = Math.ceil((exp - Date.now()) / 1e3);
273
+ return remaining > 0 ? remaining : -2;
274
+ },
275
+ async setAdd(key, member) {
276
+ const fullKey = prefix + key;
277
+ let set = cacheSets.get(fullKey);
278
+ if (!set) {
279
+ set = /* @__PURE__ */ new Set();
280
+ cacheSets.set(fullKey, set);
281
+ }
282
+ const initialSize = set.size;
283
+ set.add(member);
284
+ return set.size > initialSize ? 1 : 0;
285
+ },
286
+ async setRemove(key, member) {
287
+ const fullKey = prefix + key;
288
+ const set = cacheSets.get(fullKey);
289
+ if (!set) return 0;
290
+ return set.delete(member) ? 1 : 0;
291
+ },
292
+ async setMembers(key) {
293
+ const fullKey = prefix + key;
294
+ const set = cacheSets.get(fullKey);
295
+ return set ? Array.from(set) : [];
296
+ }
297
+ };
298
+ const rateLimiter = { async consume(key, opts) {
299
+ const fullKey = `addon:${addonId}:ratelimit:${key}`;
300
+ const now = Date.now();
301
+ const windowStart = now - opts.windowMs;
302
+ const valid = (rateLimitTimestamps.get(fullKey) ?? []).filter((t) => t > windowStart);
303
+ if (valid.length >= opts.limit) {
304
+ rateLimitTimestamps.set(fullKey, valid);
305
+ return false;
306
+ }
307
+ valid.push(now);
308
+ rateLimitTimestamps.set(fullKey, valid);
309
+ return true;
310
+ } };
311
+ const defaultUser = {
312
+ id: user.id ?? "mock-user-1",
313
+ name: user.name ?? "Mock User",
314
+ displayName: user.displayName ?? "Mock User",
315
+ avatar: user.avatar ?? "https://example.com/avatar.png",
316
+ gender: user.gender ?? "neutral"
317
+ };
318
+ return {
319
+ context: {
320
+ t: {},
321
+ db: {},
322
+ cache,
323
+ rateLimiter,
324
+ emit: async (event, payload, target) => {
325
+ emittedEvents.push({
326
+ event: `addon:${addonId}:${event}`,
327
+ payload,
328
+ target
329
+ });
330
+ },
331
+ pipeline: { register: (hooks) => {
332
+ registeredHooks.push(hooks);
333
+ } },
334
+ getConfig: async (_targetSiteId) => config,
335
+ getUser: async (userId) => userId === defaultUser.id ? defaultUser : null,
336
+ getUsers: async (userIds) => {
337
+ const map = /* @__PURE__ */ new Map();
338
+ for (const id of userIds) if (id === defaultUser.id) map.set(id, defaultUser);
339
+ return map;
340
+ },
341
+ hasPermission: (reqUser, permission) => {
342
+ return hasPermission(reqUser?.permissions ?? [], permission);
343
+ },
344
+ authorizeOwnership: ({ actor, ownerId, ownPermission, anyPermission, resourceLabel }) => {
345
+ return authorizeResourceOwnership({
346
+ actorId: actor.id,
347
+ actorPermissions: actor.permissions ?? [],
348
+ ownerId,
349
+ ownPermission,
350
+ anyPermission,
351
+ resourceLabel
352
+ });
353
+ },
354
+ can: (actor, action, subjectName, attributes) => {
355
+ if (!actor) return false;
356
+ return createAddonAbility(actor).can(action, attributes ? subject(subjectName, attributes) : subjectName);
357
+ },
358
+ verifyEntitlement: (expectedAddonId) => {
359
+ if (expectedAddonId && expectedAddonId !== addonId) return false;
360
+ return isEntitled;
361
+ }
362
+ },
363
+ emittedEvents,
364
+ registeredHooks,
365
+ cacheEntries,
366
+ cacheSets
367
+ };
368
+ }
369
+ //#endregion
370
+ //#region src/index.ts
371
+ /**
372
+ * @jchat/addon-sdk
373
+ *
374
+ * Lightweight SDK for Jchat addon developers. Provides TypeScript types for addon props, manifest
375
+ * schema, and host API access. This package is always EXTERNALISED from addon bundles — the host
376
+ * app provides the runtime implementation.
377
+ */
378
+ /** Standard error thrown or handled when an addon message pipeline hook aborts sending. */
379
+ var AddonPipelineAbortError = class extends Error {
380
+ code;
381
+ reason;
382
+ constructor(error) {
383
+ super(error.reason || error.code);
384
+ this.name = "AddonPipelineAbortError";
385
+ this.code = error.code;
386
+ this.reason = error.reason;
387
+ }
388
+ };
389
+ /** Define a manifest with full validation-friendly typing while preserving literal values. */
390
+ function defineAddonManifest(manifest) {
391
+ return manifest;
392
+ }
393
+ /** Define a typed server factory without widening its addon database interface. */
394
+ function defineAddonServer(factory) {
395
+ return factory;
396
+ }
397
+ /**
398
+ * Access the host application's SDK. Provides authenticated API access, toast notifications, and
399
+ * navigation.
400
+ *
401
+ * This function is a shell; the real implementation is provided by the Jchat host environment at
402
+ * runtime.
403
+ *
404
+ * @example
405
+ * ;```tsx
406
+ * import { useAddonSDK } from "@jchat/addon-sdk"
407
+ *
408
+ * export default function MyAddon({ mountPoint }: AddonProps) {
409
+ * const sdk = useAddonSDK()
410
+ *
411
+ * const handleClick = async () => {
412
+ * const data = await sdk.api.get("/trending")
413
+ * sdk.toast("Fetched trending posts!", "success")
414
+ * }
415
+ * }
416
+ * ```
417
+ */
418
+ function useAddonSDK() {
419
+ const hostUseAddonSDK = typeof window !== "undefined" ? window.__JCHAT_EXTERNALS__?.JchatAddonSDK?.useAddonSDK : void 0;
420
+ if (hostUseAddonSDK && hostUseAddonSDK !== useAddonSDK) return hostUseAddonSDK();
421
+ throw new Error("AddonSDK must be used within the Jchat host environment.");
422
+ }
423
+ /**
424
+ * Get the version of the addon SDK. This is useful for checking compatibility with the host
425
+ * application.
426
+ *
427
+ * @example
428
+ * ;```tsx
429
+ * import { getAddonSdkVersion } from "@jchat/addon-sdk"
430
+ *
431
+ * console.log(getAddonSdkVersion())
432
+ * ```
433
+ *
434
+ * @returns
435
+ */
436
+ function getAddonSdkVersion() {
437
+ return version;
438
+ }
439
+ /**
440
+ * Verifies purchase entitlement and identity integrity for commercial addons. Throws an error if
441
+ * the addon fails entitlement verification or if its manifest ID has been tampered with to
442
+ * impersonate another addon.
443
+ *
444
+ * @example
445
+ * ;```ts
446
+ * export default ({ verifyEntitlement, db, t }: AddonServerContext, app: AddonApp) => {
447
+ * verifyAddonEntitlement("tubescanpro", verifyEntitlement)
448
+ * return app.get("/trending", async () => { ... })
449
+ * }
450
+ * ```
451
+ *
452
+ * @param addonId - The canonical addon ID defined by the publisher (e.g. 'tubescanpro')
453
+ * @param target - Either the `verifyEntitlement` function from context or the `AddonServerContext`
454
+ * object itself
455
+ * @returns True if valid
456
+ * @throws Error if entitlement verification fails
457
+ */
458
+ function verifyAddonEntitlement(addonId, target) {
459
+ const verifyFn = typeof target === "function" ? target : target?.verifyEntitlement;
460
+ if (verifyFn) {
461
+ if (!verifyFn(addonId)) throw new Error(`addon_entitlement_verification_failed:${addonId}`);
462
+ return true;
463
+ }
464
+ return true;
465
+ }
466
+ //#endregion
467
+ export { hasPermission as _, useAddonSDK as a, useAddonInfiniteQuery as c, useQueryClient as d, AbilityBuilder as f, createMongoAbility as g, createAddonAbility as h, getAddonSdkVersion as i, useAddonMutation as l, authorizeResourceOwnership as m, defineAddonManifest as n, verifyAddonEntitlement as o, ForbiddenError as p, defineAddonServer as r, createMockAddonServerContext as s, AddonPipelineAbortError as t, useAddonQuery as u, subject as v };