@crowi/plugin-api 0.1.0-alpha.2 → 1.0.0-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,117 @@
1
+ # @crowi/plugin-api
2
+
3
+ Type-only contract for Crowi 2.0 plugins. A Crowi plugin is an ordinary
4
+ npm package that default-exports an object satisfying the `CrowiPlugin`
5
+ type from this package; the runtime loads it via `await
6
+ import('<plugin-name>')` at boot and calls each `register*` callback it
7
+ implements. See
8
+ [RFC-0001](https://github.com/crowi/crowi/blob/main/docs/rfcs/0001-plugin-architecture.md)
9
+ for the full design and the
10
+ [plugin development guide](https://crowi.wiki/docs/plugins/developing)
11
+ for a walkthrough.
12
+
13
+ > **Alpha notice**: Crowi v2 is under active alpha development and this
14
+ > package is at `0.x`. The API stability guarantee (working across every
15
+ > v2 minor) takes effect once it reaches `2.x`; the contract may still
16
+ > change during the alpha period.
17
+
18
+ ## Minimal plugin
19
+
20
+ ```ts
21
+ import type { CrowiPlugin } from '@crowi/plugin-api';
22
+
23
+ const myPlugin: CrowiPlugin = {
24
+ name: '@example/crowi-plugin-mystorage',
25
+ version: '0.1.0',
26
+
27
+ registerStorage: (registry, ctx) => {
28
+ registry.register('mystorage', {
29
+ put: async (key, body, meta) => ({ key }),
30
+ get: async (key) => { throw new Error('not implemented'); },
31
+ delete: async (key) => {},
32
+ });
33
+ },
34
+ };
35
+
36
+ export default myPlugin;
37
+ ```
38
+
39
+ `name` must match the npm package name — it doubles as the namespace
40
+ prefix for this plugin's config rows (`plugin:<name>:*`) and per-page
41
+ metadata (`page.metadata['<name>']`).
42
+
43
+ ## Authoring a config schema
44
+
45
+ A plugin declares its configurable values with `configSchema`, a Zod
46
+ object schema. The admin UI at `/admin/plugins` walks this schema to
47
+ auto-generate a config form, encrypt `@sensitive`-marked fields at
48
+ rest, and render `@action`-marked fields with a button.
49
+
50
+ This package's `peerDependencies` declares `zod: "^4"` — that is the
51
+ correct **npm package** to install, because the zod v4 package is the
52
+ one that ships a `zod/v3` compat subpath. What `peerDependencies`
53
+ cannot express is *which entry point* to import from, and that part
54
+ matters:
55
+
56
+ ```ts
57
+ // Correct — the v3 compat shim that the v4 package ships.
58
+ import { z } from 'zod/v3';
59
+
60
+ // Wrong — compiles and type-checks, but fails at plugin boot.
61
+ import { z } from 'zod';
62
+ ```
63
+
64
+ `CrowiPlugin.configSchema` is typed against `zod/v3`'s `z.ZodObject`,
65
+ and every runtime helper that reads a plugin's config schema
66
+ (`@sensitive` / `@action` marker detection, the admin form field
67
+ serializer, `listSensitiveKeys()`) introspects the `zod/v3` internal
68
+ shape (`_def.typeName`, `_def.values`, `.description`, …). A schema
69
+ built from the top-level `zod` (v4) API has a different internal shape
70
+ and none of that introspection can see through it — most importantly,
71
+ `@sensitive` fields stop being detected, and the value they guard would
72
+ be written to and read from storage as plaintext.
73
+
74
+ To turn that silent failure mode into a loud one, `PluginManager`
75
+ validates every plugin's `configSchema` at boot and throws if it wasn't
76
+ built from `zod/v3`:
77
+
78
+ ```
79
+ Plugin '<name>' declares configSchema built with the top-level 'zod' (v4) API.
80
+ Import from 'zod/v3' instead — @crowi/plugin-api's config-schema introspection
81
+ requires the zod v3 compat shape (see @crowi/plugin-api README).
82
+ ```
83
+
84
+ If you hit this error, the fix is always the same: change the `zod`
85
+ import for the file that builds `configSchema` (and `pageMetadataSchema`,
86
+ if you use it) to `import { z } from 'zod/v3'`. No other code needs to
87
+ change — `zod/v3`'s `z.object()` / `z.string()` / etc. API surface is
88
+ what every first-party Crowi plugin already uses.
89
+
90
+ ```ts
91
+ import { z } from 'zod/v3';
92
+ import type { CrowiPlugin } from '@crowi/plugin-api';
93
+
94
+ const myPlugin: CrowiPlugin = {
95
+ name: '@example/crowi-plugin-mystorage',
96
+ version: '0.1.0',
97
+
98
+ configSchema: z.object({
99
+ endpoint: z.string().url().describe('Storage endpoint URL'),
100
+ accessKey: z.string().describe('@sensitive Access key'),
101
+ }),
102
+
103
+ registerStorage: (registry, ctx) => {
104
+ const config = ctx.config<{ endpoint: string; accessKey: string }>();
105
+ // ... build the driver using config
106
+ },
107
+ };
108
+
109
+ export default myPlugin;
110
+ ```
111
+
112
+ ## See also
113
+
114
+ - [Plugin development guide](https://crowi.wiki/docs/plugins/developing) —
115
+ the full walkthrough (markers, `adminPlacement`, `configI18n`,
116
+ `PluginContext`, dependency plugins, renderer plugins).
117
+ - [RFC-0001: Plugin architecture](https://github.com/crowi/crowi/blob/main/docs/rfcs/0001-plugin-architecture.md)
package/dist/index.d.mts CHANGED
@@ -5,8 +5,36 @@ import { Context } from 'hono';
5
5
  /**
6
6
  * The context object passed to every plugin callback. It is the only
7
7
  * conduit through which a plugin reads core state (config, models,
8
- * crypto helpers, logging) — plugins must NOT import from
9
- * `@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.
10
38
  */
11
39
  interface PluginContext {
12
40
  /**
@@ -21,13 +49,19 @@ interface PluginContext {
21
49
  * Read a typed dependency plugin's config. The target plugin must
22
50
  * be listed in this plugin's `requires` array — reading another
23
51
  * plugin's config without declaring the dependency is a contract
24
- * violation and throws.
52
+ * violation and throws. In addition, the target plugin must have
53
+ * opted in with `CrowiPlugin.exposesConfigToDependents: true` —
54
+ * `requires` alone is only this plugin's side of the contract, not
55
+ * permission granted by the dependency. Throws when the dependency
56
+ * has not opted in.
25
57
  *
26
- * Useful for shared-credential plugins like `@crowi/plugin-aws`:
27
- * the base plugin owns `region` / `accessKeyId` / `secretAccessKey`,
28
- * and dependents (`@crowi/plugin-storage-aws-s3`,
29
- * `@crowi/plugin-mail-aws-ses`) read them through this method
30
- * 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.
31
65
  */
32
66
  dependencyConfig<T>(dependencyName: string): T;
33
67
  /**
@@ -42,19 +76,90 @@ interface PluginContext {
42
76
  /** Per-Page metadata accessor for this plugin's namespace. */
43
77
  pageMetadata: PageMetadataAccessor;
44
78
  /**
45
- * Mongoose model accessor. Returns the named core model. Plugins
46
- * touch core collections (Page, User, Comment, ...) through this
47
- * 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).
48
96
  *
49
97
  * Typed loosely (`unknown`) at this layer because the core model
50
98
  * types live in `@crowi/server`; plugins narrow the return type at
51
99
  * the call site.
52
100
  */
53
101
  model(name: string): unknown;
54
- /** Symmetric encrypt / decrypt against the configured KeyProvider. */
55
- crypto: PluginCrypto;
56
102
  /** Structured logger scoped to this plugin (auto-prefixed with name). */
57
103
  log: PluginLogger;
104
+ /**
105
+ * Hot-reload state primitive. Returns a {@link StateCell} that holds a
106
+ * mutable value — the driver-owned resource (an S3 client, an SMTP
107
+ * transport, a search client, ...) that `reconfigure` rebuilds when
108
+ * admin saves new config. Every call across every `PluginContext`
109
+ * instance for this plugin (the activation-time `ctx` passed to
110
+ * `registerStorage`/`registerSearch`/`registerMailSender` etc., and
111
+ * every later `reconfigure(ctx)` call) returns the **same** cell — the
112
+ * runtime keys it by plugin name, not by `ctx` instance. `initial` is
113
+ * only used the first time this plugin ever calls `state()`; later
114
+ * calls ignore it and just return the existing cell.
115
+ *
116
+ * Use this instead of a module-scope `let`/`const` — it protects
117
+ * in-flight `withValue()` callers from a concurrent `set()` swapping
118
+ * the value out from under them, and gives `set()`'s `dispose` option
119
+ * a correct place to tear down the previous value (close a client,
120
+ * end a connection pool, ...) once nothing is still using it.
121
+ */
122
+ state<T>(initial: T): StateCell<T>;
123
+ }
124
+ /**
125
+ * A hot-reload-safe mutable cell, returned by `PluginContext.state()`.
126
+ * Designed for driver plugins (storage / search / mail / ...) that
127
+ * `reconfigure()` rebuilds a stateful resource for: `withValue()` marks
128
+ * the current value "in use" for the duration of the callback so a
129
+ * concurrent `set()` cannot tear it down mid-call, and `set()`'s
130
+ * `dispose` option only runs once every such in-flight caller has
131
+ * settled.
132
+ */
133
+ interface StateCell<T> {
134
+ /**
135
+ * Atomic snapshot of the current value. Safe to read once and reuse
136
+ * across `await`s in the caller — but prefer {@link withValue} when the
137
+ * value may be disposed (e.g. an SDK client that `dispose` closes),
138
+ * since `get()` gives no in-flight protection.
139
+ */
140
+ get(): T;
141
+ /**
142
+ * Run `fn` against the current value while marking it "in use", so a
143
+ * concurrent `set()`'s `dispose` waits for `fn` to settle (resolve or
144
+ * reject) before tearing down the value `fn` captured. This is the
145
+ * primary way driver methods should read the cell.
146
+ */
147
+ withValue<R>(fn: (value: T) => R | Promise<R>): Promise<R>;
148
+ /**
149
+ * Swap in `next`. If `opts.dispose` is given, it runs — asynchronously,
150
+ * never inline — once every `withValue()` call that was in flight
151
+ * against the previous value at the moment of the swap has settled
152
+ * (immediately, on the next microtask, if none were in flight).
153
+ *
154
+ * `dispose` must handle (and log, if relevant) its own errors — a
155
+ * rejected `dispose` is swallowed by the runtime rather than
156
+ * surfaced anywhere, since there is no caller left waiting on it by
157
+ * the time it runs. Wrap the teardown in its own `try`/`catch` (or
158
+ * `.catch()`) instead of letting it throw.
159
+ */
160
+ set(next: T, opts?: {
161
+ dispose?: (prev: T) => void | Promise<void>;
162
+ }): void;
58
163
  }
59
164
  /**
60
165
  * Read-only view of core application settings exposed to plugins via
@@ -94,10 +199,6 @@ interface PageMetadataAccessor {
94
199
  /** Remove this plugin's metadata for a specific page. */
95
200
  remove(pageId: string): Promise<void>;
96
201
  }
97
- interface PluginCrypto {
98
- encrypt(plaintext: string): string;
99
- decrypt(ciphertext: string): string;
100
- }
101
202
  interface PluginLogger {
102
203
  debug(message: string, ...args: unknown[]): void;
103
204
  info(message: string, ...args: unknown[]): void;
@@ -966,17 +1067,15 @@ type PluginRouteHandler = (c: Context) => Response | Promise<Response>;
966
1067
  /** Per-route options passed alongside the handler. */
967
1068
  interface PluginRouteOptions {
968
1069
  /**
969
- * When `true`, the route is mounted **without** `createJwtAuth`, so it
970
- * is reachable by unauthenticated requests (Crowi-auth public). Use for
971
- * inbound webhooks that authenticate themselves out-of-band — e.g. the
972
- * Slack Events API endpoint, which is gated by Slack's request-signature
973
- * check rather than a Crowi session (RFC-0013 §8).
974
- *
975
- * Omitted / `false` mounts the route under `createJwtAuth`, so it
976
- * requires a valid Crowi JWT just like a core authenticated endpoint
977
- * (admin "Test connection" / `@action` targets, OAuth callbacks).
1070
+ * Authorization tier this route requires.
1071
+ * - `'public'`: no auth (self-authenticating webhooks Slack signature
1072
+ * check etc.).
1073
+ * - `'user'` (default): any authenticated Crowi user (`createJwtAuth`).
1074
+ * - `'admin'`: `user.admin === true` (`createJwtAdminRequired`) — use for
1075
+ * Test-connection / `@action` targets reached only from the admin
1076
+ * config form.
978
1077
  */
979
- public?: boolean;
1078
+ auth?: 'public' | 'user' | 'admin';
980
1079
  }
981
1080
  /**
982
1081
  * Scope passed to `registerRoutes(scope, ctx)`. Lets a plugin contribute
@@ -994,11 +1093,12 @@ interface PluginRouterScope {
994
1093
  * Mount `handler` for `method` at `<path>` under this plugin's
995
1094
  * namespace. `path` is relative to `/api/v2/plugins/<plugin-name>` and
996
1095
  * should start with `/` (e.g. `route('POST', '/events', handler, {
997
- * public: true })` → `POST /api/v2/plugins/<name>/events`).
1096
+ * auth: 'public' })` → `POST /api/v2/plugins/<name>/events`).
998
1097
  *
999
- * Pass `{ public: true }` to bypass `createJwtAuth` for self-
1000
- * authenticating inbound webhooks; omit it for routes that require a
1001
- * Crowi session.
1098
+ * Pass `{ auth: 'public' }` to bypass Crowi auth entirely for self-
1099
+ * authenticating inbound webhooks, `{ auth: 'admin' }` to require
1100
+ * `user.admin === true`, or omit `opts` for the `'user'` default (any
1101
+ * authenticated Crowi user).
1002
1102
  */
1003
1103
  route(method: PluginRouteMethod, path: string, handler: PluginRouteHandler, opts?: PluginRouteOptions): void;
1004
1104
  }
@@ -1033,10 +1133,55 @@ interface CrowiPlugin {
1033
1133
  * at boot and loads `requires` first; cycles fail boot.
1034
1134
  */
1035
1135
  requires?: string[];
1136
+ /**
1137
+ * Core Mongoose model names (e.g. `['Page', 'Bookmark']`) this plugin
1138
+ * is allowed to reach via `ctx.model(name)`. The PluginManager
1139
+ * validates every entry against the set of registered core model
1140
+ * names at boot — an unknown name fails boot with a descriptive
1141
+ * error. `ctx.model(name)` throws at call time for any `name` not
1142
+ * listed here.
1143
+ *
1144
+ * A model listed here is granted full (unrestricted) read/write
1145
+ * access — there is no read-only mode. Omit or leave empty for a
1146
+ * plugin that never calls `ctx.model()`.
1147
+ *
1148
+ * Credential-bearing core models (`Config`, `PersonalAccessToken`,
1149
+ * OAuth client/token/grant models, `Share`, `ShareAccess`) can never
1150
+ * be listed here — declaring one fails boot, and `ctx.model()` also
1151
+ * refuses to return one at call time as defense-in-depth. There is no
1152
+ * legitimate plugin use case for touching those collections directly.
1153
+ */
1154
+ modelAccess?: string[];
1155
+ /**
1156
+ * Opt in to letting *other* plugins read this plugin's config through
1157
+ * their `ctx.dependencyConfig<T>(this.name)` (they must also list this
1158
+ * plugin in their own `requires`). Defaults to `false` — a plugin's
1159
+ * config, including `@sensitive` fields, is private to itself unless
1160
+ * it explicitly declares this flag.
1161
+ *
1162
+ * Set this on a plugin that exists specifically to hold credentials
1163
+ * shared by other plugins — e.g. `@crowi/plugin-aws` sets it so
1164
+ * `@crowi/plugin-storage-aws-s3` and `@crowi/plugin-mail-aws-ses` can
1165
+ * read its `region` / `accessKeyId` / `secretAccessKey` without
1166
+ * duplicating them in their own `configSchema`. Most plugins should
1167
+ * leave this unset.
1168
+ */
1169
+ exposesConfigToDependents?: boolean;
1036
1170
  /**
1037
1171
  * Zod schema describing this plugin's *global* configurable values.
1038
1172
  * The admin UI generates a config form by walking this schema.
1039
1173
  *
1174
+ * Build this with `import { z } from 'zod/v3'` — NOT the top-level
1175
+ * `import { z } from 'zod'` (v4). `peerDependencies: { zod: "^4" }`
1176
+ * only says which npm package to install; the v4 package ships a
1177
+ * `zod/v3` compat subpath, and that subpath's runtime shape is what
1178
+ * every introspection helper here (`schema-serializer.ts`,
1179
+ * `schema-markers.ts`, `PluginManager.listSensitiveKeys()`) actually
1180
+ * walks. A schema built from the top-level v4 API fails boot with an
1181
+ * explicit error (`PluginManager.activate()`'s config-schema guard —
1182
+ * see this package's README) rather than silently losing
1183
+ * `@sensitive` detection.
1184
+ *
1040
1185
  * Mark sensitive fields with the `@sensitive` description marker
1041
1186
  * (see `SENSITIVE_FIELD_MARKER`); they are encrypted at rest via the
1042
1187
  * same KeyProvider used by core's sensitive Config.
@@ -1131,9 +1276,10 @@ interface CrowiPlugin {
1131
1276
  * (c) => Response, opts?)`. The handler receives the raw `Context`, so
1132
1277
  * `c.req.text()` / `c.req.raw` give the exact request bytes (no
1133
1278
  * validator consumes the body ahead of it — the Slack signature check
1134
- * relies on this). Pass `{ public: true }` to bypass `createJwtAuth`
1135
- * for self-authenticating webhooks; omit it for Crowi-session-gated
1136
- * routes.
1279
+ * relies on this). Pass `{ auth: 'public' }` to bypass Crowi auth for
1280
+ * self-authenticating webhooks, `{ auth: 'admin' }` for routes that
1281
+ * require `user.admin === true`, or omit `opts` for the `'user'`
1282
+ * default (any authenticated Crowi user).
1137
1283
  *
1138
1284
  * Called once at boot — but unlike the other `register*` hooks, this
1139
1285
  * runs inside `buildHonoApp` (the Hono app does not exist yet when
@@ -1220,7 +1366,7 @@ interface ActionAnnotation {
1220
1366
  /** Visible button label, e.g. "Test connection". */
1221
1367
  label: string;
1222
1368
  /** HTTP verb of the plugin endpoint to call. */
1223
- method: 'GET' | 'POST' | 'PUT' | 'DELETE';
1369
+ method: PluginRouteMethod;
1224
1370
  /** Path relative to `/api/v2/plugins/<name>/`, with leading slash. */
1225
1371
  path: string;
1226
1372
  }
@@ -1230,10 +1376,15 @@ interface ActionAnnotation {
1230
1376
  * Format: `@action "<label>" <METHOD> <path>`
1231
1377
  * e.g. `@action "Test connection" POST /test`
1232
1378
  *
1233
- * The label may include spaces when wrapped in double quotes; the
1234
- * method is one of `GET` / `POST` / `PUT` / `DELETE`; the path begins
1235
- * 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.
1236
1387
  */
1237
1388
  declare function getActionAnnotation(field: z.ZodTypeAny): ActionAnnotation | null;
1238
1389
 
1239
- export { ACTION_FIELD_MARKER, type AppInfo, type AuthContext, type AuthDriver, type AuthProfile, type AuthRegistry, type AuthVerifyResult, type CacheEntry, type CacheKey, type CacheStorage, type CodeBlockInfo, type CodeBlockRenderer, type CrowiPlugin, type EmailMessage, type EmbedFragment, type EmbedInput, type EmbedRenderer, type EventBus, type InlineExpansion, type MailSender, type MailSenderRegistry, type NodeRenderer, type NotificationPayload, type NotifierDriver, type NotifierRegistry, type PageMetadataAccessor, type PluginContext, type PluginCrypto, 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 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
@@ -5,8 +5,36 @@ import { Context } from 'hono';
5
5
  /**
6
6
  * The context object passed to every plugin callback. It is the only
7
7
  * conduit through which a plugin reads core state (config, models,
8
- * crypto helpers, logging) — plugins must NOT import from
9
- * `@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.
10
38
  */
11
39
  interface PluginContext {
12
40
  /**
@@ -21,13 +49,19 @@ interface PluginContext {
21
49
  * Read a typed dependency plugin's config. The target plugin must
22
50
  * be listed in this plugin's `requires` array — reading another
23
51
  * plugin's config without declaring the dependency is a contract
24
- * violation and throws.
52
+ * violation and throws. In addition, the target plugin must have
53
+ * opted in with `CrowiPlugin.exposesConfigToDependents: true` —
54
+ * `requires` alone is only this plugin's side of the contract, not
55
+ * permission granted by the dependency. Throws when the dependency
56
+ * has not opted in.
25
57
  *
26
- * Useful for shared-credential plugins like `@crowi/plugin-aws`:
27
- * the base plugin owns `region` / `accessKeyId` / `secretAccessKey`,
28
- * and dependents (`@crowi/plugin-storage-aws-s3`,
29
- * `@crowi/plugin-mail-aws-ses`) read them through this method
30
- * 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.
31
65
  */
32
66
  dependencyConfig<T>(dependencyName: string): T;
33
67
  /**
@@ -42,19 +76,90 @@ interface PluginContext {
42
76
  /** Per-Page metadata accessor for this plugin's namespace. */
43
77
  pageMetadata: PageMetadataAccessor;
44
78
  /**
45
- * Mongoose model accessor. Returns the named core model. Plugins
46
- * touch core collections (Page, User, Comment, ...) through this
47
- * 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).
48
96
  *
49
97
  * Typed loosely (`unknown`) at this layer because the core model
50
98
  * types live in `@crowi/server`; plugins narrow the return type at
51
99
  * the call site.
52
100
  */
53
101
  model(name: string): unknown;
54
- /** Symmetric encrypt / decrypt against the configured KeyProvider. */
55
- crypto: PluginCrypto;
56
102
  /** Structured logger scoped to this plugin (auto-prefixed with name). */
57
103
  log: PluginLogger;
104
+ /**
105
+ * Hot-reload state primitive. Returns a {@link StateCell} that holds a
106
+ * mutable value — the driver-owned resource (an S3 client, an SMTP
107
+ * transport, a search client, ...) that `reconfigure` rebuilds when
108
+ * admin saves new config. Every call across every `PluginContext`
109
+ * instance for this plugin (the activation-time `ctx` passed to
110
+ * `registerStorage`/`registerSearch`/`registerMailSender` etc., and
111
+ * every later `reconfigure(ctx)` call) returns the **same** cell — the
112
+ * runtime keys it by plugin name, not by `ctx` instance. `initial` is
113
+ * only used the first time this plugin ever calls `state()`; later
114
+ * calls ignore it and just return the existing cell.
115
+ *
116
+ * Use this instead of a module-scope `let`/`const` — it protects
117
+ * in-flight `withValue()` callers from a concurrent `set()` swapping
118
+ * the value out from under them, and gives `set()`'s `dispose` option
119
+ * a correct place to tear down the previous value (close a client,
120
+ * end a connection pool, ...) once nothing is still using it.
121
+ */
122
+ state<T>(initial: T): StateCell<T>;
123
+ }
124
+ /**
125
+ * A hot-reload-safe mutable cell, returned by `PluginContext.state()`.
126
+ * Designed for driver plugins (storage / search / mail / ...) that
127
+ * `reconfigure()` rebuilds a stateful resource for: `withValue()` marks
128
+ * the current value "in use" for the duration of the callback so a
129
+ * concurrent `set()` cannot tear it down mid-call, and `set()`'s
130
+ * `dispose` option only runs once every such in-flight caller has
131
+ * settled.
132
+ */
133
+ interface StateCell<T> {
134
+ /**
135
+ * Atomic snapshot of the current value. Safe to read once and reuse
136
+ * across `await`s in the caller — but prefer {@link withValue} when the
137
+ * value may be disposed (e.g. an SDK client that `dispose` closes),
138
+ * since `get()` gives no in-flight protection.
139
+ */
140
+ get(): T;
141
+ /**
142
+ * Run `fn` against the current value while marking it "in use", so a
143
+ * concurrent `set()`'s `dispose` waits for `fn` to settle (resolve or
144
+ * reject) before tearing down the value `fn` captured. This is the
145
+ * primary way driver methods should read the cell.
146
+ */
147
+ withValue<R>(fn: (value: T) => R | Promise<R>): Promise<R>;
148
+ /**
149
+ * Swap in `next`. If `opts.dispose` is given, it runs — asynchronously,
150
+ * never inline — once every `withValue()` call that was in flight
151
+ * against the previous value at the moment of the swap has settled
152
+ * (immediately, on the next microtask, if none were in flight).
153
+ *
154
+ * `dispose` must handle (and log, if relevant) its own errors — a
155
+ * rejected `dispose` is swallowed by the runtime rather than
156
+ * surfaced anywhere, since there is no caller left waiting on it by
157
+ * the time it runs. Wrap the teardown in its own `try`/`catch` (or
158
+ * `.catch()`) instead of letting it throw.
159
+ */
160
+ set(next: T, opts?: {
161
+ dispose?: (prev: T) => void | Promise<void>;
162
+ }): void;
58
163
  }
59
164
  /**
60
165
  * Read-only view of core application settings exposed to plugins via
@@ -94,10 +199,6 @@ interface PageMetadataAccessor {
94
199
  /** Remove this plugin's metadata for a specific page. */
95
200
  remove(pageId: string): Promise<void>;
96
201
  }
97
- interface PluginCrypto {
98
- encrypt(plaintext: string): string;
99
- decrypt(ciphertext: string): string;
100
- }
101
202
  interface PluginLogger {
102
203
  debug(message: string, ...args: unknown[]): void;
103
204
  info(message: string, ...args: unknown[]): void;
@@ -966,17 +1067,15 @@ type PluginRouteHandler = (c: Context) => Response | Promise<Response>;
966
1067
  /** Per-route options passed alongside the handler. */
967
1068
  interface PluginRouteOptions {
968
1069
  /**
969
- * When `true`, the route is mounted **without** `createJwtAuth`, so it
970
- * is reachable by unauthenticated requests (Crowi-auth public). Use for
971
- * inbound webhooks that authenticate themselves out-of-band — e.g. the
972
- * Slack Events API endpoint, which is gated by Slack's request-signature
973
- * check rather than a Crowi session (RFC-0013 §8).
974
- *
975
- * Omitted / `false` mounts the route under `createJwtAuth`, so it
976
- * requires a valid Crowi JWT just like a core authenticated endpoint
977
- * (admin "Test connection" / `@action` targets, OAuth callbacks).
1070
+ * Authorization tier this route requires.
1071
+ * - `'public'`: no auth (self-authenticating webhooks Slack signature
1072
+ * check etc.).
1073
+ * - `'user'` (default): any authenticated Crowi user (`createJwtAuth`).
1074
+ * - `'admin'`: `user.admin === true` (`createJwtAdminRequired`) — use for
1075
+ * Test-connection / `@action` targets reached only from the admin
1076
+ * config form.
978
1077
  */
979
- public?: boolean;
1078
+ auth?: 'public' | 'user' | 'admin';
980
1079
  }
981
1080
  /**
982
1081
  * Scope passed to `registerRoutes(scope, ctx)`. Lets a plugin contribute
@@ -994,11 +1093,12 @@ interface PluginRouterScope {
994
1093
  * Mount `handler` for `method` at `<path>` under this plugin's
995
1094
  * namespace. `path` is relative to `/api/v2/plugins/<plugin-name>` and
996
1095
  * should start with `/` (e.g. `route('POST', '/events', handler, {
997
- * public: true })` → `POST /api/v2/plugins/<name>/events`).
1096
+ * auth: 'public' })` → `POST /api/v2/plugins/<name>/events`).
998
1097
  *
999
- * Pass `{ public: true }` to bypass `createJwtAuth` for self-
1000
- * authenticating inbound webhooks; omit it for routes that require a
1001
- * Crowi session.
1098
+ * Pass `{ auth: 'public' }` to bypass Crowi auth entirely for self-
1099
+ * authenticating inbound webhooks, `{ auth: 'admin' }` to require
1100
+ * `user.admin === true`, or omit `opts` for the `'user'` default (any
1101
+ * authenticated Crowi user).
1002
1102
  */
1003
1103
  route(method: PluginRouteMethod, path: string, handler: PluginRouteHandler, opts?: PluginRouteOptions): void;
1004
1104
  }
@@ -1033,10 +1133,55 @@ interface CrowiPlugin {
1033
1133
  * at boot and loads `requires` first; cycles fail boot.
1034
1134
  */
1035
1135
  requires?: string[];
1136
+ /**
1137
+ * Core Mongoose model names (e.g. `['Page', 'Bookmark']`) this plugin
1138
+ * is allowed to reach via `ctx.model(name)`. The PluginManager
1139
+ * validates every entry against the set of registered core model
1140
+ * names at boot — an unknown name fails boot with a descriptive
1141
+ * error. `ctx.model(name)` throws at call time for any `name` not
1142
+ * listed here.
1143
+ *
1144
+ * A model listed here is granted full (unrestricted) read/write
1145
+ * access — there is no read-only mode. Omit or leave empty for a
1146
+ * plugin that never calls `ctx.model()`.
1147
+ *
1148
+ * Credential-bearing core models (`Config`, `PersonalAccessToken`,
1149
+ * OAuth client/token/grant models, `Share`, `ShareAccess`) can never
1150
+ * be listed here — declaring one fails boot, and `ctx.model()` also
1151
+ * refuses to return one at call time as defense-in-depth. There is no
1152
+ * legitimate plugin use case for touching those collections directly.
1153
+ */
1154
+ modelAccess?: string[];
1155
+ /**
1156
+ * Opt in to letting *other* plugins read this plugin's config through
1157
+ * their `ctx.dependencyConfig<T>(this.name)` (they must also list this
1158
+ * plugin in their own `requires`). Defaults to `false` — a plugin's
1159
+ * config, including `@sensitive` fields, is private to itself unless
1160
+ * it explicitly declares this flag.
1161
+ *
1162
+ * Set this on a plugin that exists specifically to hold credentials
1163
+ * shared by other plugins — e.g. `@crowi/plugin-aws` sets it so
1164
+ * `@crowi/plugin-storage-aws-s3` and `@crowi/plugin-mail-aws-ses` can
1165
+ * read its `region` / `accessKeyId` / `secretAccessKey` without
1166
+ * duplicating them in their own `configSchema`. Most plugins should
1167
+ * leave this unset.
1168
+ */
1169
+ exposesConfigToDependents?: boolean;
1036
1170
  /**
1037
1171
  * Zod schema describing this plugin's *global* configurable values.
1038
1172
  * The admin UI generates a config form by walking this schema.
1039
1173
  *
1174
+ * Build this with `import { z } from 'zod/v3'` — NOT the top-level
1175
+ * `import { z } from 'zod'` (v4). `peerDependencies: { zod: "^4" }`
1176
+ * only says which npm package to install; the v4 package ships a
1177
+ * `zod/v3` compat subpath, and that subpath's runtime shape is what
1178
+ * every introspection helper here (`schema-serializer.ts`,
1179
+ * `schema-markers.ts`, `PluginManager.listSensitiveKeys()`) actually
1180
+ * walks. A schema built from the top-level v4 API fails boot with an
1181
+ * explicit error (`PluginManager.activate()`'s config-schema guard —
1182
+ * see this package's README) rather than silently losing
1183
+ * `@sensitive` detection.
1184
+ *
1040
1185
  * Mark sensitive fields with the `@sensitive` description marker
1041
1186
  * (see `SENSITIVE_FIELD_MARKER`); they are encrypted at rest via the
1042
1187
  * same KeyProvider used by core's sensitive Config.
@@ -1131,9 +1276,10 @@ interface CrowiPlugin {
1131
1276
  * (c) => Response, opts?)`. The handler receives the raw `Context`, so
1132
1277
  * `c.req.text()` / `c.req.raw` give the exact request bytes (no
1133
1278
  * validator consumes the body ahead of it — the Slack signature check
1134
- * relies on this). Pass `{ public: true }` to bypass `createJwtAuth`
1135
- * for self-authenticating webhooks; omit it for Crowi-session-gated
1136
- * routes.
1279
+ * relies on this). Pass `{ auth: 'public' }` to bypass Crowi auth for
1280
+ * self-authenticating webhooks, `{ auth: 'admin' }` for routes that
1281
+ * require `user.admin === true`, or omit `opts` for the `'user'`
1282
+ * default (any authenticated Crowi user).
1137
1283
  *
1138
1284
  * Called once at boot — but unlike the other `register*` hooks, this
1139
1285
  * runs inside `buildHonoApp` (the Hono app does not exist yet when
@@ -1220,7 +1366,7 @@ interface ActionAnnotation {
1220
1366
  /** Visible button label, e.g. "Test connection". */
1221
1367
  label: string;
1222
1368
  /** HTTP verb of the plugin endpoint to call. */
1223
- method: 'GET' | 'POST' | 'PUT' | 'DELETE';
1369
+ method: PluginRouteMethod;
1224
1370
  /** Path relative to `/api/v2/plugins/<name>/`, with leading slash. */
1225
1371
  path: string;
1226
1372
  }
@@ -1230,10 +1376,15 @@ interface ActionAnnotation {
1230
1376
  * Format: `@action "<label>" <METHOD> <path>`
1231
1377
  * e.g. `@action "Test connection" POST /test`
1232
1378
  *
1233
- * The label may include spaces when wrapped in double quotes; the
1234
- * method is one of `GET` / `POST` / `PUT` / `DELETE`; the path begins
1235
- * 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.
1236
1387
  */
1237
1388
  declare function getActionAnnotation(field: z.ZodTypeAny): ActionAnnotation | null;
1238
1389
 
1239
- export { ACTION_FIELD_MARKER, type AppInfo, type AuthContext, type AuthDriver, type AuthProfile, type AuthRegistry, type AuthVerifyResult, type CacheEntry, type CacheKey, type CacheStorage, type CodeBlockInfo, type CodeBlockRenderer, type CrowiPlugin, type EmailMessage, type EmbedFragment, type EmbedInput, type EmbedRenderer, type EventBus, type InlineExpansion, type MailSender, type MailSenderRegistry, type NodeRenderer, type NotificationPayload, type NotifierDriver, type NotifierRegistry, type PageMetadataAccessor, type PluginContext, type PluginCrypto, 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 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, AppInfo, 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, 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\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.2",
3
+ "version": "1.0.0-alpha.3",
4
4
  "description": "Type-only contract for Crowi 2.0 plugins. See docs/rfcs/0001-plugin-architecture.md.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -29,8 +29,11 @@
29
29
  "zod": "^4"
30
30
  },
31
31
  "devDependencies": {
32
+ "@types/jest": "^29.5.14",
32
33
  "@types/node": "^24",
33
34
  "hono": "^4.12.25",
35
+ "jest": "^29.7.0",
36
+ "ts-jest": "^29.3.4",
34
37
  "tsup": "^8.3.5",
35
38
  "typescript": "^5.8.3",
36
39
  "zod": "^4.4.3",
@@ -39,6 +42,7 @@
39
42
  "scripts": {
40
43
  "build": "tsup",
41
44
  "dev": "tsup --watch --no-clean",
42
- "type-check": "tsc --noEmit"
45
+ "type-check": "tsc --noEmit",
46
+ "test": "jest --passWithNoTests"
43
47
  }
44
48
  }