@kubun/plugin-service-server 0.17.0 → 0.18.0

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/lib/index.d.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  import { type Server } from '@enkaku/server';
2
2
  import { type DIDResolver, type MethodRegistry } from '@kokuin/token';
3
3
  import type { KubunPlugin, PluginFactoryParams } from '@kubun/engine';
4
- import type { ServiceHandler, ServiceProtocol, ServiceServerServeParams } from '@kubun/plugin-service-api';
5
- export type { AccessRules, Handlers, ServiceAuthorizer, ServiceConfig, ServiceHandler, ServiceHandlerContext, ServiceProviderImpl, ServiceServerServeParams, } from '@kubun/plugin-service-api';
4
+ import type { ServiceAuthorizer, ServiceHandler, ServiceProtocol, ServiceServerServeParams } from '@kubun/plugin-service-api';
5
+ import { type LongLivedCallGate } from './long-lived-gate.js';
6
+ export type { AccessRules, Handlers, ServiceAuthorizer, ServiceConfig, ServiceHandler, ServiceHandlerContext, ServiceProviderImpl, ServiceServerLimits, ServiceServerServeParams, } from '@kubun/plugin-service-api';
6
7
  export { type AuthorizeParams, type AuthorizeReadParams, authorizeRead, authorizeWrite, type CreateCallerMayActForParams, createCallerMayActFor, SERVICE_ACT_FOR_ACTION, ServiceAuthorizationError, serviceUserResource, } from './authorizer.js';
8
+ export { createLongLivedCallGate, type LongLivedCallGate, type LongLivedCallGateParams, type ReleaseLongLivedCall, unverifiedCallerGate, } from './long-lived-gate.js';
7
9
  /**
8
10
  * A configured service name resolves to a `service` provider that was never
9
11
  * registered. `true`/`{ rules }` both require a provider; only `{ handlers }`
@@ -67,6 +69,19 @@ export type ServiceServerPluginAPI = {
67
69
  * verification (`methods = [controllerResolverFor(db)]`, pre-transaction).
68
70
  */
69
71
  export declare function createServiceServerPlugin(options?: ServiceServerPluginOptions): (params: PluginFactoryParams) => KubunPlugin;
72
+ /** Params for {@link wrapHandler}. */
73
+ export type WrapHandlerParams = {
74
+ /** Fully-qualified procedure name, e.g. `echo/ping`. */
75
+ procedure: string;
76
+ /** The service handler to wrap. */
77
+ handler: ServiceHandler;
78
+ /** The per-serve authorizer; absent falls back to {@link selfOnlyAuthorizer}. */
79
+ authorizer?: ServiceAuthorizer;
80
+ /** The per-serve group id, forwarded to the handler's context. */
81
+ groupID?: string;
82
+ /** Set for a long-lived procedure: each call holds a place for as long as it runs. */
83
+ gate?: LongLivedCallGate;
84
+ };
70
85
  /**
71
86
  * Wrap a service handler so the host — not the caller — supplies the verified
72
87
  * `iss` and the `procedure` name to the per-serve authorizer, and so a forged
@@ -78,4 +93,4 @@ export declare function createServiceServerPlugin(options?: ServiceServerPluginO
78
93
  * receives a bound `authorize(subject)` that calls the per-serve authorizer with
79
94
  * host-supplied `{ iss, procedure }`; a handler cannot spoof either.
80
95
  */
81
- export declare function wrapHandler(procedure: string, handler: ServiceHandler, authorizer: ServiceServerServeParams['authorizer']): ServiceHandler;
96
+ export declare function wrapHandler(params: WrapHandlerParams): ServiceHandler;
package/lib/index.js CHANGED
@@ -2,9 +2,17 @@ import { serve } from '@enkaku/server';
2
2
  import { normalizeDID } from '@kokuin/token';
3
3
  import { toHandlerError } from '@kubun/protocol';
4
4
  import { ServiceAuthorizationError } from './authorizer.js';
5
+ import { createLongLivedCallGate, unverifiedCallerGate } from './long-lived-gate.js';
5
6
  export { authorizeRead, authorizeWrite, createCallerMayActFor, SERVICE_ACT_FOR_ACTION, ServiceAuthorizationError, serviceUserResource } from './authorizer.js';
7
+ export { createLongLivedCallGate, unverifiedCallerGate } from './long-lived-gate.js';
6
8
  // The registry kind under which service providers register.
7
9
  const SERVICE_KIND = 'service';
10
+ // An app holds a handful of subscriptions open; this leaves room for many while
11
+ // keeping one caller from holding a serve's controllers.
12
+ const DEFAULT_MAX_LONG_LIVED_CALLS_PER_CALLER = 64;
13
+ // Enkaku's controller cap (10000) is shared with ordinary requests: long-lived
14
+ // calls keep most of it free for them.
15
+ const DEFAULT_MAX_LONG_LIVED_CALLS = 1000;
8
16
  /**
9
17
  * A configured service name resolves to a `service` provider that was never
10
18
  * registered. `true`/`{ rules }` both require a provider; only `{ handlers }`
@@ -67,7 +75,9 @@ const SERVICE_KIND = 'service';
67
75
  }
68
76
  return {
69
77
  handlers: provider.handlers,
70
- rules: provider.accessRules ?? {}
78
+ rules: provider.accessRules ?? {},
79
+ longLivedProcedures: provider.longLivedProcedures ?? [],
80
+ protocol: provider.protocol
71
81
  };
72
82
  }
73
83
  // Discriminate the object form by EXPLICIT key, never by shape-sniffing.
@@ -79,7 +89,9 @@ const SERVICE_KIND = 'service';
79
89
  }
80
90
  return {
81
91
  handlers: config.handlers,
82
- rules: config.rules ?? {}
92
+ rules: config.rules ?? {},
93
+ longLivedProcedures: config.longLivedProcedures ?? [],
94
+ protocol: config.protocol
83
95
  };
84
96
  }
85
97
  // `{ rules }` (override) or `{}` (≡ `true`): both require a provider.
@@ -88,7 +100,9 @@ const SERVICE_KIND = 'service';
88
100
  }
89
101
  return {
90
102
  handlers: provider.handlers,
91
- rules: config.rules ?? provider.accessRules ?? {}
103
+ rules: config.rules ?? provider.accessRules ?? {},
104
+ longLivedProcedures: provider.longLivedProcedures ?? [],
105
+ protocol: provider.protocol
92
106
  };
93
107
  }
94
108
  /**
@@ -114,7 +128,7 @@ const SERVICE_KIND = 'service';
114
128
  ];
115
129
  const api = {
116
130
  serve: (serveParams)=>{
117
- const { transport, services, authorizer, signal, requireAuth, cache } = serveParams;
131
+ const { transport, services, authorizer, signal, requireAuth, cache, groupID, limits } = serveParams;
118
132
  const providerList = engine.getProvidersSync(SERVICE_KIND);
119
133
  const providers = new Map();
120
134
  for (const provider of providerList){
@@ -125,6 +139,16 @@ const SERVICE_KIND = 'service';
125
139
  // handler's call context; a trivial handler simply ignores it.
126
140
  const mergedHandlers = {};
127
141
  const mergedRules = {};
142
+ const longLivedProcedures = new Set();
143
+ // Validation is all-or-nothing: one served service without a protocol
144
+ // would have its messages rejected as unknown, so it disables validation.
145
+ const mergedProtocol = {};
146
+ let allHaveProtocol = true;
147
+ const callGate = createLongLivedCallGate({
148
+ maxPerCaller: limits?.maxLongLivedCallsPerCaller ?? DEFAULT_MAX_LONG_LIVED_CALLS_PER_CALLER,
149
+ max: limits?.maxLongLivedCalls ?? DEFAULT_MAX_LONG_LIVED_CALLS
150
+ });
151
+ const gate = requireAuth === false ? unverifiedCallerGate(callGate) : callGate;
128
152
  for (const [name, config] of Object.entries(services)){
129
153
  const selected = resolveService(name, config, providers);
130
154
  if (selected == null) {
@@ -134,10 +158,26 @@ const SERVICE_KIND = 'service';
134
158
  if (procedure in mergedHandlers) {
135
159
  throw new ServiceProcedureCollisionError(procedure);
136
160
  }
137
- mergedHandlers[procedure] = wrapHandler(procedure, handler, authorizer);
161
+ const longLived = selected.longLivedProcedures.includes(procedure);
162
+ if (longLived) longLivedProcedures.add(procedure);
163
+ mergedHandlers[procedure] = wrapHandler({
164
+ procedure,
165
+ handler,
166
+ authorizer,
167
+ groupID,
168
+ ...longLived ? {
169
+ gate
170
+ } : {}
171
+ });
138
172
  }
139
173
  Object.assign(mergedRules, selected.rules);
174
+ if (selected.protocol == null) {
175
+ allHaveProtocol = false;
176
+ } else {
177
+ Object.assign(mergedProtocol, selected.protocol);
178
+ }
140
179
  }
180
+ const validated = allHaveProtocol && Object.keys(mergedProtocol).length > 0;
141
181
  // Build serve params exactly like the RPC plugin. The merged protocol is
142
182
  // the union of the served services' procedures, unknown statically — the
143
183
  // handler map is cast to Enkaku's `ProcedureHandlers` at this one seam,
@@ -145,6 +185,9 @@ const SERVICE_KIND = 'service';
145
185
  const baseParams = {
146
186
  runtime: params.runtime,
147
187
  handlers: mergedHandlers,
188
+ ...validated ? {
189
+ protocol: mergedProtocol
190
+ } : {},
148
191
  logger: params.getLogger('service-server'),
149
192
  signal,
150
193
  transport,
@@ -152,7 +195,24 @@ const SERVICE_KIND = 'service';
152
195
  methods,
153
196
  // Roster-seeded cache from the caller (the service tunnel lane), so a
154
197
  // co-member's short-form did:peer:4 issuer resolves on this Server.
155
- cache
198
+ cache,
199
+ // Every serve dedups against the engine's persisted ledger, so a signed
200
+ // request replayed after a restart, or into another served Server, is
201
+ // refused. There is deliberately no per-serve override: any other
202
+ // cache, Enkaku's in-memory default included, would forget on restart.
203
+ replay: {
204
+ cache: params.replayCache
205
+ },
206
+ // Long-lived calls are not expired and skip the handler concurrency cap;
207
+ // the gate bounds them instead.
208
+ limits: {
209
+ longLivedProcedures: [
210
+ ...longLivedProcedures
211
+ ],
212
+ ...limits?.callTimeoutMs == null ? {} : {
213
+ controllerTimeoutMs: limits.callTimeoutMs
214
+ }
215
+ }
156
216
  };
157
217
  // `requireAuth: false` serves the same merged procedures with no signature
158
218
  // requirement (unauthenticated local mode); it omits `identity`/`accessRules`.
@@ -208,19 +268,22 @@ const selfOnlyAuthorizer = ({ iss, subject })=>subject == null || normalizeDID(i
208
268
  * rejected outright, mirroring the RPC plugin's `resolveViewerDID`. The handler
209
269
  * receives a bound `authorize(subject)` that calls the per-serve authorizer with
210
270
  * host-supplied `{ iss, procedure }`; a handler cannot spoof either.
211
- */ export function wrapHandler(procedure, handler, authorizer) {
271
+ */ export function wrapHandler(params) {
272
+ const { procedure, handler, authorizer, groupID, gate } = params;
212
273
  const check = authorizer ?? selfOnlyAuthorizer;
213
274
  return async (context)=>{
214
275
  // One gate: apply authz context, then translate on throw. A refusal carrying a
215
276
  // registered code (KB07 from `ServiceAuthorizationError`) forwards to the caller
216
277
  // as that well-known code; an uncoded internal throw stays opaque, its message
217
278
  // never leaving the device.
279
+ let release;
218
280
  try {
219
281
  const payload = context.message?.payload;
220
282
  const iss = payload?.iss;
221
283
  if (payload?.sub != null && payload.sub !== iss) {
222
284
  throw new ServiceAuthorizationError('signed payload subject does not match issuer');
223
285
  }
286
+ release = gate?.enter(iss);
224
287
  const authorize = async (subject)=>{
225
288
  // No verified caller (the `accessRules: false` local mode): a subject-scoped
226
289
  // action cannot be authorized, so only self-acting (no subject) is allowed.
@@ -236,10 +299,13 @@ const selfOnlyAuthorizer = ({ iss, subject })=>subject == null || normalizeDID(i
236
299
  return await handler({
237
300
  ...context,
238
301
  iss,
239
- authorize
302
+ authorize,
303
+ groupID
240
304
  });
241
305
  } catch (cause) {
242
306
  throw toHandlerError(cause);
307
+ } finally{
308
+ release?.();
243
309
  }
244
310
  };
245
311
  }
@@ -0,0 +1,28 @@
1
+ export type LongLivedCallGateParams = {
2
+ /** Open calls one caller may hold. */
3
+ maxPerCaller: number;
4
+ /** Open calls all callers together may hold. */
5
+ max: number;
6
+ };
7
+ /** Frees the place a call took. Idempotent. */
8
+ export type ReleaseLongLivedCall = () => void;
9
+ export type LongLivedCallGate = {
10
+ /**
11
+ * Takes a place for one open call, or throws a `HandlerError` with Enkaku's
12
+ * handler-limit code (`EK04`). `caller` is the verified issuer; callers with none
13
+ * share one place count.
14
+ */
15
+ enter: (caller: string | undefined) => ReleaseLongLivedCall;
16
+ };
17
+ /**
18
+ * Bounds the long-lived calls a serve holds open. Enkaku exempts long-lived
19
+ * procedures from its handler concurrency cap, leaving only its controller cap,
20
+ * which ordinary requests share: without this, one caller could hold every
21
+ * controller open and starve the rest.
22
+ */
23
+ export declare function createLongLivedCallGate(params: LongLivedCallGateParams): LongLivedCallGate;
24
+ /**
25
+ * A gate for a serve that verifies no signatures: there, an issuer is whatever the
26
+ * caller wrote, so every call counts as one unverified caller's.
27
+ */
28
+ export declare function unverifiedCallerGate(gate: LongLivedCallGate): LongLivedCallGate;
@@ -0,0 +1,45 @@
1
+ import { ErrorCodes } from '@enkaku/protocol';
2
+ import { HandlerError } from '@enkaku/server';
3
+ // The bucket of callers with no verified identity, kept apart from every DID.
4
+ const UNVERIFIED = Symbol('unverified');
5
+ /**
6
+ * Bounds the long-lived calls a serve holds open. Enkaku exempts long-lived
7
+ * procedures from its handler concurrency cap, leaving only its controller cap,
8
+ * which ordinary requests share: without this, one caller could hold every
9
+ * controller open and starve the rest.
10
+ */ export function createLongLivedCallGate(params) {
11
+ const { maxPerCaller, max } = params;
12
+ const open = new Map();
13
+ let total = 0;
14
+ return {
15
+ enter: (caller)=>{
16
+ const key = caller ?? UNVERIFIED;
17
+ const held = open.get(key) ?? 0;
18
+ if (held >= maxPerCaller || total >= max) {
19
+ throw new HandlerError({
20
+ code: ErrorCodes.HANDLER_LIMIT,
21
+ message: 'Too many open long-lived calls'
22
+ });
23
+ }
24
+ open.set(key, held + 1);
25
+ total++;
26
+ let released = false;
27
+ return ()=>{
28
+ if (released) return;
29
+ released = true;
30
+ total--;
31
+ const remaining = (open.get(key) ?? 1) - 1;
32
+ if (remaining === 0) open.delete(key);
33
+ else open.set(key, remaining);
34
+ };
35
+ }
36
+ };
37
+ }
38
+ /**
39
+ * A gate for a serve that verifies no signatures: there, an issuer is whatever the
40
+ * caller wrote, so every call counts as one unverified caller's.
41
+ */ export function unverifiedCallerGate(gate) {
42
+ return {
43
+ enter: ()=>gate.enter(undefined)
44
+ };
45
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubun/plugin-service-server",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "license": "see LICENSE.md",
5
5
  "sideEffects": false,
6
6
  "type": "module",
@@ -14,21 +14,21 @@
14
14
  "LICENSE.md"
15
15
  ],
16
16
  "dependencies": {
17
- "@enkaku/protocol": "^0.21.1",
18
- "@enkaku/server": "^0.21.2",
19
- "@kokuin/capability": "^0.3.0",
20
- "@kokuin/token": "^0.5.0",
21
- "@kubun/engine": "^0.17.0",
22
- "@kubun/plugin-service-api": "^0.17.0",
23
- "@kubun/protocol": "^0.17.0"
17
+ "@enkaku/protocol": "^0.21.5",
18
+ "@enkaku/server": "^0.21.5",
19
+ "@kokuin/capability": "^0.4.2",
20
+ "@kokuin/token": "^0.5.1",
21
+ "@kubun/engine": "^0.18.0",
22
+ "@kubun/plugin-service-api": "^0.18.0",
23
+ "@kubun/protocol": "^0.18.0"
24
24
  },
25
25
  "devDependencies": {
26
- "@enkaku/client": "^0.21.2",
27
- "@enkaku/transport": "^0.21.0",
26
+ "@enkaku/client": "^0.21.5",
27
+ "@enkaku/transport": "^0.21.5",
28
28
  "@kokuin/controller": "^0.1.0",
29
- "@kubun/db": "^0.17.0",
30
- "@kubun/store-controller": "^0.17.0",
31
- "@kubun/store-graph": "^0.17.0",
29
+ "@kubun/db": "^0.18.0",
30
+ "@kubun/store-controller": "^0.18.0",
31
+ "@kubun/store-graph": "^0.18.0",
32
32
  "@kubun/test-utils": "^0.0.0"
33
33
  },
34
34
  "publishConfig": {