@crowi/plugin-api 0.1.0-alpha.2 → 1.0.0-alpha.4
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 +117 -0
- package/dist/index.d.mts +427 -90
- package/dist/index.d.ts +427 -90
- package/dist/index.js +23 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +22 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +7 -3
package/dist/index.d.mts
CHANGED
|
@@ -5,8 +5,36 @@ import { Context } from 'hono';
|
|
|
5
5
|
/**
|
|
6
6
|
* The context object passed to every plugin callback. It is the only
|
|
7
7
|
* conduit through which a plugin reads core state (config, models,
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* logging) — plugins must NOT import from `@crowi/server` directly to
|
|
9
|
+
* keep the contract surface thin.
|
|
10
|
+
*
|
|
11
|
+
* Trust boundary: a plugin only reaches what it explicitly declares, and
|
|
12
|
+
* a plugin cannot reach another plugin's or core's secrets through
|
|
13
|
+
* `PluginContext`:
|
|
14
|
+
*
|
|
15
|
+
* - `model(name)` is gated by the plugin's own `CrowiPlugin.modelAccess`
|
|
16
|
+
* allow-list (see `model()` below) — there is no ambient "any core
|
|
17
|
+
* model" access. Credential-vault models (`Config`,
|
|
18
|
+
* `PersonalAccessToken`, OAuth client/token/grant models, `Share`,
|
|
19
|
+
* `ShareAccess`) can never be granted at all: declaring one in
|
|
20
|
+
* `modelAccess` fails boot, and `model()` refuses to return one at
|
|
21
|
+
* call time even if that check were somehow bypassed.
|
|
22
|
+
* - There is intentionally no symmetric encrypt/decrypt capability on
|
|
23
|
+
* this context: the only legitimate secret-reading path is
|
|
24
|
+
* `config<T>()`, which already hands back `@sensitive` fields
|
|
25
|
+
* transparently decrypted for *this* plugin's own config.
|
|
26
|
+
* - `dependencyConfig<T>(name)` only returns another plugin's config —
|
|
27
|
+
* `@sensitive` fields included — when that plugin has explicitly
|
|
28
|
+
* opted in via `CrowiPlugin.exposesConfigToDependents: true`. Listing
|
|
29
|
+
* a plugin in `requires` is not, by itself, enough to read its config.
|
|
30
|
+
*
|
|
31
|
+
* One caveat remains, intentionally out of scope for this trust
|
|
32
|
+
* boundary: a plugin granted `modelAccess: ['User']` gets the raw
|
|
33
|
+
* Mongoose document, password hash included — there is no field
|
|
34
|
+
* projection today. Field-level read/write proxying for `User` (and any
|
|
35
|
+
* other model) is deferred to a post-2.0 repository/HTTP layer
|
|
36
|
+
* separation; until then, only grant `User` `modelAccess` to plugins you
|
|
37
|
+
* trust with that document as a whole.
|
|
10
38
|
*/
|
|
11
39
|
interface PluginContext {
|
|
12
40
|
/**
|
|
@@ -21,13 +49,19 @@ interface PluginContext {
|
|
|
21
49
|
* Read a typed dependency plugin's config. The target plugin must
|
|
22
50
|
* be listed in this plugin's `requires` array — reading another
|
|
23
51
|
* plugin's config without declaring the dependency is a contract
|
|
24
|
-
* violation and throws.
|
|
52
|
+
* violation and throws. In addition, the target plugin must have
|
|
53
|
+
* opted in with `CrowiPlugin.exposesConfigToDependents: true` —
|
|
54
|
+
* `requires` alone is only this plugin's side of the contract, not
|
|
55
|
+
* permission granted by the dependency. Throws when the dependency
|
|
56
|
+
* has not opted in.
|
|
25
57
|
*
|
|
26
|
-
* Useful for shared-credential plugins like `@crowi/plugin-aws
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* `@crowi/plugin-mail-aws-ses`)
|
|
30
|
-
*
|
|
58
|
+
* Useful for shared-credential plugins like `@crowi/plugin-aws`,
|
|
59
|
+
* which sets `exposesConfigToDependents: true` because sharing
|
|
60
|
+
* `region` / `accessKeyId` / `secretAccessKey` with dependents
|
|
61
|
+
* (`@crowi/plugin-storage-aws-s3`, `@crowi/plugin-mail-aws-ses`) is
|
|
62
|
+
* its entire purpose — they read them through this method instead of
|
|
63
|
+
* duplicating the fields in their own configSchema. Most plugins do
|
|
64
|
+
* not opt in, so most `dependencyConfig` calls against them throw.
|
|
31
65
|
*/
|
|
32
66
|
dependencyConfig<T>(dependencyName: string): T;
|
|
33
67
|
/**
|
|
@@ -42,19 +76,90 @@ interface PluginContext {
|
|
|
42
76
|
/** Per-Page metadata accessor for this plugin's namespace. */
|
|
43
77
|
pageMetadata: PageMetadataAccessor;
|
|
44
78
|
/**
|
|
45
|
-
* Mongoose model accessor
|
|
46
|
-
*
|
|
47
|
-
*
|
|
79
|
+
* Mongoose model accessor, gated by this plugin's declared
|
|
80
|
+
* `CrowiPlugin.modelAccess` allow-list. Plugins touch core
|
|
81
|
+
* collections (Page, User, Comment, ...) through this accessor
|
|
82
|
+
* rather than importing model files directly.
|
|
83
|
+
*
|
|
84
|
+
* Throws when `name` is not listed in the plugin's `modelAccess` —
|
|
85
|
+
* a plugin must declare every core model it touches. A model name
|
|
86
|
+
* listed in `modelAccess` is returned with full (unrestricted)
|
|
87
|
+
* read/write access; there is no read-only proxying. Credential-vault
|
|
88
|
+
* models (`Config`, `PersonalAccessToken`, OAuth client/token/grant
|
|
89
|
+
* models, `Share`, `ShareAccess`) can never be listed in `modelAccess`
|
|
90
|
+
* at all — declaring one fails boot, and this method also refuses to
|
|
91
|
+
* return one at call time.
|
|
92
|
+
*
|
|
93
|
+
* Caveat: `modelAccess: ['User']` hands back the raw document,
|
|
94
|
+
* password hash included — there is no field projection today (see
|
|
95
|
+
* the trust-boundary note on this interface).
|
|
48
96
|
*
|
|
49
97
|
* Typed loosely (`unknown`) at this layer because the core model
|
|
50
98
|
* types live in `@crowi/server`; plugins narrow the return type at
|
|
51
99
|
* the call site.
|
|
52
100
|
*/
|
|
53
101
|
model(name: string): unknown;
|
|
54
|
-
/** Symmetric encrypt / decrypt against the configured KeyProvider. */
|
|
55
|
-
crypto: PluginCrypto;
|
|
56
102
|
/** Structured logger scoped to this plugin (auto-prefixed with name). */
|
|
57
103
|
log: PluginLogger;
|
|
104
|
+
/**
|
|
105
|
+
* Hot-reload state primitive. Returns a {@link StateCell} that holds a
|
|
106
|
+
* mutable value — the driver-owned resource (an S3 client, an SMTP
|
|
107
|
+
* transport, a search client, ...) that `reconfigure` rebuilds when
|
|
108
|
+
* admin saves new config. Every call across every `PluginContext`
|
|
109
|
+
* instance for this plugin (the activation-time `ctx` passed to
|
|
110
|
+
* `registerStorage`/`registerSearch`/`registerMailSender` etc., and
|
|
111
|
+
* every later `reconfigure(ctx)` call) returns the **same** cell — the
|
|
112
|
+
* runtime keys it by plugin name, not by `ctx` instance. `initial` is
|
|
113
|
+
* only used the first time this plugin ever calls `state()`; later
|
|
114
|
+
* calls ignore it and just return the existing cell.
|
|
115
|
+
*
|
|
116
|
+
* Use this instead of a module-scope `let`/`const` — it protects
|
|
117
|
+
* in-flight `withValue()` callers from a concurrent `set()` swapping
|
|
118
|
+
* the value out from under them, and gives `set()`'s `dispose` option
|
|
119
|
+
* a correct place to tear down the previous value (close a client,
|
|
120
|
+
* end a connection pool, ...) once nothing is still using it.
|
|
121
|
+
*/
|
|
122
|
+
state<T>(initial: T): StateCell<T>;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* A hot-reload-safe mutable cell, returned by `PluginContext.state()`.
|
|
126
|
+
* Designed for driver plugins (storage / search / mail / ...) that
|
|
127
|
+
* `reconfigure()` rebuilds a stateful resource for: `withValue()` marks
|
|
128
|
+
* the current value "in use" for the duration of the callback so a
|
|
129
|
+
* concurrent `set()` cannot tear it down mid-call, and `set()`'s
|
|
130
|
+
* `dispose` option only runs once every such in-flight caller has
|
|
131
|
+
* settled.
|
|
132
|
+
*/
|
|
133
|
+
interface StateCell<T> {
|
|
134
|
+
/**
|
|
135
|
+
* Atomic snapshot of the current value. Safe to read once and reuse
|
|
136
|
+
* across `await`s in the caller — but prefer {@link withValue} when the
|
|
137
|
+
* value may be disposed (e.g. an SDK client that `dispose` closes),
|
|
138
|
+
* since `get()` gives no in-flight protection.
|
|
139
|
+
*/
|
|
140
|
+
get(): T;
|
|
141
|
+
/**
|
|
142
|
+
* Run `fn` against the current value while marking it "in use", so a
|
|
143
|
+
* concurrent `set()`'s `dispose` waits for `fn` to settle (resolve or
|
|
144
|
+
* reject) before tearing down the value `fn` captured. This is the
|
|
145
|
+
* primary way driver methods should read the cell.
|
|
146
|
+
*/
|
|
147
|
+
withValue<R>(fn: (value: T) => R | Promise<R>): Promise<R>;
|
|
148
|
+
/**
|
|
149
|
+
* Swap in `next`. If `opts.dispose` is given, it runs — asynchronously,
|
|
150
|
+
* never inline — once every `withValue()` call that was in flight
|
|
151
|
+
* against the previous value at the moment of the swap has settled
|
|
152
|
+
* (immediately, on the next microtask, if none were in flight).
|
|
153
|
+
*
|
|
154
|
+
* `dispose` must handle (and log, if relevant) its own errors — a
|
|
155
|
+
* rejected `dispose` is swallowed by the runtime rather than
|
|
156
|
+
* surfaced anywhere, since there is no caller left waiting on it by
|
|
157
|
+
* the time it runs. Wrap the teardown in its own `try`/`catch` (or
|
|
158
|
+
* `.catch()`) instead of letting it throw.
|
|
159
|
+
*/
|
|
160
|
+
set(next: T, opts?: {
|
|
161
|
+
dispose?: (prev: T) => void | Promise<void>;
|
|
162
|
+
}): void;
|
|
58
163
|
}
|
|
59
164
|
/**
|
|
60
165
|
* Read-only view of core application settings exposed to plugins via
|
|
@@ -94,10 +199,6 @@ interface PageMetadataAccessor {
|
|
|
94
199
|
/** Remove this plugin's metadata for a specific page. */
|
|
95
200
|
remove(pageId: string): Promise<void>;
|
|
96
201
|
}
|
|
97
|
-
interface PluginCrypto {
|
|
98
|
-
encrypt(plaintext: string): string;
|
|
99
|
-
decrypt(ciphertext: string): string;
|
|
100
|
-
}
|
|
101
202
|
interface PluginLogger {
|
|
102
203
|
debug(message: string, ...args: unknown[]): void;
|
|
103
204
|
info(message: string, ...args: unknown[]): void;
|
|
@@ -105,6 +206,66 @@ interface PluginLogger {
|
|
|
105
206
|
error(message: string, ...args: unknown[]): void;
|
|
106
207
|
}
|
|
107
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Domain events emitted by core. The full event payload shapes live in
|
|
211
|
+
* `@crowi/server`; this contract publishes only the event names so the
|
|
212
|
+
* type signature of `EventBus.on` stays type-safe at the plugin layer.
|
|
213
|
+
*
|
|
214
|
+
* `pluginHooks` are the v2.0 internal-use-only events. Community
|
|
215
|
+
* plugins should NOT subscribe — the surface is reserved while we
|
|
216
|
+
* stabilise it.
|
|
217
|
+
*/
|
|
218
|
+
interface PluginEvents {
|
|
219
|
+
'page:created': {
|
|
220
|
+
pageId: string;
|
|
221
|
+
path: string;
|
|
222
|
+
};
|
|
223
|
+
'page:updated': {
|
|
224
|
+
pageId: string;
|
|
225
|
+
path: string;
|
|
226
|
+
};
|
|
227
|
+
'page:deleted': {
|
|
228
|
+
pageId: string;
|
|
229
|
+
path: string;
|
|
230
|
+
};
|
|
231
|
+
'page:renamed': {
|
|
232
|
+
pageId: string;
|
|
233
|
+
oldPath: string;
|
|
234
|
+
newPath: string;
|
|
235
|
+
};
|
|
236
|
+
'comment:added': {
|
|
237
|
+
pageId: string;
|
|
238
|
+
commentId: string;
|
|
239
|
+
};
|
|
240
|
+
'comment:removed': {
|
|
241
|
+
pageId: string;
|
|
242
|
+
commentId: string;
|
|
243
|
+
};
|
|
244
|
+
'user:registered': {
|
|
245
|
+
userId: string;
|
|
246
|
+
};
|
|
247
|
+
'user:activated': {
|
|
248
|
+
userId: string;
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
interface EventBus {
|
|
252
|
+
on<K extends keyof PluginEvents>(event: K, listener: (payload: PluginEvents[K]) => void | Promise<void>): void;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* HTML-emitting helper for renderer plugins.
|
|
257
|
+
*
|
|
258
|
+
* A renderer plugin that builds HTML from author-controlled or external
|
|
259
|
+
* strings (an OGP title, a math error message, …) must escape them, and
|
|
260
|
+
* that escape is a security primitive — a hardening change has to reach
|
|
261
|
+
* every plugin at once, not whichever local copies someone remembers.
|
|
262
|
+
* This is the SDK's single copy (`@crowi/plugin-renderer-katex` and
|
|
263
|
+
* `@crowi/plugin-renderer-link-card` each carried an identical local one
|
|
264
|
+
* before it was hoisted here).
|
|
265
|
+
*/
|
|
266
|
+
/** Escape `&` `<` `>` `"` `'` for interpolation into HTML text or double/single-quoted attribute values. */
|
|
267
|
+
declare function escapeHtml(s: string): string;
|
|
268
|
+
|
|
108
269
|
/**
|
|
109
270
|
* Metadata accompanying a `put`. The runtime always provides
|
|
110
271
|
* `contentType`; drivers are free to store additional fields under
|
|
@@ -463,52 +624,6 @@ interface MailSenderRegistry {
|
|
|
463
624
|
register(driverName: string, driver: MailSender): void;
|
|
464
625
|
}
|
|
465
626
|
|
|
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
627
|
/**
|
|
513
628
|
* Renderer extension contract — type-only. Plugins contribute parse /
|
|
514
629
|
* transform behaviour to the server-side markdown pipeline through
|
|
@@ -590,9 +705,64 @@ interface CodeBlockRenderer {
|
|
|
590
705
|
* strip comments, etc.
|
|
591
706
|
*/
|
|
592
707
|
computeEmbedKey?(info: CodeBlockInfo): string;
|
|
708
|
+
/**
|
|
709
|
+
* Opt into a CPU-bounded admission control pool (`render-admission.ts`,
|
|
710
|
+
* `packages/api/src/renderer/core/render-admission.ts`). When present,
|
|
711
|
+
* `cachedRenderOrPending` (`packages/api/src/renderer/cache/index.ts`)
|
|
712
|
+
* and `renderCodeBlockForPreview` acquire a ticket from the named pool
|
|
713
|
+
* (keyed per `pluginName`) immediately around the `render()` call and
|
|
714
|
+
* release it on completion; when absent (the default — PlantUML /
|
|
715
|
+
* KaTeX / emoji and existing embed/URL-expansion plugins), `render()`
|
|
716
|
+
* is called directly with no admission gate, matching today's
|
|
717
|
+
* behaviour. See spec §6 for the full design (global / per-user
|
|
718
|
+
* concurrency caps + priority queue).
|
|
719
|
+
*/
|
|
720
|
+
admissionControl?: AdmissionControlConfig;
|
|
721
|
+
/**
|
|
722
|
+
* Opt into server-rendering during editor live preview
|
|
723
|
+
* (`POST /pages/preview`, which runs with no `pageId`). Default
|
|
724
|
+
* `'source'` (omitted) leaves the fenced block untouched in preview —
|
|
725
|
+
* today's behaviour for every existing `CodeBlockRenderer`. Plugins
|
|
726
|
+
* that declare `'server-render'` MUST be no-I/O and deterministic:
|
|
727
|
+
* `makePreviewCodeBlockDispatch` calls them outside the persisted-
|
|
728
|
+
* cache path (`packages/api/src/renderer/core/code-block-dispatch.ts`).
|
|
729
|
+
*/
|
|
730
|
+
previewPolicy?: 'source' | 'server-render';
|
|
593
731
|
/** Render a single code block. */
|
|
594
732
|
render(info: CodeBlockInfo, ctx: RenderContext): EmbedFragment | RenderResult | Promise<EmbedFragment | RenderResult>;
|
|
595
733
|
}
|
|
734
|
+
/**
|
|
735
|
+
* Per-`pluginName` admission-control pool declaration (§6). Shared by
|
|
736
|
+
* `CodeBlockRenderer` and `EmbedRenderer` — `EmbedRenderer` carries it so
|
|
737
|
+
* `code-block-dispatch.ts`'s `codeBlockAsEmbedRenderer` adaptor can copy
|
|
738
|
+
* `CodeBlockRenderer.admissionControl` straight through to the
|
|
739
|
+
* `EmbedRenderer` shape `cachedRenderOrPending` actually consumes.
|
|
740
|
+
*/
|
|
741
|
+
interface AdmissionControlConfig {
|
|
742
|
+
/** Process-wide concurrent `render()` calls in flight for this plugin. */
|
|
743
|
+
maxConcurrentGlobal: number;
|
|
744
|
+
/** Concurrent `render()` calls in flight for a single `actor` (kind:'user' only). */
|
|
745
|
+
maxConcurrentPerUser: number;
|
|
746
|
+
/** Max jobs allowed to wait for a slot before new requests are rejected outright. */
|
|
747
|
+
queueDepth: number;
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Who is driving this render call. Threaded through `RenderContext` so
|
|
751
|
+
* admission control (§6) can apply a per-user concurrency cap. Today
|
|
752
|
+
* every real call site is authenticated (`createJwtAuth` has no
|
|
753
|
+
* anonymous fallback), so `'user'` is the only variant actually
|
|
754
|
+
* produced — `'anonymous'` / `'system'` are reserved for future
|
|
755
|
+
* unauthenticated-read and offline-tooling call sites and must not be
|
|
756
|
+
* synthesised speculatively.
|
|
757
|
+
*/
|
|
758
|
+
type RenderActor = {
|
|
759
|
+
kind: 'user';
|
|
760
|
+
userId: string;
|
|
761
|
+
} | {
|
|
762
|
+
kind: 'anonymous';
|
|
763
|
+
} | {
|
|
764
|
+
kind: 'system';
|
|
765
|
+
};
|
|
596
766
|
interface CodeBlockInfo {
|
|
597
767
|
/** The language tag from the fence (the `ts` in ```` ```ts ````). */
|
|
598
768
|
lang: string;
|
|
@@ -636,6 +806,41 @@ interface EmbedRenderer {
|
|
|
636
806
|
* volatility (`?utm_*`) or (b) include external state (Accept-Language).
|
|
637
807
|
*/
|
|
638
808
|
computeEmbedKey?(input: EmbedInput): string;
|
|
809
|
+
/**
|
|
810
|
+
* Opt into admission control (§6). See `CodeBlockRenderer.admissionControl`
|
|
811
|
+
* for the full rationale — declared here too because `cachedRenderOrPending`
|
|
812
|
+
* (`packages/api/src/renderer/cache/index.ts`) is written against the
|
|
813
|
+
* `EmbedRenderer` shape (`code-block-dispatch.ts`'s
|
|
814
|
+
* `codeBlockAsEmbedRenderer` adaptor copies a `CodeBlockRenderer`'s
|
|
815
|
+
* declaration through unchanged). Native `EmbedRenderer` plugins
|
|
816
|
+
* (embed-tags / URL-inline-expansion) can also opt in if a future one
|
|
817
|
+
* turns out to be CPU-bound.
|
|
818
|
+
*/
|
|
819
|
+
admissionControl?: AdmissionControlConfig;
|
|
820
|
+
/**
|
|
821
|
+
* Optional per-dispatch cache-bypass predicate
|
|
822
|
+
* (feature-renderer-plugin-boundary Phase 3). Checked by the generic
|
|
823
|
+
* embed-tag dispatcher (`packages/api/src/renderer/core/embed-tags.ts`)
|
|
824
|
+
* BEFORE it touches `CacheStorage` at all for this dispatch — no
|
|
825
|
+
* `get`, no `set`. When it returns `true`, the dispatcher calls
|
|
826
|
+
* `render()` directly (via the same `normalizeRenderResult` error
|
|
827
|
+
* normalisation the preview path uses) and never persists the
|
|
828
|
+
* result.
|
|
829
|
+
*
|
|
830
|
+
* This exists because a renderer whose behaviour is gated by a
|
|
831
|
+
* runtime policy toggle (e.g. link-card's admin
|
|
832
|
+
* `security:linkCardEnabled` switch) cannot enforce a literal
|
|
833
|
+
* zero-cache-access guarantee by checking the toggle only inside
|
|
834
|
+
* `render()` — a cache HIT from before the toggle flipped would
|
|
835
|
+
* short-circuit `render()` entirely and keep serving pre-toggle
|
|
836
|
+
* output (and, symmetrically, writing a toggled-off result to the
|
|
837
|
+
* cache would keep serving it for up to that entry's TTL after the
|
|
838
|
+
* toggle flips back). Declaring the check here instead makes the
|
|
839
|
+
* dispatcher skip the cache outright for that one call. Absent (the
|
|
840
|
+
* default) or returning `false` goes through the normal cached path
|
|
841
|
+
* unchanged.
|
|
842
|
+
*/
|
|
843
|
+
shouldBypassCache?(input: EmbedInput): boolean;
|
|
639
844
|
/** Render a single embed. */
|
|
640
845
|
render(input: EmbedInput, ctx: RenderContext): RenderResult | Promise<RenderResult>;
|
|
641
846
|
/**
|
|
@@ -689,19 +894,43 @@ interface RenderResult {
|
|
|
689
894
|
ttlSec?: number;
|
|
690
895
|
/**
|
|
691
896
|
* 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
|
|
897
|
+
* timeout / unknown / blocked), plugins should set `error` instead of
|
|
898
|
+
* building an html error frame. The core caches the error using
|
|
899
|
+
* `RENDER_ERROR_TTL` and, absent `errorHtml`, substitutes a fixed
|
|
900
|
+
* placeholder when re-rendering the page.
|
|
695
901
|
*/
|
|
696
902
|
error?: RenderError;
|
|
903
|
+
/**
|
|
904
|
+
* Optional failure-display HTML, paired with `error`. When `error` is
|
|
905
|
+
* set and `errorHtml` is present, the core shows `errorHtml` instead of
|
|
906
|
+
* the generic `errorPlaceholder()` — e.g. a link-card plugin can keep
|
|
907
|
+
* its URL clickable even when the OGP fetch failed. Same trust
|
|
908
|
+
* contract as `html`: **pre-sanitised, the core does not re-escape it**.
|
|
909
|
+
*
|
|
910
|
+
* Deliberately a separate field rather than "non-empty `html` + `error`
|
|
911
|
+
* means show `html`" — that shape makes a plugin's stray/forgotten
|
|
912
|
+
* `html` leak into the error display by accident. An explicit opt-in
|
|
913
|
+
* field means a plugin that hasn't been updated for `errorHtml` keeps
|
|
914
|
+
* the current safe-by-default behaviour (placeholder).
|
|
915
|
+
*
|
|
916
|
+
* Ignored when `error` is unset.
|
|
917
|
+
*/
|
|
918
|
+
errorHtml?: string;
|
|
697
919
|
}
|
|
698
920
|
/**
|
|
699
921
|
* Error categories cached with their own per-code TTLs. See
|
|
700
922
|
* `packages/api/src/renderer/cache/index.ts:RENDER_ERROR_TTL` for the
|
|
701
|
-
* concrete numbers.
|
|
923
|
+
* concrete numbers. `blocked` is a policy-level permanent rejection
|
|
924
|
+
* (SSRF block, disallowed scheme, disallowed content-type) — distinct
|
|
925
|
+
* from `not_found` semantically but sharing its 1h persistent-failure
|
|
926
|
+
* TTL. `busy` is a transient renderer-admission rejection (e.g. a
|
|
927
|
+
* shared fetch/render concurrency semaphore's wait queue was full, or a
|
|
928
|
+
* queued request's wait deadline elapsed) — never a property of the
|
|
929
|
+
* embed's target, so it shares a short transient TTL with
|
|
930
|
+
* `network`/`timeout` rather than `blocked`'s persistent one.
|
|
702
931
|
*/
|
|
703
932
|
interface RenderError {
|
|
704
|
-
code: 'auth' | 'rate_limit' | 'not_found' | 'network' | 'timeout' | 'unknown';
|
|
933
|
+
code: 'auth' | 'rate_limit' | 'not_found' | 'network' | 'timeout' | 'unknown' | 'blocked' | 'busy';
|
|
705
934
|
/** Free-form text for log/debug — NOT inlined into the user-facing placeholder. */
|
|
706
935
|
message?: string;
|
|
707
936
|
/**
|
|
@@ -794,6 +1023,17 @@ interface CacheEntry {
|
|
|
794
1023
|
result: RenderResult;
|
|
795
1024
|
fetchedAt: Date;
|
|
796
1025
|
expiresAt: Date;
|
|
1026
|
+
/**
|
|
1027
|
+
* Present ⇔ this is a stale-if-error entry keeping a prior success on
|
|
1028
|
+
* screen (see `packages/api/src/renderer/cache/index.ts:
|
|
1029
|
+
* STALE_IF_ERROR_MAX_AGE_SEC`): the value is that ORIGINAL success's
|
|
1030
|
+
* timestamp, carried forward unchanged across consecutive failed
|
|
1031
|
+
* retries — never the failed attempt's `fetchedAt`. Success entries do
|
|
1032
|
+
* not carry it (their `fetchedAt` IS the last-good time; readers use
|
|
1033
|
+
* that directly, which also covers, value-identically, entries written
|
|
1034
|
+
* while this field was still being set on success).
|
|
1035
|
+
*/
|
|
1036
|
+
lastGoodFetchedAt?: Date;
|
|
797
1037
|
}
|
|
798
1038
|
/**
|
|
799
1039
|
* MongoDB-backed cache surface. Phase 4 ships exactly one
|
|
@@ -886,6 +1126,25 @@ interface RenderContext {
|
|
|
886
1126
|
* encrypted-config-backed implementation.
|
|
887
1127
|
*/
|
|
888
1128
|
auth?: AuthContext;
|
|
1129
|
+
/**
|
|
1130
|
+
* Who is driving this render. Required so admission control (§6) can
|
|
1131
|
+
* apply its per-user concurrency cap end-to-end — every entry point
|
|
1132
|
+
* (`Renderer.run`/`runMetadata`/`runRender`, `packages/api/src/renderer/
|
|
1133
|
+
* index.ts`) requires callers to supply this. See `RenderActor`'s doc
|
|
1134
|
+
* comment for which variant real call sites actually produce today.
|
|
1135
|
+
*/
|
|
1136
|
+
actor: RenderActor;
|
|
1137
|
+
/**
|
|
1138
|
+
* Optional cancellation signal, propagated from the originating HTTP
|
|
1139
|
+
* request (`c.req.raw.signal` on `POST /pages/preview`). A waiting
|
|
1140
|
+
* (not-yet-running) admission-control job is removed from its queue
|
|
1141
|
+
* the instant this fires; an already-running child-process render is
|
|
1142
|
+
* NOT force-killed (§6 — the cost of killing/respawning a worker
|
|
1143
|
+
* outweighs letting an already-cheap render finish and discarding the
|
|
1144
|
+
* result). Absent on the save / read call sites, which have no
|
|
1145
|
+
* request to cancel against.
|
|
1146
|
+
*/
|
|
1147
|
+
signal?: AbortSignal;
|
|
889
1148
|
}
|
|
890
1149
|
/**
|
|
891
1150
|
* The registry handed to every plugin's `registerRenderer(scope, ctx)`.
|
|
@@ -901,6 +1160,10 @@ interface RenderContext {
|
|
|
901
1160
|
*
|
|
902
1161
|
* Phase 4 stubs (warn-noop):
|
|
903
1162
|
* - `addCodeBlockRenderer` (Phase 6 lights this up)
|
|
1163
|
+
*
|
|
1164
|
+
* feature-renderer-plugin-boundary Phase 1 adds `addStylesheet(path)` —
|
|
1165
|
+
* the boot-time CSS-manifest extension point (see that method's own doc
|
|
1166
|
+
* comment).
|
|
904
1167
|
*/
|
|
905
1168
|
interface RendererRegistry {
|
|
906
1169
|
/**
|
|
@@ -939,6 +1202,30 @@ interface RendererRegistry {
|
|
|
939
1202
|
* preserved; the first match that returns `'replaced'` wins.
|
|
940
1203
|
*/
|
|
941
1204
|
addUrlInlineExpander(rule: UrlInlineExpansionRule): void;
|
|
1205
|
+
/**
|
|
1206
|
+
* Declare a static CSS asset the plugin needs the browser to load
|
|
1207
|
+
* (e.g. KaTeX's ~30KB math stylesheet). `path` MUST be an
|
|
1208
|
+
* API-relative absolute path confined to the plugin's own
|
|
1209
|
+
* `registerRoutes` namespace — `/api/v2/plugins/<this plugin's
|
|
1210
|
+
* name>/<…>` — the same prefix `PluginRouterScope.route(...)` mounts
|
|
1211
|
+
* that plugin's HTTP routes under. A URL scheme, protocol-relative
|
|
1212
|
+
* `//host`, backslash, `..` traversal segment, or a path outside the
|
|
1213
|
+
* plugin's own namespace all throw synchronously (boot-time reject —
|
|
1214
|
+
* this is not an operator-configurable external URL; see spec
|
|
1215
|
+
* §2.1's "不採用案").
|
|
1216
|
+
*
|
|
1217
|
+
* The call only stages the path in a per-plugin pending set: it is
|
|
1218
|
+
* published to the public `GET /api/v2/app/info` `rendererStylesheets`
|
|
1219
|
+
* manifest ONLY after this plugin's OWN `registerRoutes(scope, ctx)`
|
|
1220
|
+
* completes without throwing (so the manifest never advertises a path
|
|
1221
|
+
* whose route failed to mount). A plugin with no `registerRoutes` at
|
|
1222
|
+
* all, or whose `registerRoutes` throws, never gets its pending
|
|
1223
|
+
* stylesheets committed — dropped wholesale, not partially. Query /
|
|
1224
|
+
* fragment are allowed; duplicate calls with the same path are a
|
|
1225
|
+
* no-op. Call this from `registerRenderer`, not `registerRoutes` —
|
|
1226
|
+
* commit timing depends on this method having already run.
|
|
1227
|
+
*/
|
|
1228
|
+
addStylesheet(path: string): void;
|
|
942
1229
|
}
|
|
943
1230
|
|
|
944
1231
|
/**
|
|
@@ -966,17 +1253,15 @@ type PluginRouteHandler = (c: Context) => Response | Promise<Response>;
|
|
|
966
1253
|
/** Per-route options passed alongside the handler. */
|
|
967
1254
|
interface PluginRouteOptions {
|
|
968
1255
|
/**
|
|
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).
|
|
1256
|
+
* Authorization tier this route requires.
|
|
1257
|
+
* - `'public'`: no auth (self-authenticating webhooks — Slack signature
|
|
1258
|
+
* check etc.).
|
|
1259
|
+
* - `'user'` (default): any authenticated Crowi user (`createJwtAuth`).
|
|
1260
|
+
* - `'admin'`: `user.admin === true` (`createJwtAdminRequired`) — use for
|
|
1261
|
+
* Test-connection / `@action` targets reached only from the admin
|
|
1262
|
+
* config form.
|
|
978
1263
|
*/
|
|
979
|
-
|
|
1264
|
+
auth?: 'public' | 'user' | 'admin';
|
|
980
1265
|
}
|
|
981
1266
|
/**
|
|
982
1267
|
* Scope passed to `registerRoutes(scope, ctx)`. Lets a plugin contribute
|
|
@@ -994,11 +1279,12 @@ interface PluginRouterScope {
|
|
|
994
1279
|
* Mount `handler` for `method` at `<path>` under this plugin's
|
|
995
1280
|
* namespace. `path` is relative to `/api/v2/plugins/<plugin-name>` and
|
|
996
1281
|
* should start with `/` (e.g. `route('POST', '/events', handler, {
|
|
997
|
-
*
|
|
1282
|
+
* auth: 'public' })` → `POST /api/v2/plugins/<name>/events`).
|
|
998
1283
|
*
|
|
999
|
-
* Pass `{
|
|
1000
|
-
* authenticating inbound webhooks
|
|
1001
|
-
*
|
|
1284
|
+
* Pass `{ auth: 'public' }` to bypass Crowi auth entirely for self-
|
|
1285
|
+
* authenticating inbound webhooks, `{ auth: 'admin' }` to require
|
|
1286
|
+
* `user.admin === true`, or omit `opts` for the `'user'` default (any
|
|
1287
|
+
* authenticated Crowi user).
|
|
1002
1288
|
*/
|
|
1003
1289
|
route(method: PluginRouteMethod, path: string, handler: PluginRouteHandler, opts?: PluginRouteOptions): void;
|
|
1004
1290
|
}
|
|
@@ -1033,10 +1319,55 @@ interface CrowiPlugin {
|
|
|
1033
1319
|
* at boot and loads `requires` first; cycles fail boot.
|
|
1034
1320
|
*/
|
|
1035
1321
|
requires?: string[];
|
|
1322
|
+
/**
|
|
1323
|
+
* Core Mongoose model names (e.g. `['Page', 'Bookmark']`) this plugin
|
|
1324
|
+
* is allowed to reach via `ctx.model(name)`. The PluginManager
|
|
1325
|
+
* validates every entry against the set of registered core model
|
|
1326
|
+
* names at boot — an unknown name fails boot with a descriptive
|
|
1327
|
+
* error. `ctx.model(name)` throws at call time for any `name` not
|
|
1328
|
+
* listed here.
|
|
1329
|
+
*
|
|
1330
|
+
* A model listed here is granted full (unrestricted) read/write
|
|
1331
|
+
* access — there is no read-only mode. Omit or leave empty for a
|
|
1332
|
+
* plugin that never calls `ctx.model()`.
|
|
1333
|
+
*
|
|
1334
|
+
* Credential-bearing core models (`Config`, `PersonalAccessToken`,
|
|
1335
|
+
* OAuth client/token/grant models, `Share`, `ShareAccess`) can never
|
|
1336
|
+
* be listed here — declaring one fails boot, and `ctx.model()` also
|
|
1337
|
+
* refuses to return one at call time as defense-in-depth. There is no
|
|
1338
|
+
* legitimate plugin use case for touching those collections directly.
|
|
1339
|
+
*/
|
|
1340
|
+
modelAccess?: string[];
|
|
1341
|
+
/**
|
|
1342
|
+
* Opt in to letting *other* plugins read this plugin's config through
|
|
1343
|
+
* their `ctx.dependencyConfig<T>(this.name)` (they must also list this
|
|
1344
|
+
* plugin in their own `requires`). Defaults to `false` — a plugin's
|
|
1345
|
+
* config, including `@sensitive` fields, is private to itself unless
|
|
1346
|
+
* it explicitly declares this flag.
|
|
1347
|
+
*
|
|
1348
|
+
* Set this on a plugin that exists specifically to hold credentials
|
|
1349
|
+
* shared by other plugins — e.g. `@crowi/plugin-aws` sets it so
|
|
1350
|
+
* `@crowi/plugin-storage-aws-s3` and `@crowi/plugin-mail-aws-ses` can
|
|
1351
|
+
* read its `region` / `accessKeyId` / `secretAccessKey` without
|
|
1352
|
+
* duplicating them in their own `configSchema`. Most plugins should
|
|
1353
|
+
* leave this unset.
|
|
1354
|
+
*/
|
|
1355
|
+
exposesConfigToDependents?: boolean;
|
|
1036
1356
|
/**
|
|
1037
1357
|
* Zod schema describing this plugin's *global* configurable values.
|
|
1038
1358
|
* The admin UI generates a config form by walking this schema.
|
|
1039
1359
|
*
|
|
1360
|
+
* Build this with `import { z } from 'zod/v3'` — NOT the top-level
|
|
1361
|
+
* `import { z } from 'zod'` (v4). `peerDependencies: { zod: "^4" }`
|
|
1362
|
+
* only says which npm package to install; the v4 package ships a
|
|
1363
|
+
* `zod/v3` compat subpath, and that subpath's runtime shape is what
|
|
1364
|
+
* every introspection helper here (`schema-serializer.ts`,
|
|
1365
|
+
* `schema-markers.ts`, `PluginManager.listSensitiveKeys()`) actually
|
|
1366
|
+
* walks. A schema built from the top-level v4 API fails boot with an
|
|
1367
|
+
* explicit error (`PluginManager.activate()`'s config-schema guard —
|
|
1368
|
+
* see this package's README) rather than silently losing
|
|
1369
|
+
* `@sensitive` detection.
|
|
1370
|
+
*
|
|
1040
1371
|
* Mark sensitive fields with the `@sensitive` description marker
|
|
1041
1372
|
* (see `SENSITIVE_FIELD_MARKER`); they are encrypted at rest via the
|
|
1042
1373
|
* same KeyProvider used by core's sensitive Config.
|
|
@@ -1131,9 +1462,10 @@ interface CrowiPlugin {
|
|
|
1131
1462
|
* (c) => Response, opts?)`. The handler receives the raw `Context`, so
|
|
1132
1463
|
* `c.req.text()` / `c.req.raw` give the exact request bytes (no
|
|
1133
1464
|
* validator consumes the body ahead of it — the Slack signature check
|
|
1134
|
-
* relies on this). Pass `{
|
|
1135
|
-
*
|
|
1136
|
-
*
|
|
1465
|
+
* relies on this). Pass `{ auth: 'public' }` to bypass Crowi auth for
|
|
1466
|
+
* self-authenticating webhooks, `{ auth: 'admin' }` for routes that
|
|
1467
|
+
* require `user.admin === true`, or omit `opts` for the `'user'`
|
|
1468
|
+
* default (any authenticated Crowi user).
|
|
1137
1469
|
*
|
|
1138
1470
|
* Called once at boot — but unlike the other `register*` hooks, this
|
|
1139
1471
|
* runs inside `buildHonoApp` (the Hono app does not exist yet when
|
|
@@ -1220,7 +1552,7 @@ interface ActionAnnotation {
|
|
|
1220
1552
|
/** Visible button label, e.g. "Test connection". */
|
|
1221
1553
|
label: string;
|
|
1222
1554
|
/** HTTP verb of the plugin endpoint to call. */
|
|
1223
|
-
method:
|
|
1555
|
+
method: PluginRouteMethod;
|
|
1224
1556
|
/** Path relative to `/api/v2/plugins/<name>/`, with leading slash. */
|
|
1225
1557
|
path: string;
|
|
1226
1558
|
}
|
|
@@ -1230,10 +1562,15 @@ interface ActionAnnotation {
|
|
|
1230
1562
|
* Format: `@action "<label>" <METHOD> <path>`
|
|
1231
1563
|
* e.g. `@action "Test connection" POST /test`
|
|
1232
1564
|
*
|
|
1233
|
-
* The label may include spaces when wrapped in double quotes; the
|
|
1234
|
-
*
|
|
1235
|
-
*
|
|
1565
|
+
* The label may include spaces when wrapped in double quotes; the method
|
|
1566
|
+
* must be one of `PluginRouteMethod` (`GET` / `POST` — the only verbs a
|
|
1567
|
+
* plugin route can actually be mounted on, see `routes.ts`); the path
|
|
1568
|
+
* begins with `/`. A description that starts with the `@action` marker
|
|
1569
|
+
* but declares an unsupported verb (e.g. `PUT` / `DELETE`) fails to match
|
|
1570
|
+
* and returns `null` here — callers that walk a plugin's `configSchema`
|
|
1571
|
+
* (e.g. `PluginManager.activate()`) are expected to warn on that case at
|
|
1572
|
+
* boot, since it would otherwise be a silent dead button.
|
|
1236
1573
|
*/
|
|
1237
1574
|
declare function getActionAnnotation(field: z.ZodTypeAny): ActionAnnotation | null;
|
|
1238
1575
|
|
|
1239
|
-
export { ACTION_FIELD_MARKER, type AppInfo, type AuthContext, type AuthDriver, type AuthProfile, type AuthRegistry, type AuthVerifyResult, type CacheEntry, type CacheKey, type CacheStorage, type CodeBlockInfo, type CodeBlockRenderer, type CrowiPlugin, type EmailMessage, type EmbedFragment, type EmbedInput, type EmbedRenderer, type EventBus, type InlineExpansion, type MailSender, type MailSenderRegistry, type NodeRenderer, type NotificationPayload, type NotifierDriver, type NotifierRegistry, type PageMetadataAccessor, type PluginContext, type
|
|
1576
|
+
export { ACTION_FIELD_MARKER, type AdmissionControlConfig, type AppInfo, type AuthContext, type AuthDriver, type AuthProfile, type AuthRegistry, type AuthVerifyResult, type CacheEntry, type CacheKey, type CacheStorage, type CodeBlockInfo, type CodeBlockRenderer, type CrowiPlugin, type EmailMessage, type EmbedFragment, type EmbedInput, type EmbedRenderer, type EventBus, type InlineExpansion, type MailSender, type MailSenderRegistry, type NodeRenderer, type NotificationPayload, type NotifierDriver, type NotifierRegistry, type PageMetadataAccessor, type PluginContext, type PluginEvents, type PluginLogger, type PluginRouteHandler, type PluginRouteMethod, type PluginRouteOptions, type PluginRouterScope, type RenderActor, type RenderContext, type RenderError, type RenderPhase, type RenderResult, type RendererRegistry, type Reservation, SENSITIVE_FIELD_MARKER, type ScopedCacheStorage, type SearchDriver, type SearchHit, type SearchHits, type SearchPageType, type SearchQuery, type SearchQueryGrants, type SearchQueryViewer, type SearchRegistry, type SearchableDoc, type StateCell, type StorageDriver, type StoragePutMeta, type StoragePutResult, type StorageRegistry, type UrlInlineExpansionRule, escapeHtml, getActionAnnotation, isSensitiveField };
|