@crowi/plugin-api 0.1.0-alpha.1 → 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 +277 -46
- package/dist/index.d.ts +277 -46
- 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 +8 -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
|
@@ -1,11 +1,40 @@
|
|
|
1
1
|
import { z } from 'zod/v3';
|
|
2
2
|
import { Readable } from 'node:stream';
|
|
3
|
+
import { Context } from 'hono';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* The context object passed to every plugin callback. It is the only
|
|
6
7
|
* conduit through which a plugin reads core state (config, models,
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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.
|
|
9
38
|
*/
|
|
10
39
|
interface PluginContext {
|
|
11
40
|
/**
|
|
@@ -20,33 +49,141 @@ interface PluginContext {
|
|
|
20
49
|
* Read a typed dependency plugin's config. The target plugin must
|
|
21
50
|
* be listed in this plugin's `requires` array — reading another
|
|
22
51
|
* plugin's config without declaring the dependency is a contract
|
|
23
|
-
* 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.
|
|
24
57
|
*
|
|
25
|
-
* Useful for shared-credential plugins like `@crowi/plugin-aws
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* `@crowi/plugin-mail-aws-ses`)
|
|
29
|
-
*
|
|
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.
|
|
30
65
|
*/
|
|
31
66
|
dependencyConfig<T>(dependencyName: string): T;
|
|
67
|
+
/**
|
|
68
|
+
* Read core application info (the wiki name, …) — settings that live
|
|
69
|
+
* outside this plugin's own config namespace but that an integration
|
|
70
|
+
* may need (e.g. to brand an outbound manifest). Read live at call
|
|
71
|
+
* time, so it reflects admin edits made after boot.
|
|
72
|
+
*/
|
|
73
|
+
appInfo(): AppInfo;
|
|
32
74
|
/** Write a single config field, persisting to Mongo. */
|
|
33
75
|
setConfig(key: string, value: unknown): Promise<void>;
|
|
34
76
|
/** Per-Page metadata accessor for this plugin's namespace. */
|
|
35
77
|
pageMetadata: PageMetadataAccessor;
|
|
36
78
|
/**
|
|
37
|
-
* Mongoose model accessor
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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).
|
|
40
96
|
*
|
|
41
97
|
* Typed loosely (`unknown`) at this layer because the core model
|
|
42
98
|
* types live in `@crowi/server`; plugins narrow the return type at
|
|
43
99
|
* the call site.
|
|
44
100
|
*/
|
|
45
101
|
model(name: string): unknown;
|
|
46
|
-
/** Symmetric encrypt / decrypt against the configured KeyProvider. */
|
|
47
|
-
crypto: PluginCrypto;
|
|
48
102
|
/** Structured logger scoped to this plugin (auto-prefixed with name). */
|
|
49
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;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Read-only view of core application settings exposed to plugins via
|
|
166
|
+
* `ctx.appInfo()`. Intentionally a small, curated surface (not a generic
|
|
167
|
+
* "read any core config" escape hatch) — add fields here as concrete
|
|
168
|
+
* plugin needs appear.
|
|
169
|
+
*/
|
|
170
|
+
interface AppInfo {
|
|
171
|
+
/**
|
|
172
|
+
* The configured wiki name (core `app:title`), trimmed. Always a
|
|
173
|
+
* non-empty string: when the operator has not set a custom title it
|
|
174
|
+
* defaults to `'Crowi'` (the seed value), so consumers never have to
|
|
175
|
+
* handle an absent name.
|
|
176
|
+
*/
|
|
177
|
+
title: string;
|
|
178
|
+
/**
|
|
179
|
+
* The wiki's public base origin (core `CLIENT_URL` / `getBaseUrl()`),
|
|
180
|
+
* e.g. `https://wiki.example.com`. An **empty string** when no public
|
|
181
|
+
* origin is configured — unlike `title` there is no sensible default,
|
|
182
|
+
* so a plugin that needs an absolute URL (outbound webhook / manifest)
|
|
183
|
+
* must handle the empty case. Plugins read this instead of
|
|
184
|
+
* `process.env.CLIENT_URL` directly.
|
|
185
|
+
*/
|
|
186
|
+
baseUrl: string;
|
|
50
187
|
}
|
|
51
188
|
/**
|
|
52
189
|
* Per-Page metadata read / write helper. Each plugin gets a private
|
|
@@ -62,10 +199,6 @@ interface PageMetadataAccessor {
|
|
|
62
199
|
/** Remove this plugin's metadata for a specific page. */
|
|
63
200
|
remove(pageId: string): Promise<void>;
|
|
64
201
|
}
|
|
65
|
-
interface PluginCrypto {
|
|
66
|
-
encrypt(plaintext: string): string;
|
|
67
|
-
decrypt(ciphertext: string): string;
|
|
68
|
-
}
|
|
69
202
|
interface PluginLogger {
|
|
70
203
|
debug(message: string, ...args: unknown[]): void;
|
|
71
204
|
info(message: string, ...args: unknown[]): void;
|
|
@@ -910,28 +1043,64 @@ interface RendererRegistry {
|
|
|
910
1043
|
}
|
|
911
1044
|
|
|
912
1045
|
/**
|
|
913
|
-
*
|
|
914
|
-
*
|
|
915
|
-
*
|
|
916
|
-
*
|
|
917
|
-
|
|
918
|
-
|
|
1046
|
+
* HTTP method a plugin route can be mounted on. Kept to the verbs the
|
|
1047
|
+
* inbound-webhook + admin-action surface actually needs (RFC-0013 §4):
|
|
1048
|
+
* `POST` for Slack events / slash / interactivity + `@action` targets,
|
|
1049
|
+
* `GET` for OAuth callbacks + simple status endpoints.
|
|
1050
|
+
*/
|
|
1051
|
+
type PluginRouteMethod = 'GET' | 'POST';
|
|
1052
|
+
/**
|
|
1053
|
+
* A plugin route handler. It receives the raw Hono `Context` and returns
|
|
1054
|
+
* a `Response` (or a promise of one), exactly like a hand-written Hono
|
|
1055
|
+
* handler — the scope does **not** wrap it in a typed-route/validator
|
|
1056
|
+
* layer.
|
|
1057
|
+
*
|
|
1058
|
+
* **Raw body invariant** (RFC-0013 §8, a Slack hard requirement): the
|
|
1059
|
+
* route is a plain Hono route, NOT a `@hono/zod-openapi` route, so no
|
|
1060
|
+
* body-consuming validator runs ahead of the handler. `c.req.text()` /
|
|
1061
|
+
* `c.req.raw` therefore yield the *exact* bytes the client sent, which
|
|
1062
|
+
* the Slack signature check (`HMAC-SHA256` over `v0:{ts}:{rawBody}`)
|
|
1063
|
+
* depends on. `createJwtAuth` (installed on non-public routes) never
|
|
1064
|
+
* reads the body, so the invariant holds for authed routes too.
|
|
1065
|
+
*/
|
|
1066
|
+
type PluginRouteHandler = (c: Context) => Response | Promise<Response>;
|
|
1067
|
+
/** Per-route options passed alongside the handler. */
|
|
1068
|
+
interface PluginRouteOptions {
|
|
1069
|
+
/**
|
|
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.
|
|
1077
|
+
*/
|
|
1078
|
+
auth?: 'public' | 'user' | 'admin';
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* Scope passed to `registerRoutes(scope, ctx)`. Lets a plugin contribute
|
|
1082
|
+
* HTTP routes that the runtime mounts at
|
|
1083
|
+
* `/api/v2/plugins/<plugin-name>/<path>` — the `<plugin-name>` path
|
|
1084
|
+
* segment guarantees that core endpoints and other plugins cannot
|
|
1085
|
+
* collide (RFC-0013 §4).
|
|
919
1086
|
*
|
|
920
|
-
*
|
|
921
|
-
*
|
|
922
|
-
*
|
|
923
|
-
* is silently dropped. The type fixture exists so the public surface
|
|
924
|
-
* of `@crowi/plugin-api` keeps compiling against existing plugin
|
|
925
|
-
* sources (including the in-tree `__fixtures__/example-plugin.ts`)
|
|
926
|
-
* without forcing every plugin to be updated in lockstep with Phase 6.
|
|
1087
|
+
* The scope is built per-plugin inside `buildHonoApp` (the Hono app does
|
|
1088
|
+
* not exist yet when plugins activate at boot), so `<plugin-name>` is
|
|
1089
|
+
* already closed over — plugins only supply the sub-path.
|
|
927
1090
|
*/
|
|
928
1091
|
interface PluginRouterScope {
|
|
929
1092
|
/**
|
|
930
|
-
*
|
|
931
|
-
*
|
|
932
|
-
*
|
|
1093
|
+
* Mount `handler` for `method` at `<path>` under this plugin's
|
|
1094
|
+
* namespace. `path` is relative to `/api/v2/plugins/<plugin-name>` and
|
|
1095
|
+
* should start with `/` (e.g. `route('POST', '/events', handler, {
|
|
1096
|
+
* auth: 'public' })` → `POST /api/v2/plugins/<name>/events`).
|
|
1097
|
+
*
|
|
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).
|
|
933
1102
|
*/
|
|
934
|
-
|
|
1103
|
+
route(method: PluginRouteMethod, path: string, handler: PluginRouteHandler, opts?: PluginRouteOptions): void;
|
|
935
1104
|
}
|
|
936
1105
|
|
|
937
1106
|
/**
|
|
@@ -964,10 +1133,55 @@ interface CrowiPlugin {
|
|
|
964
1133
|
* at boot and loads `requires` first; cycles fail boot.
|
|
965
1134
|
*/
|
|
966
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;
|
|
967
1170
|
/**
|
|
968
1171
|
* Zod schema describing this plugin's *global* configurable values.
|
|
969
1172
|
* The admin UI generates a config form by walking this schema.
|
|
970
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
|
+
*
|
|
971
1185
|
* Mark sensitive fields with the `@sensitive` description marker
|
|
972
1186
|
* (see `SENSITIVE_FIELD_MARKER`); they are encrypted at rest via the
|
|
973
1187
|
* same KeyProvider used by core's sensitive Config.
|
|
@@ -1002,7 +1216,7 @@ interface CrowiPlugin {
|
|
|
1002
1216
|
* from a fixed allow-list to keep the bundle small.
|
|
1003
1217
|
*/
|
|
1004
1218
|
adminPlacement?: {
|
|
1005
|
-
section?: 'settings' | 'shared' | 'storage' | 'mail' | 'notification' | 'auth' | 'search' | 'renderer';
|
|
1219
|
+
section?: 'settings' | 'shared' | 'storage' | 'mail' | 'notification' | 'auth' | 'search' | 'renderer' | 'platform';
|
|
1006
1220
|
label?: string;
|
|
1007
1221
|
icon?: string;
|
|
1008
1222
|
};
|
|
@@ -1052,13 +1266,25 @@ interface CrowiPlugin {
|
|
|
1052
1266
|
*/
|
|
1053
1267
|
registerHooks?: (events: EventBus, ctx: PluginContext) => void;
|
|
1054
1268
|
/**
|
|
1055
|
-
*
|
|
1056
|
-
* `/api/v2/plugins/<name
|
|
1269
|
+
* HTTP routes the plugin contributes, mounted at
|
|
1270
|
+
* `/api/v2/plugins/<name>/<path>` (the `<name>` path segment guarantees
|
|
1057
1271
|
* that core endpoints and other plugins cannot collide). Used for
|
|
1058
|
-
*
|
|
1059
|
-
*
|
|
1060
|
-
*
|
|
1061
|
-
*
|
|
1272
|
+
* inbound webhooks (Slack events / slash / interactivity), "Test
|
|
1273
|
+
* connection" buttons, `@action` targets, OAuth callbacks, etc.
|
|
1274
|
+
*
|
|
1275
|
+
* Each route is a plain Hono handler — `scope.route(method, path,
|
|
1276
|
+
* (c) => Response, opts?)`. The handler receives the raw `Context`, so
|
|
1277
|
+
* `c.req.text()` / `c.req.raw` give the exact request bytes (no
|
|
1278
|
+
* validator consumes the body ahead of it — the Slack signature check
|
|
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).
|
|
1283
|
+
*
|
|
1284
|
+
* Called once at boot — but unlike the other `register*` hooks, this
|
|
1285
|
+
* runs inside `buildHonoApp` (the Hono app does not exist yet when
|
|
1286
|
+
* plugins activate), so a plugin's `registerRoutes` fires slightly
|
|
1287
|
+
* later than its `registerStorage` / `registerNotifier` / etc.
|
|
1062
1288
|
*/
|
|
1063
1289
|
registerRoutes?: (scope: PluginRouterScope, ctx: PluginContext) => void;
|
|
1064
1290
|
/**
|
|
@@ -1140,7 +1366,7 @@ interface ActionAnnotation {
|
|
|
1140
1366
|
/** Visible button label, e.g. "Test connection". */
|
|
1141
1367
|
label: string;
|
|
1142
1368
|
/** HTTP verb of the plugin endpoint to call. */
|
|
1143
|
-
method:
|
|
1369
|
+
method: PluginRouteMethod;
|
|
1144
1370
|
/** Path relative to `/api/v2/plugins/<name>/`, with leading slash. */
|
|
1145
1371
|
path: string;
|
|
1146
1372
|
}
|
|
@@ -1150,10 +1376,15 @@ interface ActionAnnotation {
|
|
|
1150
1376
|
* Format: `@action "<label>" <METHOD> <path>`
|
|
1151
1377
|
* e.g. `@action "Test connection" POST /test`
|
|
1152
1378
|
*
|
|
1153
|
-
* The label may include spaces when wrapped in double quotes; the
|
|
1154
|
-
*
|
|
1155
|
-
*
|
|
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.
|
|
1156
1387
|
*/
|
|
1157
1388
|
declare function getActionAnnotation(field: z.ZodTypeAny): ActionAnnotation | null;
|
|
1158
1389
|
|
|
1159
|
-
export { ACTION_FIELD_MARKER, 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
|
@@ -1,11 +1,40 @@
|
|
|
1
1
|
import { z } from 'zod/v3';
|
|
2
2
|
import { Readable } from 'node:stream';
|
|
3
|
+
import { Context } from 'hono';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* The context object passed to every plugin callback. It is the only
|
|
6
7
|
* conduit through which a plugin reads core state (config, models,
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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.
|
|
9
38
|
*/
|
|
10
39
|
interface PluginContext {
|
|
11
40
|
/**
|
|
@@ -20,33 +49,141 @@ interface PluginContext {
|
|
|
20
49
|
* Read a typed dependency plugin's config. The target plugin must
|
|
21
50
|
* be listed in this plugin's `requires` array — reading another
|
|
22
51
|
* plugin's config without declaring the dependency is a contract
|
|
23
|
-
* 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.
|
|
24
57
|
*
|
|
25
|
-
* Useful for shared-credential plugins like `@crowi/plugin-aws
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* `@crowi/plugin-mail-aws-ses`)
|
|
29
|
-
*
|
|
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.
|
|
30
65
|
*/
|
|
31
66
|
dependencyConfig<T>(dependencyName: string): T;
|
|
67
|
+
/**
|
|
68
|
+
* Read core application info (the wiki name, …) — settings that live
|
|
69
|
+
* outside this plugin's own config namespace but that an integration
|
|
70
|
+
* may need (e.g. to brand an outbound manifest). Read live at call
|
|
71
|
+
* time, so it reflects admin edits made after boot.
|
|
72
|
+
*/
|
|
73
|
+
appInfo(): AppInfo;
|
|
32
74
|
/** Write a single config field, persisting to Mongo. */
|
|
33
75
|
setConfig(key: string, value: unknown): Promise<void>;
|
|
34
76
|
/** Per-Page metadata accessor for this plugin's namespace. */
|
|
35
77
|
pageMetadata: PageMetadataAccessor;
|
|
36
78
|
/**
|
|
37
|
-
* Mongoose model accessor
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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).
|
|
40
96
|
*
|
|
41
97
|
* Typed loosely (`unknown`) at this layer because the core model
|
|
42
98
|
* types live in `@crowi/server`; plugins narrow the return type at
|
|
43
99
|
* the call site.
|
|
44
100
|
*/
|
|
45
101
|
model(name: string): unknown;
|
|
46
|
-
/** Symmetric encrypt / decrypt against the configured KeyProvider. */
|
|
47
|
-
crypto: PluginCrypto;
|
|
48
102
|
/** Structured logger scoped to this plugin (auto-prefixed with name). */
|
|
49
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;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Read-only view of core application settings exposed to plugins via
|
|
166
|
+
* `ctx.appInfo()`. Intentionally a small, curated surface (not a generic
|
|
167
|
+
* "read any core config" escape hatch) — add fields here as concrete
|
|
168
|
+
* plugin needs appear.
|
|
169
|
+
*/
|
|
170
|
+
interface AppInfo {
|
|
171
|
+
/**
|
|
172
|
+
* The configured wiki name (core `app:title`), trimmed. Always a
|
|
173
|
+
* non-empty string: when the operator has not set a custom title it
|
|
174
|
+
* defaults to `'Crowi'` (the seed value), so consumers never have to
|
|
175
|
+
* handle an absent name.
|
|
176
|
+
*/
|
|
177
|
+
title: string;
|
|
178
|
+
/**
|
|
179
|
+
* The wiki's public base origin (core `CLIENT_URL` / `getBaseUrl()`),
|
|
180
|
+
* e.g. `https://wiki.example.com`. An **empty string** when no public
|
|
181
|
+
* origin is configured — unlike `title` there is no sensible default,
|
|
182
|
+
* so a plugin that needs an absolute URL (outbound webhook / manifest)
|
|
183
|
+
* must handle the empty case. Plugins read this instead of
|
|
184
|
+
* `process.env.CLIENT_URL` directly.
|
|
185
|
+
*/
|
|
186
|
+
baseUrl: string;
|
|
50
187
|
}
|
|
51
188
|
/**
|
|
52
189
|
* Per-Page metadata read / write helper. Each plugin gets a private
|
|
@@ -62,10 +199,6 @@ interface PageMetadataAccessor {
|
|
|
62
199
|
/** Remove this plugin's metadata for a specific page. */
|
|
63
200
|
remove(pageId: string): Promise<void>;
|
|
64
201
|
}
|
|
65
|
-
interface PluginCrypto {
|
|
66
|
-
encrypt(plaintext: string): string;
|
|
67
|
-
decrypt(ciphertext: string): string;
|
|
68
|
-
}
|
|
69
202
|
interface PluginLogger {
|
|
70
203
|
debug(message: string, ...args: unknown[]): void;
|
|
71
204
|
info(message: string, ...args: unknown[]): void;
|
|
@@ -910,28 +1043,64 @@ interface RendererRegistry {
|
|
|
910
1043
|
}
|
|
911
1044
|
|
|
912
1045
|
/**
|
|
913
|
-
*
|
|
914
|
-
*
|
|
915
|
-
*
|
|
916
|
-
*
|
|
917
|
-
|
|
918
|
-
|
|
1046
|
+
* HTTP method a plugin route can be mounted on. Kept to the verbs the
|
|
1047
|
+
* inbound-webhook + admin-action surface actually needs (RFC-0013 §4):
|
|
1048
|
+
* `POST` for Slack events / slash / interactivity + `@action` targets,
|
|
1049
|
+
* `GET` for OAuth callbacks + simple status endpoints.
|
|
1050
|
+
*/
|
|
1051
|
+
type PluginRouteMethod = 'GET' | 'POST';
|
|
1052
|
+
/**
|
|
1053
|
+
* A plugin route handler. It receives the raw Hono `Context` and returns
|
|
1054
|
+
* a `Response` (or a promise of one), exactly like a hand-written Hono
|
|
1055
|
+
* handler — the scope does **not** wrap it in a typed-route/validator
|
|
1056
|
+
* layer.
|
|
1057
|
+
*
|
|
1058
|
+
* **Raw body invariant** (RFC-0013 §8, a Slack hard requirement): the
|
|
1059
|
+
* route is a plain Hono route, NOT a `@hono/zod-openapi` route, so no
|
|
1060
|
+
* body-consuming validator runs ahead of the handler. `c.req.text()` /
|
|
1061
|
+
* `c.req.raw` therefore yield the *exact* bytes the client sent, which
|
|
1062
|
+
* the Slack signature check (`HMAC-SHA256` over `v0:{ts}:{rawBody}`)
|
|
1063
|
+
* depends on. `createJwtAuth` (installed on non-public routes) never
|
|
1064
|
+
* reads the body, so the invariant holds for authed routes too.
|
|
1065
|
+
*/
|
|
1066
|
+
type PluginRouteHandler = (c: Context) => Response | Promise<Response>;
|
|
1067
|
+
/** Per-route options passed alongside the handler. */
|
|
1068
|
+
interface PluginRouteOptions {
|
|
1069
|
+
/**
|
|
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.
|
|
1077
|
+
*/
|
|
1078
|
+
auth?: 'public' | 'user' | 'admin';
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* Scope passed to `registerRoutes(scope, ctx)`. Lets a plugin contribute
|
|
1082
|
+
* HTTP routes that the runtime mounts at
|
|
1083
|
+
* `/api/v2/plugins/<plugin-name>/<path>` — the `<plugin-name>` path
|
|
1084
|
+
* segment guarantees that core endpoints and other plugins cannot
|
|
1085
|
+
* collide (RFC-0013 §4).
|
|
919
1086
|
*
|
|
920
|
-
*
|
|
921
|
-
*
|
|
922
|
-
*
|
|
923
|
-
* is silently dropped. The type fixture exists so the public surface
|
|
924
|
-
* of `@crowi/plugin-api` keeps compiling against existing plugin
|
|
925
|
-
* sources (including the in-tree `__fixtures__/example-plugin.ts`)
|
|
926
|
-
* without forcing every plugin to be updated in lockstep with Phase 6.
|
|
1087
|
+
* The scope is built per-plugin inside `buildHonoApp` (the Hono app does
|
|
1088
|
+
* not exist yet when plugins activate at boot), so `<plugin-name>` is
|
|
1089
|
+
* already closed over — plugins only supply the sub-path.
|
|
927
1090
|
*/
|
|
928
1091
|
interface PluginRouterScope {
|
|
929
1092
|
/**
|
|
930
|
-
*
|
|
931
|
-
*
|
|
932
|
-
*
|
|
1093
|
+
* Mount `handler` for `method` at `<path>` under this plugin's
|
|
1094
|
+
* namespace. `path` is relative to `/api/v2/plugins/<plugin-name>` and
|
|
1095
|
+
* should start with `/` (e.g. `route('POST', '/events', handler, {
|
|
1096
|
+
* auth: 'public' })` → `POST /api/v2/plugins/<name>/events`).
|
|
1097
|
+
*
|
|
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).
|
|
933
1102
|
*/
|
|
934
|
-
|
|
1103
|
+
route(method: PluginRouteMethod, path: string, handler: PluginRouteHandler, opts?: PluginRouteOptions): void;
|
|
935
1104
|
}
|
|
936
1105
|
|
|
937
1106
|
/**
|
|
@@ -964,10 +1133,55 @@ interface CrowiPlugin {
|
|
|
964
1133
|
* at boot and loads `requires` first; cycles fail boot.
|
|
965
1134
|
*/
|
|
966
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;
|
|
967
1170
|
/**
|
|
968
1171
|
* Zod schema describing this plugin's *global* configurable values.
|
|
969
1172
|
* The admin UI generates a config form by walking this schema.
|
|
970
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
|
+
*
|
|
971
1185
|
* Mark sensitive fields with the `@sensitive` description marker
|
|
972
1186
|
* (see `SENSITIVE_FIELD_MARKER`); they are encrypted at rest via the
|
|
973
1187
|
* same KeyProvider used by core's sensitive Config.
|
|
@@ -1002,7 +1216,7 @@ interface CrowiPlugin {
|
|
|
1002
1216
|
* from a fixed allow-list to keep the bundle small.
|
|
1003
1217
|
*/
|
|
1004
1218
|
adminPlacement?: {
|
|
1005
|
-
section?: 'settings' | 'shared' | 'storage' | 'mail' | 'notification' | 'auth' | 'search' | 'renderer';
|
|
1219
|
+
section?: 'settings' | 'shared' | 'storage' | 'mail' | 'notification' | 'auth' | 'search' | 'renderer' | 'platform';
|
|
1006
1220
|
label?: string;
|
|
1007
1221
|
icon?: string;
|
|
1008
1222
|
};
|
|
@@ -1052,13 +1266,25 @@ interface CrowiPlugin {
|
|
|
1052
1266
|
*/
|
|
1053
1267
|
registerHooks?: (events: EventBus, ctx: PluginContext) => void;
|
|
1054
1268
|
/**
|
|
1055
|
-
*
|
|
1056
|
-
* `/api/v2/plugins/<name
|
|
1269
|
+
* HTTP routes the plugin contributes, mounted at
|
|
1270
|
+
* `/api/v2/plugins/<name>/<path>` (the `<name>` path segment guarantees
|
|
1057
1271
|
* that core endpoints and other plugins cannot collide). Used for
|
|
1058
|
-
*
|
|
1059
|
-
*
|
|
1060
|
-
*
|
|
1061
|
-
*
|
|
1272
|
+
* inbound webhooks (Slack events / slash / interactivity), "Test
|
|
1273
|
+
* connection" buttons, `@action` targets, OAuth callbacks, etc.
|
|
1274
|
+
*
|
|
1275
|
+
* Each route is a plain Hono handler — `scope.route(method, path,
|
|
1276
|
+
* (c) => Response, opts?)`. The handler receives the raw `Context`, so
|
|
1277
|
+
* `c.req.text()` / `c.req.raw` give the exact request bytes (no
|
|
1278
|
+
* validator consumes the body ahead of it — the Slack signature check
|
|
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).
|
|
1283
|
+
*
|
|
1284
|
+
* Called once at boot — but unlike the other `register*` hooks, this
|
|
1285
|
+
* runs inside `buildHonoApp` (the Hono app does not exist yet when
|
|
1286
|
+
* plugins activate), so a plugin's `registerRoutes` fires slightly
|
|
1287
|
+
* later than its `registerStorage` / `registerNotifier` / etc.
|
|
1062
1288
|
*/
|
|
1063
1289
|
registerRoutes?: (scope: PluginRouterScope, ctx: PluginContext) => void;
|
|
1064
1290
|
/**
|
|
@@ -1140,7 +1366,7 @@ interface ActionAnnotation {
|
|
|
1140
1366
|
/** Visible button label, e.g. "Test connection". */
|
|
1141
1367
|
label: string;
|
|
1142
1368
|
/** HTTP verb of the plugin endpoint to call. */
|
|
1143
|
-
method:
|
|
1369
|
+
method: PluginRouteMethod;
|
|
1144
1370
|
/** Path relative to `/api/v2/plugins/<name>/`, with leading slash. */
|
|
1145
1371
|
path: string;
|
|
1146
1372
|
}
|
|
@@ -1150,10 +1376,15 @@ interface ActionAnnotation {
|
|
|
1150
1376
|
* Format: `@action "<label>" <METHOD> <path>`
|
|
1151
1377
|
* e.g. `@action "Test connection" POST /test`
|
|
1152
1378
|
*
|
|
1153
|
-
* The label may include spaces when wrapped in double quotes; the
|
|
1154
|
-
*
|
|
1155
|
-
*
|
|
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.
|
|
1156
1387
|
*/
|
|
1157
1388
|
declare function getActionAnnotation(field: z.ZodTypeAny): ActionAnnotation | null;
|
|
1158
1389
|
|
|
1159
|
-
export { ACTION_FIELD_MARKER, 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,
|
|
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",
|
|
@@ -25,10 +25,15 @@
|
|
|
25
25
|
"access": "public"
|
|
26
26
|
},
|
|
27
27
|
"peerDependencies": {
|
|
28
|
+
"hono": "^4.12.25",
|
|
28
29
|
"zod": "^4"
|
|
29
30
|
},
|
|
30
31
|
"devDependencies": {
|
|
32
|
+
"@types/jest": "^29.5.14",
|
|
31
33
|
"@types/node": "^24",
|
|
34
|
+
"hono": "^4.12.25",
|
|
35
|
+
"jest": "^29.7.0",
|
|
36
|
+
"ts-jest": "^29.3.4",
|
|
32
37
|
"tsup": "^8.3.5",
|
|
33
38
|
"typescript": "^5.8.3",
|
|
34
39
|
"zod": "^4.4.3",
|
|
@@ -37,6 +42,7 @@
|
|
|
37
42
|
"scripts": {
|
|
38
43
|
"build": "tsup",
|
|
39
44
|
"dev": "tsup --watch --no-clean",
|
|
40
|
-
"type-check": "tsc --noEmit"
|
|
45
|
+
"type-check": "tsc --noEmit",
|
|
46
|
+
"test": "jest --passWithNoTests"
|
|
41
47
|
}
|
|
42
48
|
}
|