@supacloud/elysia 0.9.0 → 0.10.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/README.md +60 -0
- package/dist/execution.d.ts +12 -0
- package/dist/identity.d.ts +37 -0
- package/dist/index.d.ts +34 -4
- package/dist/index.js +219 -17
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -102,6 +102,66 @@ fails partway through.
|
|
|
102
102
|
|
|
103
103
|
## API
|
|
104
104
|
|
|
105
|
+
### External SupAuth Identity
|
|
106
|
+
|
|
107
|
+
`createSupAuthRequestContext(options)` supplies the trusted host adapter for an
|
|
108
|
+
external SupAuth user center. It uses `jose` signature verification, requires
|
|
109
|
+
configured HTTPS issuer/JWKS endpoints, audience, subject, expiry and issued-at,
|
|
110
|
+
and accepts only ES256/RS256. It does not implement login, sessions or token issuance.
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
import { createSupAuthRequestContext } from "@supacloud/elysia";
|
|
114
|
+
|
|
115
|
+
const requestContext = createSupAuthRequestContext({
|
|
116
|
+
issuer: "https://identity.example/auth/v1",
|
|
117
|
+
audience: "authenticated",
|
|
118
|
+
clientId: "orders-oauth-client",
|
|
119
|
+
projectId: "orders",
|
|
120
|
+
jwksUrl: "https://identity.example/auth/v1/.well-known/jwks.json",
|
|
121
|
+
resolveAccess: async (identity) =>
|
|
122
|
+
accessRepository.findCurrentAccess(identity.issuer, identity.subject, "orders"),
|
|
123
|
+
});
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`accessRepository` is application-owned and must return `{ projectId, tenantId,
|
|
127
|
+
permissions }` or `null` from authoritative local data. The factory rejects
|
|
128
|
+
missing/wrong-project access, ignores forwarded subject/tenant headers and
|
|
129
|
+
returns a frozen identity/access snapshot. Commands must still authorize current
|
|
130
|
+
object relationships within their durable transaction; a permission snapshot is
|
|
131
|
+
not an RLS replacement. The bearer credential is non-enumerable on identity.
|
|
132
|
+
Never log the complete request/context.
|
|
133
|
+
|
|
134
|
+
The adapter protects all routes using that context factory, including health
|
|
135
|
+
routes; mount intentionally public routes separately. Invalid credentials and
|
|
136
|
+
invalid signing keys fail closed with sanitized 401 responses. Tokens must have
|
|
137
|
+
`role: "authenticated"` and a matching `client_id` or `azp`; when both exist they
|
|
138
|
+
must match each other and the configured `clientId`. Verification service failures
|
|
139
|
+
return sanitized 503 `AUTHENTICATION_UNAVAILABLE`, never an identity fallback. Remote
|
|
140
|
+
JWKS uses bounded fetch timeout and the library's key cache; no token-provided key
|
|
141
|
+
URL or local identity fallback is accepted. `keyResolver` is a trusted host
|
|
142
|
+
override for pinned key sets/testing, never request input.
|
|
143
|
+
|
|
144
|
+
### Execution Inspection
|
|
145
|
+
|
|
146
|
+
Set `createApplication({ onExecution })` for metadata-only events: operation,
|
|
147
|
+
stage, kind, phase, elapsed time and a bounded request correlation ID. Module,
|
|
148
|
+
route and command aspects retain declared order. Standard governance exposes
|
|
149
|
+
authorization, idempotency, transaction, handler and successful audit stages.
|
|
150
|
+
Pass the final optional observer argument to `executeJob` for job traces.
|
|
151
|
+
No request input, token, result or error cause is sent to the observer.
|
|
152
|
+
The boundary and aspect index identify the static declaration; JavaScript
|
|
153
|
+
function names are display hints and may change when consumers minify a bundle.
|
|
154
|
+
|
|
155
|
+
Observer failures are isolated from business results; this is best-effort
|
|
156
|
+
telemetry, not durable audit. Use command governance for mandatory audit.
|
|
157
|
+
An inner successful stage does not prove the enclosing transaction committed.
|
|
158
|
+
The compiler's `context`/`explain` commands show the corresponding static plan.
|
|
159
|
+
|
|
160
|
+
Command transaction/idempotency continuations, custom route executors and job
|
|
161
|
+
handlers reject repeated invocation. This prevents accidental adapter retries
|
|
162
|
+
inside one invocation; cross-request/process deduplication still requires a
|
|
163
|
+
durable idempotency adapter.
|
|
164
|
+
|
|
105
165
|
### `validatedJsonResponse(validate, value, init?): Response`
|
|
106
166
|
|
|
107
167
|
Constructs a native JSON response after a synchronous, caller-owned type guard
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface ExecutionEvent {
|
|
2
|
+
kind: "route" | "command" | "job";
|
|
3
|
+
operation: string;
|
|
4
|
+
stage: string;
|
|
5
|
+
phase: "started" | "succeeded" | "failed";
|
|
6
|
+
requestId?: string;
|
|
7
|
+
durationMs?: number;
|
|
8
|
+
}
|
|
9
|
+
/** Metadata only: never receives request bodies, credentials, results or errors. */
|
|
10
|
+
export type ExecutionObserver = (event: Readonly<ExecutionEvent>) => void | Promise<void>;
|
|
11
|
+
export declare function observeExecution<T>(observer: ExecutionObserver | undefined, event: Pick<ExecutionEvent, "kind" | "operation" | "stage" | "requestId">, next: () => T | Promise<T>): Promise<T>;
|
|
12
|
+
export declare function executionRequestId(context: unknown): string | undefined;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type JWTVerifyGetKey } from "jose";
|
|
2
|
+
import { type SupaCloudRequestContext, type TrustedRequestIdentity } from "./index";
|
|
3
|
+
export interface SupAuthIdentity extends TrustedRequestIdentity {
|
|
4
|
+
authenticated: true;
|
|
5
|
+
subject: string;
|
|
6
|
+
issuer: string;
|
|
7
|
+
clientId: string;
|
|
8
|
+
}
|
|
9
|
+
export interface SupAuthAccess {
|
|
10
|
+
projectId: string;
|
|
11
|
+
tenantId: string;
|
|
12
|
+
permissions: readonly string[];
|
|
13
|
+
}
|
|
14
|
+
export interface SupAuthRequestContext extends SupaCloudRequestContext {
|
|
15
|
+
identity: SupAuthIdentity;
|
|
16
|
+
access: Readonly<SupAuthAccess>;
|
|
17
|
+
}
|
|
18
|
+
export interface SupAuthContextOptions {
|
|
19
|
+
issuer: string;
|
|
20
|
+
audience: string;
|
|
21
|
+
/** SupAuth OAuth application binding, independent of audience/project membership. */
|
|
22
|
+
clientId: string;
|
|
23
|
+
projectId: string;
|
|
24
|
+
/** Explicit trusted JWKS endpoint; never read from a token's jku/x5u header. */
|
|
25
|
+
jwksUrl: string;
|
|
26
|
+
/** Defaults to ES256 and RS256; symmetric algorithms are not supported. */
|
|
27
|
+
algorithms?: readonly ("ES256" | "RS256")[];
|
|
28
|
+
/** Trusted host override for pinned/local keys and deterministic tests. */
|
|
29
|
+
keyResolver?: JWTVerifyGetKey;
|
|
30
|
+
/** Read current application-local access, not user-supplied tenant headers. */
|
|
31
|
+
resolveAccess(identity: Readonly<SupAuthIdentity>, request: Request): Promise<SupAuthAccess | null>;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* External user-center verification only. SupAuth/GoTrue still owns login,
|
|
35
|
+
* passwords, sessions and token issuance; applications own access decisions.
|
|
36
|
+
*/
|
|
37
|
+
export declare function createSupAuthRequestContext(options: SupAuthContextOptions): (request: Request) => Promise<SupAuthRequestContext>;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { Elysia } from "elysia";
|
|
2
|
+
import { type ExecutionObserver } from "./execution";
|
|
3
|
+
export type { ExecutionEvent, ExecutionObserver } from "./execution";
|
|
2
4
|
export interface CompiledRoute {
|
|
3
5
|
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS";
|
|
4
6
|
path: string;
|
|
@@ -23,6 +25,7 @@ export interface CompiledRoute {
|
|
|
23
25
|
aspects?: ApplicationAspect[];
|
|
24
26
|
}
|
|
25
27
|
export interface CompiledCommand {
|
|
28
|
+
rpc?: string;
|
|
26
29
|
className: string;
|
|
27
30
|
name: string;
|
|
28
31
|
permission?: string;
|
|
@@ -61,6 +64,8 @@ export interface CompiledModule {
|
|
|
61
64
|
aspects?: ApplicationAspect[];
|
|
62
65
|
}
|
|
63
66
|
export type RequestContextFactory = (request: Request) => unknown | Promise<unknown>;
|
|
67
|
+
export { createSupAuthRequestContext } from "./identity";
|
|
68
|
+
export type { SupAuthIdentity, SupAuthAccess, SupAuthRequestContext, SupAuthContextOptions } from "./identity";
|
|
64
69
|
export declare const VERIFIED_JWT_SUBJECT_HEADER = "x-supacloud-jwt-sub";
|
|
65
70
|
export declare const EXECUTION_ID_HEADER = "x-sb-execution-id";
|
|
66
71
|
export declare const IDEMPOTENCY_KEY_HEADER = "idempotency-key";
|
|
@@ -116,6 +121,15 @@ export interface CommandAudit {
|
|
|
116
121
|
failed(invocation: CommandInvocation, error: unknown): void | Promise<void>;
|
|
117
122
|
}
|
|
118
123
|
export interface CommandGovernance {
|
|
124
|
+
/** Application-owned adapters: a single RPC owns all declared persistence. */
|
|
125
|
+
rpc?: Record<string, {
|
|
126
|
+
capabilities: {
|
|
127
|
+
audit?: boolean;
|
|
128
|
+
transaction?: boolean;
|
|
129
|
+
idempotency?: boolean;
|
|
130
|
+
};
|
|
131
|
+
execute: CommandMiddleware;
|
|
132
|
+
}>;
|
|
119
133
|
authorize: CommandAuthorizer;
|
|
120
134
|
idempotency?: CommandMiddleware;
|
|
121
135
|
transaction?: CommandMiddleware;
|
|
@@ -171,6 +185,8 @@ export interface ApplicationOptions {
|
|
|
171
185
|
commandExecutor?: CommandExecutor;
|
|
172
186
|
/** Maps framework or application failures to the public HTTP contract. */
|
|
173
187
|
errorMapper?: ErrorMapper;
|
|
188
|
+
/** Best-effort execution metadata only; durable audit belongs to governance. */
|
|
189
|
+
onExecution?: ExecutionObserver;
|
|
174
190
|
}
|
|
175
191
|
export interface JobInvocation {
|
|
176
192
|
job: CompiledJob;
|
|
@@ -185,13 +201,27 @@ export type JobExecutor = (invocation: JobInvocation, next: () => unknown | Prom
|
|
|
185
201
|
* Runtime. The runtime strips incoming x-supacloud-jwt-sub values and writes
|
|
186
202
|
* the header only after JWT verification.
|
|
187
203
|
*/
|
|
188
|
-
export declare const createSupaCloudRequestContext:
|
|
204
|
+
export declare const createSupaCloudRequestContext: (request: Request) => SupaCloudRequestContext;
|
|
189
205
|
/**
|
|
190
206
|
* Compose the standard command governance order:
|
|
191
207
|
* authorization -> idempotency -> transaction -> audit -> handler.
|
|
192
208
|
* Declared governance metadata fails closed when its adapter is absent.
|
|
193
209
|
*/
|
|
194
|
-
export declare function createCommandExecutor(governance: CommandGovernance): CommandExecutor;
|
|
210
|
+
export declare function createCommandExecutor(governance: CommandGovernance, observer?: ExecutionObserver): CommandExecutor;
|
|
211
|
+
/** Execute at the business-selected boundary without binding or re-entering an HTTP route. */
|
|
212
|
+
export declare function executeCompiledCommand<Input, Result>(options: {
|
|
213
|
+
module: Pick<CompiledModule, "name" | "commands" | "aspects">;
|
|
214
|
+
command: string;
|
|
215
|
+
input: Input;
|
|
216
|
+
request: Request;
|
|
217
|
+
requestContext: unknown;
|
|
218
|
+
services?: Record<string, unknown>;
|
|
219
|
+
scope?: Record<string, unknown>;
|
|
220
|
+
governance: CommandGovernance;
|
|
221
|
+
observer?: ExecutionObserver;
|
|
222
|
+
handler: (input: Input) => Result | Promise<Result>;
|
|
223
|
+
decode: (value: unknown) => Result;
|
|
224
|
+
}): Promise<Result>;
|
|
195
225
|
export declare function requireTrustedIdentity(requestContext: unknown): Required<Pick<TrustedRequestIdentity, "subject" | "accessToken">> & TrustedRequestIdentity;
|
|
196
226
|
export declare function requireIdempotencyKey(invocation: CommandInvocation): string;
|
|
197
227
|
/**
|
|
@@ -202,7 +232,7 @@ export declare function requireIdempotencyKey(invocation: CommandInvocation): st
|
|
|
202
232
|
* exposed on the `scope` context key. Request-scoped controller instances are
|
|
203
233
|
* looked up on `scope`, everything else on `services`.
|
|
204
234
|
*/
|
|
205
|
-
export declare function createModulePlugin(compiled: CompiledModule, services: Record<string, unknown>, ctxFactory?: RequestContextFactory, options?: Pick<ApplicationOptions, "commandGovernance" | "commandExecutor" | "errorMapper">, imported?: Record<string, Record<string, unknown>>): Elysia;
|
|
235
|
+
export declare function createModulePlugin(compiled: CompiledModule, services: Record<string, unknown>, ctxFactory?: RequestContextFactory, options?: Pick<ApplicationOptions, "commandGovernance" | "commandExecutor" | "errorMapper" | "onExecution">, imported?: Record<string, Record<string, unknown>>): Elysia;
|
|
206
236
|
/**
|
|
207
237
|
* Execute one compiler-emitted Job descriptor.
|
|
208
238
|
*
|
|
@@ -210,7 +240,7 @@ export declare function createModulePlugin(compiled: CompiledModule, services: R
|
|
|
210
240
|
* accepted for command-like job implementations. The scope factory and
|
|
211
241
|
* destruction hooks are generated statically by the compiler.
|
|
212
242
|
*/
|
|
213
|
-
export declare function executeJob(compiled: CompiledModule, services: Record<string, unknown>, job: CompiledJob, input: unknown, requestContext: unknown, imported?: Record<string, Record<string, unknown>>, executor?: JobExecutor): Promise<unknown>;
|
|
243
|
+
export declare function executeJob(compiled: CompiledModule, services: Record<string, unknown>, job: CompiledJob, input: unknown, requestContext: unknown, imported?: Record<string, Record<string, unknown>>, executor?: JobExecutor, observer?: ExecutionObserver): Promise<unknown>;
|
|
214
244
|
export declare function defaultErrorResponse(error: unknown, frameworkCode?: string | number): Response;
|
|
215
245
|
/**
|
|
216
246
|
* Create the root Elysia application from compiled modules.
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,108 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import { Elysia } from "elysia";
|
|
3
3
|
|
|
4
|
+
// src/execution.ts
|
|
5
|
+
async function observeExecution(observer, event, next) {
|
|
6
|
+
const emit = (phase, durationMs) => {
|
|
7
|
+
if (!observer)
|
|
8
|
+
return;
|
|
9
|
+
try {
|
|
10
|
+
Promise.resolve(observer(Object.freeze({ ...event, phase, durationMs }))).catch(() => {});
|
|
11
|
+
} catch {}
|
|
12
|
+
};
|
|
13
|
+
const started = performance.now();
|
|
14
|
+
emit("started");
|
|
15
|
+
try {
|
|
16
|
+
const result = await next();
|
|
17
|
+
emit("succeeded", performance.now() - started);
|
|
18
|
+
return result;
|
|
19
|
+
} catch (error) {
|
|
20
|
+
emit("failed", performance.now() - started);
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function executionRequestId(context) {
|
|
25
|
+
if (!context || typeof context !== "object" || !("requestId" in context))
|
|
26
|
+
return;
|
|
27
|
+
const value = context.requestId;
|
|
28
|
+
return typeof value === "string" && /^[A-Za-z0-9._:-]{1,256}$/.test(value) ? value : undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// src/identity.ts
|
|
32
|
+
import { createRemoteJWKSet, jwtVerify, errors } from "jose";
|
|
33
|
+
function httpsUrl(value) {
|
|
34
|
+
const url = new URL(value);
|
|
35
|
+
if (url.protocol !== "https:" || url.username || url.password || url.hash) {
|
|
36
|
+
throw new TypeError("Identity endpoints must be HTTPS URLs without credentials or fragments");
|
|
37
|
+
}
|
|
38
|
+
return url;
|
|
39
|
+
}
|
|
40
|
+
function createSupAuthRequestContext(options) {
|
|
41
|
+
httpsUrl(options.issuer);
|
|
42
|
+
const jwksUrl = httpsUrl(options.jwksUrl);
|
|
43
|
+
if (!options.audience.trim() || !options.projectId.trim() || !options.clientId?.trim()) {
|
|
44
|
+
throw new TypeError("SupAuth audience, clientId and projectId are required");
|
|
45
|
+
}
|
|
46
|
+
const algorithms = [...options.algorithms ?? ["ES256", "RS256"]];
|
|
47
|
+
if (!algorithms.length || algorithms.some((value) => value !== "ES256" && value !== "RS256")) {
|
|
48
|
+
throw new TypeError("SupAuth requires ES256 or RS256");
|
|
49
|
+
}
|
|
50
|
+
const { issuer, audience, clientId, projectId, resolveAccess } = options;
|
|
51
|
+
const keyResolver = options.keyResolver ?? createRemoteJWKSet(jwksUrl, { timeoutDuration: 5000 });
|
|
52
|
+
return async (request) => {
|
|
53
|
+
const match = request.headers.get("authorization")?.match(/^Bearer ([^\s]+)$/i);
|
|
54
|
+
const token = match?.[1];
|
|
55
|
+
if (!token || token.length > 16384)
|
|
56
|
+
throw unauthenticated();
|
|
57
|
+
let subject;
|
|
58
|
+
try {
|
|
59
|
+
const { payload } = await jwtVerify(token, keyResolver, {
|
|
60
|
+
issuer,
|
|
61
|
+
audience,
|
|
62
|
+
algorithms,
|
|
63
|
+
requiredClaims: ["sub", "exp", "iat"]
|
|
64
|
+
});
|
|
65
|
+
if (typeof payload.sub !== "string" || !payload.sub.trim() || payload.sub.length > 1024 || /[\u0000-\u001f\u007f]/.test(payload.sub)) {
|
|
66
|
+
throw unauthenticated();
|
|
67
|
+
}
|
|
68
|
+
const application = payload.client_id ?? payload.azp;
|
|
69
|
+
if (application !== clientId || payload.role !== "authenticated" || payload.client_id !== undefined && payload.client_id !== clientId || payload.azp !== undefined && payload.azp !== clientId) {
|
|
70
|
+
throw unauthenticated();
|
|
71
|
+
}
|
|
72
|
+
subject = payload.sub;
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (error instanceof ApplicationError || error instanceof errors.JWTClaimValidationFailed || error instanceof errors.JWTExpired || error instanceof errors.JWTInvalid || error instanceof errors.JWSInvalid || error instanceof errors.JWSSignatureVerificationFailed || error instanceof errors.JOSEAlgNotAllowed || error instanceof errors.JOSENotSupported || error instanceof errors.JWKSNoMatchingKey)
|
|
75
|
+
throw unauthenticated();
|
|
76
|
+
throw new ApplicationError("Identity verification service unavailable", {
|
|
77
|
+
status: 503,
|
|
78
|
+
code: "AUTHENTICATION_UNAVAILABLE"
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
const identity = { authenticated: true, subject, issuer, clientId };
|
|
82
|
+
Object.defineProperty(identity, "accessToken", { value: token, enumerable: false });
|
|
83
|
+
Object.freeze(identity);
|
|
84
|
+
const access = await resolveAccess(identity, request);
|
|
85
|
+
if (!access || access.projectId !== projectId || typeof access.tenantId !== "string" || !access.tenantId.trim() || !Array.isArray(access.permissions) || access.permissions.some((permission) => typeof permission !== "string" || !permission.trim())) {
|
|
86
|
+
throw new ApplicationError("Application access denied", { status: 403, code: "APPLICATION_ACCESS_DENIED" });
|
|
87
|
+
}
|
|
88
|
+
const context = createSupaCloudRequestContext(request);
|
|
89
|
+
return {
|
|
90
|
+
...context,
|
|
91
|
+
identity,
|
|
92
|
+
access: Object.freeze({
|
|
93
|
+
projectId,
|
|
94
|
+
tenantId: access.tenantId,
|
|
95
|
+
permissions: Object.freeze([...new Set(access.permissions)])
|
|
96
|
+
})
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function unauthenticated() {
|
|
101
|
+
return new ApplicationError("Authenticated user context is required", {
|
|
102
|
+
status: 401,
|
|
103
|
+
code: "AUTHENTICATION_REQUIRED"
|
|
104
|
+
});
|
|
105
|
+
}
|
|
4
106
|
// src/feature.ts
|
|
5
107
|
function assertFeatureTransition(spec, state, event) {
|
|
6
108
|
const transition = Object.hasOwn(spec.transitions, event) ? spec.transitions[event] : undefined;
|
|
@@ -368,6 +470,14 @@ function composeAspects(...aspects) {
|
|
|
368
470
|
return dispatch(0);
|
|
369
471
|
};
|
|
370
472
|
}
|
|
473
|
+
function observedAspects(aspects, boundary, observer) {
|
|
474
|
+
return composeAspects(...aspects.map((aspect, index) => (context, next) => observeExecution(observer, {
|
|
475
|
+
kind: context.kind,
|
|
476
|
+
operation: context.name,
|
|
477
|
+
stage: `${boundary}.aspect[${index}]:${aspect.name || "anonymous"}`,
|
|
478
|
+
requestId: executionRequestId(context.requestContext)
|
|
479
|
+
}, () => aspect(context, next))));
|
|
480
|
+
}
|
|
371
481
|
function composeCommandExecutors(...executors) {
|
|
372
482
|
const active = executors.filter((e) => typeof e === "function");
|
|
373
483
|
if (active.length === 0)
|
|
@@ -482,9 +592,31 @@ function missingGovernanceAdapter(command, adapter) {
|
|
|
482
592
|
code: codes[adapter]
|
|
483
593
|
});
|
|
484
594
|
}
|
|
485
|
-
function createCommandExecutor(governance) {
|
|
595
|
+
function createCommandExecutor(governance, observer) {
|
|
486
596
|
return async (invocation, next) => {
|
|
487
597
|
const { command } = invocation;
|
|
598
|
+
for (const mode of [command.transaction, command.idempotency]) {
|
|
599
|
+
if (mode !== undefined && mode !== "required" && mode !== "none") {
|
|
600
|
+
throw new ApplicationError("Invalid command governance mode", { code: "COMMAND_MODE_INVALID" });
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
if (command.rpc !== undefined) {
|
|
604
|
+
const adapter = Object.hasOwn(governance.rpc ?? {}, command.rpc) ? governance.rpc?.[command.rpc] : undefined;
|
|
605
|
+
if (!adapter)
|
|
606
|
+
throw new ApplicationError("RPC governance adapter unavailable", { code: "COMMAND_RPC_UNAVAILABLE", status: 501 });
|
|
607
|
+
for (const capability of ["audit", "transaction", "idempotency"]) {
|
|
608
|
+
const required = capability === "audit" ? !!command.audit : command[capability] === "required";
|
|
609
|
+
if (required && adapter.capabilities[capability] !== true)
|
|
610
|
+
throw missingGovernanceAdapter(command, capability);
|
|
611
|
+
}
|
|
612
|
+
const event = {
|
|
613
|
+
kind: "command",
|
|
614
|
+
operation: command.name,
|
|
615
|
+
requestId: executionRequestId(invocation.requestContext)
|
|
616
|
+
};
|
|
617
|
+
await observeExecution(observer, { ...event, stage: "authorize" }, () => governance.authorize(invocation));
|
|
618
|
+
return observeExecution(observer, { ...event, stage: `rpc:${command.rpc}` }, () => adapter.execute(invocation, once(next)));
|
|
619
|
+
}
|
|
488
620
|
const audit = command.audit ? governance.audit : undefined;
|
|
489
621
|
const transaction = command.transaction === "required" ? governance.transaction : undefined;
|
|
490
622
|
const idempotency = command.idempotency === "required" ? governance.idempotency : undefined;
|
|
@@ -498,24 +630,31 @@ function createCommandExecutor(governance) {
|
|
|
498
630
|
throw missingGovernanceAdapter(command, "idempotency");
|
|
499
631
|
}
|
|
500
632
|
try {
|
|
501
|
-
|
|
633
|
+
const observe = (stage, next2) => observeExecution(observer, {
|
|
634
|
+
kind: "command",
|
|
635
|
+
operation: command.name,
|
|
636
|
+
stage,
|
|
637
|
+
requestId: executionRequestId(invocation.requestContext)
|
|
638
|
+
}, next2);
|
|
639
|
+
await observe("authorize", () => governance.authorize(invocation));
|
|
502
640
|
let execute = async () => {
|
|
503
|
-
const result = await
|
|
641
|
+
const result = await observe("handler", invokeOnce);
|
|
504
642
|
if (audit)
|
|
505
|
-
await audit.succeeded.call(audit, invocation, result);
|
|
643
|
+
await observe("audit", () => audit.succeeded.call(audit, invocation, result));
|
|
506
644
|
return result;
|
|
507
645
|
};
|
|
646
|
+
const invokeOnce = once(next);
|
|
508
647
|
if (command.transaction === "required") {
|
|
509
648
|
if (!transaction)
|
|
510
649
|
throw missingGovernanceAdapter(command, "transaction");
|
|
511
650
|
const inner = execute;
|
|
512
|
-
execute = () =>
|
|
651
|
+
execute = () => observe("transaction", () => transaction.call(governance, invocation, once(inner)));
|
|
513
652
|
}
|
|
514
653
|
if (command.idempotency === "required") {
|
|
515
654
|
if (!idempotency)
|
|
516
655
|
throw missingGovernanceAdapter(command, "idempotency");
|
|
517
656
|
const inner = execute;
|
|
518
|
-
execute = () =>
|
|
657
|
+
execute = () => observe("idempotency", () => idempotency.call(governance, invocation, once(inner)));
|
|
519
658
|
}
|
|
520
659
|
return await execute();
|
|
521
660
|
} catch (error) {
|
|
@@ -525,6 +664,44 @@ function createCommandExecutor(governance) {
|
|
|
525
664
|
}
|
|
526
665
|
};
|
|
527
666
|
}
|
|
667
|
+
async function executeCompiledCommand(options) {
|
|
668
|
+
const matches = options.module.commands?.filter((item) => item.className === options.command) ?? [];
|
|
669
|
+
if (matches.length !== 1)
|
|
670
|
+
throw new ApplicationError("Command descriptor missing or ambiguous", { code: "COMMAND_NOT_REGISTERED" });
|
|
671
|
+
const command = matches[0];
|
|
672
|
+
const invocation = {
|
|
673
|
+
command,
|
|
674
|
+
input: { body: options.input, params: {}, query: {} },
|
|
675
|
+
request: options.request,
|
|
676
|
+
requestContext: options.requestContext,
|
|
677
|
+
services: options.services ?? {},
|
|
678
|
+
scope: options.scope
|
|
679
|
+
};
|
|
680
|
+
const aspects = composeAspects(observedAspects(options.module.aspects ?? [], `module:${options.module.name}`, options.observer), observedAspects(command.aspects ?? [], "command", options.observer));
|
|
681
|
+
const value = await createCommandExecutor(options.governance, options.observer)(invocation, once(() => aspects({
|
|
682
|
+
kind: "command",
|
|
683
|
+
name: command.name,
|
|
684
|
+
input: options.input,
|
|
685
|
+
request: options.request,
|
|
686
|
+
requestContext: options.requestContext,
|
|
687
|
+
services: invocation.services,
|
|
688
|
+
scope: invocation.scope,
|
|
689
|
+
metadata: command
|
|
690
|
+
}, once(() => options.handler(options.input)))));
|
|
691
|
+
return options.decode(value);
|
|
692
|
+
}
|
|
693
|
+
function once(next) {
|
|
694
|
+
let called = false;
|
|
695
|
+
return async () => {
|
|
696
|
+
if (called) {
|
|
697
|
+
throw new ApplicationError("Command continuation called multiple times", {
|
|
698
|
+
code: "COMMAND_CONTINUATION_REUSED"
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
called = true;
|
|
702
|
+
return await next();
|
|
703
|
+
};
|
|
704
|
+
}
|
|
528
705
|
function requireTrustedIdentity(requestContext) {
|
|
529
706
|
const context = isRecord(requestContext) ? requestContext : {};
|
|
530
707
|
const identity = isTrustedIdentity(context.identity) ? context.identity : undefined;
|
|
@@ -570,7 +747,7 @@ function createModulePlugin(compiled, services, ctxFactory = defaultRequestConte
|
|
|
570
747
|
}
|
|
571
748
|
}
|
|
572
749
|
const requestContexts = new WeakMap;
|
|
573
|
-
const governanceExecutor = options.commandGovernance ? createCommandExecutor(options.commandGovernance) : undefined;
|
|
750
|
+
const governanceExecutor = options.commandGovernance ? createCommandExecutor(options.commandGovernance, options.onExecution) : undefined;
|
|
574
751
|
const commandExecutor = options.commandExecutor && governanceExecutor ? composeCommandExecutors(options.commandExecutor, governanceExecutor) : options.commandExecutor ?? governanceExecutor;
|
|
575
752
|
const plugin = new Elysia({ name: `supacloud:${compiled.name}` }).decorate("services", services);
|
|
576
753
|
const createRequestScope = compiled.createRequestScope;
|
|
@@ -636,7 +813,13 @@ function createModulePlugin(compiled, services, ctxFactory = defaultRequestConte
|
|
|
636
813
|
scope: requestScope,
|
|
637
814
|
requestContext
|
|
638
815
|
};
|
|
639
|
-
const
|
|
816
|
+
const handlerCall = () => route.invoker ? route.invoker(instance, input) : Reflect.apply(method, instance, [input]);
|
|
817
|
+
const invoke = once(() => route.command && options.commandGovernance ? handlerCall() : observeExecution(options.onExecution, {
|
|
818
|
+
kind: route.command ? "command" : "route",
|
|
819
|
+
operation: route.command ?? `${route.method} ${path}`,
|
|
820
|
+
stage: "handler",
|
|
821
|
+
requestId: executionRequestId(requestContext)
|
|
822
|
+
}, handlerCall));
|
|
640
823
|
const routeContext = {
|
|
641
824
|
kind: "route",
|
|
642
825
|
name: `${route.method} ${path}`,
|
|
@@ -659,9 +842,9 @@ function createModulePlugin(compiled, services, ctxFactory = defaultRequestConte
|
|
|
659
842
|
metadata: command ?? route
|
|
660
843
|
};
|
|
661
844
|
const commandAspects = route.command ? commandsByClassName.get(route.command)?.aspects ?? [] : [];
|
|
662
|
-
const routePipeline =
|
|
663
|
-
const commandPipeline =
|
|
664
|
-
const modulePipeline =
|
|
845
|
+
const routePipeline = observedAspects(route.aspects ?? [], "route", options.onExecution);
|
|
846
|
+
const commandPipeline = observedAspects(commandAspects, "command", options.onExecution);
|
|
847
|
+
const modulePipeline = observedAspects(compiled.aspects ?? [], `module:${compiled.name}`, options.onExecution);
|
|
665
848
|
const invokeRoute = () => routePipeline(routeContext, () => route.command ? commandPipeline(commandContext, () => invokeCommand()) : invoke());
|
|
666
849
|
const invokeCommand = () => {
|
|
667
850
|
if (!route.command)
|
|
@@ -685,7 +868,12 @@ function createModulePlugin(compiled, services, ctxFactory = defaultRequestConte
|
|
|
685
868
|
scope: requestScope,
|
|
686
869
|
services
|
|
687
870
|
};
|
|
688
|
-
return
|
|
871
|
+
return observeExecution(options.onExecution, {
|
|
872
|
+
kind: "command",
|
|
873
|
+
operation: command.name,
|
|
874
|
+
stage: "commandExecutor",
|
|
875
|
+
requestId: executionRequestId(requestContext)
|
|
876
|
+
}, () => commandExecutor(invocation, invoke));
|
|
689
877
|
};
|
|
690
878
|
return modulePipeline(route.command ? commandContext : routeContext, invokeRoute);
|
|
691
879
|
};
|
|
@@ -716,7 +904,7 @@ function createModulePlugin(compiled, services, ctxFactory = defaultRequestConte
|
|
|
716
904
|
}
|
|
717
905
|
return plugin;
|
|
718
906
|
}
|
|
719
|
-
async function executeJob(compiled, services, job, input, requestContext, imported = {}, executor) {
|
|
907
|
+
async function executeJob(compiled, services, job, input, requestContext, imported = {}, executor, observer) {
|
|
720
908
|
const jobScope = job.scope === "job" && compiled.createJobScope ? await compiled.createJobScope(services, requestContext, imported) : undefined;
|
|
721
909
|
try {
|
|
722
910
|
const source = job.scope === "job" ? jobScope : services;
|
|
@@ -741,9 +929,20 @@ async function executeJob(compiled, services, job, input, requestContext, import
|
|
|
741
929
|
services,
|
|
742
930
|
metadata: job
|
|
743
931
|
};
|
|
744
|
-
const invoke = () =>
|
|
745
|
-
|
|
746
|
-
|
|
932
|
+
const invoke = once(() => observeExecution(observer, {
|
|
933
|
+
kind: "job",
|
|
934
|
+
operation: job.name,
|
|
935
|
+
stage: "handler",
|
|
936
|
+
requestId: executionRequestId(requestContext)
|
|
937
|
+
}, () => Reflect.apply(method, instance, [input])));
|
|
938
|
+
const pipeline = observedAspects(compiled.aspects ?? [], `module:${compiled.name}`, observer);
|
|
939
|
+
const jobPipeline = observedAspects(job.aspects ?? [], "job", observer);
|
|
940
|
+
return await pipeline(context, () => jobPipeline(context, () => observeExecution(observer, {
|
|
941
|
+
kind: "job",
|
|
942
|
+
operation: job.name,
|
|
943
|
+
stage: "jobExecutor",
|
|
944
|
+
requestId: executionRequestId(requestContext)
|
|
945
|
+
}, executor ? () => executor(invocation, invoke) : invoke)));
|
|
747
946
|
} finally {
|
|
748
947
|
if (jobScope && compiled.destroyJobScope) {
|
|
749
948
|
await compiled.destroyJobScope(jobScope);
|
|
@@ -803,7 +1002,8 @@ function createApplication(options) {
|
|
|
803
1002
|
app.use(createModulePlugin(module, services, ctxFactory, {
|
|
804
1003
|
commandGovernance: options.commandGovernance,
|
|
805
1004
|
commandExecutor: options.commandExecutor,
|
|
806
|
-
errorMapper: options.errorMapper
|
|
1005
|
+
errorMapper: options.errorMapper,
|
|
1006
|
+
onExecution: options.onExecution
|
|
807
1007
|
}, imported));
|
|
808
1008
|
}
|
|
809
1009
|
return app;
|
|
@@ -824,9 +1024,11 @@ export {
|
|
|
824
1024
|
createMemoryPolicy,
|
|
825
1025
|
createMemorySandbox,
|
|
826
1026
|
createModulePlugin,
|
|
1027
|
+
createSupAuthRequestContext,
|
|
827
1028
|
createSupaCloudRequestContext,
|
|
828
1029
|
createTestApp,
|
|
829
1030
|
defaultErrorResponse,
|
|
1031
|
+
executeCompiledCommand,
|
|
830
1032
|
executeJob,
|
|
831
1033
|
requireIdempotencyKey,
|
|
832
1034
|
requireTrustedIdentity,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supacloud/elysia",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Elysia runtime adapter for SupaCloud compiled modules: application/request scopes, route registration and validation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
],
|
|
20
20
|
"scripts": {
|
|
21
21
|
"build": "bun run clean && bun run build:js && bun run build:types",
|
|
22
|
-
"build:js": "bun build src/index.ts --outdir dist --target node --external elysia",
|
|
22
|
+
"build:js": "bun build src/index.ts --outdir dist --target node --external elysia --external jose",
|
|
23
23
|
"build:types": "tsc -p tsconfig.json --emitDeclarationOnly",
|
|
24
24
|
"clean": "rm -rf dist",
|
|
25
25
|
"prepublishOnly": "bun run build",
|
|
@@ -40,6 +40,9 @@
|
|
|
40
40
|
"url": "https://github.com/vibeunion/supacloud.git",
|
|
41
41
|
"directory": "packages/elysia"
|
|
42
42
|
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"jose": "^6.2.11"
|
|
45
|
+
},
|
|
43
46
|
"peerDependencies": {
|
|
44
47
|
"elysia": "^1.4.30"
|
|
45
48
|
},
|