@crowi/plugin-api 0.1.0-alpha.2 → 1.0.0-alpha.11

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,12 +1,41 @@
1
1
  import { z } from 'zod/v3';
2
2
  import { Readable } from 'node:stream';
3
+ import { Configuration } from 'openid-client';
3
4
  import { Context } from 'hono';
4
5
 
5
6
  /**
6
7
  * The context object passed to every plugin callback. It is the only
7
8
  * conduit through which a plugin reads core state (config, models,
8
- * crypto helpers, logging) — plugins must NOT import from
9
- * `@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.
10
39
  */
11
40
  interface PluginContext {
12
41
  /**
@@ -21,13 +50,19 @@ interface PluginContext {
21
50
  * Read a typed dependency plugin's config. The target plugin must
22
51
  * be listed in this plugin's `requires` array — reading another
23
52
  * plugin's config without declaring the dependency is a contract
24
- * 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.
25
58
  *
26
- * Useful for shared-credential plugins like `@crowi/plugin-aws`:
27
- * the base plugin owns `region` / `accessKeyId` / `secretAccessKey`,
28
- * and dependents (`@crowi/plugin-storage-aws-s3`,
29
- * `@crowi/plugin-mail-aws-ses`) read them through this method
30
- * 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.
31
66
  */
32
67
  dependencyConfig<T>(dependencyName: string): T;
33
68
  /**
@@ -42,19 +77,90 @@ interface PluginContext {
42
77
  /** Per-Page metadata accessor for this plugin's namespace. */
43
78
  pageMetadata: PageMetadataAccessor;
44
79
  /**
45
- * Mongoose model accessor. Returns the named core model. Plugins
46
- * touch core collections (Page, User, Comment, ...) through this
47
- * 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).
48
97
  *
49
98
  * Typed loosely (`unknown`) at this layer because the core model
50
99
  * types live in `@crowi/server`; plugins narrow the return type at
51
100
  * the call site.
52
101
  */
53
102
  model(name: string): unknown;
54
- /** Symmetric encrypt / decrypt against the configured KeyProvider. */
55
- crypto: PluginCrypto;
56
103
  /** Structured logger scoped to this plugin (auto-prefixed with name). */
57
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;
58
164
  }
59
165
  /**
60
166
  * Read-only view of core application settings exposed to plugins via
@@ -94,10 +200,6 @@ interface PageMetadataAccessor {
94
200
  /** Remove this plugin's metadata for a specific page. */
95
201
  remove(pageId: string): Promise<void>;
96
202
  }
97
- interface PluginCrypto {
98
- encrypt(plaintext: string): string;
99
- decrypt(ciphertext: string): string;
100
- }
101
203
  interface PluginLogger {
102
204
  debug(message: string, ...args: unknown[]): void;
103
205
  info(message: string, ...args: unknown[]): void;
@@ -105,6 +207,161 @@ interface PluginLogger {
105
207
  error(message: string, ...args: unknown[]): void;
106
208
  }
107
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
+
108
365
  /**
109
366
  * Metadata accompanying a `put`. The runtime always provides
110
367
  * `contentType`; drivers are free to store additional fields under
@@ -302,8 +559,8 @@ interface AuthProfile {
302
559
  extra?: Record<string, unknown>;
303
560
  }
304
561
  /**
305
- * Result of `verify` — either a normalised profile (success) or an
306
- * 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.
307
564
  */
308
565
  type AuthVerifyResult = {
309
566
  ok: true;
@@ -312,38 +569,166 @@ type AuthVerifyResult = {
312
569
  ok: false;
313
570
  reason: string;
314
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
+ }
315
582
  /**
316
- * Auth provider driver. The login screen asks core for the list of
317
- * registered drivers and renders one button per driver
318
- * (`Sign in with Google`). Clicking redirects through the plugin's
319
- * registered routes (`/api/v2/plugins/<name>/oauth/start`); the
320
- * provider redirects back to `/api/v2/plugins/<name>/oauth/callback`,
321
- * which the plugin's contract handles.
322
- *
323
- * `verify` is the bridge: given whatever the plugin pulled out of the
324
- * callback (token / code / SAML response), produce a normalised
325
- * `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.
585
+ */
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).
326
615
  */
327
- interface AuthDriver {
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;
625
+ /**
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.
631
+ */
632
+ getClientConfig(): OAuthClientConfig | null;
328
633
  /**
329
- * Human-readable label for the login button (e.g. `'Google'`).
330
- * Localisation is the plugin's responsibility i18n keys can be
331
- * resolved by the plugin before registration.
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.
332
638
  */
639
+ fetchProfile(tokens: OAuthTokens): Promise<AuthVerifyResult>;
640
+ }
641
+ /** OIDC = OAuth 2.0 + standardised discovery + id_token claims. */
642
+ interface OidcAuthDriver {
643
+ kind: 'oidc';
333
644
  buttonLabel: string;
334
- /** Optional icon URL for the login button. */
335
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>;
336
661
  /**
337
- * Map provider-specific verification data into a normalised profile.
338
- * Called from inside the plugin's own callback route, with whatever
339
- * shape that route extracted. Typed as `unknown` here because the
340
- * 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).
341
665
  */
342
- 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>;
343
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;
344
691
  interface AuthRegistry {
345
692
  register(driverName: string, driver: AuthDriver): void;
346
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;
347
732
 
348
733
  /**
349
734
  * Notification payload — the runtime-neutral shape passed to every
@@ -463,52 +848,6 @@ interface MailSenderRegistry {
463
848
  register(driverName: string, driver: MailSender): void;
464
849
  }
465
850
 
466
- /**
467
- * Domain events emitted by core. The full event payload shapes live in
468
- * `@crowi/server`; this contract publishes only the event names so the
469
- * type signature of `EventBus.on` stays type-safe at the plugin layer.
470
- *
471
- * `pluginHooks` are the v2.0 internal-use-only events. Community
472
- * plugins should NOT subscribe — the surface is reserved while we
473
- * stabilise it.
474
- */
475
- interface PluginEvents {
476
- 'page:created': {
477
- pageId: string;
478
- path: string;
479
- };
480
- 'page:updated': {
481
- pageId: string;
482
- path: string;
483
- };
484
- 'page:deleted': {
485
- pageId: string;
486
- path: string;
487
- };
488
- 'page:renamed': {
489
- pageId: string;
490
- oldPath: string;
491
- newPath: string;
492
- };
493
- 'comment:added': {
494
- pageId: string;
495
- commentId: string;
496
- };
497
- 'comment:removed': {
498
- pageId: string;
499
- commentId: string;
500
- };
501
- 'user:registered': {
502
- userId: string;
503
- };
504
- 'user:activated': {
505
- userId: string;
506
- };
507
- }
508
- interface EventBus {
509
- on<K extends keyof PluginEvents>(event: K, listener: (payload: PluginEvents[K]) => void | Promise<void>): void;
510
- }
511
-
512
851
  /**
513
852
  * Renderer extension contract — type-only. Plugins contribute parse /
514
853
  * transform behaviour to the server-side markdown pipeline through
@@ -590,9 +929,64 @@ interface CodeBlockRenderer {
590
929
  * strip comments, etc.
591
930
  */
592
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';
593
955
  /** Render a single code block. */
594
956
  render(info: CodeBlockInfo, ctx: RenderContext): EmbedFragment | RenderResult | Promise<EmbedFragment | RenderResult>;
595
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
+ };
596
990
  interface CodeBlockInfo {
597
991
  /** The language tag from the fence (the `ts` in ```` ```ts ````). */
598
992
  lang: string;
@@ -636,6 +1030,41 @@ interface EmbedRenderer {
636
1030
  * volatility (`?utm_*`) or (b) include external state (Accept-Language).
637
1031
  */
638
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;
639
1068
  /** Render a single embed. */
640
1069
  render(input: EmbedInput, ctx: RenderContext): RenderResult | Promise<RenderResult>;
641
1070
  /**
@@ -667,9 +1096,36 @@ interface EmbedInput {
667
1096
  * background-refresh window (see
668
1097
  * `packages/api/src/renderer/cache/index.ts:cachedRender`).
669
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
+ }
670
1116
  interface RenderResult {
671
- /** Already-sanitised HTML the core will inline. */
1117
+ /** Already-sanitised HTML the core will inline. Unchanged — the one and only web/legacy representation. */
672
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;
673
1129
  /**
674
1130
  * Optional `<head>`-bound assets — Phase 4 records them on the
675
1131
  * cache entry but the SSR layer does not yet inject them. Phase 7
@@ -689,19 +1145,43 @@ interface RenderResult {
689
1145
  ttlSec?: number;
690
1146
  /**
691
1147
  * When the render failed (network / auth / not_found / rate_limit /
692
- * timeout / unknown), plugins should set `error` instead of building
693
- * an html error frame. The core caches the error using `RENDER_ERROR_TTL`
694
- * 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.
695
1152
  */
696
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;
697
1170
  }
698
1171
  /**
699
1172
  * Error categories cached with their own per-code TTLs. See
700
1173
  * `packages/api/src/renderer/cache/index.ts:RENDER_ERROR_TTL` for the
701
- * 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.
702
1182
  */
703
1183
  interface RenderError {
704
- code: 'auth' | 'rate_limit' | 'not_found' | 'network' | 'timeout' | 'unknown';
1184
+ code: 'auth' | 'rate_limit' | 'not_found' | 'network' | 'timeout' | 'unknown' | 'blocked' | 'busy';
705
1185
  /** Free-form text for log/debug — NOT inlined into the user-facing placeholder. */
706
1186
  message?: string;
707
1187
  /**
@@ -764,6 +1244,8 @@ type InlineExpansion = {
764
1244
  interface EmbedFragment {
765
1245
  /** Pre-sanitised HTML fragment to inline at the source position. */
766
1246
  html: string;
1247
+ /** RFC-0023 — optional structured payload paired with `html` (see `RenderResult.structured`). */
1248
+ structured?: StructuredRenderPayload;
767
1249
  /** Optional `<head>`-bound assets (CSS / JS) keyed by URL. */
768
1250
  assets?: {
769
1251
  css?: string[];
@@ -794,6 +1276,17 @@ interface CacheEntry {
794
1276
  result: RenderResult;
795
1277
  fetchedAt: Date;
796
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;
797
1290
  }
798
1291
  /**
799
1292
  * MongoDB-backed cache surface. Phase 4 ships exactly one
@@ -886,6 +1379,25 @@ interface RenderContext {
886
1379
  * encrypted-config-backed implementation.
887
1380
  */
888
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;
889
1401
  }
890
1402
  /**
891
1403
  * The registry handed to every plugin's `registerRenderer(scope, ctx)`.
@@ -901,6 +1413,10 @@ interface RenderContext {
901
1413
  *
902
1414
  * Phase 4 stubs (warn-noop):
903
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).
904
1420
  */
905
1421
  interface RendererRegistry {
906
1422
  /**
@@ -939,6 +1455,35 @@ interface RendererRegistry {
939
1455
  * preserved; the first match that returns `'replaced'` wins.
940
1456
  */
941
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;
942
1487
  }
943
1488
 
944
1489
  /**
@@ -966,22 +1511,20 @@ type PluginRouteHandler = (c: Context) => Response | Promise<Response>;
966
1511
  /** Per-route options passed alongside the handler. */
967
1512
  interface PluginRouteOptions {
968
1513
  /**
969
- * When `true`, the route is mounted **without** `createJwtAuth`, so it
970
- * is reachable by unauthenticated requests (Crowi-auth public). Use for
971
- * inbound webhooks that authenticate themselves out-of-band — e.g. the
972
- * Slack Events API endpoint, which is gated by Slack's request-signature
973
- * check rather than a Crowi session (RFC-0013 §8).
974
- *
975
- * Omitted / `false` mounts the route under `createJwtAuth`, so it
976
- * requires a valid Crowi JWT just like a core authenticated endpoint
977
- * (admin "Test connection" / `@action` targets, OAuth callbacks).
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.
978
1521
  */
979
- public?: boolean;
1522
+ auth?: 'public' | 'user' | 'admin';
980
1523
  }
981
1524
  /**
982
1525
  * Scope passed to `registerRoutes(scope, ctx)`. Lets a plugin contribute
983
1526
  * HTTP routes that the runtime mounts at
984
- * `/api/v2/plugins/<plugin-name>/<path>` — the `<plugin-name>` path
1527
+ * `/api/plugins/<plugin-name>/<path>` — the `<plugin-name>` path
985
1528
  * segment guarantees that core endpoints and other plugins cannot
986
1529
  * collide (RFC-0013 §4).
987
1530
  *
@@ -992,17 +1535,64 @@ interface PluginRouteOptions {
992
1535
  interface PluginRouterScope {
993
1536
  /**
994
1537
  * Mount `handler` for `method` at `<path>` under this plugin's
995
- * namespace. `path` is relative to `/api/v2/plugins/<plugin-name>` and
1538
+ * namespace. `path` is relative to `/api/plugins/<plugin-name>` and
996
1539
  * should start with `/` (e.g. `route('POST', '/events', handler, {
997
- * public: true })` → `POST /api/v2/plugins/<name>/events`).
1540
+ * auth: 'public' })` → `POST /api/plugins/<name>/events`).
998
1541
  *
999
- * Pass `{ public: true }` to bypass `createJwtAuth` for self-
1000
- * authenticating inbound webhooks; omit it for routes that require a
1001
- * Crowi session.
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).
1002
1546
  */
1003
1547
  route(method: PluginRouteMethod, path: string, handler: PluginRouteHandler, opts?: PluginRouteOptions): void;
1004
1548
  }
1005
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
+ }
1006
1596
  /**
1007
1597
  * The contract every Crowi plugin satisfies. Plugins export their
1008
1598
  * `CrowiPlugin` object as the package's default export; the runtime
@@ -1033,10 +1623,55 @@ interface CrowiPlugin {
1033
1623
  * at boot and loads `requires` first; cycles fail boot.
1034
1624
  */
1035
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;
1036
1660
  /**
1037
1661
  * Zod schema describing this plugin's *global* configurable values.
1038
1662
  * The admin UI generates a config form by walking this schema.
1039
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
+ *
1040
1675
  * Mark sensitive fields with the `@sensitive` description marker
1041
1676
  * (see `SENSITIVE_FIELD_MARKER`); they are encrypted at rest via the
1042
1677
  * same KeyProvider used by core's sensitive Config.
@@ -1047,6 +1682,24 @@ interface CrowiPlugin {
1047
1682
  * the field that calls the plugin's contributed REST endpoint.
1048
1683
  */
1049
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[];
1050
1703
  /**
1051
1704
  * Per-Page metadata schema. When set, every Page document has a
1052
1705
  * `metadata['<plugin-name>']` slot whose shape matches this schema,
@@ -1092,6 +1745,14 @@ interface CrowiPlugin {
1092
1745
  label?: string;
1093
1746
  description?: string;
1094
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;
1095
1756
  /** Storage driver registration. Called once at boot. */
1096
1757
  registerStorage?: (registry: StorageRegistry, ctx: PluginContext) => void;
1097
1758
  /** Search backend registration. Called once at boot. */
@@ -1122,7 +1783,7 @@ interface CrowiPlugin {
1122
1783
  registerHooks?: (events: EventBus, ctx: PluginContext) => void;
1123
1784
  /**
1124
1785
  * HTTP routes the plugin contributes, mounted at
1125
- * `/api/v2/plugins/<name>/<path>` (the `<name>` path segment guarantees
1786
+ * `/api/plugins/<name>/<path>` (the `<name>` path segment guarantees
1126
1787
  * that core endpoints and other plugins cannot collide). Used for
1127
1788
  * inbound webhooks (Slack events / slash / interactivity), "Test
1128
1789
  * connection" buttons, `@action` targets, OAuth callbacks, etc.
@@ -1131,9 +1792,10 @@ interface CrowiPlugin {
1131
1792
  * (c) => Response, opts?)`. The handler receives the raw `Context`, so
1132
1793
  * `c.req.text()` / `c.req.raw` give the exact request bytes (no
1133
1794
  * validator consumes the body ahead of it — the Slack signature check
1134
- * relies on this). Pass `{ public: true }` to bypass `createJwtAuth`
1135
- * for self-authenticating webhooks; omit it for Crowi-session-gated
1136
- * routes.
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).
1137
1799
  *
1138
1800
  * Called once at boot — but unlike the other `register*` hooks, this
1139
1801
  * runs inside `buildHonoApp` (the Hono app does not exist yet when
@@ -1172,6 +1834,39 @@ interface CrowiPlugin {
1172
1834
  * of the very UI they need to fix the misconfiguration.
1173
1835
  */
1174
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>;
1175
1870
  }
1176
1871
 
1177
1872
  /**
@@ -1201,7 +1896,7 @@ declare const SENSITIVE_FIELD_MARKER = "@sensitive";
1201
1896
  *
1202
1897
  * The admin form renders a button with the given label that calls the
1203
1898
  * plugin's contributed endpoint at the given verb / path (relative to
1204
- * `/api/v2/plugins/<name>/`). Useful for "Test connection",
1899
+ * `/api/plugins/<name>/`). Useful for "Test connection",
1205
1900
  * "Authorise with Google", etc. without forcing every plugin to ship
1206
1901
  * its own React component.
1207
1902
  */
@@ -1220,8 +1915,8 @@ interface ActionAnnotation {
1220
1915
  /** Visible button label, e.g. "Test connection". */
1221
1916
  label: string;
1222
1917
  /** HTTP verb of the plugin endpoint to call. */
1223
- method: 'GET' | 'POST' | 'PUT' | 'DELETE';
1224
- /** Path relative to `/api/v2/plugins/<name>/`, with leading slash. */
1918
+ method: PluginRouteMethod;
1919
+ /** Path relative to `/api/plugins/<name>/`, with leading slash. */
1225
1920
  path: string;
1226
1921
  }
1227
1922
  /**
@@ -1230,10 +1925,137 @@ interface ActionAnnotation {
1230
1925
  * Format: `@action "<label>" <METHOD> <path>`
1231
1926
  * e.g. `@action "Test connection" POST /test`
1232
1927
  *
1233
- * The label may include spaces when wrapped in double quotes; the
1234
- * method is one of `GET` / `POST` / `PUT` / `DELETE`; the path begins
1235
- * 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.
1236
1936
  */
1237
1937
  declare function getActionAnnotation(field: z.ZodTypeAny): ActionAnnotation | null;
1238
1938
 
1239
- export { ACTION_FIELD_MARKER, type AppInfo, 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 PluginRouteHandler, type PluginRouteMethod, type PluginRouteOptions, 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 };