@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 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
+ }