@crowi/plugin-api 0.1.0-alpha.1 → 1.0.0-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,11 +1,41 @@
1
1
  import { z } from 'zod/v3';
2
2
  import { Readable } from 'node:stream';
3
+ import { Configuration } from 'openid-client';
4
+ import { Context } from 'hono';
3
5
 
4
6
  /**
5
7
  * The context object passed to every plugin callback. It is the only
6
8
  * 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
+ * logging) — plugins must NOT import from `@crowi/server` directly to
10
+ * keep the contract surface thin.
11
+ *
12
+ * Trust boundary: a plugin only reaches what it explicitly declares, and
13
+ * a plugin cannot reach another plugin's or core's secrets through
14
+ * `PluginContext`:
15
+ *
16
+ * - `model(name)` is gated by the plugin's own `CrowiPlugin.modelAccess`
17
+ * allow-list (see `model()` below) — there is no ambient "any core
18
+ * model" access. Credential-vault models (`Config`,
19
+ * `PersonalAccessToken`, OAuth client/token/grant models, `Share`,
20
+ * `ShareAccess`) can never be granted at all: declaring one in
21
+ * `modelAccess` fails boot, and `model()` refuses to return one at
22
+ * call time even if that check were somehow bypassed.
23
+ * - There is intentionally no symmetric encrypt/decrypt capability on
24
+ * this context: the only legitimate secret-reading path is
25
+ * `config<T>()`, which already hands back `@sensitive` fields
26
+ * transparently decrypted for *this* plugin's own config.
27
+ * - `dependencyConfig<T>(name)` only returns another plugin's config —
28
+ * `@sensitive` fields included — when that plugin has explicitly
29
+ * opted in via `CrowiPlugin.exposesConfigToDependents: true`. Listing
30
+ * a plugin in `requires` is not, by itself, enough to read its config.
31
+ *
32
+ * One caveat remains, intentionally out of scope for this trust
33
+ * boundary: a plugin granted `modelAccess: ['User']` gets the raw
34
+ * Mongoose document, password hash included — there is no field
35
+ * projection today. Field-level read/write proxying for `User` (and any
36
+ * other model) is deferred to a post-2.0 repository/HTTP layer
37
+ * separation; until then, only grant `User` `modelAccess` to plugins you
38
+ * trust with that document as a whole.
9
39
  */
10
40
  interface PluginContext {
11
41
  /**
@@ -20,33 +50,141 @@ interface PluginContext {
20
50
  * Read a typed dependency plugin's config. The target plugin must
21
51
  * be listed in this plugin's `requires` array — reading another
22
52
  * plugin's config without declaring the dependency is a contract
23
- * violation and throws.
53
+ * violation and throws. In addition, the target plugin must have
54
+ * opted in with `CrowiPlugin.exposesConfigToDependents: true` —
55
+ * `requires` alone is only this plugin's side of the contract, not
56
+ * permission granted by the dependency. Throws when the dependency
57
+ * has not opted in.
24
58
  *
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.
59
+ * Useful for shared-credential plugins like `@crowi/plugin-aws`,
60
+ * which sets `exposesConfigToDependents: true` because sharing
61
+ * `region` / `accessKeyId` / `secretAccessKey` with dependents
62
+ * (`@crowi/plugin-storage-aws-s3`, `@crowi/plugin-mail-aws-ses`) is
63
+ * its entire purpose they read them through this method instead of
64
+ * duplicating the fields in their own configSchema. Most plugins do
65
+ * not opt in, so most `dependencyConfig` calls against them throw.
30
66
  */
31
67
  dependencyConfig<T>(dependencyName: string): T;
68
+ /**
69
+ * Read core application info (the wiki name, …) — settings that live
70
+ * outside this plugin's own config namespace but that an integration
71
+ * may need (e.g. to brand an outbound manifest). Read live at call
72
+ * time, so it reflects admin edits made after boot.
73
+ */
74
+ appInfo(): AppInfo;
32
75
  /** Write a single config field, persisting to Mongo. */
33
76
  setConfig(key: string, value: unknown): Promise<void>;
34
77
  /** Per-Page metadata accessor for this plugin's namespace. */
35
78
  pageMetadata: PageMetadataAccessor;
36
79
  /**
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.
80
+ * Mongoose model accessor, gated by this plugin's declared
81
+ * `CrowiPlugin.modelAccess` allow-list. Plugins touch core
82
+ * collections (Page, User, Comment, ...) through this accessor
83
+ * rather than importing model files directly.
84
+ *
85
+ * Throws when `name` is not listed in the plugin's `modelAccess` —
86
+ * a plugin must declare every core model it touches. A model name
87
+ * listed in `modelAccess` is returned with full (unrestricted)
88
+ * read/write access; there is no read-only proxying. Credential-vault
89
+ * models (`Config`, `PersonalAccessToken`, OAuth client/token/grant
90
+ * models, `Share`, `ShareAccess`) can never be listed in `modelAccess`
91
+ * at all — declaring one fails boot, and this method also refuses to
92
+ * return one at call time.
93
+ *
94
+ * Caveat: `modelAccess: ['User']` hands back the raw document,
95
+ * password hash included — there is no field projection today (see
96
+ * the trust-boundary note on this interface).
40
97
  *
41
98
  * Typed loosely (`unknown`) at this layer because the core model
42
99
  * types live in `@crowi/server`; plugins narrow the return type at
43
100
  * the call site.
44
101
  */
45
102
  model(name: string): unknown;
46
- /** Symmetric encrypt / decrypt against the configured KeyProvider. */
47
- crypto: PluginCrypto;
48
103
  /** Structured logger scoped to this plugin (auto-prefixed with name). */
49
104
  log: PluginLogger;
105
+ /**
106
+ * Hot-reload state primitive. Returns a {@link StateCell} that holds a
107
+ * mutable value — the driver-owned resource (an S3 client, an SMTP
108
+ * transport, a search client, ...) that `reconfigure` rebuilds when
109
+ * admin saves new config. Every call across every `PluginContext`
110
+ * instance for this plugin (the activation-time `ctx` passed to
111
+ * `registerStorage`/`registerSearch`/`registerMailSender` etc., and
112
+ * every later `reconfigure(ctx)` call) returns the **same** cell — the
113
+ * runtime keys it by plugin name, not by `ctx` instance. `initial` is
114
+ * only used the first time this plugin ever calls `state()`; later
115
+ * calls ignore it and just return the existing cell.
116
+ *
117
+ * Use this instead of a module-scope `let`/`const` — it protects
118
+ * in-flight `withValue()` callers from a concurrent `set()` swapping
119
+ * the value out from under them, and gives `set()`'s `dispose` option
120
+ * a correct place to tear down the previous value (close a client,
121
+ * end a connection pool, ...) once nothing is still using it.
122
+ */
123
+ state<T>(initial: T): StateCell<T>;
124
+ }
125
+ /**
126
+ * A hot-reload-safe mutable cell, returned by `PluginContext.state()`.
127
+ * Designed for driver plugins (storage / search / mail / ...) that
128
+ * `reconfigure()` rebuilds a stateful resource for: `withValue()` marks
129
+ * the current value "in use" for the duration of the callback so a
130
+ * concurrent `set()` cannot tear it down mid-call, and `set()`'s
131
+ * `dispose` option only runs once every such in-flight caller has
132
+ * settled.
133
+ */
134
+ interface StateCell<T> {
135
+ /**
136
+ * Atomic snapshot of the current value. Safe to read once and reuse
137
+ * across `await`s in the caller — but prefer {@link withValue} when the
138
+ * value may be disposed (e.g. an SDK client that `dispose` closes),
139
+ * since `get()` gives no in-flight protection.
140
+ */
141
+ get(): T;
142
+ /**
143
+ * Run `fn` against the current value while marking it "in use", so a
144
+ * concurrent `set()`'s `dispose` waits for `fn` to settle (resolve or
145
+ * reject) before tearing down the value `fn` captured. This is the
146
+ * primary way driver methods should read the cell.
147
+ */
148
+ withValue<R>(fn: (value: T) => R | Promise<R>): Promise<R>;
149
+ /**
150
+ * Swap in `next`. If `opts.dispose` is given, it runs — asynchronously,
151
+ * never inline — once every `withValue()` call that was in flight
152
+ * against the previous value at the moment of the swap has settled
153
+ * (immediately, on the next microtask, if none were in flight).
154
+ *
155
+ * `dispose` must handle (and log, if relevant) its own errors — a
156
+ * rejected `dispose` is swallowed by the runtime rather than
157
+ * surfaced anywhere, since there is no caller left waiting on it by
158
+ * the time it runs. Wrap the teardown in its own `try`/`catch` (or
159
+ * `.catch()`) instead of letting it throw.
160
+ */
161
+ set(next: T, opts?: {
162
+ dispose?: (prev: T) => void | Promise<void>;
163
+ }): void;
164
+ }
165
+ /**
166
+ * Read-only view of core application settings exposed to plugins via
167
+ * `ctx.appInfo()`. Intentionally a small, curated surface (not a generic
168
+ * "read any core config" escape hatch) — add fields here as concrete
169
+ * plugin needs appear.
170
+ */
171
+ interface AppInfo {
172
+ /**
173
+ * The configured wiki name (core `app:title`), trimmed. Always a
174
+ * non-empty string: when the operator has not set a custom title it
175
+ * defaults to `'Crowi'` (the seed value), so consumers never have to
176
+ * handle an absent name.
177
+ */
178
+ title: string;
179
+ /**
180
+ * The wiki's public base origin (core `CLIENT_URL` / `getBaseUrl()`),
181
+ * e.g. `https://wiki.example.com`. An **empty string** when no public
182
+ * origin is configured — unlike `title` there is no sensible default,
183
+ * so a plugin that needs an absolute URL (outbound webhook / manifest)
184
+ * must handle the empty case. Plugins read this instead of
185
+ * `process.env.CLIENT_URL` directly.
186
+ */
187
+ baseUrl: string;
50
188
  }
51
189
  /**
52
190
  * Per-Page metadata read / write helper. Each plugin gets a private
@@ -62,10 +200,6 @@ interface PageMetadataAccessor {
62
200
  /** Remove this plugin's metadata for a specific page. */
63
201
  remove(pageId: string): Promise<void>;
64
202
  }
65
- interface PluginCrypto {
66
- encrypt(plaintext: string): string;
67
- decrypt(ciphertext: string): string;
68
- }
69
203
  interface PluginLogger {
70
204
  debug(message: string, ...args: unknown[]): void;
71
205
  info(message: string, ...args: unknown[]): void;
@@ -73,6 +207,161 @@ interface PluginLogger {
73
207
  error(message: string, ...args: unknown[]): void;
74
208
  }
75
209
 
210
+ /**
211
+ * Contract for `CrowiPlugin.verifyConfig` — a non-blocking, post-save
212
+ * connectivity/permission probe (feature-plugin-config-live-verification).
213
+ * Deliberately its own module, separate from `context.ts`'s live
214
+ * `PluginContext`: a verification hook runs AFTER the admin save has
215
+ * already persisted (and after `reconfigure` has already rebuilt the live
216
+ * driver), against the EXACT values that request saved — not whatever the
217
+ * config cache holds by the time the hook actually runs. Handing a hook
218
+ * `PluginContext` would let it call `ctx.setConfig()` / read a
219
+ * concurrently-updated cache / touch models, none of which a read-only,
220
+ * best-effort probe should be able to do.
221
+ */
222
+ /**
223
+ * Recursively read-only view of `T`. Used for both `config()` and
224
+ * `dependencyConfig()` on {@link PluginConfigVerificationSnapshot} — a
225
+ * verification hook must not be able to mutate the plan's materialized
226
+ * values (they are shared across the plan and, for a dependency, may be
227
+ * read by more than one hook).
228
+ */
229
+ type ReadonlyDeep<T> = T extends readonly (infer U)[] ? readonly ReadonlyDeep<U>[] : T extends object ? {
230
+ readonly [K in keyof T]: ReadonlyDeep<T[K]>;
231
+ } : T;
232
+ /**
233
+ * Immutable facade a `verifyConfig` hook reads its plugin's (and its
234
+ * declared dependencies') config through. NOT `PluginContext`: this
235
+ * snapshot is materialized once, before the save that triggered
236
+ * verification, and never changes for the lifetime of the hook call — a
237
+ * concurrent save of another plugin's config (or of this plugin's config,
238
+ * from a second in-flight admin request) cannot change what an
239
+ * already-running hook sees. See `PluginManager.createVerificationPlan()`.
240
+ */
241
+ interface PluginConfigVerificationSnapshot {
242
+ /**
243
+ * This plugin's own config, as it was — or will be, for the plugin whose
244
+ * save triggered this verification — immediately after the save. Throws
245
+ * if the plugin does not declare a `configSchema` (mirrors
246
+ * `PluginContext.config()`).
247
+ */
248
+ config<T>(): ReadonlyDeep<T>;
249
+ /**
250
+ * A declared dependency's config. Same capability check as
251
+ * `PluginContext.dependencyConfig()`: `dependencyName` must be listed in
252
+ * this plugin's `requires`, AND the dependency must declare
253
+ * `exposesConfigToDependents: true`. Throws otherwise.
254
+ */
255
+ dependencyConfig<T>(dependencyName: string): ReadonlyDeep<T>;
256
+ }
257
+ /**
258
+ * Passed alongside the snapshot to every `verifyConfig` call.
259
+ *
260
+ * `timeoutMs` is a NOTICE, not a cancellation mechanism: the caller
261
+ * (`PluginManager`) stops waiting on the hook's returned promise after
262
+ * this many milliseconds and normalizes the result to
263
+ * `{ status: 'failed', reason: 'unreachable' }`, but it never aborts the
264
+ * hook itself — there is no `AbortSignal` here, and none is passed down to
265
+ * any `StorageDriver` call a storage hook makes (the driver's public `put`
266
+ * / `get` / `delete` contract takes no such option; see
267
+ * `@crowi/plugin-api`'s `registries/storage.ts`). A hook whose underlying
268
+ * I/O is still in flight when the caller gives up keeps running in the
269
+ * background; hooks that touch external resources they created (e.g. a
270
+ * storage probe object) should not rely on ever being told to stop, and
271
+ * should clean up opportunistically rather than assume they'll get to run
272
+ * to completion before anyone stops watching.
273
+ */
274
+ interface PluginConfigVerificationOptions {
275
+ timeoutMs: number;
276
+ }
277
+ /** The closed set of reasons a verification probe can fail for. Anything a driver can't confidently place in one of these falls into `'unknown'` — a wrong specific reason would mislead an operator more than an honest "couldn't tell". */
278
+ type VerificationFailureReason = 'unreachable' | 'auth-failed' | 'resource-missing' | 'write-denied' | 'unknown';
279
+ /**
280
+ * What a `verifyConfig` hook resolves to. Deliberately a closed,
281
+ * allow-listed shape — the caller projects whatever a hook returns onto
282
+ * this union (an invalid shape normalizes to `{ status: 'failed', reason:
283
+ * 'unknown' }`), so a hook can never smuggle raw SDK error text, a stack
284
+ * trace, an endpoint, or credential material into the admin response or
285
+ * logs by returning it as an extra field.
286
+ */
287
+ type PluginConfigVerificationResult = {
288
+ status: 'ok';
289
+ } | {
290
+ status: 'failed';
291
+ reason: VerificationFailureReason;
292
+ };
293
+ /**
294
+ * Key namespace every storage `verifyConfig` probe writes its round-trip
295
+ * object under — deliberately disjoint from `attachment/*` (core's
296
+ * uploaded-file namespace) so a probe object can never collide with,
297
+ * shadow, or get mistaken for a real attachment. A storage hook builds its
298
+ * probe key as `` `${CONFIG_VERIFICATION_KEY_PREFIX}<random>` ``; because
299
+ * cleanup after a probe is best-effort (see the `timeoutMs` note on
300
+ * {@link PluginConfigVerificationOptions}), an operator who finds a
301
+ * leftover object can safely delete anything under this prefix.
302
+ */
303
+ declare const CONFIG_VERIFICATION_KEY_PREFIX = "__crowi_config_verification__/";
304
+
305
+ /**
306
+ * Domain events emitted by core. The full event payload shapes live in
307
+ * `@crowi/server`; this contract publishes only the event names so the
308
+ * type signature of `EventBus.on` stays type-safe at the plugin layer.
309
+ *
310
+ * `pluginHooks` are the v2.0 internal-use-only events. Community
311
+ * plugins should NOT subscribe — the surface is reserved while we
312
+ * stabilise it.
313
+ */
314
+ interface PluginEvents {
315
+ 'page:created': {
316
+ pageId: string;
317
+ path: string;
318
+ };
319
+ 'page:updated': {
320
+ pageId: string;
321
+ path: string;
322
+ };
323
+ 'page:deleted': {
324
+ pageId: string;
325
+ path: string;
326
+ };
327
+ 'page:renamed': {
328
+ pageId: string;
329
+ oldPath: string;
330
+ newPath: string;
331
+ };
332
+ 'comment:added': {
333
+ pageId: string;
334
+ commentId: string;
335
+ };
336
+ 'comment:removed': {
337
+ pageId: string;
338
+ commentId: string;
339
+ };
340
+ 'user:registered': {
341
+ userId: string;
342
+ };
343
+ 'user:activated': {
344
+ userId: string;
345
+ };
346
+ }
347
+ interface EventBus {
348
+ on<K extends keyof PluginEvents>(event: K, listener: (payload: PluginEvents[K]) => void | Promise<void>): void;
349
+ }
350
+
351
+ /**
352
+ * HTML-emitting helper for renderer plugins.
353
+ *
354
+ * A renderer plugin that builds HTML from author-controlled or external
355
+ * strings (an OGP title, a math error message, …) must escape them, and
356
+ * that escape is a security primitive — a hardening change has to reach
357
+ * every plugin at once, not whichever local copies someone remembers.
358
+ * This is the SDK's single copy (`@crowi/plugin-renderer-katex` and
359
+ * `@crowi/plugin-renderer-link-card` each carried an identical local one
360
+ * before it was hoisted here).
361
+ */
362
+ /** Escape `&` `<` `>` `"` `'` for interpolation into HTML text or double/single-quoted attribute values. */
363
+ declare function escapeHtml(s: string): string;
364
+
76
365
  /**
77
366
  * Metadata accompanying a `put`. The runtime always provides
78
367
  * `contentType`; drivers are free to store additional fields under
@@ -270,8 +559,8 @@ interface AuthProfile {
270
559
  extra?: Record<string, unknown>;
271
560
  }
272
561
  /**
273
- * Result of `verify` — either a normalised profile (success) or an
274
- * error reason the login UI surfaces.
562
+ * Result of `verify` / `fetchProfile` — either a normalised profile
563
+ * (success) or an error reason the login UI surfaces.
275
564
  */
276
565
  type AuthVerifyResult = {
277
566
  ok: true;
@@ -280,38 +569,166 @@ type AuthVerifyResult = {
280
569
  ok: false;
281
570
  reason: string;
282
571
  };
572
+ /** One field to render on a `credential` driver's sign-in form. */
573
+ interface CredentialField {
574
+ /** Form field name, e.g. `'username'` / `'password'`. */
575
+ name: string;
576
+ /** Human-readable label rendered next to the field. */
577
+ label: string;
578
+ /** Input type. Defaults to `'text'` when omitted. */
579
+ type?: 'text' | 'email' | 'password';
580
+ required?: boolean;
581
+ }
283
582
  /**
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.
583
+ * Direct-credential auth: the user submits credentials to Crowi itself
584
+ * (LDAP, local password). No redirect, no external IdP round-trip.
294
585
  */
295
- interface AuthDriver {
586
+ interface CredentialAuthDriver {
587
+ kind: 'credential';
588
+ /** Usually omitted — credential drivers render as the sign-in form. */
589
+ buttonLabel?: string;
590
+ /** Fields to render on the sign-in form (e.g. [username, password]). */
591
+ fields: CredentialField[];
592
+ verify(credentials: Record<string, string>): Promise<AuthVerifyResult>;
593
+ }
594
+ /**
595
+ * OAuth 2.0 / OIDC client credentials, read lazily at request time (see
596
+ * `getClientConfig()` below) rather than captured at registration.
597
+ */
598
+ interface OAuthClientConfig {
599
+ clientId: string;
600
+ clientSecret: string;
601
+ }
602
+ /** Token response from an OAuth 2.0 / OIDC token endpoint. */
603
+ interface OAuthTokens {
604
+ accessToken: string;
605
+ tokenType?: string;
606
+ expiresIn?: number;
607
+ refreshToken?: string;
608
+ scope?: string;
609
+ /** Present for an OIDC token response — the raw, still-unverified id_token JWT. */
610
+ idToken?: string;
611
+ }
612
+ /**
613
+ * Redirect/federated auth: the browser bounces to an external IdP using
614
+ * the plain OAuth 2.0 authorization-code flow (no id_token).
615
+ */
616
+ interface OAuth2AuthDriver {
617
+ kind: 'oauth2';
618
+ buttonLabel: string;
619
+ iconUrl?: string;
620
+ authorizeUrl: string;
621
+ tokenUrl: string;
622
+ scopes: string[];
623
+ /** Declare when the IdP supports PKCE (S256). */
624
+ pkce?: boolean;
296
625
  /**
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.
626
+ * Lazy accessor, evaluated per request NOT captured at registration.
627
+ * Returns null while the plugin is unconfigured; core then hides the
628
+ * provider from the provider list (enablement) and rejects `/start`.
629
+ * Lazy evaluation is also what makes admin config changes take effect
630
+ * without re-registering the driver.
300
631
  */
632
+ getClientConfig(): OAuthClientConfig | null;
633
+ /**
634
+ * Exchange completed; fetch the provider profile and map it. Returns
635
+ * `AuthVerifyResult` so the driver can REJECT after a successful
636
+ * exchange (e.g. an org-membership gate) — a successful exchange does
637
+ * not by itself guarantee a successful sign-in.
638
+ */
639
+ fetchProfile(tokens: OAuthTokens): Promise<AuthVerifyResult>;
640
+ }
641
+ /** OIDC = OAuth 2.0 + standardised discovery + id_token claims. */
642
+ interface OidcAuthDriver {
643
+ kind: 'oidc';
301
644
  buttonLabel: string;
302
- /** Optional icon URL for the login button. */
303
645
  iconUrl?: string;
646
+ /** `…/.well-known/openid-configuration` */
647
+ discoveryUrl: string;
648
+ /** Default `['openid', 'email', 'profile']`. */
649
+ scopes: string[];
650
+ /** OIDC always uses PKCE. */
651
+ pkce: true;
652
+ /** Same lazy contract as `OAuth2AuthDriver.getClientConfig()`. */
653
+ getClientConfig(): OAuthClientConfig | null;
654
+ /**
655
+ * Resolve (and cache) the `openid-client` `Configuration` for this
656
+ * driver's current credentials. Returns `null` without performing any
657
+ * network I/O while `getClientConfig()` is unconfigured. See the
658
+ * discovery-cache doc comment below for the caching contract.
659
+ */
660
+ getConfiguration(): Promise<Configuration | null>;
304
661
  /**
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.
662
+ * Optional policy gate, called after core validates the id_token and
663
+ * before `mapClaims` the OIDC analogue of `fetchProfile`'s
664
+ * rejection (e.g. a Google Workspace `hd` domain restriction).
309
665
  */
310
- verify(verificationData: unknown): Promise<AuthVerifyResult>;
666
+ authorize?(claims: Record<string, unknown>): Promise<{
667
+ ok: true;
668
+ } | {
669
+ ok: false;
670
+ reason: string;
671
+ }>;
672
+ /** Optional claim → AuthProfile override; default maps sub/email/name. */
673
+ mapClaims?(claims: Record<string, unknown>): Partial<AuthProfile>;
311
674
  }
675
+ /**
676
+ * `'saml'` is reserved (RFC-0014 §9) for a future `SamlAuthDriver` — it is
677
+ * a valid `AuthDriverKind` so downstream code can already discriminate on
678
+ * it, but no `SamlAuthDriver` interface exists yet and `AuthDriver` below
679
+ * does not include it as a member. SAML's required attributes and
680
+ * callback shape are still undecided; adding a member type ahead of that
681
+ * design would let a plugin construct a value with no real runtime.
682
+ */
683
+ type AuthDriverKind = 'credential' | 'oauth2' | 'oidc' | 'saml';
684
+ /**
685
+ * Auth provider driver. The login screen asks core for the list of
686
+ * registered drivers and renders one button per `oauth2`/`oidc` driver
687
+ * (`Sign in with Google`) or one sign-in form per `credential` driver.
688
+ * See RFC-0014 §3 for the full design rationale.
689
+ */
690
+ type AuthDriver = CredentialAuthDriver | OAuth2AuthDriver | OidcAuthDriver;
312
691
  interface AuthRegistry {
313
692
  register(driverName: string, driver: AuthDriver): void;
314
693
  }
694
+ interface CreateOAuth2DriverOptions {
695
+ buttonLabel: string;
696
+ iconUrl?: string;
697
+ authorizeUrl: string;
698
+ tokenUrl: string;
699
+ /** Defaults to `[]` when omitted. */
700
+ scopes?: string[];
701
+ pkce?: boolean;
702
+ getClientConfig(): OAuthClientConfig | null;
703
+ fetchProfile(tokens: OAuthTokens): Promise<AuthVerifyResult>;
704
+ }
705
+ /**
706
+ * Build a plain OAuth 2.0 authorization-code driver. Synchronous, I/O-free
707
+ * — see the module doc comment above.
708
+ */
709
+ declare function createOAuth2Driver(options: CreateOAuth2DriverOptions): OAuth2AuthDriver;
710
+ interface CreateOidcDriverOptions {
711
+ buttonLabel: string;
712
+ iconUrl?: string;
713
+ discoveryUrl: string;
714
+ /** Defaults to `['openid', 'email', 'profile']` when omitted. */
715
+ scopes?: string[];
716
+ getClientConfig(): OAuthClientConfig | null;
717
+ authorize?(claims: Record<string, unknown>): Promise<{
718
+ ok: true;
719
+ } | {
720
+ ok: false;
721
+ reason: string;
722
+ }>;
723
+ mapClaims?(claims: Record<string, unknown>): Partial<AuthProfile>;
724
+ }
725
+ /**
726
+ * Build an OIDC driver. Synchronous, I/O-free at call time — see the
727
+ * module doc comment above. `getConfiguration()` on the returned driver
728
+ * is the only entry point that performs discovery, and only on first use
729
+ * (see `resolveOidcConfiguration` below).
730
+ */
731
+ declare function createOidcDriver(options: CreateOidcDriverOptions): OidcAuthDriver;
315
732
 
316
733
  /**
317
734
  * Notification payload — the runtime-neutral shape passed to every
@@ -431,52 +848,6 @@ interface MailSenderRegistry {
431
848
  register(driverName: string, driver: MailSender): void;
432
849
  }
433
850
 
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
851
  /**
481
852
  * Renderer extension contract — type-only. Plugins contribute parse /
482
853
  * transform behaviour to the server-side markdown pipeline through
@@ -558,9 +929,64 @@ interface CodeBlockRenderer {
558
929
  * strip comments, etc.
559
930
  */
560
931
  computeEmbedKey?(info: CodeBlockInfo): string;
932
+ /**
933
+ * Opt into a CPU-bounded admission control pool (`render-admission.ts`,
934
+ * `packages/api/src/renderer/core/render-admission.ts`). When present,
935
+ * `cachedRenderOrPending` (`packages/api/src/renderer/cache/index.ts`)
936
+ * and `renderCodeBlockForPreview` acquire a ticket from the named pool
937
+ * (keyed per `pluginName`) immediately around the `render()` call and
938
+ * release it on completion; when absent (the default — PlantUML /
939
+ * KaTeX / emoji and existing embed/URL-expansion plugins), `render()`
940
+ * is called directly with no admission gate, matching today's
941
+ * behaviour. See spec §6 for the full design (global / per-user
942
+ * concurrency caps + priority queue).
943
+ */
944
+ admissionControl?: AdmissionControlConfig;
945
+ /**
946
+ * Opt into server-rendering during editor live preview
947
+ * (`POST /pages/preview`, which runs with no `pageId`). Default
948
+ * `'source'` (omitted) leaves the fenced block untouched in preview —
949
+ * today's behaviour for every existing `CodeBlockRenderer`. Plugins
950
+ * that declare `'server-render'` MUST be no-I/O and deterministic:
951
+ * `makePreviewCodeBlockDispatch` calls them outside the persisted-
952
+ * cache path (`packages/api/src/renderer/core/code-block-dispatch.ts`).
953
+ */
954
+ previewPolicy?: 'source' | 'server-render';
561
955
  /** Render a single code block. */
562
956
  render(info: CodeBlockInfo, ctx: RenderContext): EmbedFragment | RenderResult | Promise<EmbedFragment | RenderResult>;
563
957
  }
958
+ /**
959
+ * Per-`pluginName` admission-control pool declaration (§6). Shared by
960
+ * `CodeBlockRenderer` and `EmbedRenderer` — `EmbedRenderer` carries it so
961
+ * `code-block-dispatch.ts`'s `codeBlockAsEmbedRenderer` adaptor can copy
962
+ * `CodeBlockRenderer.admissionControl` straight through to the
963
+ * `EmbedRenderer` shape `cachedRenderOrPending` actually consumes.
964
+ */
965
+ interface AdmissionControlConfig {
966
+ /** Process-wide concurrent `render()` calls in flight for this plugin. */
967
+ maxConcurrentGlobal: number;
968
+ /** Concurrent `render()` calls in flight for a single `actor` (kind:'user' only). */
969
+ maxConcurrentPerUser: number;
970
+ /** Max jobs allowed to wait for a slot before new requests are rejected outright. */
971
+ queueDepth: number;
972
+ }
973
+ /**
974
+ * Who is driving this render call. Threaded through `RenderContext` so
975
+ * admission control (§6) can apply a per-user concurrency cap. Today
976
+ * every real call site is authenticated (`createJwtAuth` has no
977
+ * anonymous fallback), so `'user'` is the only variant actually
978
+ * produced — `'anonymous'` / `'system'` are reserved for future
979
+ * unauthenticated-read and offline-tooling call sites and must not be
980
+ * synthesised speculatively.
981
+ */
982
+ type RenderActor = {
983
+ kind: 'user';
984
+ userId: string;
985
+ } | {
986
+ kind: 'anonymous';
987
+ } | {
988
+ kind: 'system';
989
+ };
564
990
  interface CodeBlockInfo {
565
991
  /** The language tag from the fence (the `ts` in ```` ```ts ````). */
566
992
  lang: string;
@@ -604,6 +1030,41 @@ interface EmbedRenderer {
604
1030
  * volatility (`?utm_*`) or (b) include external state (Accept-Language).
605
1031
  */
606
1032
  computeEmbedKey?(input: EmbedInput): string;
1033
+ /**
1034
+ * Opt into admission control (§6). See `CodeBlockRenderer.admissionControl`
1035
+ * for the full rationale — declared here too because `cachedRenderOrPending`
1036
+ * (`packages/api/src/renderer/cache/index.ts`) is written against the
1037
+ * `EmbedRenderer` shape (`code-block-dispatch.ts`'s
1038
+ * `codeBlockAsEmbedRenderer` adaptor copies a `CodeBlockRenderer`'s
1039
+ * declaration through unchanged). Native `EmbedRenderer` plugins
1040
+ * (embed-tags / URL-inline-expansion) can also opt in if a future one
1041
+ * turns out to be CPU-bound.
1042
+ */
1043
+ admissionControl?: AdmissionControlConfig;
1044
+ /**
1045
+ * Optional per-dispatch cache-bypass predicate
1046
+ * (feature-renderer-plugin-boundary Phase 3). Checked by the generic
1047
+ * embed-tag dispatcher (`packages/api/src/renderer/core/embed-tags.ts`)
1048
+ * BEFORE it touches `CacheStorage` at all for this dispatch — no
1049
+ * `get`, no `set`. When it returns `true`, the dispatcher calls
1050
+ * `render()` directly (via the same `normalizeRenderResult` error
1051
+ * normalisation the preview path uses) and never persists the
1052
+ * result.
1053
+ *
1054
+ * This exists because a renderer whose behaviour is gated by a
1055
+ * runtime policy toggle (e.g. link-card's admin
1056
+ * `security:linkCardEnabled` switch) cannot enforce a literal
1057
+ * zero-cache-access guarantee by checking the toggle only inside
1058
+ * `render()` — a cache HIT from before the toggle flipped would
1059
+ * short-circuit `render()` entirely and keep serving pre-toggle
1060
+ * output (and, symmetrically, writing a toggled-off result to the
1061
+ * cache would keep serving it for up to that entry's TTL after the
1062
+ * toggle flips back). Declaring the check here instead makes the
1063
+ * dispatcher skip the cache outright for that one call. Absent (the
1064
+ * default) or returning `false` goes through the normal cached path
1065
+ * unchanged.
1066
+ */
1067
+ shouldBypassCache?(input: EmbedInput): boolean;
607
1068
  /** Render a single embed. */
608
1069
  render(input: EmbedInput, ctx: RenderContext): RenderResult | Promise<RenderResult>;
609
1070
  /**
@@ -635,9 +1096,36 @@ interface EmbedInput {
635
1096
  * background-refresh window (see
636
1097
  * `packages/api/src/renderer/cache/index.ts:cachedRender`).
637
1098
  */
1099
+ /**
1100
+ * RFC-0023 (design doc §12) — the structured (typed) counterpart of a
1101
+ * producer's `html` output. Additive and optional everywhere: a plugin
1102
+ * that never sets it keeps today's behaviour byte-for-byte.
1103
+ *
1104
+ * `node` is the producer-shaped typed node (`type` selects the sidecar
1105
+ * kind — `'crowiDiagram'` / `'crowiLinkCard'` / `'crowiPlaceholder'`).
1106
+ * Deliberately loose (`Record<string, unknown>`) at this SDK layer:
1107
+ * `@crowi/plugin-api` does not depend on `@crowi/api-contract`, so the
1108
+ * authoritative shape lives in the api-contract sidecar schemas and the
1109
+ * api-side dispatch mapper validates against them before stamping a
1110
+ * sidecar onto the persisted AST (invalid payloads degrade to a plain
1111
+ * `html` node, never poisoning what the web reads).
1112
+ */
1113
+ interface StructuredRenderPayload {
1114
+ node: Record<string, unknown>;
1115
+ }
638
1116
  interface RenderResult {
639
- /** Already-sanitised HTML the core will inline. */
1117
+ /** Already-sanitised HTML the core will inline. Unchanged — the one and only web/legacy representation. */
640
1118
  html: string;
1119
+ /**
1120
+ * RFC-0023 — optional structured payload paired with `html`. Both
1121
+ * must describe the SAME render outcome: the dispatch layer stamps
1122
+ * this (schema-validated) as a sidecar on the `html` node it splices,
1123
+ * and the `X-Crowi-Ast-Version: 1` projection turns it into a typed
1124
+ * node. On an `error` result, pair it with `errorHtml` when the
1125
+ * error display carries real content (e.g. link-card's fallback
1126
+ * card); leave it unset to get the generic structured placeholder.
1127
+ */
1128
+ structured?: StructuredRenderPayload;
641
1129
  /**
642
1130
  * Optional `<head>`-bound assets — Phase 4 records them on the
643
1131
  * cache entry but the SSR layer does not yet inject them. Phase 7
@@ -657,19 +1145,43 @@ interface RenderResult {
657
1145
  ttlSec?: number;
658
1146
  /**
659
1147
  * 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.
1148
+ * timeout / unknown / blocked), plugins should set `error` instead of
1149
+ * building an html error frame. The core caches the error using
1150
+ * `RENDER_ERROR_TTL` and, absent `errorHtml`, substitutes a fixed
1151
+ * placeholder when re-rendering the page.
663
1152
  */
664
1153
  error?: RenderError;
1154
+ /**
1155
+ * Optional failure-display HTML, paired with `error`. When `error` is
1156
+ * set and `errorHtml` is present, the core shows `errorHtml` instead of
1157
+ * the generic `errorPlaceholder()` — e.g. a link-card plugin can keep
1158
+ * its URL clickable even when the OGP fetch failed. Same trust
1159
+ * contract as `html`: **pre-sanitised, the core does not re-escape it**.
1160
+ *
1161
+ * Deliberately a separate field rather than "non-empty `html` + `error`
1162
+ * means show `html`" — that shape makes a plugin's stray/forgotten
1163
+ * `html` leak into the error display by accident. An explicit opt-in
1164
+ * field means a plugin that hasn't been updated for `errorHtml` keeps
1165
+ * the current safe-by-default behaviour (placeholder).
1166
+ *
1167
+ * Ignored when `error` is unset.
1168
+ */
1169
+ errorHtml?: string;
665
1170
  }
666
1171
  /**
667
1172
  * Error categories cached with their own per-code TTLs. See
668
1173
  * `packages/api/src/renderer/cache/index.ts:RENDER_ERROR_TTL` for the
669
- * concrete numbers.
1174
+ * concrete numbers. `blocked` is a policy-level permanent rejection
1175
+ * (SSRF block, disallowed scheme, disallowed content-type) — distinct
1176
+ * from `not_found` semantically but sharing its 1h persistent-failure
1177
+ * TTL. `busy` is a transient renderer-admission rejection (e.g. a
1178
+ * shared fetch/render concurrency semaphore's wait queue was full, or a
1179
+ * queued request's wait deadline elapsed) — never a property of the
1180
+ * embed's target, so it shares a short transient TTL with
1181
+ * `network`/`timeout` rather than `blocked`'s persistent one.
670
1182
  */
671
1183
  interface RenderError {
672
- code: 'auth' | 'rate_limit' | 'not_found' | 'network' | 'timeout' | 'unknown';
1184
+ code: 'auth' | 'rate_limit' | 'not_found' | 'network' | 'timeout' | 'unknown' | 'blocked' | 'busy';
673
1185
  /** Free-form text for log/debug — NOT inlined into the user-facing placeholder. */
674
1186
  message?: string;
675
1187
  /**
@@ -732,6 +1244,8 @@ type InlineExpansion = {
732
1244
  interface EmbedFragment {
733
1245
  /** Pre-sanitised HTML fragment to inline at the source position. */
734
1246
  html: string;
1247
+ /** RFC-0023 — optional structured payload paired with `html` (see `RenderResult.structured`). */
1248
+ structured?: StructuredRenderPayload;
735
1249
  /** Optional `<head>`-bound assets (CSS / JS) keyed by URL. */
736
1250
  assets?: {
737
1251
  css?: string[];
@@ -762,6 +1276,17 @@ interface CacheEntry {
762
1276
  result: RenderResult;
763
1277
  fetchedAt: Date;
764
1278
  expiresAt: Date;
1279
+ /**
1280
+ * Present ⇔ this is a stale-if-error entry keeping a prior success on
1281
+ * screen (see `packages/api/src/renderer/cache/index.ts:
1282
+ * STALE_IF_ERROR_MAX_AGE_SEC`): the value is that ORIGINAL success's
1283
+ * timestamp, carried forward unchanged across consecutive failed
1284
+ * retries — never the failed attempt's `fetchedAt`. Success entries do
1285
+ * not carry it (their `fetchedAt` IS the last-good time; readers use
1286
+ * that directly, which also covers, value-identically, entries written
1287
+ * while this field was still being set on success).
1288
+ */
1289
+ lastGoodFetchedAt?: Date;
765
1290
  }
766
1291
  /**
767
1292
  * MongoDB-backed cache surface. Phase 4 ships exactly one
@@ -854,6 +1379,25 @@ interface RenderContext {
854
1379
  * encrypted-config-backed implementation.
855
1380
  */
856
1381
  auth?: AuthContext;
1382
+ /**
1383
+ * Who is driving this render. Required so admission control (§6) can
1384
+ * apply its per-user concurrency cap end-to-end — every entry point
1385
+ * (`Renderer.run`/`runMetadata`/`runRender`, `packages/api/src/renderer/
1386
+ * index.ts`) requires callers to supply this. See `RenderActor`'s doc
1387
+ * comment for which variant real call sites actually produce today.
1388
+ */
1389
+ actor: RenderActor;
1390
+ /**
1391
+ * Optional cancellation signal, propagated from the originating HTTP
1392
+ * request (`c.req.raw.signal` on `POST /pages/preview`). A waiting
1393
+ * (not-yet-running) admission-control job is removed from its queue
1394
+ * the instant this fires; an already-running child-process render is
1395
+ * NOT force-killed (§6 — the cost of killing/respawning a worker
1396
+ * outweighs letting an already-cheap render finish and discarding the
1397
+ * result). Absent on the save / read call sites, which have no
1398
+ * request to cancel against.
1399
+ */
1400
+ signal?: AbortSignal;
857
1401
  }
858
1402
  /**
859
1403
  * The registry handed to every plugin's `registerRenderer(scope, ctx)`.
@@ -869,6 +1413,10 @@ interface RenderContext {
869
1413
  *
870
1414
  * Phase 4 stubs (warn-noop):
871
1415
  * - `addCodeBlockRenderer` (Phase 6 lights this up)
1416
+ *
1417
+ * feature-renderer-plugin-boundary Phase 1 adds `addStylesheet(path)` —
1418
+ * the boot-time CSS-manifest extension point (see that method's own doc
1419
+ * comment).
872
1420
  */
873
1421
  interface RendererRegistry {
874
1422
  /**
@@ -907,33 +1455,144 @@ interface RendererRegistry {
907
1455
  * preserved; the first match that returns `'replaced'` wins.
908
1456
  */
909
1457
  addUrlInlineExpander(rule: UrlInlineExpansionRule): void;
1458
+ /**
1459
+ * Declare a static CSS asset the plugin needs the browser to load
1460
+ * (e.g. KaTeX's ~30KB math stylesheet). `path` MUST be an
1461
+ * API-relative absolute path confined to the plugin's own
1462
+ * `registerRoutes` namespace — `/api/plugins/<this plugin's
1463
+ * name>/<…>` — the same prefix `PluginRouterScope.route(...)` mounts
1464
+ * that plugin's HTTP routes under. A URL scheme, protocol-relative
1465
+ * `//host`, backslash, `..` traversal segment, or a path outside the
1466
+ * plugin's own namespace all throw synchronously (boot-time reject —
1467
+ * this is not an operator-configurable external URL; see spec
1468
+ * §2.1's "不採用案"). During the `feature-api-v2-path-removal`
1469
+ * migration period the legacy `/api/v2/plugins/<name>/<…>` prefix is
1470
+ * also accepted and silently normalised to the canonical `/api/plugins/`
1471
+ * form before publication — a plugin package that hasn't bumped its own
1472
+ * `addStylesheet(...)` call site yet still gets a working manifest
1473
+ * entry; this dual-accept is transitional, not a permanent alias.
1474
+ *
1475
+ * The call only stages the path in a per-plugin pending set: it is
1476
+ * published to the public `GET /api/app/info` `rendererStylesheets`
1477
+ * manifest ONLY after this plugin's OWN `registerRoutes(scope, ctx)`
1478
+ * completes without throwing (so the manifest never advertises a path
1479
+ * whose route failed to mount). A plugin with no `registerRoutes` at
1480
+ * all, or whose `registerRoutes` throws, never gets its pending
1481
+ * stylesheets committed — dropped wholesale, not partially. Query /
1482
+ * fragment are allowed; duplicate calls with the same path are a
1483
+ * no-op. Call this from `registerRenderer`, not `registerRoutes` —
1484
+ * commit timing depends on this method having already run.
1485
+ */
1486
+ addStylesheet(path: string): void;
910
1487
  }
911
1488
 
912
1489
  /**
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.
1490
+ * HTTP method a plugin route can be mounted on. Kept to the verbs the
1491
+ * inbound-webhook + admin-action surface actually needs (RFC-0013 §4):
1492
+ * `POST` for Slack events / slash / interactivity + `@action` targets,
1493
+ * `GET` for OAuth callbacks + simple status endpoints.
1494
+ */
1495
+ type PluginRouteMethod = 'GET' | 'POST';
1496
+ /**
1497
+ * A plugin route handler. It receives the raw Hono `Context` and returns
1498
+ * a `Response` (or a promise of one), exactly like a hand-written Hono
1499
+ * handler — the scope does **not** wrap it in a typed-route/validator
1500
+ * layer.
1501
+ *
1502
+ * **Raw body invariant** (RFC-0013 §8, a Slack hard requirement): the
1503
+ * route is a plain Hono route, NOT a `@hono/zod-openapi` route, so no
1504
+ * body-consuming validator runs ahead of the handler. `c.req.text()` /
1505
+ * `c.req.raw` therefore yield the *exact* bytes the client sent, which
1506
+ * the Slack signature check (`HMAC-SHA256` over `v0:{ts}:{rawBody}`)
1507
+ * depends on. `createJwtAuth` (installed on non-public routes) never
1508
+ * reads the body, so the invariant holds for authed routes too.
1509
+ */
1510
+ type PluginRouteHandler = (c: Context) => Response | Promise<Response>;
1511
+ /** Per-route options passed alongside the handler. */
1512
+ interface PluginRouteOptions {
1513
+ /**
1514
+ * Authorization tier this route requires.
1515
+ * - `'public'`: no auth (self-authenticating webhooks — Slack signature
1516
+ * check etc.).
1517
+ * - `'user'` (default): any authenticated Crowi user (`createJwtAuth`).
1518
+ * - `'admin'`: `user.admin === true` (`createJwtAdminRequired`) — use for
1519
+ * Test-connection / `@action` targets reached only from the admin
1520
+ * config form.
1521
+ */
1522
+ auth?: 'public' | 'user' | 'admin';
1523
+ }
1524
+ /**
1525
+ * Scope passed to `registerRoutes(scope, ctx)`. Lets a plugin contribute
1526
+ * HTTP routes that the runtime mounts at
1527
+ * `/api/plugins/<plugin-name>/<path>` — the `<plugin-name>` path
1528
+ * segment guarantees that core endpoints and other plugins cannot
1529
+ * collide (RFC-0013 §4).
919
1530
  *
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.
1531
+ * The scope is built per-plugin inside `buildHonoApp` (the Hono app does
1532
+ * not exist yet when plugins activate at boot), so `<plugin-name>` is
1533
+ * already closed over plugins only supply the sub-path.
927
1534
  */
928
1535
  interface PluginRouterScope {
929
1536
  /**
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.
1537
+ * Mount `handler` for `method` at `<path>` under this plugin's
1538
+ * namespace. `path` is relative to `/api/plugins/<plugin-name>` and
1539
+ * should start with `/` (e.g. `route('POST', '/events', handler, {
1540
+ * auth: 'public' })` → `POST /api/plugins/<name>/events`).
1541
+ *
1542
+ * Pass `{ auth: 'public' }` to bypass Crowi auth entirely for self-
1543
+ * authenticating inbound webhooks, `{ auth: 'admin' }` to require
1544
+ * `user.admin === true`, or omit `opts` for the `'user'` default (any
1545
+ * authenticated Crowi user).
933
1546
  */
934
- register(contract: unknown, implementation: unknown): void;
1547
+ route(method: PluginRouteMethod, path: string, handler: PluginRouteHandler, opts?: PluginRouteOptions): void;
935
1548
  }
936
1549
 
1550
+ /**
1551
+ * Declares that, when a specific driver from a specific registry is
1552
+ * selected (`crowi.config.json:<registry>.driver === driver`), this
1553
+ * plugin's own config becomes required to actually work at runtime —
1554
+ * even though the `configSchema` field itself is optional / defaults to
1555
+ * `''` so `configSchema.parse()` alone can't detect "present but
1556
+ * unusable" (see `@crowi/plugin-storage-aws-s3`'s `bucket` and
1557
+ * `@crowi/plugin-search-elasticsearch` / `@crowi/plugin-search-opensearch`'s
1558
+ * `url`, both `z.string().default('')`).
1559
+ *
1560
+ * This is metadata only — it never carries an actual config value.
1561
+ * `registry` / `driver` / every name in `requiredConfigFields` must be
1562
+ * non-empty. The runtime (`PluginManager.getReadinessIssues()`) reads
1563
+ * this once per admin readiness check, cross-references it against the
1564
+ * currently selected driver and the plugin's current config namespace,
1565
+ * and reports which declared fields are still empty — never the values
1566
+ * themselves. See RFC-none / feature-plugin-config-readiness.
1567
+ */
1568
+ interface PluginReadinessDeclaration {
1569
+ /** Which driver registry this declaration is scoped to. */
1570
+ registry: 'storage' | 'search' | 'mail';
1571
+ /** The driver name (as registered via `registry.register(name, …)`) this declaration applies to. */
1572
+ driver: string;
1573
+ /** `configSchema` field names that must be non-empty for `driver` to actually work once selected. */
1574
+ requiredConfigFields: string[];
1575
+ }
1576
+ /**
1577
+ * One all-or-nothing group of `configSchema` fields — see
1578
+ * `CrowiPlugin.configAtomicGroups`.
1579
+ */
1580
+ interface PluginConfigAtomicGroup {
1581
+ /**
1582
+ * Stable identifier, part of the physical storage key
1583
+ * (`plugin:<plugin>:__atomic:<name>`). Renaming it orphans the stored
1584
+ * document, so treat it like a migration.
1585
+ */
1586
+ name: string;
1587
+ /** The `configSchema` field names stored together. Non-empty, no duplicates, and each field may belong to only one group. */
1588
+ keys: readonly string[];
1589
+ /**
1590
+ * Encrypt the whole stored group at rest. Set this when ANY member is
1591
+ * secret: the group is one value, so it is either all encrypted or all
1592
+ * not — there is no per-field choice left once they share a document.
1593
+ */
1594
+ sensitive?: boolean;
1595
+ }
937
1596
  /**
938
1597
  * The contract every Crowi plugin satisfies. Plugins export their
939
1598
  * `CrowiPlugin` object as the package's default export; the runtime
@@ -964,10 +1623,55 @@ interface CrowiPlugin {
964
1623
  * at boot and loads `requires` first; cycles fail boot.
965
1624
  */
966
1625
  requires?: string[];
1626
+ /**
1627
+ * Core Mongoose model names (e.g. `['Page', 'Bookmark']`) this plugin
1628
+ * is allowed to reach via `ctx.model(name)`. The PluginManager
1629
+ * validates every entry against the set of registered core model
1630
+ * names at boot — an unknown name fails boot with a descriptive
1631
+ * error. `ctx.model(name)` throws at call time for any `name` not
1632
+ * listed here.
1633
+ *
1634
+ * A model listed here is granted full (unrestricted) read/write
1635
+ * access — there is no read-only mode. Omit or leave empty for a
1636
+ * plugin that never calls `ctx.model()`.
1637
+ *
1638
+ * Credential-bearing core models (`Config`, `PersonalAccessToken`,
1639
+ * OAuth client/token/grant models, `Share`, `ShareAccess`) can never
1640
+ * be listed here — declaring one fails boot, and `ctx.model()` also
1641
+ * refuses to return one at call time as defense-in-depth. There is no
1642
+ * legitimate plugin use case for touching those collections directly.
1643
+ */
1644
+ modelAccess?: string[];
1645
+ /**
1646
+ * Opt in to letting *other* plugins read this plugin's config through
1647
+ * their `ctx.dependencyConfig<T>(this.name)` (they must also list this
1648
+ * plugin in their own `requires`). Defaults to `false` — a plugin's
1649
+ * config, including `@sensitive` fields, is private to itself unless
1650
+ * it explicitly declares this flag.
1651
+ *
1652
+ * Set this on a plugin that exists specifically to hold credentials
1653
+ * shared by other plugins — e.g. `@crowi/plugin-aws` sets it so
1654
+ * `@crowi/plugin-storage-aws-s3` and `@crowi/plugin-mail-aws-ses` can
1655
+ * read its `region` / `accessKeyId` / `secretAccessKey` without
1656
+ * duplicating them in their own `configSchema`. Most plugins should
1657
+ * leave this unset.
1658
+ */
1659
+ exposesConfigToDependents?: boolean;
967
1660
  /**
968
1661
  * Zod schema describing this plugin's *global* configurable values.
969
1662
  * The admin UI generates a config form by walking this schema.
970
1663
  *
1664
+ * Build this with `import { z } from 'zod/v3'` — NOT the top-level
1665
+ * `import { z } from 'zod'` (v4). `peerDependencies: { zod: "^4" }`
1666
+ * only says which npm package to install; the v4 package ships a
1667
+ * `zod/v3` compat subpath, and that subpath's runtime shape is what
1668
+ * every introspection helper here (`schema-serializer.ts`,
1669
+ * `schema-markers.ts`, `PluginManager.listSensitiveKeys()`) actually
1670
+ * walks. A schema built from the top-level v4 API fails boot with an
1671
+ * explicit error (`PluginManager.activate()`'s config-schema guard —
1672
+ * see this package's README) rather than silently losing
1673
+ * `@sensitive` detection.
1674
+ *
971
1675
  * Mark sensitive fields with the `@sensitive` description marker
972
1676
  * (see `SENSITIVE_FIELD_MARKER`); they are encrypted at rest via the
973
1677
  * same KeyProvider used by core's sensitive Config.
@@ -978,6 +1682,24 @@ interface CrowiPlugin {
978
1682
  * the field that calls the plugin's contributed REST endpoint.
979
1683
  */
980
1684
  configSchema?: z.ZodObject<Record<string, z.ZodTypeAny>>;
1685
+ /**
1686
+ * RFC-0014 phase 4 — `configSchema` fields that must never be visible
1687
+ * to anyone in a half-written state, declared as groups that are stored
1688
+ * as ONE Config document instead of one row per field.
1689
+ *
1690
+ * The motivating case is an OAuth client id + secret. Written as
1691
+ * separate rows, a failure between them leaves the instance advertising
1692
+ * a new client id paired with the previous secret — a configuration
1693
+ * that never existed and cannot authenticate, visible to every replica
1694
+ * until an operator notices. As a single document there is no
1695
+ * in-between: readers see the whole previous pair or the whole new one.
1696
+ *
1697
+ * This is a STORAGE contract, not a general escape hatch for making
1698
+ * arbitrary keys atomic — the fields still appear to the plugin (and to
1699
+ * the admin form) as ordinary flat config, and are only reassembled at
1700
+ * the persistence boundary.
1701
+ */
1702
+ configAtomicGroups?: readonly PluginConfigAtomicGroup[];
981
1703
  /**
982
1704
  * Per-Page metadata schema. When set, every Page document has a
983
1705
  * `metadata['<plugin-name>']` slot whose shape matches this schema,
@@ -1002,7 +1724,7 @@ interface CrowiPlugin {
1002
1724
  * from a fixed allow-list to keep the bundle small.
1003
1725
  */
1004
1726
  adminPlacement?: {
1005
- section?: 'settings' | 'shared' | 'storage' | 'mail' | 'notification' | 'auth' | 'search' | 'renderer';
1727
+ section?: 'settings' | 'shared' | 'storage' | 'mail' | 'notification' | 'auth' | 'search' | 'renderer' | 'platform';
1006
1728
  label?: string;
1007
1729
  icon?: string;
1008
1730
  };
@@ -1023,6 +1745,14 @@ interface CrowiPlugin {
1023
1745
  label?: string;
1024
1746
  description?: string;
1025
1747
  }>>;
1748
+ /**
1749
+ * Declares which of this plugin's own `configSchema` fields must be
1750
+ * non-empty for a specific driver selection to actually work at
1751
+ * runtime (see {@link PluginReadinessDeclaration}). Optional — a
1752
+ * plugin with no readiness declaration is never surfaced by the
1753
+ * admin readiness check, same as before this field existed.
1754
+ */
1755
+ readiness?: PluginReadinessDeclaration;
1026
1756
  /** Storage driver registration. Called once at boot. */
1027
1757
  registerStorage?: (registry: StorageRegistry, ctx: PluginContext) => void;
1028
1758
  /** Search backend registration. Called once at boot. */
@@ -1052,13 +1782,25 @@ interface CrowiPlugin {
1052
1782
  */
1053
1783
  registerHooks?: (events: EventBus, ctx: PluginContext) => void;
1054
1784
  /**
1055
- * ts-rest contract that the plugin contributes. Mounted at
1056
- * `/api/v2/plugins/<name>/*` (the `<name>` path segment guarantees
1785
+ * HTTP routes the plugin contributes, mounted at
1786
+ * `/api/plugins/<name>/<path>` (the `<name>` path segment guarantees
1057
1787
  * 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.
1788
+ * inbound webhooks (Slack events / slash / interactivity), "Test
1789
+ * connection" buttons, `@action` targets, OAuth callbacks, etc.
1790
+ *
1791
+ * Each route is a plain Hono handler — `scope.route(method, path,
1792
+ * (c) => Response, opts?)`. The handler receives the raw `Context`, so
1793
+ * `c.req.text()` / `c.req.raw` give the exact request bytes (no
1794
+ * validator consumes the body ahead of it — the Slack signature check
1795
+ * relies on this). Pass `{ auth: 'public' }` to bypass Crowi auth for
1796
+ * self-authenticating webhooks, `{ auth: 'admin' }` for routes that
1797
+ * require `user.admin === true`, or omit `opts` for the `'user'`
1798
+ * default (any authenticated Crowi user).
1799
+ *
1800
+ * Called once at boot — but unlike the other `register*` hooks, this
1801
+ * runs inside `buildHonoApp` (the Hono app does not exist yet when
1802
+ * plugins activate), so a plugin's `registerRoutes` fires slightly
1803
+ * later than its `registerStorage` / `registerNotifier` / etc.
1062
1804
  */
1063
1805
  registerRoutes?: (scope: PluginRouterScope, ctx: PluginContext) => void;
1064
1806
  /**
@@ -1092,6 +1834,39 @@ interface CrowiPlugin {
1092
1834
  * of the very UI they need to fix the misconfiguration.
1093
1835
  */
1094
1836
  reconfigure?: (ctx: PluginContext) => void | Promise<void>;
1837
+ /**
1838
+ * Non-blocking connectivity/permission probe run once, after a save that
1839
+ * touched this plugin's own config OR a dependency's config (any plugin
1840
+ * that has this one in `requires`) has already persisted AND already
1841
+ * run `reconfigure` — feature-plugin-config-live-verification. Never
1842
+ * gates the save: whatever this returns is reported alongside a save
1843
+ * that already succeeded, never rolled back.
1844
+ *
1845
+ * Receives a {@link PluginConfigVerificationSnapshot}, NOT the live
1846
+ * `PluginContext` — do not close over or otherwise reach for `ctx` from
1847
+ * inside this hook. The snapshot is a read-only, point-in-time view
1848
+ * materialized from the exact values the triggering save just wrote (own
1849
+ * config) plus any declared, `exposesConfigToDependents`-opted-in
1850
+ * dependency config — it never reflects a LATER save by a different
1851
+ * admin request, even one that lands while this hook is still running.
1852
+ * `snapshot.config()` / `snapshot.dependencyConfig()` can throw if the
1853
+ * runtime couldn't materialize a value (e.g. an existing, currently
1854
+ * invalid dependency config); the runtime skips calling this hook
1855
+ * entirely in that case rather than invoking it with a broken snapshot.
1856
+ *
1857
+ * `options.timeoutMs` is a notice, not a cancellation signal — see its
1858
+ * doc. Must resolve within that budget or the caller treats it as
1859
+ * `{ status: 'failed', reason: 'unreachable' }`; a promise that keeps
1860
+ * running past that point should still clean up after itself when it
1861
+ * eventually settles, but must never throw out of that cleanup in a way
1862
+ * that becomes an unhandled rejection.
1863
+ *
1864
+ * The returned result (and anything it internally logs) must never
1865
+ * include raw SDK/driver error text, stack traces, endpoints, or
1866
+ * credential material — only the closed `reason` enum. See
1867
+ * `PluginConfigVerificationResult`.
1868
+ */
1869
+ verifyConfig?: (snapshot: PluginConfigVerificationSnapshot, options: PluginConfigVerificationOptions) => Promise<PluginConfigVerificationResult>;
1095
1870
  }
1096
1871
 
1097
1872
  /**
@@ -1121,7 +1896,7 @@ declare const SENSITIVE_FIELD_MARKER = "@sensitive";
1121
1896
  *
1122
1897
  * The admin form renders a button with the given label that calls the
1123
1898
  * plugin's contributed endpoint at the given verb / path (relative to
1124
- * `/api/v2/plugins/<name>/`). Useful for "Test connection",
1899
+ * `/api/plugins/<name>/`). Useful for "Test connection",
1125
1900
  * "Authorise with Google", etc. without forcing every plugin to ship
1126
1901
  * its own React component.
1127
1902
  */
@@ -1140,8 +1915,8 @@ interface ActionAnnotation {
1140
1915
  /** Visible button label, e.g. "Test connection". */
1141
1916
  label: string;
1142
1917
  /** 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. */
1918
+ method: PluginRouteMethod;
1919
+ /** Path relative to `/api/plugins/<name>/`, with leading slash. */
1145
1920
  path: string;
1146
1921
  }
1147
1922
  /**
@@ -1150,10 +1925,137 @@ interface ActionAnnotation {
1150
1925
  * Format: `@action "<label>" <METHOD> <path>`
1151
1926
  * e.g. `@action "Test connection" POST /test`
1152
1927
  *
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 `/`.
1928
+ * The label may include spaces when wrapped in double quotes; the method
1929
+ * must be one of `PluginRouteMethod` (`GET` / `POST` the only verbs a
1930
+ * plugin route can actually be mounted on, see `routes.ts`); the path
1931
+ * begins with `/`. A description that starts with the `@action` marker
1932
+ * but declares an unsupported verb (e.g. `PUT` / `DELETE`) fails to match
1933
+ * and returns `null` here — callers that walk a plugin's `configSchema`
1934
+ * (e.g. `PluginManager.activate()`) are expected to warn on that case at
1935
+ * boot, since it would otherwise be a silent dead button.
1156
1936
  */
1157
1937
  declare function getActionAnnotation(field: z.ZodTypeAny): ActionAnnotation | null;
1158
1938
 
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 };
1939
+ /**
1940
+ * Parses a root `<svg>` element's `viewBox` (`minX minY width height`) to
1941
+ * derive intrinsic pixel dimensions. Shared by
1942
+ * `@crowi/plugin-renderer-mermaid` (its original home — the `<img>`
1943
+ * `width`/`height` intrinsic-size fix) and, since RFC-0023,
1944
+ * `@crowi/plugin-renderer-plantuml`'s SVG sidecar path — both need the
1945
+ * same derivation and both already bundle this package, so it lives
1946
+ * here rather than being copied per plugin.
1947
+ *
1948
+ * Reads attributes off the sanitized SVG source string only — never
1949
+ * decodes any `data:` payload.
1950
+ */
1951
+ declare function extractSvgDimensions(svg: string): {
1952
+ width: number;
1953
+ height: number;
1954
+ } | null;
1955
+
1956
+ /**
1957
+ * Renderer-specific knobs for `sanitizeSvg`. The sanitizer itself is a
1958
+ * single shared implementation (`sanitize.ts`) — per-renderer differences
1959
+ * are expressed as parameters here, never as a second copy of the DOM
1960
+ * walk (spec §9: "実装自体をrenderer間で複製しない").
1961
+ */
1962
+ interface SanitizeSvgPolicy {
1963
+ /**
1964
+ * When `true`, `href` / `xlink:href` values pointing at an `https:`
1965
+ * URL are preserved (PlantUML's existing "preserves href to a safe
1966
+ * URL" behaviour, consumed starting Phase 3). When `false`, every
1967
+ * `href` / `xlink:href` is stripped unless it is a local fragment
1968
+ * reference (`#id`) — Mermaid's strict policy (spec §1 layer 1 already
1969
+ * disables Mermaid's own click callbacks, so no link should survive
1970
+ * either).
1971
+ *
1972
+ * Regardless of this flag, `javascript:`, `data:`, and
1973
+ * protocol-relative (`//host/...`) URLs are ALWAYS stripped — no
1974
+ * policy may re-allow those.
1975
+ */
1976
+ allowSafeHref: boolean;
1977
+ }
1978
+
1979
+ type SanitizeSvgResult = {
1980
+ ok: true;
1981
+ svg: string;
1982
+ } | {
1983
+ ok: false;
1984
+ reason: string;
1985
+ };
1986
+ /**
1987
+ * DOM-based SVG sanitizer shared by `@crowi/plugin-renderer-mermaid` and
1988
+ * (from Phase 3) `@crowi/plugin-renderer-plantuml`. Spec §2 layer 2 / §9.
1989
+ *
1990
+ * Design: allowlist-first for elements (unknown/unexpected element names
1991
+ * are dropped with their whole subtree — safer than trying to enumerate
1992
+ * every dangerous tag), then a small set of attribute-level rules that
1993
+ * apply uniformly to every surviving element. This is a from-scratch DOM
1994
+ * walk, not a regex pass (`packages/plugin-renderer-plantuml/src/
1995
+ * sanitize.ts`'s existing implementation is explicitly documented there
1996
+ * as "not a substitute for DOMPurify" — this package is the replacement
1997
+ * both renderers converge on, PlantUML starting Phase 3).
1998
+ *
1999
+ * What gets removed:
2000
+ * - Any element not in `ALLOWED_ELEMENTS` (`script`, `foreignObject`,
2001
+ * `iframe`, `object`, `embed`, SMIL `animate*`/`set`/`discard`, ...) —
2002
+ * dropped together with its entire subtree.
2003
+ * - `on*` event-handler attributes (any casing).
2004
+ * - The `style` attribute (inline styles). Mermaid/PlantUML's real
2005
+ * styling lives in the `<style>` *element* (class-based), which is
2006
+ * sanitized separately below rather than dropped — dropping inline
2007
+ * `style=""` is a deliberate hardening tradeoff (removes a CSS-value
2008
+ * injection vector) the regression tests confirm does not break
2009
+ * either renderer's *structural* output.
2010
+ * - `@import` at-rules and non-local-fragment `url(...)` function
2011
+ * values inside `<style>` element text content (external stylesheet
2012
+ * / font / image loads) — see `sanitizeStyleText` below for why the
2013
+ * element itself is not dropped wholesale.
2014
+ * - `xmlns` / `xmlns:*` declarations on any non-root element (namespace
2015
+ * declarations only ever legitimately live on the root `<svg>`).
2016
+ * - Any root-level `xmlns:*` declaration other than a correctly-bound
2017
+ * `xmlns:xlink` (see `isEssentialRootNamespaceDeclaration`). These are
2018
+ * already functionally inert under the strict unprefixed-SVG-element
2019
+ * invariant enforced elsewhere in this file, but are dropped anyway
2020
+ * as defence-in-depth against relying on that invariant alone.
2021
+ * - `xml:base` on any element (root or descendant). Left in place, it
2022
+ * would silently change the base URI every *local-fragment* `href` /
2023
+ * `xlink:href` / `url(#id)` reference in its subtree resolves
2024
+ * against — turning an in-document `#id` reference into an external
2025
+ * `https://evil.example/#id` fetch some SVG consumers follow,
2026
+ * defeating the local-fragment-only guarantees above even though
2027
+ * every individual `href`/`url()` value still looks safe in
2028
+ * isolation.
2029
+ * - `ProcessingInstruction` nodes anywhere in the tree
2030
+ * (`<?xml-stylesheet ...?>` etc).
2031
+ * - `href` / `xlink:href` values that are not a local fragment
2032
+ * reference (`#id`) and not allowed by `policy.allowSafeHref`.
2033
+ * `javascript:`, `data:`, and protocol-relative (`//...`) values are
2034
+ * ALWAYS stripped regardless of policy.
2035
+ * - `url(...)` references inside SVG *presentation attributes* that
2036
+ * accept a `<FuncIRI>` (`fill`, `stroke`, `filter`, `clip-path`,
2037
+ * `mask`, `cursor`, `marker-start`, `marker-mid`, `marker-end`) when
2038
+ * the reference target is not a local fragment (`#id`) — e.g.
2039
+ * `fill="url(https://evil.example/paint.svg)"` or
2040
+ * `filter="url(data:image/svg+xml;base64,...)"`. These are the same
2041
+ * class of external-resource load as `href`/`style` but reachable via
2042
+ * a different attribute name, so they get the same href-style
2043
+ * drop-the-attribute treatment. `url(#localId)` references (the
2044
+ * normal way Mermaid/PlantUML wire arrowhead markers and gradients)
2045
+ * are always preserved.
2046
+ *
2047
+ * What is explicitly preserved:
2048
+ * - `href` / `xlink:href` local fragment references (`#id`) — legitimate
2049
+ * internal `<use>` / gradient / clip-path wiring.
2050
+ * - `https:` `href` values when `policy.allowSafeHref` is `true`.
2051
+ * - `url(#id)` local fragment references in presentation attributes
2052
+ * (`fill="url(#gradient)"`, `marker-end="url(#arrowhead)"`, ...).
2053
+ *
2054
+ * A parse failure (malformed XML) or a sanitized result whose root is not
2055
+ * a single `<svg>` element both return `{ ok: false }` — callers must
2056
+ * treat that as "invalid output" (spec §2 layer 2), never fall back to
2057
+ * the unsanitized input.
2058
+ */
2059
+ declare function sanitizeSvg(input: string, policy: SanitizeSvgPolicy): SanitizeSvgResult;
2060
+
2061
+ export { ACTION_FIELD_MARKER, type AdmissionControlConfig, type AppInfo, type AuthContext, type AuthDriver, type AuthDriverKind, type AuthProfile, type AuthRegistry, type AuthVerifyResult, CONFIG_VERIFICATION_KEY_PREFIX, type CacheEntry, type CacheKey, type CacheStorage, type CodeBlockInfo, type CodeBlockRenderer, type CreateOAuth2DriverOptions, type CreateOidcDriverOptions, type CredentialAuthDriver, type CredentialField, 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 OAuth2AuthDriver, type OAuthClientConfig, type OAuthTokens, type OidcAuthDriver, type PageMetadataAccessor, type PluginConfigAtomicGroup, type PluginConfigVerificationOptions, type PluginConfigVerificationResult, type PluginConfigVerificationSnapshot, type PluginContext, type PluginEvents, type PluginLogger, type PluginReadinessDeclaration, type PluginRouteHandler, type PluginRouteMethod, type PluginRouteOptions, type PluginRouterScope, type ReadonlyDeep, type RenderActor, type RenderContext, type RenderError, type RenderPhase, type RenderResult, type RendererRegistry, type Reservation, SENSITIVE_FIELD_MARKER, type SanitizeSvgPolicy, type SanitizeSvgResult, type ScopedCacheStorage, type SearchDriver, type SearchHit, type SearchHits, type SearchPageType, type SearchQuery, type SearchQueryGrants, type SearchQueryViewer, type SearchRegistry, type SearchableDoc, type StateCell, type StorageDriver, type StoragePutMeta, type StoragePutResult, type StorageRegistry, type StructuredRenderPayload, type UrlInlineExpansionRule, type VerificationFailureReason, createOAuth2Driver, createOidcDriver, escapeHtml, extractSvgDimensions, getActionAnnotation, isSensitiveField, sanitizeSvg };