@crowi/plugin-api 0.1.0-alpha.2 → 1.0.0-alpha.3
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 +190 -39
- package/dist/index.d.ts +190 -39
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -2
package/README.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# @crowi/plugin-api
|
|
2
|
+
|
|
3
|
+
Type-only contract for Crowi 2.0 plugins. A Crowi plugin is an ordinary
|
|
4
|
+
npm package that default-exports an object satisfying the `CrowiPlugin`
|
|
5
|
+
type from this package; the runtime loads it via `await
|
|
6
|
+
import('<plugin-name>')` at boot and calls each `register*` callback it
|
|
7
|
+
implements. See
|
|
8
|
+
[RFC-0001](https://github.com/crowi/crowi/blob/main/docs/rfcs/0001-plugin-architecture.md)
|
|
9
|
+
for the full design and the
|
|
10
|
+
[plugin development guide](https://crowi.wiki/docs/plugins/developing)
|
|
11
|
+
for a walkthrough.
|
|
12
|
+
|
|
13
|
+
> **Alpha notice**: Crowi v2 is under active alpha development and this
|
|
14
|
+
> package is at `0.x`. The API stability guarantee (working across every
|
|
15
|
+
> v2 minor) takes effect once it reaches `2.x`; the contract may still
|
|
16
|
+
> change during the alpha period.
|
|
17
|
+
|
|
18
|
+
## Minimal plugin
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import type { CrowiPlugin } from '@crowi/plugin-api';
|
|
22
|
+
|
|
23
|
+
const myPlugin: CrowiPlugin = {
|
|
24
|
+
name: '@example/crowi-plugin-mystorage',
|
|
25
|
+
version: '0.1.0',
|
|
26
|
+
|
|
27
|
+
registerStorage: (registry, ctx) => {
|
|
28
|
+
registry.register('mystorage', {
|
|
29
|
+
put: async (key, body, meta) => ({ key }),
|
|
30
|
+
get: async (key) => { throw new Error('not implemented'); },
|
|
31
|
+
delete: async (key) => {},
|
|
32
|
+
});
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export default myPlugin;
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`name` must match the npm package name — it doubles as the namespace
|
|
40
|
+
prefix for this plugin's config rows (`plugin:<name>:*`) and per-page
|
|
41
|
+
metadata (`page.metadata['<name>']`).
|
|
42
|
+
|
|
43
|
+
## Authoring a config schema
|
|
44
|
+
|
|
45
|
+
A plugin declares its configurable values with `configSchema`, a Zod
|
|
46
|
+
object schema. The admin UI at `/admin/plugins` walks this schema to
|
|
47
|
+
auto-generate a config form, encrypt `@sensitive`-marked fields at
|
|
48
|
+
rest, and render `@action`-marked fields with a button.
|
|
49
|
+
|
|
50
|
+
This package's `peerDependencies` declares `zod: "^4"` — that is the
|
|
51
|
+
correct **npm package** to install, because the zod v4 package is the
|
|
52
|
+
one that ships a `zod/v3` compat subpath. What `peerDependencies`
|
|
53
|
+
cannot express is *which entry point* to import from, and that part
|
|
54
|
+
matters:
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
// Correct — the v3 compat shim that the v4 package ships.
|
|
58
|
+
import { z } from 'zod/v3';
|
|
59
|
+
|
|
60
|
+
// Wrong — compiles and type-checks, but fails at plugin boot.
|
|
61
|
+
import { z } from 'zod';
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`CrowiPlugin.configSchema` is typed against `zod/v3`'s `z.ZodObject`,
|
|
65
|
+
and every runtime helper that reads a plugin's config schema
|
|
66
|
+
(`@sensitive` / `@action` marker detection, the admin form field
|
|
67
|
+
serializer, `listSensitiveKeys()`) introspects the `zod/v3` internal
|
|
68
|
+
shape (`_def.typeName`, `_def.values`, `.description`, …). A schema
|
|
69
|
+
built from the top-level `zod` (v4) API has a different internal shape
|
|
70
|
+
and none of that introspection can see through it — most importantly,
|
|
71
|
+
`@sensitive` fields stop being detected, and the value they guard would
|
|
72
|
+
be written to and read from storage as plaintext.
|
|
73
|
+
|
|
74
|
+
To turn that silent failure mode into a loud one, `PluginManager`
|
|
75
|
+
validates every plugin's `configSchema` at boot and throws if it wasn't
|
|
76
|
+
built from `zod/v3`:
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
Plugin '<name>' declares configSchema built with the top-level 'zod' (v4) API.
|
|
80
|
+
Import from 'zod/v3' instead — @crowi/plugin-api's config-schema introspection
|
|
81
|
+
requires the zod v3 compat shape (see @crowi/plugin-api README).
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
If you hit this error, the fix is always the same: change the `zod`
|
|
85
|
+
import for the file that builds `configSchema` (and `pageMetadataSchema`,
|
|
86
|
+
if you use it) to `import { z } from 'zod/v3'`. No other code needs to
|
|
87
|
+
change — `zod/v3`'s `z.object()` / `z.string()` / etc. API surface is
|
|
88
|
+
what every first-party Crowi plugin already uses.
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
import { z } from 'zod/v3';
|
|
92
|
+
import type { CrowiPlugin } from '@crowi/plugin-api';
|
|
93
|
+
|
|
94
|
+
const myPlugin: CrowiPlugin = {
|
|
95
|
+
name: '@example/crowi-plugin-mystorage',
|
|
96
|
+
version: '0.1.0',
|
|
97
|
+
|
|
98
|
+
configSchema: z.object({
|
|
99
|
+
endpoint: z.string().url().describe('Storage endpoint URL'),
|
|
100
|
+
accessKey: z.string().describe('@sensitive Access key'),
|
|
101
|
+
}),
|
|
102
|
+
|
|
103
|
+
registerStorage: (registry, ctx) => {
|
|
104
|
+
const config = ctx.config<{ endpoint: string; accessKey: string }>();
|
|
105
|
+
// ... build the driver using config
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export default myPlugin;
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## See also
|
|
113
|
+
|
|
114
|
+
- [Plugin development guide](https://crowi.wiki/docs/plugins/developing) —
|
|
115
|
+
the full walkthrough (markers, `adminPlacement`, `configI18n`,
|
|
116
|
+
`PluginContext`, dependency plugins, renderer plugins).
|
|
117
|
+
- [RFC-0001: Plugin architecture](https://github.com/crowi/crowi/blob/main/docs/rfcs/0001-plugin-architecture.md)
|
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;
|
|
@@ -966,17 +1067,15 @@ type PluginRouteHandler = (c: Context) => Response | Promise<Response>;
|
|
|
966
1067
|
/** Per-route options passed alongside the handler. */
|
|
967
1068
|
interface PluginRouteOptions {
|
|
968
1069
|
/**
|
|
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).
|
|
1070
|
+
* Authorization tier this route requires.
|
|
1071
|
+
* - `'public'`: no auth (self-authenticating webhooks — Slack signature
|
|
1072
|
+
* check etc.).
|
|
1073
|
+
* - `'user'` (default): any authenticated Crowi user (`createJwtAuth`).
|
|
1074
|
+
* - `'admin'`: `user.admin === true` (`createJwtAdminRequired`) — use for
|
|
1075
|
+
* Test-connection / `@action` targets reached only from the admin
|
|
1076
|
+
* config form.
|
|
978
1077
|
*/
|
|
979
|
-
|
|
1078
|
+
auth?: 'public' | 'user' | 'admin';
|
|
980
1079
|
}
|
|
981
1080
|
/**
|
|
982
1081
|
* Scope passed to `registerRoutes(scope, ctx)`. Lets a plugin contribute
|
|
@@ -994,11 +1093,12 @@ interface PluginRouterScope {
|
|
|
994
1093
|
* Mount `handler` for `method` at `<path>` under this plugin's
|
|
995
1094
|
* namespace. `path` is relative to `/api/v2/plugins/<plugin-name>` and
|
|
996
1095
|
* should start with `/` (e.g. `route('POST', '/events', handler, {
|
|
997
|
-
*
|
|
1096
|
+
* auth: 'public' })` → `POST /api/v2/plugins/<name>/events`).
|
|
998
1097
|
*
|
|
999
|
-
* Pass `{
|
|
1000
|
-
* authenticating inbound webhooks
|
|
1001
|
-
*
|
|
1098
|
+
* Pass `{ auth: 'public' }` to bypass Crowi auth entirely for self-
|
|
1099
|
+
* authenticating inbound webhooks, `{ auth: 'admin' }` to require
|
|
1100
|
+
* `user.admin === true`, or omit `opts` for the `'user'` default (any
|
|
1101
|
+
* authenticated Crowi user).
|
|
1002
1102
|
*/
|
|
1003
1103
|
route(method: PluginRouteMethod, path: string, handler: PluginRouteHandler, opts?: PluginRouteOptions): void;
|
|
1004
1104
|
}
|
|
@@ -1033,10 +1133,55 @@ interface CrowiPlugin {
|
|
|
1033
1133
|
* at boot and loads `requires` first; cycles fail boot.
|
|
1034
1134
|
*/
|
|
1035
1135
|
requires?: string[];
|
|
1136
|
+
/**
|
|
1137
|
+
* Core Mongoose model names (e.g. `['Page', 'Bookmark']`) this plugin
|
|
1138
|
+
* is allowed to reach via `ctx.model(name)`. The PluginManager
|
|
1139
|
+
* validates every entry against the set of registered core model
|
|
1140
|
+
* names at boot — an unknown name fails boot with a descriptive
|
|
1141
|
+
* error. `ctx.model(name)` throws at call time for any `name` not
|
|
1142
|
+
* listed here.
|
|
1143
|
+
*
|
|
1144
|
+
* A model listed here is granted full (unrestricted) read/write
|
|
1145
|
+
* access — there is no read-only mode. Omit or leave empty for a
|
|
1146
|
+
* plugin that never calls `ctx.model()`.
|
|
1147
|
+
*
|
|
1148
|
+
* Credential-bearing core models (`Config`, `PersonalAccessToken`,
|
|
1149
|
+
* OAuth client/token/grant models, `Share`, `ShareAccess`) can never
|
|
1150
|
+
* be listed here — declaring one fails boot, and `ctx.model()` also
|
|
1151
|
+
* refuses to return one at call time as defense-in-depth. There is no
|
|
1152
|
+
* legitimate plugin use case for touching those collections directly.
|
|
1153
|
+
*/
|
|
1154
|
+
modelAccess?: string[];
|
|
1155
|
+
/**
|
|
1156
|
+
* Opt in to letting *other* plugins read this plugin's config through
|
|
1157
|
+
* their `ctx.dependencyConfig<T>(this.name)` (they must also list this
|
|
1158
|
+
* plugin in their own `requires`). Defaults to `false` — a plugin's
|
|
1159
|
+
* config, including `@sensitive` fields, is private to itself unless
|
|
1160
|
+
* it explicitly declares this flag.
|
|
1161
|
+
*
|
|
1162
|
+
* Set this on a plugin that exists specifically to hold credentials
|
|
1163
|
+
* shared by other plugins — e.g. `@crowi/plugin-aws` sets it so
|
|
1164
|
+
* `@crowi/plugin-storage-aws-s3` and `@crowi/plugin-mail-aws-ses` can
|
|
1165
|
+
* read its `region` / `accessKeyId` / `secretAccessKey` without
|
|
1166
|
+
* duplicating them in their own `configSchema`. Most plugins should
|
|
1167
|
+
* leave this unset.
|
|
1168
|
+
*/
|
|
1169
|
+
exposesConfigToDependents?: boolean;
|
|
1036
1170
|
/**
|
|
1037
1171
|
* Zod schema describing this plugin's *global* configurable values.
|
|
1038
1172
|
* The admin UI generates a config form by walking this schema.
|
|
1039
1173
|
*
|
|
1174
|
+
* Build this with `import { z } from 'zod/v3'` — NOT the top-level
|
|
1175
|
+
* `import { z } from 'zod'` (v4). `peerDependencies: { zod: "^4" }`
|
|
1176
|
+
* only says which npm package to install; the v4 package ships a
|
|
1177
|
+
* `zod/v3` compat subpath, and that subpath's runtime shape is what
|
|
1178
|
+
* every introspection helper here (`schema-serializer.ts`,
|
|
1179
|
+
* `schema-markers.ts`, `PluginManager.listSensitiveKeys()`) actually
|
|
1180
|
+
* walks. A schema built from the top-level v4 API fails boot with an
|
|
1181
|
+
* explicit error (`PluginManager.activate()`'s config-schema guard —
|
|
1182
|
+
* see this package's README) rather than silently losing
|
|
1183
|
+
* `@sensitive` detection.
|
|
1184
|
+
*
|
|
1040
1185
|
* Mark sensitive fields with the `@sensitive` description marker
|
|
1041
1186
|
* (see `SENSITIVE_FIELD_MARKER`); they are encrypted at rest via the
|
|
1042
1187
|
* same KeyProvider used by core's sensitive Config.
|
|
@@ -1131,9 +1276,10 @@ interface CrowiPlugin {
|
|
|
1131
1276
|
* (c) => Response, opts?)`. The handler receives the raw `Context`, so
|
|
1132
1277
|
* `c.req.text()` / `c.req.raw` give the exact request bytes (no
|
|
1133
1278
|
* validator consumes the body ahead of it — the Slack signature check
|
|
1134
|
-
* relies on this). Pass `{
|
|
1135
|
-
*
|
|
1136
|
-
*
|
|
1279
|
+
* relies on this). Pass `{ auth: 'public' }` to bypass Crowi auth for
|
|
1280
|
+
* self-authenticating webhooks, `{ auth: 'admin' }` for routes that
|
|
1281
|
+
* require `user.admin === true`, or omit `opts` for the `'user'`
|
|
1282
|
+
* default (any authenticated Crowi user).
|
|
1137
1283
|
*
|
|
1138
1284
|
* Called once at boot — but unlike the other `register*` hooks, this
|
|
1139
1285
|
* runs inside `buildHonoApp` (the Hono app does not exist yet when
|
|
@@ -1220,7 +1366,7 @@ interface ActionAnnotation {
|
|
|
1220
1366
|
/** Visible button label, e.g. "Test connection". */
|
|
1221
1367
|
label: string;
|
|
1222
1368
|
/** HTTP verb of the plugin endpoint to call. */
|
|
1223
|
-
method:
|
|
1369
|
+
method: PluginRouteMethod;
|
|
1224
1370
|
/** Path relative to `/api/v2/plugins/<name>/`, with leading slash. */
|
|
1225
1371
|
path: string;
|
|
1226
1372
|
}
|
|
@@ -1230,10 +1376,15 @@ interface ActionAnnotation {
|
|
|
1230
1376
|
* Format: `@action "<label>" <METHOD> <path>`
|
|
1231
1377
|
* e.g. `@action "Test connection" POST /test`
|
|
1232
1378
|
*
|
|
1233
|
-
* The label may include spaces when wrapped in double quotes; the
|
|
1234
|
-
*
|
|
1235
|
-
*
|
|
1379
|
+
* The label may include spaces when wrapped in double quotes; the method
|
|
1380
|
+
* must be one of `PluginRouteMethod` (`GET` / `POST` — the only verbs a
|
|
1381
|
+
* plugin route can actually be mounted on, see `routes.ts`); the path
|
|
1382
|
+
* begins with `/`. A description that starts with the `@action` marker
|
|
1383
|
+
* but declares an unsupported verb (e.g. `PUT` / `DELETE`) fails to match
|
|
1384
|
+
* and returns `null` here — callers that walk a plugin's `configSchema`
|
|
1385
|
+
* (e.g. `PluginManager.activate()`) are expected to warn on that case at
|
|
1386
|
+
* boot, since it would otherwise be a silent dead button.
|
|
1236
1387
|
*/
|
|
1237
1388
|
declare function getActionAnnotation(field: z.ZodTypeAny): ActionAnnotation | null;
|
|
1238
1389
|
|
|
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
|
|
1390
|
+
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 PluginEvents, type PluginLogger, type PluginRouteHandler, type PluginRouteMethod, type PluginRouteOptions, type PluginRouterScope, type RenderContext, type RenderError, type RenderPhase, type RenderResult, type RendererRegistry, type Reservation, SENSITIVE_FIELD_MARKER, type ScopedCacheStorage, type SearchDriver, type SearchHit, type SearchHits, type SearchPageType, type SearchQuery, type SearchQueryGrants, type SearchQueryViewer, type SearchRegistry, type SearchableDoc, type StateCell, type StorageDriver, type StoragePutMeta, type StoragePutResult, type StorageRegistry, type UrlInlineExpansionRule, getActionAnnotation, isSensitiveField };
|
package/dist/index.d.ts
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;
|
|
@@ -966,17 +1067,15 @@ type PluginRouteHandler = (c: Context) => Response | Promise<Response>;
|
|
|
966
1067
|
/** Per-route options passed alongside the handler. */
|
|
967
1068
|
interface PluginRouteOptions {
|
|
968
1069
|
/**
|
|
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).
|
|
1070
|
+
* Authorization tier this route requires.
|
|
1071
|
+
* - `'public'`: no auth (self-authenticating webhooks — Slack signature
|
|
1072
|
+
* check etc.).
|
|
1073
|
+
* - `'user'` (default): any authenticated Crowi user (`createJwtAuth`).
|
|
1074
|
+
* - `'admin'`: `user.admin === true` (`createJwtAdminRequired`) — use for
|
|
1075
|
+
* Test-connection / `@action` targets reached only from the admin
|
|
1076
|
+
* config form.
|
|
978
1077
|
*/
|
|
979
|
-
|
|
1078
|
+
auth?: 'public' | 'user' | 'admin';
|
|
980
1079
|
}
|
|
981
1080
|
/**
|
|
982
1081
|
* Scope passed to `registerRoutes(scope, ctx)`. Lets a plugin contribute
|
|
@@ -994,11 +1093,12 @@ interface PluginRouterScope {
|
|
|
994
1093
|
* Mount `handler` for `method` at `<path>` under this plugin's
|
|
995
1094
|
* namespace. `path` is relative to `/api/v2/plugins/<plugin-name>` and
|
|
996
1095
|
* should start with `/` (e.g. `route('POST', '/events', handler, {
|
|
997
|
-
*
|
|
1096
|
+
* auth: 'public' })` → `POST /api/v2/plugins/<name>/events`).
|
|
998
1097
|
*
|
|
999
|
-
* Pass `{
|
|
1000
|
-
* authenticating inbound webhooks
|
|
1001
|
-
*
|
|
1098
|
+
* Pass `{ auth: 'public' }` to bypass Crowi auth entirely for self-
|
|
1099
|
+
* authenticating inbound webhooks, `{ auth: 'admin' }` to require
|
|
1100
|
+
* `user.admin === true`, or omit `opts` for the `'user'` default (any
|
|
1101
|
+
* authenticated Crowi user).
|
|
1002
1102
|
*/
|
|
1003
1103
|
route(method: PluginRouteMethod, path: string, handler: PluginRouteHandler, opts?: PluginRouteOptions): void;
|
|
1004
1104
|
}
|
|
@@ -1033,10 +1133,55 @@ interface CrowiPlugin {
|
|
|
1033
1133
|
* at boot and loads `requires` first; cycles fail boot.
|
|
1034
1134
|
*/
|
|
1035
1135
|
requires?: string[];
|
|
1136
|
+
/**
|
|
1137
|
+
* Core Mongoose model names (e.g. `['Page', 'Bookmark']`) this plugin
|
|
1138
|
+
* is allowed to reach via `ctx.model(name)`. The PluginManager
|
|
1139
|
+
* validates every entry against the set of registered core model
|
|
1140
|
+
* names at boot — an unknown name fails boot with a descriptive
|
|
1141
|
+
* error. `ctx.model(name)` throws at call time for any `name` not
|
|
1142
|
+
* listed here.
|
|
1143
|
+
*
|
|
1144
|
+
* A model listed here is granted full (unrestricted) read/write
|
|
1145
|
+
* access — there is no read-only mode. Omit or leave empty for a
|
|
1146
|
+
* plugin that never calls `ctx.model()`.
|
|
1147
|
+
*
|
|
1148
|
+
* Credential-bearing core models (`Config`, `PersonalAccessToken`,
|
|
1149
|
+
* OAuth client/token/grant models, `Share`, `ShareAccess`) can never
|
|
1150
|
+
* be listed here — declaring one fails boot, and `ctx.model()` also
|
|
1151
|
+
* refuses to return one at call time as defense-in-depth. There is no
|
|
1152
|
+
* legitimate plugin use case for touching those collections directly.
|
|
1153
|
+
*/
|
|
1154
|
+
modelAccess?: string[];
|
|
1155
|
+
/**
|
|
1156
|
+
* Opt in to letting *other* plugins read this plugin's config through
|
|
1157
|
+
* their `ctx.dependencyConfig<T>(this.name)` (they must also list this
|
|
1158
|
+
* plugin in their own `requires`). Defaults to `false` — a plugin's
|
|
1159
|
+
* config, including `@sensitive` fields, is private to itself unless
|
|
1160
|
+
* it explicitly declares this flag.
|
|
1161
|
+
*
|
|
1162
|
+
* Set this on a plugin that exists specifically to hold credentials
|
|
1163
|
+
* shared by other plugins — e.g. `@crowi/plugin-aws` sets it so
|
|
1164
|
+
* `@crowi/plugin-storage-aws-s3` and `@crowi/plugin-mail-aws-ses` can
|
|
1165
|
+
* read its `region` / `accessKeyId` / `secretAccessKey` without
|
|
1166
|
+
* duplicating them in their own `configSchema`. Most plugins should
|
|
1167
|
+
* leave this unset.
|
|
1168
|
+
*/
|
|
1169
|
+
exposesConfigToDependents?: boolean;
|
|
1036
1170
|
/**
|
|
1037
1171
|
* Zod schema describing this plugin's *global* configurable values.
|
|
1038
1172
|
* The admin UI generates a config form by walking this schema.
|
|
1039
1173
|
*
|
|
1174
|
+
* Build this with `import { z } from 'zod/v3'` — NOT the top-level
|
|
1175
|
+
* `import { z } from 'zod'` (v4). `peerDependencies: { zod: "^4" }`
|
|
1176
|
+
* only says which npm package to install; the v4 package ships a
|
|
1177
|
+
* `zod/v3` compat subpath, and that subpath's runtime shape is what
|
|
1178
|
+
* every introspection helper here (`schema-serializer.ts`,
|
|
1179
|
+
* `schema-markers.ts`, `PluginManager.listSensitiveKeys()`) actually
|
|
1180
|
+
* walks. A schema built from the top-level v4 API fails boot with an
|
|
1181
|
+
* explicit error (`PluginManager.activate()`'s config-schema guard —
|
|
1182
|
+
* see this package's README) rather than silently losing
|
|
1183
|
+
* `@sensitive` detection.
|
|
1184
|
+
*
|
|
1040
1185
|
* Mark sensitive fields with the `@sensitive` description marker
|
|
1041
1186
|
* (see `SENSITIVE_FIELD_MARKER`); they are encrypted at rest via the
|
|
1042
1187
|
* same KeyProvider used by core's sensitive Config.
|
|
@@ -1131,9 +1276,10 @@ interface CrowiPlugin {
|
|
|
1131
1276
|
* (c) => Response, opts?)`. The handler receives the raw `Context`, so
|
|
1132
1277
|
* `c.req.text()` / `c.req.raw` give the exact request bytes (no
|
|
1133
1278
|
* validator consumes the body ahead of it — the Slack signature check
|
|
1134
|
-
* relies on this). Pass `{
|
|
1135
|
-
*
|
|
1136
|
-
*
|
|
1279
|
+
* relies on this). Pass `{ auth: 'public' }` to bypass Crowi auth for
|
|
1280
|
+
* self-authenticating webhooks, `{ auth: 'admin' }` for routes that
|
|
1281
|
+
* require `user.admin === true`, or omit `opts` for the `'user'`
|
|
1282
|
+
* default (any authenticated Crowi user).
|
|
1137
1283
|
*
|
|
1138
1284
|
* Called once at boot — but unlike the other `register*` hooks, this
|
|
1139
1285
|
* runs inside `buildHonoApp` (the Hono app does not exist yet when
|
|
@@ -1220,7 +1366,7 @@ interface ActionAnnotation {
|
|
|
1220
1366
|
/** Visible button label, e.g. "Test connection". */
|
|
1221
1367
|
label: string;
|
|
1222
1368
|
/** HTTP verb of the plugin endpoint to call. */
|
|
1223
|
-
method:
|
|
1369
|
+
method: PluginRouteMethod;
|
|
1224
1370
|
/** Path relative to `/api/v2/plugins/<name>/`, with leading slash. */
|
|
1225
1371
|
path: string;
|
|
1226
1372
|
}
|
|
@@ -1230,10 +1376,15 @@ interface ActionAnnotation {
|
|
|
1230
1376
|
* Format: `@action "<label>" <METHOD> <path>`
|
|
1231
1377
|
* e.g. `@action "Test connection" POST /test`
|
|
1232
1378
|
*
|
|
1233
|
-
* The label may include spaces when wrapped in double quotes; the
|
|
1234
|
-
*
|
|
1235
|
-
*
|
|
1379
|
+
* The label may include spaces when wrapped in double quotes; the method
|
|
1380
|
+
* must be one of `PluginRouteMethod` (`GET` / `POST` — the only verbs a
|
|
1381
|
+
* plugin route can actually be mounted on, see `routes.ts`); the path
|
|
1382
|
+
* begins with `/`. A description that starts with the `@action` marker
|
|
1383
|
+
* but declares an unsupported verb (e.g. `PUT` / `DELETE`) fails to match
|
|
1384
|
+
* and returns `null` here — callers that walk a plugin's `configSchema`
|
|
1385
|
+
* (e.g. `PluginManager.activate()`) are expected to warn on that case at
|
|
1386
|
+
* boot, since it would otherwise be a silent dead button.
|
|
1236
1387
|
*/
|
|
1237
1388
|
declare function getActionAnnotation(field: z.ZodTypeAny): ActionAnnotation | null;
|
|
1238
1389
|
|
|
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
|
|
1390
|
+
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 PluginEvents, type PluginLogger, type PluginRouteHandler, type PluginRouteMethod, type PluginRouteOptions, type PluginRouterScope, type RenderContext, type RenderError, type RenderPhase, type RenderResult, type RendererRegistry, type Reservation, SENSITIVE_FIELD_MARKER, type ScopedCacheStorage, type SearchDriver, type SearchHit, type SearchHits, type SearchPageType, type SearchQuery, type SearchQueryGrants, type SearchQueryViewer, type SearchRegistry, type SearchableDoc, type StateCell, type StorageDriver, type StoragePutMeta, type StoragePutResult, type StorageRegistry, type UrlInlineExpansionRule, getActionAnnotation, isSensitiveField };
|
package/dist/index.js
CHANGED
|
@@ -40,7 +40,7 @@ function getActionAnnotation(field) {
|
|
|
40
40
|
const trimmed = description.trimStart();
|
|
41
41
|
if (!trimmed.startsWith(ACTION_FIELD_MARKER)) return null;
|
|
42
42
|
const rest = trimmed.slice(ACTION_FIELD_MARKER.length).trimStart();
|
|
43
|
-
const match = rest.match(/^"([^"]+)"\s+(GET|POST
|
|
43
|
+
const match = rest.match(/^"([^"]+)"\s+(GET|POST)\s+(\/\S*)/);
|
|
44
44
|
if (!match) return null;
|
|
45
45
|
const [, label, method, path] = match;
|
|
46
46
|
return { label, method, path };
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/schema-markers.ts"],"sourcesContent":["/**\n * @crowi/plugin-api — type-only contract for Crowi 2.0 plugins.\n *\n * Plugins author against this package. The runtime (@crowi/server) loads\n * plugins listed in `crowi.config.json`, calls each plugin's\n * `register*` callbacks, and routes all the side effects (storage,\n * search, auth, notifications) through the typed registries declared\n * here.\n *\n * For the design rationale see `docs/rfcs/0001-plugin-architecture.md`\n * in the Crowi monorepo.\n */\n\nexport type { CrowiPlugin } from './plugin';\n\nexport type { PluginContext, AppInfo, PageMetadataAccessor,
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/schema-markers.ts"],"sourcesContent":["/**\n * @crowi/plugin-api — type-only contract for Crowi 2.0 plugins.\n *\n * Plugins author against this package. The runtime (@crowi/server) loads\n * plugins listed in `crowi.config.json`, calls each plugin's\n * `register*` callbacks, and routes all the side effects (storage,\n * search, auth, notifications) through the typed registries declared\n * here.\n *\n * For the design rationale see `docs/rfcs/0001-plugin-architecture.md`\n * in the Crowi monorepo.\n */\n\nexport type { CrowiPlugin } from './plugin';\n\nexport type { PluginContext, AppInfo, PageMetadataAccessor, PluginLogger, StateCell } from './context';\n\nexport type { StorageDriver, StorageRegistry, StoragePutMeta, StoragePutResult } from './registries/storage';\n\nexport type {\n SearchDriver,\n SearchRegistry,\n SearchableDoc,\n SearchQuery,\n SearchQueryViewer,\n SearchQueryGrants,\n SearchPageType,\n SearchHits,\n SearchHit,\n} from './registries/search';\n\nexport type { AuthDriver, AuthRegistry, AuthProfile, AuthVerifyResult } from './registries/auth';\n\nexport type { NotifierDriver, NotifierRegistry, NotificationPayload } from './registries/notifier';\n\nexport type { MailSender, MailSenderRegistry, EmailMessage } from './registries/mail';\n\nexport type {\n RendererRegistry,\n NodeRenderer,\n CodeBlockRenderer,\n CodeBlockInfo,\n EmbedRenderer,\n EmbedInput,\n EmbedFragment,\n UrlInlineExpansionRule,\n InlineExpansion,\n RenderContext,\n RenderPhase,\n RenderResult,\n RenderError,\n Reservation,\n CacheStorage,\n ScopedCacheStorage,\n CacheKey,\n CacheEntry,\n AuthContext,\n} from './renderer';\n\nexport type { EventBus, PluginEvents } from './events';\n\nexport type { PluginRouterScope, PluginRouteHandler, PluginRouteMethod, PluginRouteOptions } from './routes';\n\nexport { SENSITIVE_FIELD_MARKER, ACTION_FIELD_MARKER, isSensitiveField, getActionAnnotation } from './schema-markers';\n","import type { z } from 'zod/v3';\n\nimport type { PluginRouteMethod } from './routes';\n\n/**\n * `configSchema` description-string markers.\n *\n * The admin UI walks the schema and looks at each field's\n * `description` (set via `z.string().describe('@sensitive ...')`). A\n * description starting with one of these marker tokens unlocks special\n * UI behaviour without forcing every field to declare a custom Zod\n * type.\n */\n\n/**\n * Marker that flags a config field as sensitive (encrypted at rest).\n * Usage:\n *\n * z.string().describe('@sensitive AWS secret access key')\n *\n * The runtime auto-encrypts on write and decrypts on read, using the\n * same KeyProvider as core sensitive Config. The admin UI renders the\n * field via `<SecretField>` (saved badge / clear pending / undo).\n */\nexport const SENSITIVE_FIELD_MARKER = '@sensitive';\n\n/**\n * Marker that adds an action button next to a config field. Usage:\n *\n * z.string().describe('@action \"Test connection\" POST /test')\n *\n * The admin form renders a button with the given label that calls the\n * plugin's contributed endpoint at the given verb / path (relative to\n * `/api/v2/plugins/<name>/`). Useful for \"Test connection\",\n * \"Authorise with Google\", etc. without forcing every plugin to ship\n * its own React component.\n */\nexport const ACTION_FIELD_MARKER = '@action';\n\n/**\n * True if the schema field is marked `@sensitive`.\n *\n * `field` is `z.ZodTypeAny` (intentionally loose); call sites pass the\n * value type from `configSchema.shape[key]`.\n */\nexport function isSensitiveField(field: z.ZodTypeAny): boolean {\n const description = field.description;\n return typeof description === 'string' && description.trimStart().startsWith(SENSITIVE_FIELD_MARKER);\n}\n\n/**\n * Parsed `@action` annotation extracted from a field's `description`.\n */\nexport interface ActionAnnotation {\n /** Visible button label, e.g. \"Test connection\". */\n label: string;\n /** HTTP verb of the plugin endpoint to call. */\n method: PluginRouteMethod;\n /** Path relative to `/api/v2/plugins/<name>/`, with leading slash. */\n path: string;\n}\n\n/**\n * Parse an `@action` annotation off a field, or return null if absent.\n *\n * Format: `@action \"<label>\" <METHOD> <path>`\n * e.g. `@action \"Test connection\" POST /test`\n *\n * The label may include spaces when wrapped in double quotes; the method\n * must be one of `PluginRouteMethod` (`GET` / `POST` — the only verbs a\n * plugin route can actually be mounted on, see `routes.ts`); the path\n * begins with `/`. A description that starts with the `@action` marker\n * but declares an unsupported verb (e.g. `PUT` / `DELETE`) fails to match\n * and returns `null` here — callers that walk a plugin's `configSchema`\n * (e.g. `PluginManager.activate()`) are expected to warn on that case at\n * boot, since it would otherwise be a silent dead button.\n */\nexport function getActionAnnotation(field: z.ZodTypeAny): ActionAnnotation | null {\n const description = field.description;\n if (typeof description !== 'string') return null;\n const trimmed = description.trimStart();\n if (!trimmed.startsWith(ACTION_FIELD_MARKER)) return null;\n\n const rest = trimmed.slice(ACTION_FIELD_MARKER.length).trimStart();\n // `\"<label>\" <METHOD> <path>`\n const match = rest.match(/^\"([^\"]+)\"\\s+(GET|POST)\\s+(\\/\\S*)/);\n if (!match) return null;\n\n const [, label, method, path] = match;\n return { label, method: method as ActionAnnotation['method'], path };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACwBO,IAAM,yBAAyB;AAa/B,IAAM,sBAAsB;AAQ5B,SAAS,iBAAiB,OAA8B;AAC7D,QAAM,cAAc,MAAM;AAC1B,SAAO,OAAO,gBAAgB,YAAY,YAAY,UAAU,EAAE,WAAW,sBAAsB;AACrG;AA6BO,SAAS,oBAAoB,OAA8C;AAChF,QAAM,cAAc,MAAM;AAC1B,MAAI,OAAO,gBAAgB,SAAU,QAAO;AAC5C,QAAM,UAAU,YAAY,UAAU;AACtC,MAAI,CAAC,QAAQ,WAAW,mBAAmB,EAAG,QAAO;AAErD,QAAM,OAAO,QAAQ,MAAM,oBAAoB,MAAM,EAAE,UAAU;AAEjE,QAAM,QAAQ,KAAK,MAAM,mCAAmC;AAC5D,MAAI,CAAC,MAAO,QAAO;AAEnB,QAAM,CAAC,EAAE,OAAO,QAAQ,IAAI,IAAI;AAChC,SAAO,EAAE,OAAO,QAA8C,KAAK;AACrE;","names":[]}
|
package/dist/index.mjs
CHANGED
|
@@ -11,7 +11,7 @@ function getActionAnnotation(field) {
|
|
|
11
11
|
const trimmed = description.trimStart();
|
|
12
12
|
if (!trimmed.startsWith(ACTION_FIELD_MARKER)) return null;
|
|
13
13
|
const rest = trimmed.slice(ACTION_FIELD_MARKER.length).trimStart();
|
|
14
|
-
const match = rest.match(/^"([^"]+)"\s+(GET|POST
|
|
14
|
+
const match = rest.match(/^"([^"]+)"\s+(GET|POST)\s+(\/\S*)/);
|
|
15
15
|
if (!match) return null;
|
|
16
16
|
const [, label, method, path] = match;
|
|
17
17
|
return { label, method, path };
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/schema-markers.ts"],"sourcesContent":["import type { z } from 'zod/v3';\n\n/**\n * `configSchema` description-string markers.\n *\n * The admin UI walks the schema and looks at each field's\n * `description` (set via `z.string().describe('@sensitive ...')`). A\n * description starting with one of these marker tokens unlocks special\n * UI behaviour without forcing every field to declare a custom Zod\n * type.\n */\n\n/**\n * Marker that flags a config field as sensitive (encrypted at rest).\n * Usage:\n *\n * z.string().describe('@sensitive AWS secret access key')\n *\n * The runtime auto-encrypts on write and decrypts on read, using the\n * same KeyProvider as core sensitive Config. The admin UI renders the\n * field via `<SecretField>` (saved badge / clear pending / undo).\n */\nexport const SENSITIVE_FIELD_MARKER = '@sensitive';\n\n/**\n * Marker that adds an action button next to a config field. Usage:\n *\n * z.string().describe('@action \"Test connection\" POST /test')\n *\n * The admin form renders a button with the given label that calls the\n * plugin's contributed endpoint at the given verb / path (relative to\n * `/api/v2/plugins/<name>/`). Useful for \"Test connection\",\n * \"Authorise with Google\", etc. without forcing every plugin to ship\n * its own React component.\n */\nexport const ACTION_FIELD_MARKER = '@action';\n\n/**\n * True if the schema field is marked `@sensitive`.\n *\n * `field` is `z.ZodTypeAny` (intentionally loose); call sites pass the\n * value type from `configSchema.shape[key]`.\n */\nexport function isSensitiveField(field: z.ZodTypeAny): boolean {\n const description = field.description;\n return typeof description === 'string' && description.trimStart().startsWith(SENSITIVE_FIELD_MARKER);\n}\n\n/**\n * Parsed `@action` annotation extracted from a field's `description`.\n */\nexport interface ActionAnnotation {\n /** Visible button label, e.g. \"Test connection\". */\n label: string;\n /** HTTP verb of the plugin endpoint to call. */\n method:
|
|
1
|
+
{"version":3,"sources":["../src/schema-markers.ts"],"sourcesContent":["import type { z } from 'zod/v3';\n\nimport type { PluginRouteMethod } from './routes';\n\n/**\n * `configSchema` description-string markers.\n *\n * The admin UI walks the schema and looks at each field's\n * `description` (set via `z.string().describe('@sensitive ...')`). A\n * description starting with one of these marker tokens unlocks special\n * UI behaviour without forcing every field to declare a custom Zod\n * type.\n */\n\n/**\n * Marker that flags a config field as sensitive (encrypted at rest).\n * Usage:\n *\n * z.string().describe('@sensitive AWS secret access key')\n *\n * The runtime auto-encrypts on write and decrypts on read, using the\n * same KeyProvider as core sensitive Config. The admin UI renders the\n * field via `<SecretField>` (saved badge / clear pending / undo).\n */\nexport const SENSITIVE_FIELD_MARKER = '@sensitive';\n\n/**\n * Marker that adds an action button next to a config field. Usage:\n *\n * z.string().describe('@action \"Test connection\" POST /test')\n *\n * The admin form renders a button with the given label that calls the\n * plugin's contributed endpoint at the given verb / path (relative to\n * `/api/v2/plugins/<name>/`). Useful for \"Test connection\",\n * \"Authorise with Google\", etc. without forcing every plugin to ship\n * its own React component.\n */\nexport const ACTION_FIELD_MARKER = '@action';\n\n/**\n * True if the schema field is marked `@sensitive`.\n *\n * `field` is `z.ZodTypeAny` (intentionally loose); call sites pass the\n * value type from `configSchema.shape[key]`.\n */\nexport function isSensitiveField(field: z.ZodTypeAny): boolean {\n const description = field.description;\n return typeof description === 'string' && description.trimStart().startsWith(SENSITIVE_FIELD_MARKER);\n}\n\n/**\n * Parsed `@action` annotation extracted from a field's `description`.\n */\nexport interface ActionAnnotation {\n /** Visible button label, e.g. \"Test connection\". */\n label: string;\n /** HTTP verb of the plugin endpoint to call. */\n method: PluginRouteMethod;\n /** Path relative to `/api/v2/plugins/<name>/`, with leading slash. */\n path: string;\n}\n\n/**\n * Parse an `@action` annotation off a field, or return null if absent.\n *\n * Format: `@action \"<label>\" <METHOD> <path>`\n * e.g. `@action \"Test connection\" POST /test`\n *\n * The label may include spaces when wrapped in double quotes; the method\n * must be one of `PluginRouteMethod` (`GET` / `POST` — the only verbs a\n * plugin route can actually be mounted on, see `routes.ts`); the path\n * begins with `/`. A description that starts with the `@action` marker\n * but declares an unsupported verb (e.g. `PUT` / `DELETE`) fails to match\n * and returns `null` here — callers that walk a plugin's `configSchema`\n * (e.g. `PluginManager.activate()`) are expected to warn on that case at\n * boot, since it would otherwise be a silent dead button.\n */\nexport function getActionAnnotation(field: z.ZodTypeAny): ActionAnnotation | null {\n const description = field.description;\n if (typeof description !== 'string') return null;\n const trimmed = description.trimStart();\n if (!trimmed.startsWith(ACTION_FIELD_MARKER)) return null;\n\n const rest = trimmed.slice(ACTION_FIELD_MARKER.length).trimStart();\n // `\"<label>\" <METHOD> <path>`\n const match = rest.match(/^\"([^\"]+)\"\\s+(GET|POST)\\s+(\\/\\S*)/);\n if (!match) return null;\n\n const [, label, method, path] = match;\n return { label, method: method as ActionAnnotation['method'], path };\n}\n"],"mappings":";AAwBO,IAAM,yBAAyB;AAa/B,IAAM,sBAAsB;AAQ5B,SAAS,iBAAiB,OAA8B;AAC7D,QAAM,cAAc,MAAM;AAC1B,SAAO,OAAO,gBAAgB,YAAY,YAAY,UAAU,EAAE,WAAW,sBAAsB;AACrG;AA6BO,SAAS,oBAAoB,OAA8C;AAChF,QAAM,cAAc,MAAM;AAC1B,MAAI,OAAO,gBAAgB,SAAU,QAAO;AAC5C,QAAM,UAAU,YAAY,UAAU;AACtC,MAAI,CAAC,QAAQ,WAAW,mBAAmB,EAAG,QAAO;AAErD,QAAM,OAAO,QAAQ,MAAM,oBAAoB,MAAM,EAAE,UAAU;AAEjE,QAAM,QAAQ,KAAK,MAAM,mCAAmC;AAC5D,MAAI,CAAC,MAAO,QAAO;AAEnB,QAAM,CAAC,EAAE,OAAO,QAAQ,IAAI,IAAI;AAChC,SAAO,EAAE,OAAO,QAA8C,KAAK;AACrE;","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crowi/plugin-api",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.0.0-alpha.3",
|
|
4
4
|
"description": "Type-only contract for Crowi 2.0 plugins. See docs/rfcs/0001-plugin-architecture.md.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -29,8 +29,11 @@
|
|
|
29
29
|
"zod": "^4"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
|
+
"@types/jest": "^29.5.14",
|
|
32
33
|
"@types/node": "^24",
|
|
33
34
|
"hono": "^4.12.25",
|
|
35
|
+
"jest": "^29.7.0",
|
|
36
|
+
"ts-jest": "^29.3.4",
|
|
34
37
|
"tsup": "^8.3.5",
|
|
35
38
|
"typescript": "^5.8.3",
|
|
36
39
|
"zod": "^4.4.3",
|
|
@@ -39,6 +42,7 @@
|
|
|
39
42
|
"scripts": {
|
|
40
43
|
"build": "tsup",
|
|
41
44
|
"dev": "tsup --watch --no-clean",
|
|
42
|
-
"type-check": "tsc --noEmit"
|
|
45
|
+
"type-check": "tsc --noEmit",
|
|
46
|
+
"test": "jest --passWithNoTests"
|
|
43
47
|
}
|
|
44
48
|
}
|