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