@crowi/plugin-api 0.1.0-alpha.2 → 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/README.md +151 -0
- package/dist/index.d.mts +940 -118
- package/dist/index.d.ts +940 -118
- package/dist/index.js +360 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +343 -2
- package/dist/index.mjs.map +1 -1
- package/package.json +15 -6
package/dist/index.d.mts
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
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* `@crowi/plugin-mail-aws-ses`)
|
|
30
|
-
*
|
|
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
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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
|
|
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
|
-
*
|
|
317
|
-
*
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
|
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
|
-
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
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
|
-
*
|
|
338
|
-
*
|
|
339
|
-
*
|
|
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
|
-
|
|
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
|
|
693
|
-
* an html error frame. The core caches the error using
|
|
694
|
-
* and substitutes a fixed
|
|
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
|
-
*
|
|
970
|
-
*
|
|
971
|
-
*
|
|
972
|
-
*
|
|
973
|
-
*
|
|
974
|
-
*
|
|
975
|
-
*
|
|
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
|
-
|
|
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/
|
|
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/
|
|
1538
|
+
* namespace. `path` is relative to `/api/plugins/<plugin-name>` and
|
|
996
1539
|
* should start with `/` (e.g. `route('POST', '/events', handler, {
|
|
997
|
-
*
|
|
1540
|
+
* auth: 'public' })` → `POST /api/plugins/<name>/events`).
|
|
998
1541
|
*
|
|
999
|
-
* Pass `{
|
|
1000
|
-
* authenticating inbound webhooks
|
|
1001
|
-
*
|
|
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/
|
|
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 `{
|
|
1135
|
-
*
|
|
1136
|
-
*
|
|
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/
|
|
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:
|
|
1224
|
-
/** Path relative to `/api/
|
|
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
|
-
*
|
|
1235
|
-
*
|
|
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
|
-
|
|
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 };
|