@crowi/plugin-api 0.1.0-alpha.0
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/LICENSE +21 -0
- package/dist/index.d.mts +1159 -0
- package/dist/index.d.ts +1159 -0
- package/dist/index.js +55 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +25 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +37 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,1159 @@
|
|
|
1
|
+
import { z } from 'zod/v3';
|
|
2
|
+
import { Readable } from 'node:stream';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The context object passed to every plugin callback. It is the only
|
|
6
|
+
* conduit through which a plugin reads core state (config, models,
|
|
7
|
+
* crypto helpers, logging) — plugins must NOT import from
|
|
8
|
+
* `@crowi/server` directly to keep the contract surface thin.
|
|
9
|
+
*/
|
|
10
|
+
interface PluginContext {
|
|
11
|
+
/**
|
|
12
|
+
* Read this plugin's typed config. The runtime parses
|
|
13
|
+
* `plugin:<plugin-name>:*` rows from the Mongo Config collection
|
|
14
|
+
* through the plugin's `configSchema` and returns the result.
|
|
15
|
+
*
|
|
16
|
+
* Throws if `configSchema` is not declared on the plugin.
|
|
17
|
+
*/
|
|
18
|
+
config<T>(): T;
|
|
19
|
+
/**
|
|
20
|
+
* Read a typed dependency plugin's config. The target plugin must
|
|
21
|
+
* be listed in this plugin's `requires` array — reading another
|
|
22
|
+
* plugin's config without declaring the dependency is a contract
|
|
23
|
+
* violation and throws.
|
|
24
|
+
*
|
|
25
|
+
* Useful for shared-credential plugins like `@crowi/plugin-aws`:
|
|
26
|
+
* the base plugin owns `region` / `accessKeyId` / `secretAccessKey`,
|
|
27
|
+
* and dependents (`@crowi/plugin-storage-aws-s3`,
|
|
28
|
+
* `@crowi/plugin-mail-aws-ses`) read them through this method
|
|
29
|
+
* instead of duplicating the fields in their own configSchema.
|
|
30
|
+
*/
|
|
31
|
+
dependencyConfig<T>(dependencyName: string): T;
|
|
32
|
+
/** Write a single config field, persisting to Mongo. */
|
|
33
|
+
setConfig(key: string, value: unknown): Promise<void>;
|
|
34
|
+
/** Per-Page metadata accessor for this plugin's namespace. */
|
|
35
|
+
pageMetadata: PageMetadataAccessor;
|
|
36
|
+
/**
|
|
37
|
+
* Mongoose model accessor. Returns the named core model. Plugins
|
|
38
|
+
* touch core collections (Page, User, Comment, ...) through this
|
|
39
|
+
* accessor rather than importing model files directly.
|
|
40
|
+
*
|
|
41
|
+
* Typed loosely (`unknown`) at this layer because the core model
|
|
42
|
+
* types live in `@crowi/server`; plugins narrow the return type at
|
|
43
|
+
* the call site.
|
|
44
|
+
*/
|
|
45
|
+
model(name: string): unknown;
|
|
46
|
+
/** Symmetric encrypt / decrypt against the configured KeyProvider. */
|
|
47
|
+
crypto: PluginCrypto;
|
|
48
|
+
/** Structured logger scoped to this plugin (auto-prefixed with name). */
|
|
49
|
+
log: PluginLogger;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Per-Page metadata read / write helper. Each plugin gets a private
|
|
53
|
+
* namespace at `page.metadata['<plugin-name>']`; this accessor scopes
|
|
54
|
+
* reads and writes to just that slot so plugins cannot accidentally
|
|
55
|
+
* trample on each other.
|
|
56
|
+
*/
|
|
57
|
+
interface PageMetadataAccessor {
|
|
58
|
+
/** Read this plugin's metadata for a specific page. Returns null when unset. */
|
|
59
|
+
get<T>(pageId: string): Promise<T | null>;
|
|
60
|
+
/** Replace this plugin's metadata for a specific page. */
|
|
61
|
+
set<T>(pageId: string, value: T): Promise<void>;
|
|
62
|
+
/** Remove this plugin's metadata for a specific page. */
|
|
63
|
+
remove(pageId: string): Promise<void>;
|
|
64
|
+
}
|
|
65
|
+
interface PluginCrypto {
|
|
66
|
+
encrypt(plaintext: string): string;
|
|
67
|
+
decrypt(ciphertext: string): string;
|
|
68
|
+
}
|
|
69
|
+
interface PluginLogger {
|
|
70
|
+
debug(message: string, ...args: unknown[]): void;
|
|
71
|
+
info(message: string, ...args: unknown[]): void;
|
|
72
|
+
warn(message: string, ...args: unknown[]): void;
|
|
73
|
+
error(message: string, ...args: unknown[]): void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Metadata accompanying a `put`. The runtime always provides
|
|
78
|
+
* `contentType`; drivers are free to store additional fields under
|
|
79
|
+
* implementation-specific custom metadata.
|
|
80
|
+
*/
|
|
81
|
+
interface StoragePutMeta {
|
|
82
|
+
contentType: string;
|
|
83
|
+
}
|
|
84
|
+
/** Result of a successful `put`. The driver echoes back the stored key. */
|
|
85
|
+
interface StoragePutResult {
|
|
86
|
+
key: string;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Storage driver — the file-system abstraction every uploader plugin
|
|
90
|
+
* implements. The runtime resolves the active driver at boot from
|
|
91
|
+
* `crowi.config.json:storage.driver`.
|
|
92
|
+
*
|
|
93
|
+
* Object keys are opaque strings owned by the *caller* (core's
|
|
94
|
+
* file-uploader service), e.g. `attachment/<pageId>/<filename>`.
|
|
95
|
+
* Drivers must round-trip them verbatim to preserve compatibility
|
|
96
|
+
* with files uploaded under v1.x.
|
|
97
|
+
*/
|
|
98
|
+
interface StorageDriver {
|
|
99
|
+
/** Write a blob and return its (round-tripped) key. */
|
|
100
|
+
put(key: string, body: Buffer | Readable, meta: StoragePutMeta): Promise<StoragePutResult>;
|
|
101
|
+
/**
|
|
102
|
+
* Stream a blob back. Throws if the key does not exist (drivers must
|
|
103
|
+
* use a recognisable error code; e.g. `NoSuchKey` for S3 / `ENOENT`
|
|
104
|
+
* for local).
|
|
105
|
+
*/
|
|
106
|
+
get(key: string): Promise<Readable>;
|
|
107
|
+
/** Delete a blob. Idempotent — no-op if the key is already absent. */
|
|
108
|
+
delete(key: string): Promise<void>;
|
|
109
|
+
/**
|
|
110
|
+
* Optional: produce a time-limited signed URL the browser can fetch
|
|
111
|
+
* directly. When the active driver does not implement this, core
|
|
112
|
+
* falls back to streaming via `get()` through the API.
|
|
113
|
+
*/
|
|
114
|
+
signedUrl?(key: string, expiresInSec: number): Promise<string>;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Storage registry passed to `registerStorage`. A plugin contributes
|
|
118
|
+
* one or more drivers under string keys; the active driver is selected
|
|
119
|
+
* by `crowi.config.json:storage.driver`.
|
|
120
|
+
*/
|
|
121
|
+
interface StorageRegistry {
|
|
122
|
+
/**
|
|
123
|
+
* Register a driver under a stable name (e.g. `'s3'`, `'local'`).
|
|
124
|
+
* Names must be unique across all plugins; the PluginManager fails
|
|
125
|
+
* boot on collision.
|
|
126
|
+
*/
|
|
127
|
+
register(driverName: string, driver: StorageDriver): void;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Document shape passed to `index()`. Mirrors the fields the legacy
|
|
132
|
+
* search service indexes (path / body / title / tags / metadata).
|
|
133
|
+
* Drivers may project a subset; `id` and `body` are required.
|
|
134
|
+
*/
|
|
135
|
+
interface SearchableDoc {
|
|
136
|
+
id: string;
|
|
137
|
+
path: string;
|
|
138
|
+
body: string;
|
|
139
|
+
/** Optional human-friendly title (often derived from the first heading). */
|
|
140
|
+
title?: string;
|
|
141
|
+
tags?: string[];
|
|
142
|
+
/**
|
|
143
|
+
* Free-form metadata for driver-specific use. Not searchable through
|
|
144
|
+
* the contract; drivers that want to surface custom fields should
|
|
145
|
+
* declare them in their own configSchema.
|
|
146
|
+
*/
|
|
147
|
+
meta?: Record<string, unknown>;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Page-type filter. Mirrors the legacy ES Searcher's portal/public/user
|
|
151
|
+
* filter, generalised so future drivers (Mongo, Meilisearch, Algolia)
|
|
152
|
+
* can implement them with their own backend semantics.
|
|
153
|
+
*
|
|
154
|
+
* - `portal`: directory-style pages (path ends with `/`), excluding `/user/*`
|
|
155
|
+
* - `public`: leaf pages (path does not end with `/`), excluding `/user/*`
|
|
156
|
+
* - `user`: `/user/*` pages
|
|
157
|
+
*/
|
|
158
|
+
type SearchPageType = 'portal' | 'public' | 'user';
|
|
159
|
+
/**
|
|
160
|
+
* The viewer running the search. Drivers consult this to apply
|
|
161
|
+
* grant-aware filtering: pages with `GRANT_OWNER` / `GRANT_RESTRICTED`
|
|
162
|
+
* / `GRANT_SPECIFIED` are only visible to listed users; the driver
|
|
163
|
+
* builds the filter so callers can stay grant-agnostic.
|
|
164
|
+
*/
|
|
165
|
+
interface SearchQueryViewer {
|
|
166
|
+
/** Mongo ObjectId string of the user. */
|
|
167
|
+
id: string;
|
|
168
|
+
username: string;
|
|
169
|
+
isAdmin?: boolean;
|
|
170
|
+
}
|
|
171
|
+
interface SearchQueryGrants {
|
|
172
|
+
/** Restrict results to one or more page types. */
|
|
173
|
+
types?: SearchPageType[];
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Search request. Intentionally minimal in v2.0 — query string + paging
|
|
177
|
+
* + optional filters. Richer queries (faceting, highlighting, custom
|
|
178
|
+
* scoring) are deferred to a future RFC.
|
|
179
|
+
*/
|
|
180
|
+
interface SearchQuery {
|
|
181
|
+
q: string;
|
|
182
|
+
/** 1-based page number. Defaults to 1. */
|
|
183
|
+
page?: number;
|
|
184
|
+
/** Items per page. Defaults to 50, capped at 200. */
|
|
185
|
+
limit?: number;
|
|
186
|
+
/** Optional path-prefix filter (e.g. `/team/eng/`). */
|
|
187
|
+
pathPrefix?: string;
|
|
188
|
+
/**
|
|
189
|
+
* Identity of the user running the search. When set, drivers apply
|
|
190
|
+
* grant-aware filtering so private pages (owner-only / restricted)
|
|
191
|
+
* are hidden from non-authorised viewers. When omitted, drivers
|
|
192
|
+
* return only public pages (anonymous behaviour).
|
|
193
|
+
*/
|
|
194
|
+
viewer?: SearchQueryViewer;
|
|
195
|
+
/** Page-type / metadata filters. */
|
|
196
|
+
grants?: SearchQueryGrants;
|
|
197
|
+
}
|
|
198
|
+
interface SearchHit {
|
|
199
|
+
id: string;
|
|
200
|
+
path: string;
|
|
201
|
+
/**
|
|
202
|
+
* Optional ranked snippet around the match. Drivers may return raw
|
|
203
|
+
* text or HTML with the matched terms wrapped in `<mark>`; consumers
|
|
204
|
+
* must sanitise for HTML render.
|
|
205
|
+
*/
|
|
206
|
+
snippet?: string;
|
|
207
|
+
/** Driver-specific relevance score; higher is better. */
|
|
208
|
+
score?: number;
|
|
209
|
+
}
|
|
210
|
+
interface SearchHits {
|
|
211
|
+
total: number;
|
|
212
|
+
hits: SearchHit[];
|
|
213
|
+
/**
|
|
214
|
+
* Optional driver-reported elapsed time in milliseconds. Backends that
|
|
215
|
+
* surface their own timing (e.g. Elasticsearch's `took`) populate this;
|
|
216
|
+
* drivers without a meaningful measurement (regex / Mongo `$text`) may
|
|
217
|
+
* omit it. Surfaced under `meta.took` on the API response.
|
|
218
|
+
*/
|
|
219
|
+
took?: number;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Search backend driver. Active driver is selected by
|
|
223
|
+
* `crowi.config.json:search.driver`. The default is `'mongo'` (Mongo
|
|
224
|
+
* `$regex` over path / title / body), provided by `@crowi/plugin-search-mongo`.
|
|
225
|
+
*/
|
|
226
|
+
interface SearchDriver {
|
|
227
|
+
/**
|
|
228
|
+
* Index or update a document. Called from the page-saved event hook;
|
|
229
|
+
* implementations must be idempotent on the same `doc.id`.
|
|
230
|
+
*/
|
|
231
|
+
index(doc: SearchableDoc): Promise<void>;
|
|
232
|
+
/** Remove a document from the index. Idempotent. */
|
|
233
|
+
remove(id: string): Promise<void>;
|
|
234
|
+
/** Run a search query and return hits ordered by relevance. */
|
|
235
|
+
query(q: SearchQuery): Promise<SearchHits>;
|
|
236
|
+
/**
|
|
237
|
+
* Optional: rebuild the full index from scratch. Triggered by the
|
|
238
|
+
* admin "Rebuild index" maintenance op. Drivers without a persistent
|
|
239
|
+
* index (e.g. Mongo regex) can omit this.
|
|
240
|
+
*/
|
|
241
|
+
rebuild?(): Promise<void>;
|
|
242
|
+
}
|
|
243
|
+
interface SearchRegistry {
|
|
244
|
+
register(driverName: string, driver: SearchDriver): void;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Auth provider profile, normalised across providers (Google / GitHub /
|
|
249
|
+
* future SAML / OIDC). Plugins map the upstream token / profile into
|
|
250
|
+
* this shape; core looks up or provisions a User by `providerUserId`.
|
|
251
|
+
*/
|
|
252
|
+
interface AuthProfile {
|
|
253
|
+
/**
|
|
254
|
+
* Stable identifier for this user *within the provider's namespace*.
|
|
255
|
+
* Google: the `sub` claim. GitHub: the numeric account id as string.
|
|
256
|
+
* Plugins must NEVER use email / username for this — those rotate.
|
|
257
|
+
*/
|
|
258
|
+
providerUserId: string;
|
|
259
|
+
/** Email address from the provider. May be empty if not granted. */
|
|
260
|
+
email?: string;
|
|
261
|
+
/** Display name from the provider. */
|
|
262
|
+
name?: string;
|
|
263
|
+
/** Avatar URL from the provider. */
|
|
264
|
+
imageUrl?: string;
|
|
265
|
+
/**
|
|
266
|
+
* Free-form additional fields the plugin wants to persist on the
|
|
267
|
+
* user document (e.g. github org membership). Stored under the
|
|
268
|
+
* plugin's pageMetadata-style namespace on User.
|
|
269
|
+
*/
|
|
270
|
+
extra?: Record<string, unknown>;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Result of `verify` — either a normalised profile (success) or an
|
|
274
|
+
* error reason the login UI surfaces.
|
|
275
|
+
*/
|
|
276
|
+
type AuthVerifyResult = {
|
|
277
|
+
ok: true;
|
|
278
|
+
profile: AuthProfile;
|
|
279
|
+
} | {
|
|
280
|
+
ok: false;
|
|
281
|
+
reason: string;
|
|
282
|
+
};
|
|
283
|
+
/**
|
|
284
|
+
* Auth provider driver. The login screen asks core for the list of
|
|
285
|
+
* registered drivers and renders one button per driver
|
|
286
|
+
* (`Sign in with Google`). Clicking redirects through the plugin's
|
|
287
|
+
* registered routes (`/api/v2/plugins/<name>/oauth/start`); the
|
|
288
|
+
* provider redirects back to `/api/v2/plugins/<name>/oauth/callback`,
|
|
289
|
+
* which the plugin's contract handles.
|
|
290
|
+
*
|
|
291
|
+
* `verify` is the bridge: given whatever the plugin pulled out of the
|
|
292
|
+
* callback (token / code / SAML response), produce a normalised
|
|
293
|
+
* `AuthProfile` or a failure reason.
|
|
294
|
+
*/
|
|
295
|
+
interface AuthDriver {
|
|
296
|
+
/**
|
|
297
|
+
* Human-readable label for the login button (e.g. `'Google'`).
|
|
298
|
+
* Localisation is the plugin's responsibility — i18n keys can be
|
|
299
|
+
* resolved by the plugin before registration.
|
|
300
|
+
*/
|
|
301
|
+
buttonLabel: string;
|
|
302
|
+
/** Optional icon URL for the login button. */
|
|
303
|
+
iconUrl?: string;
|
|
304
|
+
/**
|
|
305
|
+
* Map provider-specific verification data into a normalised profile.
|
|
306
|
+
* Called from inside the plugin's own callback route, with whatever
|
|
307
|
+
* shape that route extracted. Typed as `unknown` here because the
|
|
308
|
+
* shape is plugin-private.
|
|
309
|
+
*/
|
|
310
|
+
verify(verificationData: unknown): Promise<AuthVerifyResult>;
|
|
311
|
+
}
|
|
312
|
+
interface AuthRegistry {
|
|
313
|
+
register(driverName: string, driver: AuthDriver): void;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Notification payload — the runtime-neutral shape passed to every
|
|
318
|
+
* notifier driver. Drivers translate it into provider-specific
|
|
319
|
+
* messages (Slack: `chat.postMessage`; Webhook: HTTP POST; etc.).
|
|
320
|
+
*/
|
|
321
|
+
interface NotificationPayload {
|
|
322
|
+
/** Plain-text title (e.g. "Page updated: /team/eng/foo"). */
|
|
323
|
+
title: string;
|
|
324
|
+
/** Optional plain-text body / details. */
|
|
325
|
+
body?: string;
|
|
326
|
+
/**
|
|
327
|
+
* Absolute URL the notification should link to. Drivers that render
|
|
328
|
+
* clickable text use this; otherwise it appears in the body.
|
|
329
|
+
*/
|
|
330
|
+
url?: string;
|
|
331
|
+
/**
|
|
332
|
+
* Originating event kind, opaque to drivers but useful for debug logs
|
|
333
|
+
* and for plugins that filter (e.g. only forward 'page:updated').
|
|
334
|
+
*/
|
|
335
|
+
event: string;
|
|
336
|
+
/**
|
|
337
|
+
* Provider-routing hint pulled from the source page's plugin metadata
|
|
338
|
+
* (e.g. `{ channel: '#eng' }` for slack). Drivers cast this to their
|
|
339
|
+
* own typed shape.
|
|
340
|
+
*/
|
|
341
|
+
routing?: Record<string, unknown>;
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Notifier driver. Active driver is selected at registration time and
|
|
345
|
+
* called for every event the core opts to forward. Multiple drivers
|
|
346
|
+
* can be registered simultaneously (a single page-save can fan out to
|
|
347
|
+
* Slack and Webhook); the runtime calls each registered driver in
|
|
348
|
+
* parallel.
|
|
349
|
+
*/
|
|
350
|
+
interface NotifierDriver {
|
|
351
|
+
/**
|
|
352
|
+
* Send the notification. Implementations should swallow transient
|
|
353
|
+
* provider errors (log + continue) — a flaky Slack must not break
|
|
354
|
+
* the page-save handler. Persistent misconfiguration should throw
|
|
355
|
+
* so the admin sees it.
|
|
356
|
+
*/
|
|
357
|
+
send(payload: NotificationPayload): Promise<void>;
|
|
358
|
+
}
|
|
359
|
+
interface NotifierRegistry {
|
|
360
|
+
register(driverName: string, driver: NotifierDriver): void;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Mail transport abstraction.
|
|
365
|
+
*
|
|
366
|
+
* The core MailService owns *what* an email says — the from address,
|
|
367
|
+
* subject, and rendered body are resolved before a driver is ever
|
|
368
|
+
* called — so every sender produces an identical message. A mail
|
|
369
|
+
* sender driver is a pure transport: it takes a fully-assembled
|
|
370
|
+
* `EmailMessage` and physically delivers it (SMTP, Resend HTTP API,
|
|
371
|
+
* AWS SES, …).
|
|
372
|
+
*
|
|
373
|
+
* Unlike `NotifierDriver` (a fan-out sink that may have several active
|
|
374
|
+
* drivers at once), exactly one mail sender is active, selected by
|
|
375
|
+
* `crowi.config.json:mail.driver` — the same single-active-driver model
|
|
376
|
+
* as storage and search.
|
|
377
|
+
*/
|
|
378
|
+
/**
|
|
379
|
+
* Runtime-neutral, fully-assembled email. The core builds this; drivers
|
|
380
|
+
* translate it into their provider's request shape (nodemailer
|
|
381
|
+
* `Mail.Options`, SES `SendEmailCommand`, Resend `emails.send`, …).
|
|
382
|
+
*
|
|
383
|
+
* Recipient fields are always arrays — the core normalises them once so
|
|
384
|
+
* every driver receives the same shape and none has to branch on
|
|
385
|
+
* `string | string[]`. This is otherwise a subset of nodemailer's
|
|
386
|
+
* `Mail.Options`, which accepts string arrays directly.
|
|
387
|
+
*/
|
|
388
|
+
interface EmailMessage {
|
|
389
|
+
/** Recipient(s), normalised to an array by the core. */
|
|
390
|
+
to: string[];
|
|
391
|
+
/** Sender address, already resolved from `mail:from` by the core. */
|
|
392
|
+
from: string;
|
|
393
|
+
/** Subject line, already resolved by the core. */
|
|
394
|
+
subject: string;
|
|
395
|
+
/** Plain-text body, already rendered by the core. */
|
|
396
|
+
text: string;
|
|
397
|
+
/** Optional HTML body. */
|
|
398
|
+
html?: string;
|
|
399
|
+
/** Optional Reply-To address. */
|
|
400
|
+
replyTo?: string;
|
|
401
|
+
/** Optional CC recipient(s). */
|
|
402
|
+
cc?: string[];
|
|
403
|
+
/** Optional BCC recipient(s). */
|
|
404
|
+
bcc?: string[];
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Mail sender driver — the transport every mail plugin implements. The
|
|
408
|
+
* runtime resolves the single active driver at boot from
|
|
409
|
+
* `crowi.config.json:mail.driver`.
|
|
410
|
+
*/
|
|
411
|
+
interface MailSender {
|
|
412
|
+
/**
|
|
413
|
+
* Deliver a fully-assembled message. Should throw on persistent
|
|
414
|
+
* misconfiguration (missing host / API key) so the admin sees it via
|
|
415
|
+
* the test-mail endpoint; the core decides whether a send failure is
|
|
416
|
+
* fatal to the surrounding operation.
|
|
417
|
+
*/
|
|
418
|
+
send(message: EmailMessage): Promise<void>;
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Mail sender registry passed to `registerMailSender`. A plugin
|
|
422
|
+
* contributes one or more drivers under string keys (e.g. `'smtp'`,
|
|
423
|
+
* `'resend'`, `'ses'`); the active driver is selected by
|
|
424
|
+
* `crowi.config.json:mail.driver`.
|
|
425
|
+
*/
|
|
426
|
+
interface MailSenderRegistry {
|
|
427
|
+
/**
|
|
428
|
+
* Register a driver under a stable name. Names must be unique across
|
|
429
|
+
* all plugins; the PluginManager fails boot on collision.
|
|
430
|
+
*/
|
|
431
|
+
register(driverName: string, driver: MailSender): void;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Domain events emitted by core. The full event payload shapes live in
|
|
436
|
+
* `@crowi/server`; this contract publishes only the event names so the
|
|
437
|
+
* type signature of `EventBus.on` stays type-safe at the plugin layer.
|
|
438
|
+
*
|
|
439
|
+
* `pluginHooks` are the v2.0 internal-use-only events. Community
|
|
440
|
+
* plugins should NOT subscribe — the surface is reserved while we
|
|
441
|
+
* stabilise it.
|
|
442
|
+
*/
|
|
443
|
+
interface PluginEvents {
|
|
444
|
+
'page:created': {
|
|
445
|
+
pageId: string;
|
|
446
|
+
path: string;
|
|
447
|
+
};
|
|
448
|
+
'page:updated': {
|
|
449
|
+
pageId: string;
|
|
450
|
+
path: string;
|
|
451
|
+
};
|
|
452
|
+
'page:deleted': {
|
|
453
|
+
pageId: string;
|
|
454
|
+
path: string;
|
|
455
|
+
};
|
|
456
|
+
'page:renamed': {
|
|
457
|
+
pageId: string;
|
|
458
|
+
oldPath: string;
|
|
459
|
+
newPath: string;
|
|
460
|
+
};
|
|
461
|
+
'comment:added': {
|
|
462
|
+
pageId: string;
|
|
463
|
+
commentId: string;
|
|
464
|
+
};
|
|
465
|
+
'comment:removed': {
|
|
466
|
+
pageId: string;
|
|
467
|
+
commentId: string;
|
|
468
|
+
};
|
|
469
|
+
'user:registered': {
|
|
470
|
+
userId: string;
|
|
471
|
+
};
|
|
472
|
+
'user:activated': {
|
|
473
|
+
userId: string;
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
interface EventBus {
|
|
477
|
+
on<K extends keyof PluginEvents>(event: K, listener: (payload: PluginEvents[K]) => void | Promise<void>): void;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Renderer extension contract — type-only. Plugins contribute parse /
|
|
482
|
+
* transform behaviour to the server-side markdown pipeline through
|
|
483
|
+
* `registerRenderer(scope, ctx)`. The runtime owns the unified.js
|
|
484
|
+
* pipeline; plugins push unified plugins, node renderers, code-block
|
|
485
|
+
* renderers, embed renderers, and URL inline-expansion rules into the
|
|
486
|
+
* passed `RendererRegistry`.
|
|
487
|
+
*
|
|
488
|
+
* Phase 4 of RFC-0002 implements the I/O surface for plugins:
|
|
489
|
+
* - `CacheStorage` (MongoDB-backed) with stale-while-revalidate
|
|
490
|
+
* and error-cache TTLs.
|
|
491
|
+
* - `Reservation` API: plugins declare the shape of their placeholder
|
|
492
|
+
* so the core renders a stable layout while the embed loads.
|
|
493
|
+
* - `AuthContext`: shape confirmed here; Phase 7 wires the encrypted-
|
|
494
|
+
* config lookup. **Phase 6 no-I/O plugins (PlantUML / KaTeX /
|
|
495
|
+
* Mermaid / emoji) must NOT touch `AuthContext`** — the registry
|
|
496
|
+
* impl will throw at the call site until Phase 7 lands.
|
|
497
|
+
* - `addEmbedTag` / `addUrlInlineExpander`: registered renderers are
|
|
498
|
+
* dispatched against the `@[tag](url)` mdast parser and the URL
|
|
499
|
+
* inline-expansion walker respectively.
|
|
500
|
+
*
|
|
501
|
+
* Phase 2 covered `addUnifiedPlugin` + `addNodeRenderer`; Phase 3 added
|
|
502
|
+
* SSR HTML generation + bundled shiki. Phase 4 promotes the warn-noop
|
|
503
|
+
* `addEmbedTag` / `addUrlInlineExpander` to live registrations.
|
|
504
|
+
*/
|
|
505
|
+
/**
|
|
506
|
+
* Identifier for the unified pipeline phase a plugin wants to attach a
|
|
507
|
+
* `unified` transformer to. Phase 2 only honours `'transform'`; `'pre'`
|
|
508
|
+
* is reserved for Phase 3 (parse-time tweaks before remark-gfm) and
|
|
509
|
+
* `'post'` for the future hydrate phase.
|
|
510
|
+
*/
|
|
511
|
+
type RenderPhase = 'pre' | 'transform' | 'post';
|
|
512
|
+
/**
|
|
513
|
+
* mdast node type → custom AST visitor. Plugins use this when they want
|
|
514
|
+
* to mutate a specific node type (e.g. rewrite `code` blocks, swap
|
|
515
|
+
* `link` targets) without writing a full unified transformer. The
|
|
516
|
+
* runtime invokes the renderer once per matching node, depth-first.
|
|
517
|
+
*
|
|
518
|
+
* The `node` parameter is intentionally typed loosely (`unknown`) at
|
|
519
|
+
* this contract layer; plugins narrow at the call site against the
|
|
520
|
+
* mdast type they registered for.
|
|
521
|
+
*/
|
|
522
|
+
interface NodeRenderer {
|
|
523
|
+
(node: unknown, ctx: RenderContext): void | Promise<void>;
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Code-block renderer — invoked for fenced code blocks whose `lang`
|
|
527
|
+
* matches the registered language tag (e.g. `mermaid`, `plantuml`,
|
|
528
|
+
* `katex`). Phase 6 lights this up.
|
|
529
|
+
*
|
|
530
|
+
* Shape mirrors `EmbedRenderer`: a required `cacheVersion` so the core
|
|
531
|
+
* can route renders through the same SWR + error-cache wrapper, an
|
|
532
|
+
* optional `reservation` for layout-stable placeholders, and an
|
|
533
|
+
* optional `computeEmbedKey` override (the default hashes
|
|
534
|
+
* `{lang, source}`).
|
|
535
|
+
*
|
|
536
|
+
* Phase 4 declared a bare callable; Phase 6 expands the shape to an
|
|
537
|
+
* object so plugins can declare cacheVersion / reservation alongside
|
|
538
|
+
* `render`. This is non-breaking because the Phase 4 stub discarded all
|
|
539
|
+
* registrations — there is no production implementer to migrate.
|
|
540
|
+
*/
|
|
541
|
+
interface CodeBlockRenderer {
|
|
542
|
+
/**
|
|
543
|
+
* Bumped by the plugin whenever the rendered HTML shape changes.
|
|
544
|
+
* Read-side cache hits ignore entries with a stale version, so
|
|
545
|
+
* version bumps are an instant "invalidate all my cached output"
|
|
546
|
+
* without operator action.
|
|
547
|
+
*/
|
|
548
|
+
cacheVersion: number;
|
|
549
|
+
/**
|
|
550
|
+
* Optional placeholder declaration. Used in the same two cases as
|
|
551
|
+
* `EmbedRenderer.reservation`: layout placeholder while rendering,
|
|
552
|
+
* and fall-back when cache rejects on size limit or `render` errors.
|
|
553
|
+
*/
|
|
554
|
+
reservation?: Reservation;
|
|
555
|
+
/**
|
|
556
|
+
* Optional custom cache-key computer. Default = sha256(JSON.stringify({
|
|
557
|
+
* lang, source})). Plugins can override to canonicalise whitespace,
|
|
558
|
+
* strip comments, etc.
|
|
559
|
+
*/
|
|
560
|
+
computeEmbedKey?(info: CodeBlockInfo): string;
|
|
561
|
+
/** Render a single code block. */
|
|
562
|
+
render(info: CodeBlockInfo, ctx: RenderContext): EmbedFragment | RenderResult | Promise<EmbedFragment | RenderResult>;
|
|
563
|
+
}
|
|
564
|
+
interface CodeBlockInfo {
|
|
565
|
+
/** The language tag from the fence (the `ts` in ```` ```ts ````). */
|
|
566
|
+
lang: string;
|
|
567
|
+
/** Raw fenced source (no surrounding backticks). */
|
|
568
|
+
source: string;
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Embed-tag renderer — invoked for `@[tag](url)` embeds whose `tag`
|
|
572
|
+
* matches a registered name. The plugin receives the parsed `EmbedInput`
|
|
573
|
+
* and returns a `RenderResult` containing pre-sanitised HTML plus
|
|
574
|
+
* optional cache + reservation metadata. Phase 4 dispatches via the
|
|
575
|
+
* cache wrapper; the result is persisted into MongoDB
|
|
576
|
+
* `PluginRenderCache` keyed by `(pluginName, pluginCacheVersion, pageId,
|
|
577
|
+
* embedKey)`.
|
|
578
|
+
*
|
|
579
|
+
* The renderer is responsible for HTML sanitisation. The core does NOT
|
|
580
|
+
* escape `RenderResult.html` and trusts the plugin's output.
|
|
581
|
+
*/
|
|
582
|
+
interface EmbedRenderer {
|
|
583
|
+
/**
|
|
584
|
+
* Bumped by the plugin whenever the rendered HTML shape changes.
|
|
585
|
+
* Read-side cache hits ignore entries with a stale version, so
|
|
586
|
+
* version bumps are an instant "invalidate all my cached output"
|
|
587
|
+
* without operator action.
|
|
588
|
+
*/
|
|
589
|
+
cacheVersion: number;
|
|
590
|
+
/**
|
|
591
|
+
* Optional placeholder declaration. Used in two cases:
|
|
592
|
+
* 1. While rendering for the first time (mode: 'edit' or cache
|
|
593
|
+
* stampede protection), the core renders this reservation so
|
|
594
|
+
* page layout doesn't shift when the real HTML lands.
|
|
595
|
+
* 2. When `render()` rejects or the cached entry exceeds size
|
|
596
|
+
* limits, the core falls back to a reservation-shaped
|
|
597
|
+
* placeholder.
|
|
598
|
+
*/
|
|
599
|
+
reservation?: Reservation;
|
|
600
|
+
/**
|
|
601
|
+
* Optional custom cache-key computer. Phase 4 defaults to
|
|
602
|
+
* `sha256(JSON.stringify(input))` when omitted — that covers
|
|
603
|
+
* arg-only inputs. Plugins can override to (a) ignore query-string
|
|
604
|
+
* volatility (`?utm_*`) or (b) include external state (Accept-Language).
|
|
605
|
+
*/
|
|
606
|
+
computeEmbedKey?(input: EmbedInput): string;
|
|
607
|
+
/** Render a single embed. */
|
|
608
|
+
render(input: EmbedInput, ctx: RenderContext): RenderResult | Promise<RenderResult>;
|
|
609
|
+
/**
|
|
610
|
+
* Optional batched render for plugins that can amortise a single
|
|
611
|
+
* upstream call across N inputs (Phase 7+ GitHub GraphQL).
|
|
612
|
+
* Phase 4 registry impl calls `render` 1 input at a time; this
|
|
613
|
+
* field is reserved for the Phase 7 batching path.
|
|
614
|
+
*/
|
|
615
|
+
renderBatch?(inputs: EmbedInput[], ctx: RenderContext): Promise<RenderResult[]>;
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* What an `EmbedRenderer` receives. Phase 4 plumbs `tag` + `url` from
|
|
619
|
+
* `@[tag](url)`; plugins are free to pull additional state via
|
|
620
|
+
* `RenderContext.auth.config<S>()` (Phase 7) or
|
|
621
|
+
* `RenderContext.pageMetadata`.
|
|
622
|
+
*/
|
|
623
|
+
interface EmbedInput {
|
|
624
|
+
/** The bracketed tag — `[A-Za-z0-9_-]{1,64}`. */
|
|
625
|
+
tag: string;
|
|
626
|
+
/** The parenthesised URL. Free-form; plugins validate per-renderer. */
|
|
627
|
+
url: string;
|
|
628
|
+
/** Page id the embed is being rendered for; cache key includes this. */
|
|
629
|
+
pageId: string;
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* What an `EmbedRenderer` returns. Phase 4 caches the whole `RenderResult`
|
|
633
|
+
* (html + error meta + ttl) so a subsequent read can short-circuit.
|
|
634
|
+
* Stale-while-revalidate uses `ttlSec * DEFAULT_STALE_MULTIPLIER` as the
|
|
635
|
+
* background-refresh window (see
|
|
636
|
+
* `packages/api/src/renderer/cache/index.ts:cachedRender`).
|
|
637
|
+
*/
|
|
638
|
+
interface RenderResult {
|
|
639
|
+
/** Already-sanitised HTML the core will inline. */
|
|
640
|
+
html: string;
|
|
641
|
+
/**
|
|
642
|
+
* Optional `<head>`-bound assets — Phase 4 records them on the
|
|
643
|
+
* cache entry but the SSR layer does not yet inject them. Phase 7
|
|
644
|
+
* will close that loop together with `hydrate` script wiring.
|
|
645
|
+
*/
|
|
646
|
+
assets?: {
|
|
647
|
+
css?: string[];
|
|
648
|
+
js?: string[];
|
|
649
|
+
};
|
|
650
|
+
/**
|
|
651
|
+
* How long the entry stays fresh. After this, reads still get the
|
|
652
|
+
* cached html but a background re-render is scheduled.
|
|
653
|
+
*
|
|
654
|
+
* Default 300s (5 minutes) when omitted. Error responses ignore
|
|
655
|
+
* `ttlSec` and use the per-code defaults from `RENDER_ERROR_TTL`.
|
|
656
|
+
*/
|
|
657
|
+
ttlSec?: number;
|
|
658
|
+
/**
|
|
659
|
+
* When the render failed (network / auth / not_found / rate_limit /
|
|
660
|
+
* timeout / unknown), plugins should set `error` instead of building
|
|
661
|
+
* an html error frame. The core caches the error using `RENDER_ERROR_TTL`
|
|
662
|
+
* and substitutes a fixed placeholder when re-rendering the page.
|
|
663
|
+
*/
|
|
664
|
+
error?: RenderError;
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Error categories cached with their own per-code TTLs. See
|
|
668
|
+
* `packages/api/src/renderer/cache/index.ts:RENDER_ERROR_TTL` for the
|
|
669
|
+
* concrete numbers.
|
|
670
|
+
*/
|
|
671
|
+
interface RenderError {
|
|
672
|
+
code: 'auth' | 'rate_limit' | 'not_found' | 'network' | 'timeout' | 'unknown';
|
|
673
|
+
/** Free-form text for log/debug — NOT inlined into the user-facing placeholder. */
|
|
674
|
+
message?: string;
|
|
675
|
+
/**
|
|
676
|
+
* Server-supplied retry-after hint in seconds. When set on a
|
|
677
|
+
* `rate_limit` error, it overrides the default 5min TTL.
|
|
678
|
+
*/
|
|
679
|
+
retryAfterSec?: number;
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* Placeholder shape declaration. Three variants align with the typical
|
|
683
|
+
* embed shapes we anticipate:
|
|
684
|
+
*
|
|
685
|
+
* - `fixed`: pixel-precise (e.g. exact-size avatar / icon)
|
|
686
|
+
* - `aspect`: responsive width with a locked aspect ratio
|
|
687
|
+
* (e.g. video thumbnail)
|
|
688
|
+
* - `card`: small / medium / large card style for link-preview-y
|
|
689
|
+
* embeds where the exact size flexes with available width
|
|
690
|
+
*
|
|
691
|
+
* Numbers are plugin-declared and treated as trusted — they are
|
|
692
|
+
* interpolated into the placeholder HTML's inline style. User-supplied
|
|
693
|
+
* tag args never reach style values.
|
|
694
|
+
*/
|
|
695
|
+
type Reservation = {
|
|
696
|
+
variant: 'fixed';
|
|
697
|
+
widthPx?: number;
|
|
698
|
+
heightPx: number;
|
|
699
|
+
} | {
|
|
700
|
+
variant: 'aspect';
|
|
701
|
+
aspectRatio: number;
|
|
702
|
+
} | {
|
|
703
|
+
variant: 'card';
|
|
704
|
+
size: 'small' | 'medium' | 'large';
|
|
705
|
+
};
|
|
706
|
+
/**
|
|
707
|
+
* URL inline-expansion rule — when an inline link target matches the
|
|
708
|
+
* registered host / pattern, the plugin can inline-expand the link to
|
|
709
|
+
* a richer fragment (e.g. GitHub issue card). Phase 4 lights this up
|
|
710
|
+
* through the `core/url-inline-expand.ts` transform; plugins return
|
|
711
|
+
* either `'replaced'` (HTML to substitute) or `'unchanged'` (let the
|
|
712
|
+
* next expander try, falling through to plain autolink).
|
|
713
|
+
*/
|
|
714
|
+
interface UrlInlineExpansionRule {
|
|
715
|
+
/** Bumped to invalidate cached expansions, same semantics as `EmbedRenderer.cacheVersion`. */
|
|
716
|
+
cacheVersion: number;
|
|
717
|
+
/** Pattern the URL must match. RegExp or substring matcher. */
|
|
718
|
+
match: RegExp | ((url: string) => boolean);
|
|
719
|
+
/** Produce the expanded fragment OR signal "no opinion, fall through". */
|
|
720
|
+
expand: (url: string, ctx: RenderContext) => InlineExpansion | Promise<InlineExpansion>;
|
|
721
|
+
}
|
|
722
|
+
/** Result of an `UrlInlineExpansionRule.expand` call. */
|
|
723
|
+
type InlineExpansion = {
|
|
724
|
+
kind: 'unchanged';
|
|
725
|
+
} | ({
|
|
726
|
+
kind: 'replaced';
|
|
727
|
+
} & RenderResult);
|
|
728
|
+
/**
|
|
729
|
+
* The fragment a code-block renderer produces (Phase 6). Kept in the
|
|
730
|
+
* contract so Phase 6 plugins type-check against the final shape.
|
|
731
|
+
*/
|
|
732
|
+
interface EmbedFragment {
|
|
733
|
+
/** Pre-sanitised HTML fragment to inline at the source position. */
|
|
734
|
+
html: string;
|
|
735
|
+
/** Optional `<head>`-bound assets (CSS / JS) keyed by URL. */
|
|
736
|
+
assets?: {
|
|
737
|
+
css?: string[];
|
|
738
|
+
js?: string[];
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Shared key shape for `CacheStorage.get` / `set`. The 4-tuple
|
|
743
|
+
* `(pluginName, pluginCacheVersion, pageId, embedKey)` is the unique
|
|
744
|
+
* compound index on `PluginRenderCache`. `pluginCacheVersion` lives in
|
|
745
|
+
* the key (not just on the document) so reads can early-out without a
|
|
746
|
+
* second roundtrip when the plugin bumped its version.
|
|
747
|
+
*/
|
|
748
|
+
interface CacheKey {
|
|
749
|
+
pluginName: string;
|
|
750
|
+
pluginCacheVersion: number;
|
|
751
|
+
pageId: string;
|
|
752
|
+
embedKey: string;
|
|
753
|
+
}
|
|
754
|
+
/**
|
|
755
|
+
* What `CacheStorage.get` returns on a hit. `fetchedAt` lets the SWR
|
|
756
|
+
* wrapper decide fresh / stale / expired without doing a second `now()`
|
|
757
|
+
* roundtrip; `expiresAt` is the TTL deadline written when the entry
|
|
758
|
+
* was last set.
|
|
759
|
+
*/
|
|
760
|
+
interface CacheEntry {
|
|
761
|
+
html: string;
|
|
762
|
+
result: RenderResult;
|
|
763
|
+
fetchedAt: Date;
|
|
764
|
+
expiresAt: Date;
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* MongoDB-backed cache surface. Phase 4 ships exactly one
|
|
768
|
+
* implementation (`packages/api/src/renderer/cache/mongodb-cache.ts`);
|
|
769
|
+
* the interface is abstracted so a future Redis hot tier can plug in
|
|
770
|
+
* without contract changes.
|
|
771
|
+
*
|
|
772
|
+
* Plugins access a **per-plugin scoped** view of this interface
|
|
773
|
+
* (`RenderContext.cache`): the runtime auto-stamps `pluginName` on
|
|
774
|
+
* every `get` / `set` so a plugin cannot read / write another plugin's
|
|
775
|
+
* cache.
|
|
776
|
+
*/
|
|
777
|
+
interface CacheStorage {
|
|
778
|
+
get(key: CacheKey): Promise<CacheEntry | null>;
|
|
779
|
+
set(key: CacheKey, entry: CacheEntry): Promise<void>;
|
|
780
|
+
/** Drop every entry for a page; returns the number of deleted rows. */
|
|
781
|
+
invalidatePage(pageId: string): Promise<number>;
|
|
782
|
+
/** Drop every entry written by a plugin; returns the number of deleted rows. */
|
|
783
|
+
invalidatePlugin(pluginName: string): Promise<number>;
|
|
784
|
+
/** Drop every cached entry; returns the number of deleted rows. */
|
|
785
|
+
invalidateAll(): Promise<number>;
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* Per-plugin scoped cache surface handed to a plugin via
|
|
789
|
+
* `RenderContext.cache`. Same shape as `CacheStorage` minus the
|
|
790
|
+
* `pluginName` requirement on key — the runtime stamps it.
|
|
791
|
+
*/
|
|
792
|
+
interface ScopedCacheStorage {
|
|
793
|
+
get(key: Omit<CacheKey, 'pluginName'>): Promise<CacheEntry | null>;
|
|
794
|
+
set(key: Omit<CacheKey, 'pluginName'>, entry: CacheEntry): Promise<void>;
|
|
795
|
+
/** Convenience for plugin-driven invalidation. Always scoped to this plugin. Returns deleted count. */
|
|
796
|
+
invalidatePage(pageId: string): Promise<number>;
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* Authentication context for plugins that need to look up their own
|
|
800
|
+
* (encrypted) config / per-user tokens.
|
|
801
|
+
*
|
|
802
|
+
* **Phase 4 ships the interface only.** The registry impl in
|
|
803
|
+
* `packages/api/src/renderer/registry.ts` throws
|
|
804
|
+
* `Error('AuthContext not yet implemented — Phase 7')` from the
|
|
805
|
+
* `config()` callsite. Phase 7 will wire this against RFC-0001's
|
|
806
|
+
* encrypted-config lookup and a per-plugin namespace.
|
|
807
|
+
*
|
|
808
|
+
* Phase 6 no-I/O plugins (PlantUML / KaTeX / Mermaid / emoji) must NOT
|
|
809
|
+
* call `AuthContext.config()` — they are no-I/O by definition. The
|
|
810
|
+
* thrown-error stub will surface accidental coupling immediately.
|
|
811
|
+
*/
|
|
812
|
+
interface AuthContext {
|
|
813
|
+
/**
|
|
814
|
+
* Parse this plugin's encrypted config row through the supplied
|
|
815
|
+
* Zod schema and return the typed values. Throws on Phase 4 (stub).
|
|
816
|
+
*/
|
|
817
|
+
config<S extends z.ZodTypeAny>(schema: S): z.infer<S>;
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* Context passed to every renderer callback. Phase 2 exposed `mode` +
|
|
821
|
+
* `log`; Phase 4 adds `cache` (per-plugin scoped) and `auth` (interface
|
|
822
|
+
* only; throws on access). Existing core plugins (headings / wikilinks
|
|
823
|
+
* / mentions / code-blocks / syntax-highlight) do not read `cache` or
|
|
824
|
+
* `auth` so the addition is non-breaking.
|
|
825
|
+
*/
|
|
826
|
+
interface RenderContext {
|
|
827
|
+
/**
|
|
828
|
+
* What the pipeline is being run for. `'save'` = persisting a new
|
|
829
|
+
* revision (cache writes are appropriate); `'read'` = on-the-fly
|
|
830
|
+
* fallback for an old revision; `'view'` = view-mode page render
|
|
831
|
+
* (cache reads + writes); `'edit'` = edit-mode draft (Phase 7 will
|
|
832
|
+
* special-case this to return placeholder only).
|
|
833
|
+
*
|
|
834
|
+
* Phase 4 treats `'view'` and `'edit'` identically — both run the
|
|
835
|
+
* cached render path. The branching is reserved for Phase 7 where
|
|
836
|
+
* the edit-mode Yjs integration lands.
|
|
837
|
+
*/
|
|
838
|
+
mode: 'save' | 'read' | 'view' | 'edit';
|
|
839
|
+
/** Structured logger scoped to the registering plugin. */
|
|
840
|
+
log: PluginLogger;
|
|
841
|
+
/**
|
|
842
|
+
* Per-plugin scoped cache. Provided to `EmbedRenderer.render` /
|
|
843
|
+
* `UrlInlineExpansionRule.expand` callsites by the dispatch layer.
|
|
844
|
+
* Absent on the core-transform path (headings / wikilinks / mentions /
|
|
845
|
+
* code-blocks / syntax-highlight never consult the cache), so the
|
|
846
|
+
* field is optional and consumers that do need it can rely on the
|
|
847
|
+
* dispatch layer always providing it.
|
|
848
|
+
*/
|
|
849
|
+
cache?: ScopedCacheStorage;
|
|
850
|
+
/**
|
|
851
|
+
* Auth surface. Phase 4 stub: `config()` throws when called. Absent on
|
|
852
|
+
* the core-transform path; provided by the dispatch layer to embed
|
|
853
|
+
* renderer / inline-expander callsites. Phase 7 will wire the real
|
|
854
|
+
* encrypted-config-backed implementation.
|
|
855
|
+
*/
|
|
856
|
+
auth?: AuthContext;
|
|
857
|
+
}
|
|
858
|
+
/**
|
|
859
|
+
* The registry handed to every plugin's `registerRenderer(scope, ctx)`.
|
|
860
|
+
* Each method tags the registration with the registering plugin so the
|
|
861
|
+
* runtime can attribute warnings ("plugin X tried to register
|
|
862
|
+
* something the runtime doesn't support yet").
|
|
863
|
+
*
|
|
864
|
+
* Phase 4 honours:
|
|
865
|
+
* - `addUnifiedPlugin(plugin, { phase: 'transform' })`
|
|
866
|
+
* - `addNodeRenderer(type, renderer)`
|
|
867
|
+
* - `addEmbedTag(name, renderer)` — last-wins + boot warn on collision
|
|
868
|
+
* - `addUrlInlineExpander(rule)` — registration-order list
|
|
869
|
+
*
|
|
870
|
+
* Phase 4 stubs (warn-noop):
|
|
871
|
+
* - `addCodeBlockRenderer` (Phase 6 lights this up)
|
|
872
|
+
*/
|
|
873
|
+
interface RendererRegistry {
|
|
874
|
+
/**
|
|
875
|
+
* Append a `unified` transformer plugin. `options.phase` controls
|
|
876
|
+
* whether it runs before the parser tweaks (`'pre'`), after the core
|
|
877
|
+
* 4 transforms (`'transform'`), or in the hydrate phase (`'post'`).
|
|
878
|
+
* Phase 2: only `'transform'` is honoured; other phases warn-noop.
|
|
879
|
+
*
|
|
880
|
+
* The plugin signature follows unified.js conventions
|
|
881
|
+
* (`() => (tree, file) => void`). Typed loosely here so plugins can
|
|
882
|
+
* pull `unified` themselves without the contract dragging in the
|
|
883
|
+
* dep.
|
|
884
|
+
*/
|
|
885
|
+
addUnifiedPlugin(plugin: unknown, options?: {
|
|
886
|
+
phase?: RenderPhase;
|
|
887
|
+
}): void;
|
|
888
|
+
/**
|
|
889
|
+
* Register a per-mdast-node-type renderer. The runtime walks the
|
|
890
|
+
* tree once and dispatches each node to every renderer registered
|
|
891
|
+
* for its `type`, in registration order.
|
|
892
|
+
*/
|
|
893
|
+
addNodeRenderer(type: string, renderer: NodeRenderer): void;
|
|
894
|
+
/**
|
|
895
|
+
* Register a code-block renderer for a language tag. Phase 4 warns
|
|
896
|
+
* and discards. Phase 6 lights this up.
|
|
897
|
+
*/
|
|
898
|
+
addCodeBlockRenderer(lang: string, renderer: CodeBlockRenderer): void;
|
|
899
|
+
/**
|
|
900
|
+
* Register an embed-tag renderer for `@[name](url)`. Collisions are
|
|
901
|
+
* resolved last-wins with a boot-time warn (see RFC §"Plugin tag
|
|
902
|
+
* collision").
|
|
903
|
+
*/
|
|
904
|
+
addEmbedTag(name: string, renderer: EmbedRenderer): void;
|
|
905
|
+
/**
|
|
906
|
+
* Register a URL inline-expansion rule. Order of registration is
|
|
907
|
+
* preserved; the first match that returns `'replaced'` wins.
|
|
908
|
+
*/
|
|
909
|
+
addUrlInlineExpander(rule: UrlInlineExpansionRule): void;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* Scope passed to `registerRoutes`. The HTTP route contribution surface
|
|
914
|
+
* for plugins is **not currently wired** to the runtime — it has never
|
|
915
|
+
* been invoked end-to-end. RFC-0006 Phase 6 removed the framework
|
|
916
|
+
* dependency that previously typed the contract argument; the API
|
|
917
|
+
* surface is therefore a deliberate no-op stub until a follow-up RFC
|
|
918
|
+
* redesigns plugin HTTP contribution on top of Hono.
|
|
919
|
+
*
|
|
920
|
+
* Plugins that declare a `registerRoutes` callback today see it
|
|
921
|
+
* receive this scope and call `scope.register(...)` with arbitrary
|
|
922
|
+
* arguments — both arguments are accepted as `unknown` and the call
|
|
923
|
+
* is silently dropped. The type fixture exists so the public surface
|
|
924
|
+
* of `@crowi/plugin-api` keeps compiling against existing plugin
|
|
925
|
+
* sources (including the in-tree `__fixtures__/example-plugin.ts`)
|
|
926
|
+
* without forcing every plugin to be updated in lockstep with Phase 6.
|
|
927
|
+
*/
|
|
928
|
+
interface PluginRouterScope {
|
|
929
|
+
/**
|
|
930
|
+
* No-op register. Both arguments are typed as `unknown` because the
|
|
931
|
+
* runtime never reads them; concrete shapes are reserved for the
|
|
932
|
+
* follow-up RFC that wires plugin HTTP contribution onto Hono.
|
|
933
|
+
*/
|
|
934
|
+
register(contract: unknown, implementation: unknown): void;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* The contract every Crowi plugin satisfies. Plugins export their
|
|
939
|
+
* `CrowiPlugin` object as the package's default export; the runtime
|
|
940
|
+
* imports it via `await import('<plugin-name>')` at boot.
|
|
941
|
+
*
|
|
942
|
+
* Every `register*` callback is optional — implement only the
|
|
943
|
+
* extension points your plugin actually contributes to. A storage-only
|
|
944
|
+
* plugin needs only `registerStorage`; an auth provider that also
|
|
945
|
+
* exposes admin "Test connection" needs `registerAuth` plus
|
|
946
|
+
* `registerRoutes`.
|
|
947
|
+
*/
|
|
948
|
+
interface CrowiPlugin {
|
|
949
|
+
/**
|
|
950
|
+
* Stable npm package name. Doubles as the namespace prefix for this
|
|
951
|
+
* plugin's config rows (`plugin:<name>:*`) and per-Page metadata
|
|
952
|
+
* (`page.metadata['<name>']`). Must match the `name` field in the
|
|
953
|
+
* package's `package.json`.
|
|
954
|
+
*/
|
|
955
|
+
name: string;
|
|
956
|
+
/**
|
|
957
|
+
* The plugin's own version (matches the npm package's semver).
|
|
958
|
+
* Surfaced in `crowi plugin list` and emitted in boot logs.
|
|
959
|
+
*/
|
|
960
|
+
version: string;
|
|
961
|
+
/**
|
|
962
|
+
* Other plugins this plugin needs at runtime, by npm name (e.g.
|
|
963
|
+
* `['@crowi/plugin-aws']`). The PluginManager resolves the dependency graph
|
|
964
|
+
* at boot and loads `requires` first; cycles fail boot.
|
|
965
|
+
*/
|
|
966
|
+
requires?: string[];
|
|
967
|
+
/**
|
|
968
|
+
* Zod schema describing this plugin's *global* configurable values.
|
|
969
|
+
* The admin UI generates a config form by walking this schema.
|
|
970
|
+
*
|
|
971
|
+
* Mark sensitive fields with the `@sensitive` description marker
|
|
972
|
+
* (see `SENSITIVE_FIELD_MARKER`); they are encrypted at rest via the
|
|
973
|
+
* same KeyProvider used by core's sensitive Config.
|
|
974
|
+
*
|
|
975
|
+
* Mark fields that need a "Test connection" / "Authorise" button
|
|
976
|
+
* with `@action <button-label> <verb> <path>` (see
|
|
977
|
+
* `ACTION_FIELD_MARKER`); the form renders an extra button next to
|
|
978
|
+
* the field that calls the plugin's contributed REST endpoint.
|
|
979
|
+
*/
|
|
980
|
+
configSchema?: z.ZodObject<Record<string, z.ZodTypeAny>>;
|
|
981
|
+
/**
|
|
982
|
+
* Per-Page metadata schema. When set, every Page document has a
|
|
983
|
+
* `metadata['<plugin-name>']` slot whose shape matches this schema,
|
|
984
|
+
* and the page-edit UI renders a section for the plugin where the
|
|
985
|
+
* operator can fill in those values per-page.
|
|
986
|
+
*
|
|
987
|
+
* Use case: Slack channel mapping per page (`{ channel: '#eng' }`),
|
|
988
|
+
* custom page metadata for downstream integrations.
|
|
989
|
+
*/
|
|
990
|
+
pageMetadataSchema?: z.ZodObject<Record<string, z.ZodTypeAny>>;
|
|
991
|
+
/**
|
|
992
|
+
* How this plugin appears in the admin sidebar. Optional — when
|
|
993
|
+
* omitted, the runtime derives the section from the plugin's
|
|
994
|
+
* `register*` hooks (registerStorage → 'storage', registerAuth →
|
|
995
|
+
* 'auth', etc.). Plugins with no register* hooks (config-only
|
|
996
|
+
* "base plugins" like `@crowi/plugin-aws`) MUST declare `section: 'shared'`
|
|
997
|
+
* to appear in the sidebar at all.
|
|
998
|
+
*
|
|
999
|
+
* `label` overrides the default sidebar text (which would otherwise
|
|
1000
|
+
* be the plugin's npm name). `icon` is the lucide-react icon name
|
|
1001
|
+
* (e.g. `'cloud'`, `'database'`); admin sidebar only renders icons
|
|
1002
|
+
* from a fixed allow-list to keep the bundle small.
|
|
1003
|
+
*/
|
|
1004
|
+
adminPlacement?: {
|
|
1005
|
+
section?: 'settings' | 'shared' | 'storage' | 'mail' | 'notification' | 'auth' | 'search' | 'renderer';
|
|
1006
|
+
label?: string;
|
|
1007
|
+
icon?: string;
|
|
1008
|
+
};
|
|
1009
|
+
/**
|
|
1010
|
+
* Optional localized overrides for the admin config-form field labels and
|
|
1011
|
+
* descriptions, keyed by locale then by `configSchema` field name. The
|
|
1012
|
+
* admin API overlays the entry matching the requesting admin's locale on
|
|
1013
|
+
* top of the schema-derived field; the Zod `.describe()` text stays the
|
|
1014
|
+
* (English) default when a locale or field is missing. Lets a plugin ship
|
|
1015
|
+
* its own translations without the host app knowing about them.
|
|
1016
|
+
*
|
|
1017
|
+
* @example
|
|
1018
|
+
* configI18n: {
|
|
1019
|
+
* ja: { serverUrl: { description: 'PlantUML サーバーのベース URL。' } },
|
|
1020
|
+
* }
|
|
1021
|
+
*/
|
|
1022
|
+
configI18n?: Record<string, Record<string, {
|
|
1023
|
+
label?: string;
|
|
1024
|
+
description?: string;
|
|
1025
|
+
}>>;
|
|
1026
|
+
/** Storage driver registration. Called once at boot. */
|
|
1027
|
+
registerStorage?: (registry: StorageRegistry, ctx: PluginContext) => void;
|
|
1028
|
+
/** Search backend registration. Called once at boot. */
|
|
1029
|
+
registerSearch?: (registry: SearchRegistry, ctx: PluginContext) => void;
|
|
1030
|
+
/** Auth provider registration. Called once at boot. */
|
|
1031
|
+
registerAuth?: (registry: AuthRegistry, ctx: PluginContext) => void;
|
|
1032
|
+
/** Notification sink registration. Called once at boot. */
|
|
1033
|
+
registerNotifier?: (registry: NotifierRegistry, ctx: PluginContext) => void;
|
|
1034
|
+
/**
|
|
1035
|
+
* Mail sender (transport) registration. Called once at boot. Exactly
|
|
1036
|
+
* one registered driver is active, selected by
|
|
1037
|
+
* `crowi.config.json:mail.driver` (default `'smtp'`). The core
|
|
1038
|
+
* assembles the message; the driver only delivers it.
|
|
1039
|
+
*/
|
|
1040
|
+
registerMailSender?: (registry: MailSenderRegistry, ctx: PluginContext) => void;
|
|
1041
|
+
/**
|
|
1042
|
+
* Renderer extension registration. Called once at boot, AFTER the
|
|
1043
|
+
* core bundled renderer (TOC / wikilinks / mentions / code-block
|
|
1044
|
+
* languages) has already populated the registry. Phase 2 honours
|
|
1045
|
+
* `addUnifiedPlugin({ phase: 'transform' })` and `addNodeRenderer`;
|
|
1046
|
+
* other registrations warn-noop until Phase 3. See RFC-0002.
|
|
1047
|
+
*/
|
|
1048
|
+
registerRenderer?: (registry: RendererRegistry, ctx: PluginContext) => void;
|
|
1049
|
+
/**
|
|
1050
|
+
* Event subscription registration. Reserved for v2.0 internal use;
|
|
1051
|
+
* not yet a stable extension point for community plugins.
|
|
1052
|
+
*/
|
|
1053
|
+
registerHooks?: (events: EventBus, ctx: PluginContext) => void;
|
|
1054
|
+
/**
|
|
1055
|
+
* ts-rest contract that the plugin contributes. Mounted at
|
|
1056
|
+
* `/api/v2/plugins/<name>/*` (the `<name>` path segment guarantees
|
|
1057
|
+
* that core endpoints and other plugins cannot collide). Used for
|
|
1058
|
+
* "Test connection" buttons, OAuth callbacks, custom admin views,
|
|
1059
|
+
* etc. The contract surface uses ts-rest so the admin UI can call
|
|
1060
|
+
* plugin endpoints with the same `apiClient.<plugin>.<method>` shape
|
|
1061
|
+
* it uses for core endpoints.
|
|
1062
|
+
*/
|
|
1063
|
+
registerRoutes?: (scope: PluginRouterScope, ctx: PluginContext) => void;
|
|
1064
|
+
/**
|
|
1065
|
+
* Run-once setup when this plugin is first activated. Typically used
|
|
1066
|
+
* for legacy v1.x → v2.0 config migration: copy `upload:aws:*` rows
|
|
1067
|
+
* into `plugin:<name>:*`, etc. Idempotent — the runtime tracks which
|
|
1068
|
+
* plugins have already had `onInstall` invoked and skips on subsequent
|
|
1069
|
+
* boots.
|
|
1070
|
+
*/
|
|
1071
|
+
onInstall?: (ctx: PluginContext) => Promise<void>;
|
|
1072
|
+
/**
|
|
1073
|
+
* Symmetric to `onInstall`; called when the plugin is removed via
|
|
1074
|
+
* `crowi plugin remove`. Note: by default config rows are *kept*
|
|
1075
|
+
* (the operator may reinstall later) — `onUninstall` runs only when
|
|
1076
|
+
* `--purge` is passed.
|
|
1077
|
+
*/
|
|
1078
|
+
onUninstall?: (ctx: PluginContext) => Promise<void>;
|
|
1079
|
+
/**
|
|
1080
|
+
* Called when this plugin's own config (`plugin:<name>:*`) or any of
|
|
1081
|
+
* its `requires` dependency configs change via the admin UI / API.
|
|
1082
|
+
* Implementations should refresh any cached state — clients,
|
|
1083
|
+
* connection pools, derived values — so subsequent driver method
|
|
1084
|
+
* calls see the new values.
|
|
1085
|
+
*
|
|
1086
|
+
* Optional. If omitted, the plugin is treated as "config changes
|
|
1087
|
+
* require a server restart" (back-compat for the existing
|
|
1088
|
+
* register-once / closure-captured driver pattern).
|
|
1089
|
+
*
|
|
1090
|
+
* Best-effort: a thrown error is logged and reported to the admin
|
|
1091
|
+
* UI but does NOT crash the server — that would lock operators out
|
|
1092
|
+
* of the very UI they need to fix the misconfiguration.
|
|
1093
|
+
*/
|
|
1094
|
+
reconfigure?: (ctx: PluginContext) => void | Promise<void>;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* `configSchema` description-string markers.
|
|
1099
|
+
*
|
|
1100
|
+
* The admin UI walks the schema and looks at each field's
|
|
1101
|
+
* `description` (set via `z.string().describe('@sensitive ...')`). A
|
|
1102
|
+
* description starting with one of these marker tokens unlocks special
|
|
1103
|
+
* UI behaviour without forcing every field to declare a custom Zod
|
|
1104
|
+
* type.
|
|
1105
|
+
*/
|
|
1106
|
+
/**
|
|
1107
|
+
* Marker that flags a config field as sensitive (encrypted at rest).
|
|
1108
|
+
* Usage:
|
|
1109
|
+
*
|
|
1110
|
+
* z.string().describe('@sensitive AWS secret access key')
|
|
1111
|
+
*
|
|
1112
|
+
* The runtime auto-encrypts on write and decrypts on read, using the
|
|
1113
|
+
* same KeyProvider as core sensitive Config. The admin UI renders the
|
|
1114
|
+
* field via `<SecretField>` (saved badge / clear pending / undo).
|
|
1115
|
+
*/
|
|
1116
|
+
declare const SENSITIVE_FIELD_MARKER = "@sensitive";
|
|
1117
|
+
/**
|
|
1118
|
+
* Marker that adds an action button next to a config field. Usage:
|
|
1119
|
+
*
|
|
1120
|
+
* z.string().describe('@action "Test connection" POST /test')
|
|
1121
|
+
*
|
|
1122
|
+
* The admin form renders a button with the given label that calls the
|
|
1123
|
+
* plugin's contributed endpoint at the given verb / path (relative to
|
|
1124
|
+
* `/api/v2/plugins/<name>/`). Useful for "Test connection",
|
|
1125
|
+
* "Authorise with Google", etc. without forcing every plugin to ship
|
|
1126
|
+
* its own React component.
|
|
1127
|
+
*/
|
|
1128
|
+
declare const ACTION_FIELD_MARKER = "@action";
|
|
1129
|
+
/**
|
|
1130
|
+
* True if the schema field is marked `@sensitive`.
|
|
1131
|
+
*
|
|
1132
|
+
* `field` is `z.ZodTypeAny` (intentionally loose); call sites pass the
|
|
1133
|
+
* value type from `configSchema.shape[key]`.
|
|
1134
|
+
*/
|
|
1135
|
+
declare function isSensitiveField(field: z.ZodTypeAny): boolean;
|
|
1136
|
+
/**
|
|
1137
|
+
* Parsed `@action` annotation extracted from a field's `description`.
|
|
1138
|
+
*/
|
|
1139
|
+
interface ActionAnnotation {
|
|
1140
|
+
/** Visible button label, e.g. "Test connection". */
|
|
1141
|
+
label: string;
|
|
1142
|
+
/** HTTP verb of the plugin endpoint to call. */
|
|
1143
|
+
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
|
1144
|
+
/** Path relative to `/api/v2/plugins/<name>/`, with leading slash. */
|
|
1145
|
+
path: string;
|
|
1146
|
+
}
|
|
1147
|
+
/**
|
|
1148
|
+
* Parse an `@action` annotation off a field, or return null if absent.
|
|
1149
|
+
*
|
|
1150
|
+
* Format: `@action "<label>" <METHOD> <path>`
|
|
1151
|
+
* e.g. `@action "Test connection" POST /test`
|
|
1152
|
+
*
|
|
1153
|
+
* The label may include spaces when wrapped in double quotes; the
|
|
1154
|
+
* method is one of `GET` / `POST` / `PUT` / `DELETE`; the path begins
|
|
1155
|
+
* with `/`.
|
|
1156
|
+
*/
|
|
1157
|
+
declare function getActionAnnotation(field: z.ZodTypeAny): ActionAnnotation | null;
|
|
1158
|
+
|
|
1159
|
+
export { ACTION_FIELD_MARKER, type AuthContext, type AuthDriver, type AuthProfile, type AuthRegistry, type AuthVerifyResult, type CacheEntry, type CacheKey, type CacheStorage, type CodeBlockInfo, type CodeBlockRenderer, type CrowiPlugin, type EmailMessage, type EmbedFragment, type EmbedInput, type EmbedRenderer, type EventBus, type InlineExpansion, type MailSender, type MailSenderRegistry, type NodeRenderer, type NotificationPayload, type NotifierDriver, type NotifierRegistry, type PageMetadataAccessor, type PluginContext, type PluginCrypto, type PluginEvents, type PluginLogger, type PluginRouterScope, type RenderContext, type RenderError, type RenderPhase, type RenderResult, type RendererRegistry, type Reservation, SENSITIVE_FIELD_MARKER, type ScopedCacheStorage, type SearchDriver, type SearchHit, type SearchHits, type SearchPageType, type SearchQuery, type SearchQueryGrants, type SearchQueryViewer, type SearchRegistry, type SearchableDoc, type StorageDriver, type StoragePutMeta, type StoragePutResult, type StorageRegistry, type UrlInlineExpansionRule, getActionAnnotation, isSensitiveField };
|