@kubun/plugin-service-server 0.14.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/LICENSE.md +57 -0
- package/lib/authorizer.d.ts +88 -0
- package/lib/authorizer.js +140 -0
- package/lib/index.d.ts +81 -0
- package/lib/index.js +227 -0
- package/package.json +46 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# The Prosperity Public License 3.0.0
|
|
2
|
+
|
|
3
|
+
Contributor: Paul Le Cam
|
|
4
|
+
|
|
5
|
+
Source Code: https://github.com/PaulLeCam/kubun
|
|
6
|
+
|
|
7
|
+
## Purpose
|
|
8
|
+
|
|
9
|
+
This license allows you to use and share this software for noncommercial purposes for free and to try this software for commercial purposes for thirty days.
|
|
10
|
+
|
|
11
|
+
## Agreement
|
|
12
|
+
|
|
13
|
+
In order to receive this license, you have to agree to its rules. Those rules are both obligations under that agreement and conditions to your license. Don't do anything with this software that triggers a rule you can't or won't follow.
|
|
14
|
+
|
|
15
|
+
## Notices
|
|
16
|
+
|
|
17
|
+
Make sure everyone who gets a copy of any part of this software from you, with or without changes, also gets the text of this license and the contributor and source code lines above.
|
|
18
|
+
|
|
19
|
+
## Commercial Trial
|
|
20
|
+
|
|
21
|
+
Limit your use of this software for commercial purposes to a thirty-day trial period. If you use this software for work, your company gets one trial period for all personnel, not one trial per person.
|
|
22
|
+
|
|
23
|
+
## Contributions Back
|
|
24
|
+
|
|
25
|
+
Developing feedback, changes, or additions that you contribute back to the contributor on the terms of a standardized public software license such as [the Blue Oak Model License 1.0.0](https://blueoakcouncil.org/license/1.0.0), [the Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0.html), [the MIT license](https://spdx.org/licenses/MIT.html), or [the two-clause BSD license](https://spdx.org/licenses/BSD-2-Clause.html) doesn't count as use for a commercial purpose.
|
|
26
|
+
|
|
27
|
+
## Personal Uses
|
|
28
|
+
|
|
29
|
+
Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, doesn't count as use for a commercial purpose.
|
|
30
|
+
|
|
31
|
+
## Noncommercial Organizations
|
|
32
|
+
|
|
33
|
+
Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution doesn't count as use for a commercial purpose regardless of the source of funding or obligations resulting from the funding.
|
|
34
|
+
|
|
35
|
+
## Defense
|
|
36
|
+
|
|
37
|
+
Don't make any legal claim against anyone accusing this software, with or without changes, alone or with other technology, of infringing any patent.
|
|
38
|
+
|
|
39
|
+
## Copyright
|
|
40
|
+
|
|
41
|
+
The contributor licenses you to do everything with this software that would otherwise infringe their copyright in it.
|
|
42
|
+
|
|
43
|
+
## Patent
|
|
44
|
+
|
|
45
|
+
The contributor licenses you to do everything with this software that would otherwise infringe any patents they can license or become able to license.
|
|
46
|
+
|
|
47
|
+
## Reliability
|
|
48
|
+
|
|
49
|
+
The contributor can't revoke this license.
|
|
50
|
+
|
|
51
|
+
## Excuse
|
|
52
|
+
|
|
53
|
+
You're excused for unknowingly breaking [Notices](#notices) if you take all practical steps to comply within thirty days of learning you broke the rule.
|
|
54
|
+
|
|
55
|
+
## No Liability
|
|
56
|
+
|
|
57
|
+
***As far as the law allows, this software comes as is, without any warranty or condition, and the contributor won't be liable to anyone for any damages related to this software or this license, under any kind of legal claim.***
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { type VerifyTokenHook } from '@kokuin/capability';
|
|
2
|
+
import { type DIDMethodResolver } from '@kokuin/token';
|
|
3
|
+
import type { ServiceAuthorizer, ServiceHandlerContext } from '@kubun/plugin-service-api';
|
|
4
|
+
import type { KubunErrorCode } from '@kubun/protocol';
|
|
5
|
+
/**
|
|
6
|
+
* The capability action a caller presents to act for another subject over a
|
|
7
|
+
* generic service lane. Deliberately NOT `credential/administer`: that action is
|
|
8
|
+
* scoped to administering a controller's credential keys, so reusing it would
|
|
9
|
+
* both over-grant (a credential-admin delegate could suddenly act for the user
|
|
10
|
+
* on any service) and under-express (a "device acts for me" grant is not about
|
|
11
|
+
* credentials). It also keeps the host free of a `@kubun/credential` dependency.
|
|
12
|
+
* Matched by segment like every action, so a `service/*` grant covers it.
|
|
13
|
+
*/
|
|
14
|
+
export declare const SERVICE_ACT_FOR_ACTION = "service/act-for";
|
|
15
|
+
/**
|
|
16
|
+
* The user-wide capability resource for acting on a subject's behalf, reusing the
|
|
17
|
+
* generic `urn:kubun:user:<did>` resource documents and p2p already use — one
|
|
18
|
+
* delegation covers "act for this user" rather than naming a service. The action
|
|
19
|
+
* segment (`service/act-for`) is what separates a service grant from a
|
|
20
|
+
* `document/write` grant over the same resource, so the reuse is safe. The DID is
|
|
21
|
+
* normalized so the resource is an equality key.
|
|
22
|
+
*/
|
|
23
|
+
export declare function serviceUserResource(subject: string): string;
|
|
24
|
+
/**
|
|
25
|
+
* A subject authorization was refused for a write. Reads never throw this (they
|
|
26
|
+
* return the empty/not-found result instead, so a denial is indistinguishable
|
|
27
|
+
* from an absent record); writes do, so the caller receives an explicit error
|
|
28
|
+
* rather than a silent no-op.
|
|
29
|
+
*
|
|
30
|
+
* Carries the registered `KB07` ('Access denied') code so the host's translation
|
|
31
|
+
* gate forwards it to a remote caller as a well-known code rather than an opaque
|
|
32
|
+
* generic — the same code the graph lane already uses for access denials.
|
|
33
|
+
*/
|
|
34
|
+
export declare class ServiceAuthorizationError extends Error {
|
|
35
|
+
#private;
|
|
36
|
+
constructor(message?: string);
|
|
37
|
+
get code(): KubunErrorCode;
|
|
38
|
+
}
|
|
39
|
+
export type CreateCallerMayActForParams = {
|
|
40
|
+
/** Resolves a `did:kokuin:` subject's log so a delegation cap's signature and deny set verify. */
|
|
41
|
+
controllerResolver: DIDMethodResolver;
|
|
42
|
+
/** Delegation-revocation hook, consulted for every capability in the chain. */
|
|
43
|
+
revocationChecker?: VerifyTokenHook;
|
|
44
|
+
/** How the host supplies the caller's delegation tokens for a given subject. */
|
|
45
|
+
delegationTokens?: (subject: string) => Array<string> | Promise<Array<string>>;
|
|
46
|
+
/** Evaluate capability expiry at this time (epoch seconds) rather than now(). */
|
|
47
|
+
atTime?: number;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* The cross-engine service authorizer: does the verified caller `iss` speak for
|
|
51
|
+
* the named `subject`? Mirrors the `controller-authority.ts`
|
|
52
|
+
* chain-then-independent, fail-closed pattern.
|
|
53
|
+
*
|
|
54
|
+
* - No `subject` → the caller acts only for itself; nothing to gate → allowed.
|
|
55
|
+
* - `iss === subject` (normalized) → a verified device signing as its own
|
|
56
|
+
* controller DID; Enkaku already proved liveness → allowed (the shared-identity
|
|
57
|
+
* path).
|
|
58
|
+
* - Otherwise a delegation cap from `subject` to `iss` must authorize
|
|
59
|
+
* `service/act-for` on the subject's user resource; no tokens or no match →
|
|
60
|
+
* denied.
|
|
61
|
+
*/
|
|
62
|
+
export declare function createCallerMayActFor(params: CreateCallerMayActForParams): ServiceAuthorizer;
|
|
63
|
+
/** Shared inputs to the read/write authorization gates. */
|
|
64
|
+
export type AuthorizeParams = {
|
|
65
|
+
/** The handler context, for its `authorize` gate. */
|
|
66
|
+
context: Pick<ServiceHandlerContext, 'authorize'>;
|
|
67
|
+
/** The authorization subject (the owner DID); omitted for a self-only call. */
|
|
68
|
+
subject?: string;
|
|
69
|
+
};
|
|
70
|
+
export type AuthorizeReadParams<Result> = AuthorizeParams & {
|
|
71
|
+
/** Returned on denial — the not-found/empty shape, so a denial reveals nothing. */
|
|
72
|
+
emptyResult: Result;
|
|
73
|
+
/** Runs and returns the result once the caller is authorized. */
|
|
74
|
+
proceed: () => Result | Promise<Result>;
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Read-side authorization convention: if the caller may not act for `subject`,
|
|
78
|
+
* return `emptyResult` (the not-found/empty shape) so a denial is
|
|
79
|
+
* indistinguishable from an absent record — no existence oracle. Otherwise run
|
|
80
|
+
* `proceed` and return its result.
|
|
81
|
+
*/
|
|
82
|
+
export declare function authorizeRead<Result>(params: AuthorizeReadParams<Result>): Promise<Result>;
|
|
83
|
+
/**
|
|
84
|
+
* Write-side authorization convention: if the caller may not act for `subject`,
|
|
85
|
+
* throw {@link ServiceAuthorizationError} so the caller receives an explicit auth
|
|
86
|
+
* error rather than a silent no-op. Otherwise return.
|
|
87
|
+
*/
|
|
88
|
+
export declare function authorizeWrite(params: AuthorizeParams): Promise<void>;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { checkCapability } from '@kokuin/capability';
|
|
2
|
+
import { normalizeDID } from '@kokuin/token';
|
|
3
|
+
/**
|
|
4
|
+
* The capability action a caller presents to act for another subject over a
|
|
5
|
+
* generic service lane. Deliberately NOT `credential/administer`: that action is
|
|
6
|
+
* scoped to administering a controller's credential keys, so reusing it would
|
|
7
|
+
* both over-grant (a credential-admin delegate could suddenly act for the user
|
|
8
|
+
* on any service) and under-express (a "device acts for me" grant is not about
|
|
9
|
+
* credentials). It also keeps the host free of a `@kubun/credential` dependency.
|
|
10
|
+
* Matched by segment like every action, so a `service/*` grant covers it.
|
|
11
|
+
*/ export const SERVICE_ACT_FOR_ACTION = 'service/act-for';
|
|
12
|
+
/**
|
|
13
|
+
* The user-wide capability resource for acting on a subject's behalf, reusing the
|
|
14
|
+
* generic `urn:kubun:user:<did>` resource documents and p2p already use — one
|
|
15
|
+
* delegation covers "act for this user" rather than naming a service. The action
|
|
16
|
+
* segment (`service/act-for`) is what separates a service grant from a
|
|
17
|
+
* `document/write` grant over the same resource, so the reuse is safe. The DID is
|
|
18
|
+
* normalized so the resource is an equality key.
|
|
19
|
+
*/ export function serviceUserResource(subject) {
|
|
20
|
+
return `urn:kubun:user:${normalizeDID(subject)}`;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A subject authorization was refused for a write. Reads never throw this (they
|
|
24
|
+
* return the empty/not-found result instead, so a denial is indistinguishable
|
|
25
|
+
* from an absent record); writes do, so the caller receives an explicit error
|
|
26
|
+
* rather than a silent no-op.
|
|
27
|
+
*
|
|
28
|
+
* Carries the registered `KB07` ('Access denied') code so the host's translation
|
|
29
|
+
* gate forwards it to a remote caller as a well-known code rather than an opaque
|
|
30
|
+
* generic — the same code the graph lane already uses for access denials.
|
|
31
|
+
*/ export class ServiceAuthorizationError extends Error {
|
|
32
|
+
#code = 'KB07';
|
|
33
|
+
constructor(message = 'Service authorization denied'){
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = 'ServiceAuthorizationError';
|
|
36
|
+
}
|
|
37
|
+
get code() {
|
|
38
|
+
return this.#code;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The cross-engine service authorizer: does the verified caller `iss` speak for
|
|
43
|
+
* the named `subject`? Mirrors the `controller-authority.ts`
|
|
44
|
+
* chain-then-independent, fail-closed pattern.
|
|
45
|
+
*
|
|
46
|
+
* - No `subject` → the caller acts only for itself; nothing to gate → allowed.
|
|
47
|
+
* - `iss === subject` (normalized) → a verified device signing as its own
|
|
48
|
+
* controller DID; Enkaku already proved liveness → allowed (the shared-identity
|
|
49
|
+
* path).
|
|
50
|
+
* - Otherwise a delegation cap from `subject` to `iss` must authorize
|
|
51
|
+
* `service/act-for` on the subject's user resource; no tokens or no match →
|
|
52
|
+
* denied.
|
|
53
|
+
*/ export function createCallerMayActFor(params) {
|
|
54
|
+
const { controllerResolver, revocationChecker, delegationTokens, atTime } = params;
|
|
55
|
+
return async ({ iss, subject })=>{
|
|
56
|
+
if (subject == null) {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
const caller = normalizeDID(iss);
|
|
60
|
+
const owner = normalizeDID(subject);
|
|
61
|
+
if (caller === owner) {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
const tokens = await delegationTokens?.(subject) ?? [];
|
|
65
|
+
if (tokens.length === 0) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
// A user-wide grant covers the subject's whole lane; `*` covers any subject.
|
|
69
|
+
const resources = [
|
|
70
|
+
serviceUserResource(subject),
|
|
71
|
+
'*'
|
|
72
|
+
];
|
|
73
|
+
const methods = [
|
|
74
|
+
controllerResolver
|
|
75
|
+
];
|
|
76
|
+
// First, try the tokens as one delegation chain (an A→B→C grant).
|
|
77
|
+
for (const res of resources){
|
|
78
|
+
try {
|
|
79
|
+
await checkCapability({
|
|
80
|
+
act: SERVICE_ACT_FOR_ACTION,
|
|
81
|
+
res
|
|
82
|
+
}, {
|
|
83
|
+
iss: caller,
|
|
84
|
+
sub: owner,
|
|
85
|
+
cap: tokens
|
|
86
|
+
}, {
|
|
87
|
+
atTime,
|
|
88
|
+
verifyToken: revocationChecker,
|
|
89
|
+
methods
|
|
90
|
+
});
|
|
91
|
+
return true;
|
|
92
|
+
} catch {
|
|
93
|
+
// This resource does not match the chain — continue.
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// Then each token independently (several unrelated direct grants).
|
|
97
|
+
for (const token of tokens){
|
|
98
|
+
for (const res of resources){
|
|
99
|
+
try {
|
|
100
|
+
await checkCapability({
|
|
101
|
+
act: SERVICE_ACT_FOR_ACTION,
|
|
102
|
+
res
|
|
103
|
+
}, {
|
|
104
|
+
iss: caller,
|
|
105
|
+
sub: owner,
|
|
106
|
+
cap: token
|
|
107
|
+
}, {
|
|
108
|
+
atTime,
|
|
109
|
+
verifyToken: revocationChecker,
|
|
110
|
+
methods
|
|
111
|
+
});
|
|
112
|
+
return true;
|
|
113
|
+
} catch {
|
|
114
|
+
// This token does not match this resource — continue.
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return false;
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Read-side authorization convention: if the caller may not act for `subject`,
|
|
123
|
+
* return `emptyResult` (the not-found/empty shape) so a denial is
|
|
124
|
+
* indistinguishable from an absent record — no existence oracle. Otherwise run
|
|
125
|
+
* `proceed` and return its result.
|
|
126
|
+
*/ export async function authorizeRead(params) {
|
|
127
|
+
if (!await params.context.authorize(params.subject)) {
|
|
128
|
+
return params.emptyResult;
|
|
129
|
+
}
|
|
130
|
+
return await params.proceed();
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Write-side authorization convention: if the caller may not act for `subject`,
|
|
134
|
+
* throw {@link ServiceAuthorizationError} so the caller receives an explicit auth
|
|
135
|
+
* error rather than a silent no-op. Otherwise return.
|
|
136
|
+
*/ export async function authorizeWrite(params) {
|
|
137
|
+
if (!await params.context.authorize(params.subject)) {
|
|
138
|
+
throw new ServiceAuthorizationError();
|
|
139
|
+
}
|
|
140
|
+
}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { type Server } from '@enkaku/server';
|
|
2
|
+
import { type DIDResolver, type MethodRegistry } from '@kokuin/token';
|
|
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';
|
|
6
|
+
export { type AuthorizeParams, type AuthorizeReadParams, authorizeRead, authorizeWrite, type CreateCallerMayActForParams, createCallerMayActFor, SERVICE_ACT_FOR_ACTION, ServiceAuthorizationError, serviceUserResource, } from './authorizer.js';
|
|
7
|
+
/**
|
|
8
|
+
* A configured service name resolves to a `service` provider that was never
|
|
9
|
+
* registered. `true`/`{ rules }` both require a provider; only `{ handlers }`
|
|
10
|
+
* may name an unregistered service.
|
|
11
|
+
*/
|
|
12
|
+
export declare class UnknownServiceError extends Error {
|
|
13
|
+
#private;
|
|
14
|
+
constructor(service: string);
|
|
15
|
+
get service(): string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* A custom service (`{ handlers }`) reuses the name of a provider-registered
|
|
19
|
+
* service. A custom impl must not shadow a registered one — the name is the
|
|
20
|
+
* collision.
|
|
21
|
+
*/
|
|
22
|
+
export declare class ServiceNameCollisionError extends Error {
|
|
23
|
+
#private;
|
|
24
|
+
constructor(service: string);
|
|
25
|
+
get service(): string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Two served services expose the same fully-qualified procedure name. The merged
|
|
29
|
+
* handler map would silently drop one, so it is a serve-time error.
|
|
30
|
+
*/
|
|
31
|
+
export declare class ServiceProcedureCollisionError extends Error {
|
|
32
|
+
#private;
|
|
33
|
+
constructor(procedure: string);
|
|
34
|
+
get procedure(): string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Options for the service-server plugin.
|
|
38
|
+
*/
|
|
39
|
+
export type ServiceServerPluginOptions = {
|
|
40
|
+
/**
|
|
41
|
+
* Method registry forwarded to Enkaku's transport-level message-signature
|
|
42
|
+
* verification. Defaults to the engine's own `did:kokuin:` controller resolver
|
|
43
|
+
* so a `did:kokuin:`-signed message over an authenticated socket verifies out
|
|
44
|
+
* of the box; pass a registry to override that default.
|
|
45
|
+
*/
|
|
46
|
+
methods?: MethodRegistry;
|
|
47
|
+
/**
|
|
48
|
+
* DID resolver forwarded to Enkaku's transport-level message-signature
|
|
49
|
+
* verification. Supply one only to resolve a DID method the default `methods`
|
|
50
|
+
* registry does not cover.
|
|
51
|
+
*/
|
|
52
|
+
resolver?: DIDResolver;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* API provided by the service-server plugin.
|
|
56
|
+
*/
|
|
57
|
+
export type ServiceServerPluginAPI = {
|
|
58
|
+
serve: (params: ServiceServerServeParams) => Server<ServiceProtocol>;
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Creates the service-server host plugin.
|
|
62
|
+
*
|
|
63
|
+
* The host owns the Enkaku serve core extracted from the RPC plugin: per-serve it
|
|
64
|
+
* collects the engine's `service` providers, applies the per-serve `services`
|
|
65
|
+
* config against them, merges the selected handlers/access-rules, and serves the
|
|
66
|
+
* union over the given transport with per-message `did:kokuin:` signature
|
|
67
|
+
* verification (`methods = [controllerResolverFor(db)]`, pre-transaction).
|
|
68
|
+
*/
|
|
69
|
+
export declare function createServiceServerPlugin(options?: ServiceServerPluginOptions): (params: PluginFactoryParams) => KubunPlugin;
|
|
70
|
+
/**
|
|
71
|
+
* Wrap a service handler so the host — not the caller — supplies the verified
|
|
72
|
+
* `iss` and the `procedure` name to the per-serve authorizer, and so a forged
|
|
73
|
+
* subject is rejected before the handler body runs.
|
|
74
|
+
*
|
|
75
|
+
* The verified caller is the signed payload's `iss`. A payload `sub` that differs
|
|
76
|
+
* from `iss` is a forged-subject attempt (running as someone else) and is
|
|
77
|
+
* rejected outright, mirroring the RPC plugin's `resolveViewerDID`. The handler
|
|
78
|
+
* receives a bound `authorize(subject)` that calls the per-serve authorizer with
|
|
79
|
+
* host-supplied `{ iss, procedure }`; a handler cannot spoof either.
|
|
80
|
+
*/
|
|
81
|
+
export declare function wrapHandler(procedure: string, handler: ServiceHandler, authorizer: ServiceServerServeParams['authorizer']): ServiceHandler;
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { serve } from '@enkaku/server';
|
|
2
|
+
import { normalizeDID } from '@kokuin/token';
|
|
3
|
+
import { toHandlerError } from '@kubun/protocol';
|
|
4
|
+
import { ServiceAuthorizationError } from './authorizer.js';
|
|
5
|
+
export { authorizeRead, authorizeWrite, createCallerMayActFor, SERVICE_ACT_FOR_ACTION, ServiceAuthorizationError, serviceUserResource } from './authorizer.js';
|
|
6
|
+
// The registry kind under which service providers register.
|
|
7
|
+
const SERVICE_KIND = 'service';
|
|
8
|
+
/**
|
|
9
|
+
* A configured service name resolves to a `service` provider that was never
|
|
10
|
+
* registered. `true`/`{ rules }` both require a provider; only `{ handlers }`
|
|
11
|
+
* may name an unregistered service.
|
|
12
|
+
*/ export class UnknownServiceError extends Error {
|
|
13
|
+
#service;
|
|
14
|
+
constructor(service){
|
|
15
|
+
super(`No "service" provider registered for "${service}"`);
|
|
16
|
+
this.name = 'UnknownServiceError';
|
|
17
|
+
this.#service = service;
|
|
18
|
+
}
|
|
19
|
+
get service() {
|
|
20
|
+
return this.#service;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* A custom service (`{ handlers }`) reuses the name of a provider-registered
|
|
25
|
+
* service. A custom impl must not shadow a registered one — the name is the
|
|
26
|
+
* collision.
|
|
27
|
+
*/ export class ServiceNameCollisionError extends Error {
|
|
28
|
+
#service;
|
|
29
|
+
constructor(service){
|
|
30
|
+
super(`Custom service "${service}" collides with a provider-registered service of the same name`);
|
|
31
|
+
this.name = 'ServiceNameCollisionError';
|
|
32
|
+
this.#service = service;
|
|
33
|
+
}
|
|
34
|
+
get service() {
|
|
35
|
+
return this.#service;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Two served services expose the same fully-qualified procedure name. The merged
|
|
40
|
+
* handler map would silently drop one, so it is a serve-time error.
|
|
41
|
+
*/ export class ServiceProcedureCollisionError extends Error {
|
|
42
|
+
#procedure;
|
|
43
|
+
constructor(procedure){
|
|
44
|
+
super(`Procedure "${procedure}" is exposed by more than one served service`);
|
|
45
|
+
this.name = 'ServiceProcedureCollisionError';
|
|
46
|
+
this.#procedure = procedure;
|
|
47
|
+
}
|
|
48
|
+
get procedure() {
|
|
49
|
+
return this.#procedure;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Apply the config resolution table for one `[name, config]` entry against the
|
|
54
|
+
* set of provider-registered services. Returns the service to serve, or `null`
|
|
55
|
+
* when the entry serves nothing (a disabled or no-op entry). Throws the named
|
|
56
|
+
* error for the collision / unknown-service cells.
|
|
57
|
+
*/ function resolveService(name, config, providers) {
|
|
58
|
+
const provider = providers.get(name);
|
|
59
|
+
// Boolean toggles a provider-registered service.
|
|
60
|
+
if (typeof config === 'boolean') {
|
|
61
|
+
if (!config) {
|
|
62
|
+
// `false`: registered-but-disabled, or a plain no-op when unregistered.
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
if (provider == null) {
|
|
66
|
+
throw new UnknownServiceError(name);
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
handlers: provider.handlers,
|
|
70
|
+
rules: provider.accessRules ?? {}
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
// Discriminate the object form by EXPLICIT key, never by shape-sniffing.
|
|
74
|
+
const isCustom = 'handlers' in config && config.handlers != null;
|
|
75
|
+
if (isCustom) {
|
|
76
|
+
// A custom impl must not shadow a provider-registered service.
|
|
77
|
+
if (provider != null) {
|
|
78
|
+
throw new ServiceNameCollisionError(name);
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
handlers: config.handlers,
|
|
82
|
+
rules: config.rules ?? {}
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
// `{ rules }` (override) or `{}` (≡ `true`): both require a provider.
|
|
86
|
+
if (provider == null) {
|
|
87
|
+
throw new UnknownServiceError(name);
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
handlers: provider.handlers,
|
|
91
|
+
rules: config.rules ?? provider.accessRules ?? {}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Creates the service-server host plugin.
|
|
96
|
+
*
|
|
97
|
+
* The host owns the Enkaku serve core extracted from the RPC plugin: per-serve it
|
|
98
|
+
* collects the engine's `service` providers, applies the per-serve `services`
|
|
99
|
+
* config against them, merges the selected handlers/access-rules, and serves the
|
|
100
|
+
* union over the given transport with per-message `did:kokuin:` signature
|
|
101
|
+
* verification (`methods = [controllerResolverFor(db)]`, pre-transaction).
|
|
102
|
+
*/ export function createServiceServerPlugin(options) {
|
|
103
|
+
return (params)=>{
|
|
104
|
+
const servers = [];
|
|
105
|
+
const engine = params.engine;
|
|
106
|
+
// Same pre-transaction, deadlock-free position as the RPC plugin: Enkaku's
|
|
107
|
+
// per-message signature verification runs before any handler opens a write
|
|
108
|
+
// transaction, so resolving a `did:kokuin:` controller over the base db is
|
|
109
|
+
// correct here.
|
|
110
|
+
const methods = options?.methods ?? [
|
|
111
|
+
params.controllerResolverFor(params.db)
|
|
112
|
+
];
|
|
113
|
+
const api = {
|
|
114
|
+
serve: (serveParams)=>{
|
|
115
|
+
const { transport, services, authorizer, signal, requireAuth } = serveParams;
|
|
116
|
+
const providerList = engine.getProvidersSync(SERVICE_KIND);
|
|
117
|
+
const providers = new Map();
|
|
118
|
+
for (const provider of providerList){
|
|
119
|
+
providers.set(provider.name, provider);
|
|
120
|
+
}
|
|
121
|
+
// Compose the merged handler map + access rules for THIS serve. Each
|
|
122
|
+
// service's handlers are wrapped so the per-serve authorizer rides the
|
|
123
|
+
// handler's call context; a trivial handler simply ignores it.
|
|
124
|
+
const mergedHandlers = {};
|
|
125
|
+
const mergedRules = {};
|
|
126
|
+
for (const [name, config] of Object.entries(services)){
|
|
127
|
+
const selected = resolveService(name, config, providers);
|
|
128
|
+
if (selected == null) {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
for (const [procedure, handler] of Object.entries(selected.handlers)){
|
|
132
|
+
if (procedure in mergedHandlers) {
|
|
133
|
+
throw new ServiceProcedureCollisionError(procedure);
|
|
134
|
+
}
|
|
135
|
+
mergedHandlers[procedure] = wrapHandler(procedure, handler, authorizer);
|
|
136
|
+
}
|
|
137
|
+
Object.assign(mergedRules, selected.rules);
|
|
138
|
+
}
|
|
139
|
+
// Build serve params exactly like the RPC plugin. The merged protocol is
|
|
140
|
+
// the union of the served services' procedures, unknown statically — the
|
|
141
|
+
// handler map is cast to Enkaku's `ProcedureHandlers` at this one seam,
|
|
142
|
+
// where each concrete service otherwise keeps its own typed protocol.
|
|
143
|
+
const baseParams = {
|
|
144
|
+
runtime: params.runtime,
|
|
145
|
+
handlers: mergedHandlers,
|
|
146
|
+
logger: params.getLogger('service-server'),
|
|
147
|
+
signal,
|
|
148
|
+
transport,
|
|
149
|
+
resolver: options?.resolver,
|
|
150
|
+
methods
|
|
151
|
+
};
|
|
152
|
+
// `requireAuth: false` serves the same merged procedures with no signature
|
|
153
|
+
// requirement (unauthenticated local mode); it omits `identity`/`accessRules`.
|
|
154
|
+
const enkakuServeParams = requireAuth === false ? {
|
|
155
|
+
...baseParams,
|
|
156
|
+
requireAuth: false
|
|
157
|
+
} : {
|
|
158
|
+
...baseParams,
|
|
159
|
+
identity: params.identity,
|
|
160
|
+
accessRules: mergedRules
|
|
161
|
+
};
|
|
162
|
+
const server = serve(enkakuServeParams);
|
|
163
|
+
servers.push(server);
|
|
164
|
+
return server;
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
return {
|
|
168
|
+
name: 'service-server',
|
|
169
|
+
api,
|
|
170
|
+
dispose: async ()=>{
|
|
171
|
+
for (const server of servers){
|
|
172
|
+
await server.dispose();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
// Absent an explicit per-serve authorizer, a caller may act only for ITSELF: a
|
|
179
|
+
// missing authorizer is safe (self-only), never open. A verified device signing
|
|
180
|
+
// as its own controller DID is the shared-identity path; anything else needs a
|
|
181
|
+
// configured authorizer to grant it.
|
|
182
|
+
const selfOnlyAuthorizer = ({ iss, subject })=>subject == null || normalizeDID(iss) === normalizeDID(subject);
|
|
183
|
+
/**
|
|
184
|
+
* Wrap a service handler so the host — not the caller — supplies the verified
|
|
185
|
+
* `iss` and the `procedure` name to the per-serve authorizer, and so a forged
|
|
186
|
+
* subject is rejected before the handler body runs.
|
|
187
|
+
*
|
|
188
|
+
* The verified caller is the signed payload's `iss`. A payload `sub` that differs
|
|
189
|
+
* from `iss` is a forged-subject attempt (running as someone else) and is
|
|
190
|
+
* rejected outright, mirroring the RPC plugin's `resolveViewerDID`. The handler
|
|
191
|
+
* receives a bound `authorize(subject)` that calls the per-serve authorizer with
|
|
192
|
+
* host-supplied `{ iss, procedure }`; a handler cannot spoof either.
|
|
193
|
+
*/ export function wrapHandler(procedure, handler, authorizer) {
|
|
194
|
+
const check = authorizer ?? selfOnlyAuthorizer;
|
|
195
|
+
return async (context)=>{
|
|
196
|
+
// One gate: apply authz context, then translate on throw. A refusal carrying a
|
|
197
|
+
// registered code (KB07 from `ServiceAuthorizationError`) forwards to the caller
|
|
198
|
+
// as that well-known code; an uncoded internal throw stays opaque, its message
|
|
199
|
+
// never leaving the device.
|
|
200
|
+
try {
|
|
201
|
+
const payload = context.message?.payload;
|
|
202
|
+
const iss = payload?.iss;
|
|
203
|
+
if (payload?.sub != null && payload.sub !== iss) {
|
|
204
|
+
throw new ServiceAuthorizationError('signed payload subject does not match issuer');
|
|
205
|
+
}
|
|
206
|
+
const authorize = async (subject)=>{
|
|
207
|
+
// No verified caller (the `accessRules: false` local mode): a subject-scoped
|
|
208
|
+
// action cannot be authorized, so only self-acting (no subject) is allowed.
|
|
209
|
+
if (iss == null) {
|
|
210
|
+
return subject == null;
|
|
211
|
+
}
|
|
212
|
+
return await check({
|
|
213
|
+
iss,
|
|
214
|
+
procedure,
|
|
215
|
+
subject
|
|
216
|
+
});
|
|
217
|
+
};
|
|
218
|
+
return await handler({
|
|
219
|
+
...context,
|
|
220
|
+
iss,
|
|
221
|
+
authorize
|
|
222
|
+
});
|
|
223
|
+
} catch (cause) {
|
|
224
|
+
throw toHandlerError(cause);
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kubun/plugin-service-server",
|
|
3
|
+
"version": "0.14.0",
|
|
4
|
+
"license": "see LICENSE.md",
|
|
5
|
+
"sideEffects": false,
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.js"
|
|
9
|
+
},
|
|
10
|
+
"main": "lib/index.js",
|
|
11
|
+
"types": "lib/index.d.ts",
|
|
12
|
+
"files": [
|
|
13
|
+
"lib/*",
|
|
14
|
+
"LICENSE.md"
|
|
15
|
+
],
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@enkaku/protocol": "^0.21.0",
|
|
18
|
+
"@enkaku/server": "^0.21.2",
|
|
19
|
+
"@kokuin/capability": "^0.3.0",
|
|
20
|
+
"@kokuin/token": "^0.5.0",
|
|
21
|
+
"@kubun/engine": "^0.14.0",
|
|
22
|
+
"@kubun/plugin-service-api": "^0.14.0",
|
|
23
|
+
"@kubun/protocol": "^0.14.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@enkaku/client": "^0.21.0",
|
|
27
|
+
"@enkaku/transport": "^0.21.0",
|
|
28
|
+
"@kokuin/controller": "^0.1.0",
|
|
29
|
+
"@kubun/db": "^0.14.0",
|
|
30
|
+
"@kubun/store-controller": "^0.14.0",
|
|
31
|
+
"@kubun/store-graph": "^0.14.0",
|
|
32
|
+
"@kubun/test-utils": "^0.13.0"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
|
|
39
|
+
"build:clean": "del lib",
|
|
40
|
+
"build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
|
|
41
|
+
"build:types": "tsc --emitDeclarationOnly --skipLibCheck",
|
|
42
|
+
"test": "pnpm run test:types && pnpm run test:unit",
|
|
43
|
+
"test:types": "tsc --noEmit -p tsconfig.test.json",
|
|
44
|
+
"test:unit": "vitest run"
|
|
45
|
+
}
|
|
46
|
+
}
|