@jchat/addon-sdk 1.0.0-alpha.1 → 1.0.0-alpha.10

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/src/index.ts DELETED
@@ -1,299 +0,0 @@
1
- import type { IconPack } from "@jchat/icon"
2
- import type { Elysia } from "elysia"
3
- import type { Kysely } from "kysely"
4
- import type { AddonManifest, AddonPermission, MountPoint } from "./contracts"
5
-
6
- export {
7
- MOUNT_POINTS,
8
- type AddonManifest,
9
- type AddonPermission,
10
- type AddonPermissionDefinition,
11
- type ConfigSchemaField,
12
- type MountPoint
13
- } from "./contracts"
14
-
15
- export type AddonJsonBody = Record<string, unknown> | unknown[] | null
16
-
17
- /**
18
- * @jchat/addon-sdk
19
- *
20
- * Lightweight SDK for Jchat addon developers. Provides TypeScript types for addon props, manifest
21
- * schema, and host API access. This package is always EXTERNALISED from addon bundles — the host
22
- * app provides the runtime implementation.
23
- */
24
-
25
- /** Minimal user profile accessible to addon backends. */
26
- export interface AddonUser {
27
- /** Unique user identifier */
28
- id: string
29
- /** Display name */
30
- name: string
31
- /** Full display name */
32
- displayName: string
33
- /** Full URL to the user's avatar image */
34
- avatar: string
35
- /** User's gender identifier */
36
- gender: string
37
- }
38
-
39
- export interface AddonRequestUser {
40
- id?: string
41
- permissions?: string[]
42
- siteId?: string
43
- }
44
-
45
- /**
46
- * Typed, schema-scoped database facade for an addon. The host deliberately omits raw execution,
47
- * schema switching, dialect/connection access, and lifecycle methods.
48
- */
49
- export type AddonDatabase<TDatabase = Record<string, Record<string, unknown>>> = Pick<
50
- Kysely<TDatabase>,
51
- "deleteFrom" | "fn" | "insertInto" | "selectFrom" | "transaction" | "updateTable"
52
- >
53
-
54
- /**
55
- * Context provided to an addon's server-side factory. Parameterize it with the addon's table
56
- * interface: `AddonServerContext<MyAddonDatabase>`.
57
- */
58
- export interface AddonServerContext<TDatabase = Record<string, Record<string, unknown>>> {
59
- /**
60
- * Retrieve the encrypted settings for this addon for a specific site. Internal secrets are
61
- * automatically decrypted using the host's master key.
62
- */
63
- getConfig: (siteId: string) => Promise<Record<string, unknown>>
64
-
65
- /** Fetch a specific user's public profile */
66
- getUser: (userId: string) => Promise<AddonUser | null>
67
-
68
- /** Efficiently fetch multiple user profiles in a single query. Returns a Map keyed by user ID. */
69
- getUsers: (userIds: string[]) => Promise<Map<string, AddonUser>>
70
-
71
- /**
72
- * Check whether the request user holds a given permission. Provides autocomplete for core Jchat
73
- * permissions while also accepting any custom addon permission string.
74
- *
75
- * @param user - The user object from the request context
76
- * @param permission - The permission to check, e.g., 'message.send' or 'addon.stories.view'
77
- */
78
- hasPermission: (user: AddonRequestUser | null | undefined, permission: AddonPermission) => boolean
79
-
80
- /**
81
- * A Kysely database instance scoped entirely to your addon's tables. You cannot access core Jchat
82
- * tables (Users, Rooms, etc.) through this instance.
83
- */
84
- db: AddonDatabase<TDatabase>
85
- }
86
-
87
- /**
88
- * The entry point for your addon's server bundle. You should default-export a function matching
89
- * this signature.
90
- *
91
- * @example
92
- * ;```ts
93
- * import { type AddonServerFactory } from '@jchat/addon-sdk'
94
- *
95
- * const factory: AddonServerFactory = ({ db, hasPermission }, app) => {
96
- * return app.get('/trending', async () => { ... })
97
- * }
98
- * export default factory
99
- * ```
100
- */
101
- export type AddonServerFactory<TDatabase = Record<string, Record<string, unknown>>> = (
102
- ctx: AddonServerContext<TDatabase>,
103
- app: Elysia
104
- ) => Elysia
105
-
106
- /** Props passed to your addon's root React component by the Jchat host. */
107
- export interface AddonProps {
108
- /** The mount point where this addon instance is rendered (e.g., 'chat-sidebar') */
109
- mountPoint: MountPoint
110
- /** Information about the currently logged-in user (if any) */
111
- user?: {
112
- id: string
113
- name: string
114
- displayName?: string
115
- avatar?: string
116
- gender?: string
117
- }
118
- /** The ID of the room the user is currently viewing (if applicable) */
119
- roomId?: string
120
- /** The ID of the active Jchat site */
121
- siteId?: string
122
- }
123
-
124
- export interface AddonApi {
125
- get: <TResponse = unknown>(path: string) => Promise<TResponse>
126
- post: <TResponse = unknown>(path: string, body?: AddonJsonBody) => Promise<TResponse>
127
- put: <TResponse = unknown>(path: string, body?: AddonJsonBody) => Promise<TResponse>
128
- delete: <TResponse = unknown>(path: string) => Promise<TResponse>
129
- }
130
-
131
- export type AddonEventListener = (data: unknown) => void
132
-
133
- /** A font option provided by the JChat host. */
134
- export interface AddonFont {
135
- /** Display label shown to users (e.g. 'Modern', 'Elegant') */
136
- label: string
137
- /** Font identifier stored in the database (e.g. 'poppins', 'lato') */
138
- value: string
139
- /** CSS font-family string ready for use in inline styles */
140
- family: string
141
- /** Google Fonts name for loading the font via a <link> tag */
142
- googleName: string
143
- /** Relative size multiplier — apply this to base font sizes to match the host's rendering */
144
- scale: number
145
- }
146
-
147
- /**
148
- * The bridge between your addon and the Jchat host application. Use the `useAddonSDK()` hook to
149
- * access this interface in your components.
150
- */
151
- export interface AddonSDK {
152
- /**
153
- * Make authenticated API calls to your addon's backend or core Jchat endpoints. All calls
154
- * automatically include the current user's sessionId and site validation headers.
155
- */
156
- api: AddonApi
157
-
158
- /**
159
- * Display a temporary notification toast in the host UI.
160
- *
161
- * @param message - The text content to display
162
- * @param variant - Visual style: 'default' (blue), 'success' (green), or 'error' (red)
163
- */
164
- toast: (message: string, variant?: "default" | "success" | "error") => void
165
-
166
- /**
167
- * Navigate the host application to a different internal route.
168
- *
169
- * @param path - The target location, e.g., '/settings' or '/addons/store'
170
- */
171
- navigate: (path: string) => void
172
-
173
- /**
174
- * Interaction utilities for the active Slate message editor. Only available if your addon is
175
- * mounted near the chat input.
176
- */
177
- editor: {
178
- /**
179
- * Trigger a specialized editor command defined by the host.
180
- *
181
- * @example
182
- * sdk.editor.dispatchCommand("INSERT_YOUTUBE_COMMAND", "dQw4w9WgXcQ")
183
- */
184
- dispatchCommand: (command: string, payload?: unknown) => void
185
-
186
- /**
187
- * Insert a low-level Slate node directly at the user's cursor position.
188
- *
189
- * @param node - A valid Slate Descendant or Fragment object
190
- */
191
- insertNode: (node: unknown) => void
192
-
193
- /**
194
- * Upload and attach a file to the current message without inserting it inline. Users will see
195
- * the file in the attachment bar below the editor.
196
- */
197
- addAttachment: (attachment: { url: string; name: string; type: string }) => void
198
-
199
- /** Force the browser focus back to the editor input */
200
- focus: () => void
201
- }
202
-
203
- /** Event Bus for reacting to real-time application state changes. */
204
- events: {
205
- /**
206
- * Register a persistent listener for a specific event.
207
- *
208
- * @param event - Event name, e.g., 'room.message', 'user.typing'
209
- */
210
- on: (event: string, callback: AddonEventListener) => void
211
-
212
- /** Unregister a previously attached listener */
213
- off: (event: string, callback: AddonEventListener) => void
214
- }
215
-
216
- /**
217
- * The list of fonts available in this JChat deployment, as configured by the site administrator.
218
- * Use this to build a font picker or apply a matching font family in your addon UI.
219
- */
220
- supportedFonts?: AddonFont[]
221
- }
222
-
223
- /**
224
- * Seed data for your addon's tables, provided inside the addon's isolated schema.
225
- *
226
- * These documents are inserted into the addon's dedicated schema on installation and can be used to
227
- * provide default values, references, or supporting data. The host guarantees that these documents
228
- * do not conflict with core schemas or other addons.
229
- */
230
- export interface AddonSeedDocument {
231
- /** Addon-owned table name. Core prefixes the addon's isolated PostgreSQL schema. */
232
- table: string
233
- /** Behavior when a row conflicts with an existing unique or primary key. */
234
- conflict?: "error" | "ignore" | "update"
235
- /** Required for update behavior; identifies the unique key used by ON CONFLICT. */
236
- conflictColumns?: string[]
237
- /** JSON-compatible rows inserted using parameterized values. */
238
- rows: Record<string, unknown>[]
239
- }
240
-
241
- /** Define a manifest with full validation-friendly typing while preserving literal values. */
242
- export function defineAddonManifest<const TManifest extends AddonManifest>(
243
- manifest: TManifest
244
- ): TManifest {
245
- return manifest
246
- }
247
-
248
- /** Define a typed server factory without widening its addon database interface. */
249
- export function defineAddonServer<TDatabase = Record<string, Record<string, unknown>>>(
250
- factory: AddonServerFactory<TDatabase>
251
- ): AddonServerFactory<TDatabase> {
252
- return factory
253
- }
254
-
255
- /**
256
- * Access the host application's SDK. Provides authenticated API access, toast notifications, and
257
- * navigation.
258
- *
259
- * This function is a shell; the real implementation is provided by the Jchat host environment at
260
- * runtime.
261
- *
262
- * @example
263
- * ;```tsx
264
- * import { useAddonSDK } from "@jchat/addon-sdk"
265
- *
266
- * export default function MyAddon({ mountPoint }: AddonProps) {
267
- * const sdk = useAddonSDK()
268
- *
269
- * const handleClick = async () => {
270
- * const data = await sdk.api.get("/trending")
271
- * sdk.toast("Fetched trending posts!", "success")
272
- * }
273
- * }
274
- * ```
275
- */
276
- export function useAddonSDK(): AddonSDK {
277
- throw new Error("AddonSDK must be used within the Jchat host environment.")
278
- }
279
-
280
- /**
281
- * Access the host application's active icon pack. Returns the icon set matching the site's
282
- * configured icon pack preference.
283
- *
284
- * This function is a shell; the real implementation is provided by the Jchat host environment at
285
- * runtime.
286
- *
287
- * @example
288
- * ;```tsx
289
- * import { useIcons } from "@jchat/addon-sdk"
290
- *
291
- * export default function MyAddon() {
292
- * const { Icons } = useIcons()
293
- * return <Icons.Send className="size-5" />
294
- * }
295
- * ```
296
- */
297
- export function useIcons(): { Icons: IconPack } {
298
- throw new Error("useIcons must be used within the Jchat host environment.")
299
- }
package/src/vite.test.ts DELETED
@@ -1,62 +0,0 @@
1
- import { describe, expect, test } from "bun:test"
2
- import crypto from "node:crypto"
3
- import { createAddonServerBuildArguments, requireAddonSigningKey, signAddonManifest } from "./vite"
4
-
5
- describe("addon package signing", () => {
6
- test("builds a self-contained Bun ESM server artifact", () => {
7
- expect(createAddonServerBuildArguments("src/api.ts", "build/api.js", false)).toEqual([
8
- "build",
9
- "src/api.ts",
10
- "--target=bun",
11
- "--format=esm",
12
- "--outfile=build/api.js",
13
- "--minify"
14
- ])
15
- })
16
-
17
- test("permits unsigned database artifacts only in explicit development builds", () => {
18
- expect(requireAddonSigningKey(undefined, true)).toBeUndefined()
19
- expect(() => requireAddonSigningKey(undefined, false)).toThrow(
20
- "require JCHAT_ADDON_SIGNING_PRIVATE_KEY"
21
- )
22
- expect(requireAddonSigningKey("private-key", false)).toBe("private-key")
23
- })
24
-
25
- test("signs the canonical package digest", () => {
26
- const { privateKey, publicKey } = crypto.generateKeyPairSync("rsa", {
27
- modulusLength: 2048
28
- })
29
- const signed = signAddonManifest(
30
- {
31
- id: "sample",
32
- name: "Sample",
33
- version: "1.0.0",
34
- compatibility: { jchat: "^1.0.0", addonSdk: "^1.0.0" },
35
- publisher: { id: "test" },
36
- server: {
37
- bundle: "api.ts",
38
- migrations: ["migrations/001.sql"],
39
- seeds: ["seeds/items.json"]
40
- }
41
- },
42
- [
43
- { path: "manifest.json", contents: Buffer.from("") },
44
- { path: "migrations/001.sql", contents: Buffer.from("safe") },
45
- {
46
- path: "seeds/items.json",
47
- contents: Buffer.from('{"table":"Items","rows":[]}')
48
- }
49
- ],
50
- privateKey.export({ format: "pem", type: "pkcs8" }).toString()
51
- )
52
- expect(signed.signature?.digest).toHaveLength(64)
53
- expect(
54
- crypto.verify(
55
- "sha256",
56
- Buffer.from(signed.signature?.digest ?? ""),
57
- publicKey,
58
- Buffer.from(signed.signature?.value ?? "", "base64url")
59
- )
60
- ).toBe(true)
61
- })
62
- })
package/src/vite.ts DELETED
@@ -1,289 +0,0 @@
1
- import type { Plugin } from "vite"
2
- import { execFileSync } from "node:child_process"
3
- import crypto from "node:crypto"
4
- import fs from "node:fs"
5
- import path from "node:path"
6
- import type { AddonManifest } from "./contracts"
7
-
8
- /**
9
- * Vite plugin to configure Jchat addon manifest generation, bundle backend scripts, copy
10
- * migrations, declarative seed data, and zip production build artifacts.
11
- */
12
- export function jchatAddon(options: JchatAddonPluginOptions): Plugin {
13
- const { manifest: manifestData } = options
14
- let shouldZip = options.zip
15
- let developmentBuild = false
16
-
17
- return {
18
- name: "jchat-addon",
19
- apply: "build",
20
- config(_, env) {
21
- return {
22
- define: {
23
- "process.env.NODE_ENV": JSON.stringify(env.mode || "production")
24
- }
25
- }
26
- },
27
- configResolved(config) {
28
- developmentBuild = config.mode === "development"
29
- if (shouldZip === undefined) {
30
- shouldZip = config.mode === "production"
31
- }
32
- },
33
- async writeBundle() {
34
- const rootDir = process.cwd()
35
- const pkgPath = path.resolve(rootDir, "package.json")
36
- if (!fs.existsSync(pkgPath)) return
37
-
38
- const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"))
39
- const buildDir = path.resolve(rootDir, "build")
40
- if (!fs.existsSync(buildDir)) return
41
-
42
- const files = fs.readdirSync(buildDir)
43
- const bundleFile = files.find((f) => f.includes("ui") && f.endsWith(".js"))
44
-
45
- if (!bundleFile) return
46
-
47
- if (options.css !== false) {
48
- embedCssIntoBundle(buildDir, manifestData.id)
49
- }
50
-
51
- let manifest: AddonManifest = {
52
- ...manifestData,
53
- version: pkg.version,
54
- frontend: {
55
- ...manifestData.frontend,
56
- bundle: `./${bundleFile}`,
57
- mountPoints: manifestData.frontend?.mountPoints ?? []
58
- }
59
- }
60
-
61
- const serverSrcPath = path.resolve(rootDir, "src/api.ts")
62
- const hasServer = fs.existsSync(serverSrcPath)
63
- if (hasServer) {
64
- execFileSync(
65
- "bun",
66
- createAddonServerBuildArguments(
67
- serverSrcPath,
68
- path.resolve(buildDir, "api.js"),
69
- developmentBuild
70
- ),
71
- { stdio: "inherit" }
72
- )
73
- manifest = {
74
- ...manifest,
75
- server: {
76
- ...manifest.server,
77
- bundle: "./api.js"
78
- }
79
- }
80
- }
81
-
82
- const databaseFiles = [
83
- ...(manifest.server?.migrations ?? []),
84
- ...(manifest.server?.seeds ?? [])
85
- ]
86
- for (const directory of ["migrations", "seeds"]) {
87
- fs.rmSync(path.resolve(buildDir, directory), {
88
- recursive: true,
89
- force: true
90
- })
91
- }
92
- for (const relativePath of databaseFiles) {
93
- if (
94
- path.isAbsolute(relativePath) ||
95
- relativePath.includes("\\") ||
96
- relativePath.split("/").includes("..")
97
- ) {
98
- throw new Error(`Unsafe addon database file path: ${relativePath}`)
99
- }
100
- const source = path.resolve(rootDir, relativePath)
101
- if (!fs.existsSync(source) || !fs.statSync(source).isFile()) {
102
- throw new Error(`Missing addon database file: ${relativePath}`)
103
- }
104
- const destination = path.resolve(buildDir, relativePath)
105
- fs.mkdirSync(path.dirname(destination), { recursive: true })
106
- fs.copyFileSync(source, destination)
107
- }
108
-
109
- if (manifest.server?.migrations?.length || manifest.server?.seeds?.length) {
110
- const signingPrivateKey = requireAddonSigningKey(
111
- options.signingPrivateKey ?? process.env.JCHAT_ADDON_SIGNING_PRIVATE_KEY,
112
- developmentBuild
113
- )
114
- if (signingPrivateKey) {
115
- const archivePaths = [
116
- "manifest.json",
117
- bundleFile,
118
- ...(manifest.server?.migrations ?? []),
119
- ...(manifest.server?.seeds ?? [])
120
- ]
121
- if (hasServer) archivePaths.push("api.js")
122
- manifest = signAddonManifest(
123
- manifest,
124
- archivePaths.map((file) => ({
125
- path: file,
126
- contents:
127
- file === "manifest.json"
128
- ? Buffer.from("")
129
- : fs.readFileSync(path.resolve(buildDir, file))
130
- })),
131
- signingPrivateKey
132
- )
133
- }
134
- }
135
-
136
- // Save manifest.json
137
- const manifestJson = JSON.stringify(manifest, null, 2)
138
- fs.writeFileSync(path.resolve(buildDir, "manifest.json"), manifestJson)
139
-
140
- if (shouldZip) {
141
- const zipName = `${manifest.id}-${pkg.version}.zip`
142
- const filesToZip = ["manifest.json", bundleFile]
143
-
144
- if (fs.existsSync(path.resolve(buildDir, "api.js"))) {
145
- filesToZip.push("api.js")
146
- }
147
-
148
- const migrationsDest = path.resolve(buildDir, "migrations")
149
- if (fs.existsSync(migrationsDest)) {
150
- filesToZip.push("migrations")
151
- }
152
- const seedsDest = path.resolve(buildDir, "seeds")
153
- if (fs.existsSync(seedsDest)) {
154
- filesToZip.push("seeds")
155
- }
156
-
157
- execFileSync("tar", ["-a", "-cf", zipName, ...filesToZip], {
158
- cwd: buildDir,
159
- stdio: "inherit"
160
- })
161
- console.log(`Build Complete: ${manifest.id} v${pkg.version} → ${zipName}`)
162
- }
163
- }
164
- }
165
- }
166
-
167
- export interface JchatAddonPluginOptions {
168
- manifest: AddonManifest
169
- zip?: boolean
170
- signingPrivateKey?: string
171
- /**
172
- * Embed Tailwind CSS output into the JS bundle and scope it to the addon's DOM container.
173
- * Defaults to `true`. Set to `false` only if the addon has no CSS.
174
- */
175
- css?: boolean
176
- }
177
-
178
- export function createAddonServerBuildArguments(
179
- entryPath: string,
180
- outputPath: string,
181
- developmentBuild: boolean
182
- ): string[] {
183
- return [
184
- "build",
185
- entryPath,
186
- "--target=bun",
187
- "--format=esm",
188
- `--outfile=${outputPath}`,
189
- developmentBuild ? "--sourcemap=external" : "--minify"
190
- ]
191
- }
192
-
193
- export function signAddonManifest(
194
- manifest: AddonManifest,
195
- files: readonly { path: string; contents: Buffer }[],
196
- privateKey: string
197
- ): AddonManifest {
198
- if (!manifest.publisher?.id) throw new Error("Signed addons require manifest.publisher.id")
199
- const unsigned: AddonManifest = {
200
- ...manifest,
201
- signature: { algorithm: "rsa-sha256", digest: "", value: "" }
202
- }
203
- const hash = crypto.createHash("sha256")
204
- for (const file of [...files].sort((a, b) => a.path.localeCompare(b.path))) {
205
- hash.update(file.path)
206
- hash.update("\0")
207
- hash.update(file.path === "manifest.json" ? stableStringify(unsigned) : file.contents)
208
- hash.update("\0")
209
- }
210
- const digest = hash.digest("hex")
211
- return {
212
- ...unsigned,
213
- signature: {
214
- algorithm: "rsa-sha256",
215
- digest,
216
- value: crypto.sign("sha256", Buffer.from(digest), privateKey).toString("base64url")
217
- }
218
- }
219
- }
220
-
221
- export function requireAddonSigningKey(
222
- signingPrivateKey: string | undefined,
223
- developmentBuild: boolean
224
- ): string | undefined {
225
- if (signingPrivateKey) return signingPrivateKey
226
- if (developmentBuild) return undefined
227
- throw new Error("Database-bearing addon builds require JCHAT_ADDON_SIGNING_PRIVATE_KEY")
228
- }
229
-
230
- function embedCssIntoBundle(buildDir: string, addonId: string): void {
231
- const files = fs.readdirSync(buildDir)
232
- const cssFile = files.find((f) => f.endsWith(".css"))
233
- const jsFile = files.find((f) => f.includes("ui") && f.endsWith(".js"))
234
-
235
- if (!cssFile || !jsFile) return
236
-
237
- const cssPath = path.join(buildDir, cssFile)
238
- const jsPath = path.join(buildDir, jsFile)
239
- let css = fs.readFileSync(cssPath, "utf-8")
240
- const js = fs.readFileSync(jsPath, "utf-8")
241
-
242
- if (css.length < 10) {
243
- css = ""
244
- } else {
245
- // Strip out the host theme root and dark definitions to allow the addon to inherit them
246
- css = css.replace(/:root\s*\{[^}]+\}/g, "")
247
- css = css.replace(/\.dark\s*\{[^}]+\}/g, "")
248
- css = css.replace(/\[data-theme=[^\]]+\]\s*\{[^}]+\}/g, "")
249
- css = css.replace(/\.dark\s*\[data-theme=[^\]]+\]\s*\{[^}]+\}/g, "")
250
-
251
- // Inject a base reset so addons don't default to currentColor (white in dark mode)
252
- const resetCss = `*, ::before, ::after { border-color: var(--border); }`
253
- css = `@scope (#addon-${addonId}) {\n${resetCss}\n${css}\n}`
254
- }
255
-
256
- // Inject the CSS into the head. Since addon styles are scoped,
257
- // we don't need to force them to the top of the head.
258
- const injection = `
259
- (function() {
260
- if (typeof document !== 'undefined' && ${JSON.stringify(css)}.length > 0) {
261
- const style = document.createElement('style');
262
- style.type = 'text/css';
263
- style.textContent = ${JSON.stringify(css)};
264
- document.head.appendChild(style);
265
- }
266
- })();
267
- `
268
- fs.writeFileSync(jsPath, injection + js)
269
- try {
270
- fs.unlinkSync(cssPath)
271
- } catch {
272
- // ignore ENOENT if another plugin or build process already removed it
273
- }
274
- }
275
-
276
- function stableStringify(value: unknown): string {
277
- const sortValue = (child: unknown): unknown => {
278
- if (Array.isArray(child)) return child.map(sortValue)
279
- if (child && typeof child === "object") {
280
- return Object.fromEntries(
281
- Object.entries(child)
282
- .sort(([left], [right]) => left.localeCompare(right))
283
- .map(([key, nested]) => [key, sortValue(nested)])
284
- )
285
- }
286
- return child
287
- }
288
- return JSON.stringify(sortValue(value))
289
- }