@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/CHANGELOG.md +17 -0
- package/dist/cli.d.mts +30 -11
- package/dist/cli.mjs +28 -10
- package/dist/contracts2.d.mts +1 -0
- package/dist/index.mjs +2 -193
- package/dist/index2.d.mts +173 -4
- package/dist/signing.mjs +63 -0
- package/dist/signing2.d.mts +63 -0
- package/dist/src.mjs +467 -0
- package/dist/testing.mjs +1 -143
- package/dist/ui.d.mts +42 -1
- package/dist/ui.mjs +32 -1
- package/package.json +3 -1
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");
|
package/dist/signing2.d.mts
CHANGED
|
@@ -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 };
|