@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 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
- * crypto helpers, logging) — plugins must NOT import from
8
- * `@crowi/server` directly to keep the contract surface thin.
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
- * the base plugin owns `region` / `accessKeyId` / `secretAccessKey`,
27
- * and dependents (`@crowi/plugin-storage-aws-s3`,
28
- * `@crowi/plugin-mail-aws-ses`) read them through this method
29
- * instead of duplicating the fields in their own configSchema.
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. Returns the named core model. Plugins
38
- * touch core collections (Page, User, Comment, ...) through this
39
- * accessor rather than importing model files directly.
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
- * Scope passed to `registerRoutes`. The HTTP route contribution surface
914
- * for plugins is **not currently wired** to the runtime — it has never
915
- * been invoked end-to-end. RFC-0006 Phase 6 removed the framework
916
- * dependency that previously typed the contract argument; the API
917
- * surface is therefore a deliberate no-op stub until a follow-up RFC
918
- * redesigns plugin HTTP contribution on top of Hono.
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
- * Plugins that declare a `registerRoutes` callback today see it
921
- * receive this scope and call `scope.register(...)` with arbitrary
922
- * arguments both arguments are accepted as `unknown` and the call
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
- * No-op register. Both arguments are typed as `unknown` because the
931
- * runtime never reads them; concrete shapes are reserved for the
932
- * follow-up RFC that wires plugin HTTP contribution onto Hono.
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
- register(contract: unknown, implementation: unknown): void;
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
- * ts-rest contract that the plugin contributes. Mounted at
1056
- * `/api/v2/plugins/<name>/*` (the `<name>` path segment guarantees
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
- * "Test connection" buttons, OAuth callbacks, custom admin views,
1059
- * etc. The contract surface uses ts-rest so the admin UI can call
1060
- * plugin endpoints with the same `apiClient.<plugin>.<method>` shape
1061
- * it uses for core endpoints.
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: 'GET' | 'POST' | 'PUT' | 'DELETE';
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
- * method is one of `GET` / `POST` / `PUT` / `DELETE`; the path begins
1155
- * with `/`.
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 PluginCrypto, type PluginEvents, type PluginLogger, 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 StorageDriver, type StoragePutMeta, type StoragePutResult, type StorageRegistry, type UrlInlineExpansionRule, getActionAnnotation, isSensitiveField };
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
- * crypto helpers, logging) — plugins must NOT import from
8
- * `@crowi/server` directly to keep the contract surface thin.
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
- * the base plugin owns `region` / `accessKeyId` / `secretAccessKey`,
27
- * and dependents (`@crowi/plugin-storage-aws-s3`,
28
- * `@crowi/plugin-mail-aws-ses`) read them through this method
29
- * instead of duplicating the fields in their own configSchema.
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. Returns the named core model. Plugins
38
- * touch core collections (Page, User, Comment, ...) through this
39
- * accessor rather than importing model files directly.
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
- * Scope passed to `registerRoutes`. The HTTP route contribution surface
914
- * for plugins is **not currently wired** to the runtime — it has never
915
- * been invoked end-to-end. RFC-0006 Phase 6 removed the framework
916
- * dependency that previously typed the contract argument; the API
917
- * surface is therefore a deliberate no-op stub until a follow-up RFC
918
- * redesigns plugin HTTP contribution on top of Hono.
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
- * Plugins that declare a `registerRoutes` callback today see it
921
- * receive this scope and call `scope.register(...)` with arbitrary
922
- * arguments both arguments are accepted as `unknown` and the call
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
- * No-op register. Both arguments are typed as `unknown` because the
931
- * runtime never reads them; concrete shapes are reserved for the
932
- * follow-up RFC that wires plugin HTTP contribution onto Hono.
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
- register(contract: unknown, implementation: unknown): void;
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
- * ts-rest contract that the plugin contributes. Mounted at
1056
- * `/api/v2/plugins/<name>/*` (the `<name>` path segment guarantees
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
- * "Test connection" buttons, OAuth callbacks, custom admin views,
1059
- * etc. The contract surface uses ts-rest so the admin UI can call
1060
- * plugin endpoints with the same `apiClient.<plugin>.<method>` shape
1061
- * it uses for core endpoints.
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: 'GET' | 'POST' | 'PUT' | 'DELETE';
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
- * method is one of `GET` / `POST` / `PUT` / `DELETE`; the path begins
1155
- * with `/`.
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 PluginCrypto, type PluginEvents, type PluginLogger, 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 StorageDriver, type StoragePutMeta, type StoragePutResult, type StorageRegistry, type UrlInlineExpansionRule, getActionAnnotation, isSensitiveField };
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|PUT|DELETE)\s+(\/\S*)/);
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, PageMetadataAccessor, PluginCrypto, PluginLogger } 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 } from './routes';\n\nexport { SENSITIVE_FIELD_MARKER, ACTION_FIELD_MARKER, isSensitiveField, getActionAnnotation } from './schema-markers';\n","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: 'GET' | 'POST' | 'PUT' | 'DELETE';\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\n * method is one of `GET` / `POST` / `PUT` / `DELETE`; the path begins\n * with `/`.\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|PUT|DELETE)\\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;;;ACsBO,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;AAwBO,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,8CAA8C;AACvE,MAAI,CAAC,MAAO,QAAO;AAEnB,QAAM,CAAC,EAAE,OAAO,QAAQ,IAAI,IAAI;AAChC,SAAO,EAAE,OAAO,QAA8C,KAAK;AACrE;","names":[]}
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|PUT|DELETE)\s+(\/\S*)/);
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 };
@@ -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: 'GET' | 'POST' | 'PUT' | 'DELETE';\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\n * method is one of `GET` / `POST` / `PUT` / `DELETE`; the path begins\n * with `/`.\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|PUT|DELETE)\\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":";AAsBO,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;AAwBO,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,8CAA8C;AACvE,MAAI,CAAC,MAAO,QAAO;AAEnB,QAAM,CAAC,EAAE,OAAO,QAAQ,IAAI,IAAI;AAChC,SAAO,EAAE,OAAO,QAA8C,KAAK;AACrE;","names":[]}
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": "0.1.0-alpha.1",
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
  }