@kubun/plugin-service-server 0.17.1 → 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
@@ -2,8 +2,10 @@ 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
4
  import type { ServiceAuthorizer, ServiceHandler, ServiceProtocol, ServiceServerServeParams } from '@kubun/plugin-service-api';
5
- export type { AccessRules, Handlers, ServiceAuthorizer, ServiceConfig, ServiceHandler, ServiceHandlerContext, ServiceProviderImpl, 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 }`
@@ -77,6 +79,8 @@ export type WrapHandlerParams = {
77
79
  authorizer?: ServiceAuthorizer;
78
80
  /** The per-serve group id, forwarded to the handler's context. */
79
81
  groupID?: string;
82
+ /** Set for a long-lived procedure: each call holds a place for as long as it runs. */
83
+ gate?: LongLivedCallGate;
80
84
  };
81
85
  /**
82
86
  * Wrap a service handler so the host — not the caller — supplies the verified
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, groupID } = 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,15 +158,26 @@ const SERVICE_KIND = 'service';
134
158
  if (procedure in mergedHandlers) {
135
159
  throw new ServiceProcedureCollisionError(procedure);
136
160
  }
161
+ const longLived = selected.longLivedProcedures.includes(procedure);
162
+ if (longLived) longLivedProcedures.add(procedure);
137
163
  mergedHandlers[procedure] = wrapHandler({
138
164
  procedure,
139
165
  handler,
140
166
  authorizer,
141
- groupID
167
+ groupID,
168
+ ...longLived ? {
169
+ gate
170
+ } : {}
142
171
  });
143
172
  }
144
173
  Object.assign(mergedRules, selected.rules);
174
+ if (selected.protocol == null) {
175
+ allHaveProtocol = false;
176
+ } else {
177
+ Object.assign(mergedProtocol, selected.protocol);
178
+ }
145
179
  }
180
+ const validated = allHaveProtocol && Object.keys(mergedProtocol).length > 0;
146
181
  // Build serve params exactly like the RPC plugin. The merged protocol is
147
182
  // the union of the served services' procedures, unknown statically — the
148
183
  // handler map is cast to Enkaku's `ProcedureHandlers` at this one seam,
@@ -150,6 +185,9 @@ const SERVICE_KIND = 'service';
150
185
  const baseParams = {
151
186
  runtime: params.runtime,
152
187
  handlers: mergedHandlers,
188
+ ...validated ? {
189
+ protocol: mergedProtocol
190
+ } : {},
153
191
  logger: params.getLogger('service-server'),
154
192
  signal,
155
193
  transport,
@@ -157,7 +195,24 @@ const SERVICE_KIND = 'service';
157
195
  methods,
158
196
  // Roster-seeded cache from the caller (the service tunnel lane), so a
159
197
  // co-member's short-form did:peer:4 issuer resolves on this Server.
160
- 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
+ }
161
216
  };
162
217
  // `requireAuth: false` serves the same merged procedures with no signature
163
218
  // requirement (unauthenticated local mode); it omits `identity`/`accessRules`.
@@ -214,19 +269,21 @@ const selfOnlyAuthorizer = ({ iss, subject })=>subject == null || normalizeDID(i
214
269
  * receives a bound `authorize(subject)` that calls the per-serve authorizer with
215
270
  * host-supplied `{ iss, procedure }`; a handler cannot spoof either.
216
271
  */ export function wrapHandler(params) {
217
- const { procedure, handler, authorizer, groupID } = params;
272
+ const { procedure, handler, authorizer, groupID, gate } = params;
218
273
  const check = authorizer ?? selfOnlyAuthorizer;
219
274
  return async (context)=>{
220
275
  // One gate: apply authz context, then translate on throw. A refusal carrying a
221
276
  // registered code (KB07 from `ServiceAuthorizationError`) forwards to the caller
222
277
  // as that well-known code; an uncoded internal throw stays opaque, its message
223
278
  // never leaving the device.
279
+ let release;
224
280
  try {
225
281
  const payload = context.message?.payload;
226
282
  const iss = payload?.iss;
227
283
  if (payload?.sub != null && payload.sub !== iss) {
228
284
  throw new ServiceAuthorizationError('signed payload subject does not match issuer');
229
285
  }
286
+ release = gate?.enter(iss);
230
287
  const authorize = async (subject)=>{
231
288
  // No verified caller (the `accessRules: false` local mode): a subject-scoped
232
289
  // action cannot be authorized, so only self-acting (no subject) is allowed.
@@ -247,6 +304,8 @@ const selfOnlyAuthorizer = ({ iss, subject })=>subject == null || normalizeDID(i
247
304
  });
248
305
  } catch (cause) {
249
306
  throw toHandlerError(cause);
307
+ } finally{
308
+ release?.();
250
309
  }
251
310
  };
252
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.1",
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.2",
22
- "@kubun/plugin-service-api": "^0.17.1",
23
- "@kubun/protocol": "^0.17.1"
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.1",
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": {