@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 +5 -1
- package/lib/index.js +66 -7
- package/lib/long-lived-gate.d.ts +28 -0
- package/lib/long-lived-gate.js +45 -0
- package/package.json +13 -13
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
|
-
|
|
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.
|
|
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.
|
|
18
|
-
"@enkaku/server": "^0.21.
|
|
19
|
-
"@kokuin/capability": "^0.
|
|
20
|
-
"@kokuin/token": "^0.5.
|
|
21
|
-
"@kubun/engine": "^0.
|
|
22
|
-
"@kubun/plugin-service-api": "^0.
|
|
23
|
-
"@kubun/protocol": "^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.
|
|
27
|
-
"@enkaku/transport": "^0.21.
|
|
26
|
+
"@enkaku/client": "^0.21.5",
|
|
27
|
+
"@enkaku/transport": "^0.21.5",
|
|
28
28
|
"@kokuin/controller": "^0.1.0",
|
|
29
|
-
"@kubun/db": "^0.
|
|
30
|
-
"@kubun/store-controller": "^0.
|
|
31
|
-
"@kubun/store-graph": "^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": {
|