@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 +18 -3
- package/lib/index.js +74 -8
- 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
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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": {
|