@teikyo/enkaku 0.0.0-stage → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Paul Le Cam
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,72 @@
1
- # Temporary Holding Version
1
+ # @teikyo/enkaku
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Enkaku RPC plugin for Sozai HTTP servers. It serves an Enkaku server over the Enkaku HTTP transport as the `enkaku:rpc` plugin, with access optionally gated by `access:grants` and delegated capabilities checked by `access:revocation`.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ pnpm add @teikyo/enkaku @sozai/http-server hono
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ Plugin name: `enkaku:rpc` (exported as `ENKAKU_RPC`).
14
+
15
+ ```ts
16
+ import { createServer } from '@sozai/http-server'
17
+ import { accessGrantsPlugin, accessRevocationPlugin } from '@teikyo/access'
18
+ import { ENKAKU_RPC, enkakuPlugin } from '@teikyo/enkaku'
19
+ import { hozonDBPlugin } from '@teikyo/hozon'
20
+
21
+ const server = await createServer({
22
+ plugins: [
23
+ hozonDBPlugin({ db: 'app.db' }),
24
+ accessGrantsPlugin(),
25
+ accessRevocationPlugin(),
26
+ enkakuPlugin({
27
+ path: '/rpc',
28
+ identity: serverIdentity,
29
+ protocol,
30
+ handlers,
31
+ access: { grants: true, revocation: true },
32
+ }),
33
+ ],
34
+ })
35
+ await server.listen()
36
+ ```
37
+
38
+ The plugin exports `{ server, transport }` to plugins depending on `ENKAKU_RPC`.
39
+
40
+ ## Options
41
+
42
+ Every `serve()` parameter of `@enkaku/server` is accepted except `transport` and `signal`, plus:
43
+
44
+ | Option | Type | Description |
45
+ | --- | --- | --- |
46
+ | `path` | `string` | Path the transport is served on, for every HTTP method. Body size and response deadline limits are disabled for it, since the transport enforces its own. |
47
+ | `transport` | `ServerTransportOptions` | Options for the `@enkaku/http-serve` transport, such as `allowedOrigin`. |
48
+ | `access.grants` | `true \| Array<string>` | Admit the gated patterns through `access:grants`: `true` gates every procedure (`*`), a non-empty array the listed patterns. Requires an `identity`. |
49
+ | `access.revocation` | `true` | Run the `access:revocation` hook on delegated capabilities, before the caller's own `verifyToken`. Requires an `identity`. |
50
+
51
+ `dependsOn` follows the options: `access:grants` and `access:revocation` are only required when used.
52
+
53
+ ## Access rules
54
+
55
+ With `access.grants`, the caller's `accessRules` are kept and the gated patterns are added with the grants predicate. A caller rule that overlaps a gated pattern is rejected when `enkakuPlugin()` is called, with `Access rule "<caller>" overlaps grant-gated pattern "<gated>"`: Enkaku admits a procedure on the first rule that allows it, so an overlapping rule could bypass the grant check. With `grants: true`, every caller rule overlaps `*`, so none may be given. Before the overlap check, every caller rule key and gated pattern is validated with `@kokuin/capability`'s `assertValidPattern` (exact names or a trailing `/*` wildcard); an invalid one throws `Invalid access rule pattern "<caller>"` or `Invalid grant-gated pattern "<gated>"`, so a typo such as `admin*` cannot slip past the check.
56
+
57
+ The revocation hook only runs when Enkaku checks a capability, that is when a caller is admitted as a delegate of a granted subject; a granted issuer calling directly presents no capability.
58
+
59
+ ## CORS and shutdown
60
+
61
+ The transport is the only layer setting CORS headers, from `transport.allowedOrigin`. Its origin check is Enkaku's own behaviour, not the plugin's:
62
+
63
+ - Without `allowedOrigin`, any request carrying an `Origin` header is rejected with 403, which means every browser client.
64
+ - With `allowedOrigin` set (without `*`), any request without an `Origin` header is rejected with 403, which includes most non-browser clients.
65
+
66
+ On shutdown the plugin starts disposing the Enkaku server and disposes the transport. Disposing the transport shuts down its HTTP bridge, which comes from `@enkaku/http-serve` >= 0.21.4:
67
+
68
+ - Requests still awaiting a reply are answered immediately with `503 { "error": "Server shutting down" }`, with the CORS headers the transport would have sent. Handlers are aborted during disposal, so their results are not delivered.
69
+ - Open event stream responses end, after the writes already queued for them have been processed.
70
+ - Requests arriving afterwards are answered with the same 503.
71
+
72
+ This lets the HTTP server drain without waiting for `graceMs`. The server's disposal is awaited in the close phase, bounded by `limits.cleanupTimeoutMs` (default 30 000 ms).
package/lib/index.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Enkaku RPC plugin for Sozai HTTP servers, with grant-gated access and capability revocation.
3
+ *
4
+ * @module enkaku
5
+ */
6
+ export { mountTransport } from './mount.js';
7
+ export { ENKAKU_RPC, type EnkakuAccessParams, type EnkakuPluginParams, type EnkakuRPC, enkakuPlugin, } from './plugin.js';
8
+ export { type BuildAccessRulesParams, buildAccessRules } from './rules.js';
package/lib/index.js ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Enkaku RPC plugin for Sozai HTTP servers, with grant-gated access and capability revocation.
3
+ *
4
+ * @module enkaku
5
+ */ export { mountTransport } from './mount.js';
6
+ export { ENKAKU_RPC, enkakuPlugin } from './plugin.js';
7
+ export { buildAccessRules } from './rules.js';
package/lib/mount.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ import { ServerTransport, type ServerTransportOptions } from '@enkaku/http-serve';
2
+ import type { ProtocolDefinition } from '@enkaku/protocol';
3
+ import type { AnyPluginName, PluginContext } from '@sozai/http-server';
4
+ /**
5
+ * Create an Enkaku HTTP transport and serve it on every method at `path`. The body size limit and
6
+ * response deadline are disabled for the path: the transport enforces its own, and event streams
7
+ * stay open for as long as their session.
8
+ *
9
+ * The transport is the only layer setting CORS headers, from `options.allowedOrigin`. Disposing
10
+ * the transport shuts its HTTP bridge down: requests in flight and arriving later are answered
11
+ * with a 503 and event streams end.
12
+ */
13
+ export declare function mountTransport<Protocol extends ProtocolDefinition>(ctx: PluginContext<ReadonlyArray<AnyPluginName>>, path: string, options?: ServerTransportOptions): ServerTransport<Protocol>;
package/lib/mount.js ADDED
@@ -0,0 +1,18 @@
1
+ import { ServerTransport } from '@enkaku/http-serve';
2
+ /**
3
+ * Create an Enkaku HTTP transport and serve it on every method at `path`. The body size limit and
4
+ * response deadline are disabled for the path: the transport enforces its own, and event streams
5
+ * stay open for as long as their session.
6
+ *
7
+ * The transport is the only layer setting CORS headers, from `options.allowedOrigin`. Disposing
8
+ * the transport shuts its HTTP bridge down: requests in flight and arriving later are answered
9
+ * with a 503 and event streams end.
10
+ */ export function mountTransport(ctx, path, options) {
11
+ const transport = new ServerTransport(options);
12
+ ctx.limits(path, {
13
+ bodyBytes: false,
14
+ timeoutMs: false
15
+ });
16
+ ctx.route('all', path, (c)=>transport.fetch(c.req.raw));
17
+ return transport;
18
+ }
@@ -0,0 +1,30 @@
1
+ import type { ServerTransport, ServerTransportOptions } from '@enkaku/http-serve';
2
+ import type { ProtocolDefinition } from '@enkaku/protocol';
3
+ import { type ServeParams, type Server } from '@enkaku/server';
4
+ import { type AnyHTTPPlugin, type PluginName } from '@sozai/http-server';
5
+ export type EnkakuRPC = {
6
+ server: Server<ProtocolDefinition>;
7
+ transport: ServerTransport<ProtocolDefinition>;
8
+ };
9
+ export declare const ENKAKU_RPC: PluginName<'enkaku:rpc', EnkakuRPC>;
10
+ /** `Omit` applied to each member of a union, keeping the identity and `requireAuth` variants apart. */
11
+ type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;
12
+ export type EnkakuAccessParams = {
13
+ /** Admit procedures through `access:grants`: `true` gates every procedure, an array the listed patterns. */
14
+ grants?: true | Array<string>;
15
+ /** Reject revoked delegated capabilities through `access:revocation`. */
16
+ revocation?: true;
17
+ };
18
+ export type EnkakuPluginParams<Protocol extends ProtocolDefinition> = DistributiveOmit<ServeParams<Protocol>, 'transport' | 'signal'> & {
19
+ /** Path the transport is served on, for every HTTP method. */
20
+ path: string;
21
+ transport?: ServerTransportOptions;
22
+ access?: EnkakuAccessParams;
23
+ };
24
+ /**
25
+ * Serve an Enkaku RPC server on `path`. With `access.grants`, the gated patterns admit callers
26
+ * through `access:grants`; with `access.revocation`, delegated capabilities are checked against
27
+ * `access:revocation` before the caller's own `verifyToken` hook.
28
+ */
29
+ export declare function enkakuPlugin<Protocol extends ProtocolDefinition>(params: EnkakuPluginParams<Protocol>): AnyHTTPPlugin;
30
+ export {};
package/lib/plugin.js ADDED
@@ -0,0 +1,101 @@
1
+ import { DEFAULT_RESOURCE_LIMITS, serve } from '@enkaku/server';
2
+ import { pluginName } from '@sozai/http-server';
3
+ import { ACCESS_GRANTS, ACCESS_REVOCATION } from '@teikyo/access';
4
+ import { mountTransport } from './mount.js';
5
+ import { buildAccessRules } from './rules.js';
6
+ export const ENKAKU_RPC = pluginName()('enkaku:rpc');
7
+ const denyAll = ()=>false;
8
+ function gatedPatterns(grants) {
9
+ return grants === true ? [
10
+ '*'
11
+ ] : grants;
12
+ }
13
+ function composeVerifyToken(first, second) {
14
+ if (first == null || second == null) {
15
+ return first ?? second;
16
+ }
17
+ return async (token, raw)=>{
18
+ await first(token, raw);
19
+ await second(token, raw);
20
+ };
21
+ }
22
+ /**
23
+ * Serve an Enkaku RPC server on `path`. With `access.grants`, the gated patterns admit callers
24
+ * through `access:grants`; with `access.revocation`, delegated capabilities are checked against
25
+ * `access:revocation` before the caller's own `verifyToken` hook.
26
+ */ export function enkakuPlugin(params) {
27
+ const { path, transport: transportOptions, access = {}, ...serveParams } = params;
28
+ // Without an identity Enkaku performs no access checks, so neither option could take effect.
29
+ if (serveParams.identity == null) {
30
+ if (access.grants != null) {
31
+ throw new Error('access.grants requires an identity');
32
+ }
33
+ if (access.revocation != null) {
34
+ throw new Error('access.revocation requires an identity');
35
+ }
36
+ }
37
+ if (Array.isArray(access.grants) && access.grants.length === 0) {
38
+ throw new Error('access.grants must not be empty');
39
+ }
40
+ if (access.grants != null) {
41
+ // Fail at the call site rather than during server setup.
42
+ buildAccessRules({
43
+ accessRules: serveParams.accessRules,
44
+ gated: gatedPatterns(access.grants),
45
+ allow: denyAll
46
+ });
47
+ }
48
+ const dependsOn = [];
49
+ if (access.grants != null) {
50
+ dependsOn.push(ACCESS_GRANTS);
51
+ }
52
+ if (access.revocation != null) {
53
+ dependsOn.push(ACCESS_REVOCATION);
54
+ }
55
+ return {
56
+ name: ENKAKU_RPC,
57
+ dependsOn,
58
+ setup (ctx) {
59
+ let accessRules = serveParams.accessRules;
60
+ if (access.grants != null) {
61
+ accessRules = buildAccessRules({
62
+ accessRules,
63
+ gated: gatedPatterns(access.grants),
64
+ allow: ctx.use(ACCESS_GRANTS).allow()
65
+ });
66
+ }
67
+ const revocationHook = access.revocation == null ? undefined : ctx.use(ACCESS_REVOCATION).verifyToken;
68
+ const verifyToken = composeVerifyToken(revocationHook, serveParams.verifyToken);
69
+ const transport = mountTransport(ctx, path, transportOptions);
70
+ const common = {
71
+ logger: serveParams.logger ?? ctx.logger,
72
+ verifyToken,
73
+ transport
74
+ };
75
+ const server = serveParams.identity == null ? serve({
76
+ ...serveParams,
77
+ ...common
78
+ }) : serve({
79
+ ...serveParams,
80
+ ...common,
81
+ accessRules
82
+ });
83
+ let disposing;
84
+ ctx.onShutdown(async ()=>{
85
+ disposing = server.dispose();
86
+ // Observed here so a rejection before the close hook is not unhandled; onClose reports it.
87
+ void disposing.catch(()=>{});
88
+ await transport.dispose();
89
+ });
90
+ ctx.onClose(async ()=>{
91
+ await (disposing ?? server.dispose());
92
+ }, {
93
+ timeoutMs: serveParams.limits?.cleanupTimeoutMs ?? DEFAULT_RESOURCE_LIMITS.cleanupTimeoutMs
94
+ });
95
+ return {
96
+ server: server,
97
+ transport: transport
98
+ };
99
+ }
100
+ };
101
+ }
package/lib/rules.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { AccessRules, AllowPredicate } from '@enkaku/server';
2
+ export type BuildAccessRulesParams = {
3
+ /** Rules supplied by the caller, kept as they are. */
4
+ accessRules?: AccessRules;
5
+ /** Patterns admitted only through `allow`. */
6
+ gated: Array<string>;
7
+ allow: AllowPredicate;
8
+ };
9
+ /**
10
+ * Combine caller rules with grant-gated patterns. A caller rule overlapping a gated pattern is
11
+ * rejected: Enkaku admits a procedure on the first matching rule that allows it, so such a rule
12
+ * could bypass the grant check. Every pattern is validated with `assertValidPattern` first.
13
+ */
14
+ export declare function buildAccessRules(params: BuildAccessRulesParams): AccessRules;
package/lib/rules.js ADDED
@@ -0,0 +1,41 @@
1
+ import { assertValidPattern } from '@kokuin/capability';
2
+ import { patternsOverlap } from '@teikyo/access';
3
+ function assertPattern(kind, pattern) {
4
+ try {
5
+ assertValidPattern(pattern);
6
+ } catch (cause) {
7
+ throw new Error(`Invalid ${kind} pattern "${pattern}"`, {
8
+ cause
9
+ });
10
+ }
11
+ }
12
+ /**
13
+ * Combine caller rules with grant-gated patterns. A caller rule overlapping a gated pattern is
14
+ * rejected: Enkaku admits a procedure on the first matching rule that allows it, so such a rule
15
+ * could bypass the grant check. Every pattern is validated with `assertValidPattern` first.
16
+ */ export function buildAccessRules(params) {
17
+ const rules = {
18
+ ...params.accessRules
19
+ };
20
+ // Validate first: an invalid pattern (`admin*` for `admin/*`) matches nothing, so it would pass
21
+ // the overlap check while the caller rule it was meant to exclude admits without grants.
22
+ for (const caller of Object.keys(rules)){
23
+ assertPattern('access rule', caller);
24
+ }
25
+ for (const gated of params.gated){
26
+ assertPattern('grant-gated', gated);
27
+ }
28
+ for (const caller of Object.keys(rules)){
29
+ for (const gated of params.gated){
30
+ if (patternsOverlap(caller, gated)) {
31
+ throw new Error(`Access rule "${caller}" overlaps grant-gated pattern "${gated}"`);
32
+ }
33
+ }
34
+ }
35
+ for (const gated of params.gated){
36
+ rules[gated] = {
37
+ allow: params.allow
38
+ };
39
+ }
40
+ return rules;
41
+ }
package/package.json CHANGED
@@ -1,6 +1,58 @@
1
1
  {
2
2
  "name": "@teikyo/enkaku",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "description": "Enkaku RPC plugin for Sozai HTTP servers",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/TairuFramework/teikyo.git",
8
+ "directory": "packages/enkaku"
9
+ },
10
+ "license": "MIT",
11
+ "sideEffects": false,
12
+ "type": "module",
13
+ "exports": {
14
+ ".": "./lib/index.js"
15
+ },
16
+ "main": "lib/index.js",
17
+ "types": "lib/index.d.ts",
18
+ "files": [
19
+ "lib/*"
20
+ ],
21
+ "dependencies": {
22
+ "@enkaku/http-serve": "^0.21.4",
23
+ "@enkaku/protocol": "^0.21.4",
24
+ "@enkaku/server": "^0.21.4",
25
+ "@kokuin/capability": "^0.4.0",
26
+ "@sozai/http-server": "^0.1.0",
27
+ "@teikyo/access": "^0.1.1"
28
+ },
29
+ "devDependencies": {
30
+ "@enkaku/client": "^0.21.3",
31
+ "@enkaku/http-fetch": "^0.21.4",
32
+ "@kokuin/token": "^0.5.0",
33
+ "@teikyo/hozon": "^0.1.1",
34
+ "@types/node": "^26.6.4",
35
+ "del-cli": "^7.0.0",
36
+ "hono": "^4.13.13",
37
+ "vitest": "5.0.3"
38
+ },
39
+ "peerDependencies": {
40
+ "hono": "^4.13.13"
41
+ },
42
+ "engines": {
43
+ "node": ">=24"
44
+ },
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "scripts": {
49
+ "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
50
+ "build:clean": "del-cli lib",
51
+ "build:js": "del-cli \"lib/**/*.js\" && swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
52
+ "build:types": "del-cli \"lib/**/*.d.ts\" \"lib/**/*.d.ts.map\" && tsc --emitDeclarationOnly --skipLibCheck",
53
+ "build:types:ci": "tsc --emitDeclarationOnly --skipLibCheck --declarationMap false",
54
+ "test": "pnpm run test:types && pnpm run test:unit",
55
+ "test:types": "tsc --noEmit --skipLibCheck -p tsconfig.test.json",
56
+ "test:unit": "vitest run"
57
+ }
6
58
  }