@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.
@@ -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 };