@supacloud/elysia 0.8.1 → 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 CHANGED
@@ -91,8 +91,9 @@ write retry. Confirm the outcome using the application's durable receipt or
91
91
  read-back protocol. A custom `errorMapper` can override this public envelope.
92
92
 
93
93
  Native `Response` objects are passed through by Elysia, including JSON responses.
94
- Handlers returning a native `Response` must validate their JSON payload before
95
- constructing it. The adapter does not consume or parse binary/streaming responses.
94
+ Use `validatedJsonResponse` to opt into validation when constructing a native
95
+ JSON response. Otherwise handlers must validate their JSON payload themselves.
96
+ The adapter does not consume or parse binary/streaming responses.
96
97
 
97
98
  Jobs are executed explicitly with `executeJob(compiledModule, services, job,
98
99
  input, requestContext)`. The asynchronous compiler-generated job scope is
@@ -101,6 +102,99 @@ fails partway through.
101
102
 
102
103
  ## API
103
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
+
165
+ ### `validatedJsonResponse(validate, value, init?): Response`
166
+
167
+ Constructs a native JSON response after a synchronous, caller-owned type guard
168
+ validates the actual serialized JSON snapshot. The value type is inferred from
169
+ the guard; compatible extra fields are preserved. For a TypeBox contract, the
170
+ guard can delegate to `Value.Check(schema, value)` or a compiled validator.
171
+ No additional schema dependency is required by the adapter.
172
+
173
+ ```ts
174
+ import { validatedJsonResponse } from "@supacloud/elysia";
175
+ import { isReportReceipt } from "./contracts";
176
+
177
+ return validatedJsonResponse(isReportReceipt, receipt, {
178
+ status: 201,
179
+ headers: { "x-request-id": requestId },
180
+ });
181
+ ```
182
+
183
+ The helper serializes once, validates that wire snapshot, and sends those same
184
+ bytes. Validation cannot mutate the outgoing body; it is not a transform or
185
+ coercion hook. Guards must be synchronous and side-effect free. Serialization
186
+ failures and invalid receipts throw a sanitized `ApplicationError` with HTTP 500
187
+ and `RESPONSE_VALIDATION_ERROR`, without retaining payloads or validator causes.
188
+ The application's `errorMapper` can map this to its outcome-confirmation
189
+ protocol. The helper never retries a command or implies rollback.
190
+
191
+ `init` uses native `Response` options. The helper explicitly rejects null-body
192
+ statuses 204, 205 and 304, including on runtimes that otherwise accept a body
193
+ with those statuses. The default content type is
194
+ `application/json`, and explicitly supplied headers are preserved. This helper
195
+ is for bounded JSON payloads, not files or streams. Existing native `Response`
196
+ passthrough is unchanged.
197
+
104
198
  ### `createApplication(options: ApplicationOptions): Elysia`
105
199
 
106
200
  Creates the root Elysia application from compiled modules.
@@ -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;
@@ -144,6 +158,13 @@ export declare class ApplicationError extends Error implements PublicApplication
144
158
  readonly details?: unknown;
145
159
  constructor(message: string, options?: ApplicationErrorOptions);
146
160
  }
161
+ export type JsonResponseValidator<T> = (value: unknown) => value is T;
162
+ /**
163
+ * Validate the serialized JSON snapshot before creating a native Response.
164
+ * Validators must be synchronous and side-effect free; response failures do not
165
+ * imply that application writes were rolled back.
166
+ */
167
+ export declare function validatedJsonResponse<T>(validate: JsonResponseValidator<T>, value: NoInfer<T>, init?: ResponseInit): Response;
147
168
  export interface ErrorContext {
148
169
  request: Request;
149
170
  requestContext: unknown;
@@ -164,6 +185,8 @@ export interface ApplicationOptions {
164
185
  commandExecutor?: CommandExecutor;
165
186
  /** Maps framework or application failures to the public HTTP contract. */
166
187
  errorMapper?: ErrorMapper;
188
+ /** Best-effort execution metadata only; durable audit belongs to governance. */
189
+ onExecution?: ExecutionObserver;
167
190
  }
168
191
  export interface JobInvocation {
169
192
  job: CompiledJob;
@@ -178,13 +201,27 @@ export type JobExecutor = (invocation: JobInvocation, next: () => unknown | Prom
178
201
  * Runtime. The runtime strips incoming x-supacloud-jwt-sub values and writes
179
202
  * the header only after JWT verification.
180
203
  */
181
- export declare const createSupaCloudRequestContext: RequestContextFactory;
204
+ export declare const createSupaCloudRequestContext: (request: Request) => SupaCloudRequestContext;
182
205
  /**
183
206
  * Compose the standard command governance order:
184
207
  * authorization -> idempotency -> transaction -> audit -> handler.
185
208
  * Declared governance metadata fails closed when its adapter is absent.
186
209
  */
187
- 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>;
188
225
  export declare function requireTrustedIdentity(requestContext: unknown): Required<Pick<TrustedRequestIdentity, "subject" | "accessToken">> & TrustedRequestIdentity;
189
226
  export declare function requireIdempotencyKey(invocation: CommandInvocation): string;
190
227
  /**
@@ -195,7 +232,7 @@ export declare function requireIdempotencyKey(invocation: CommandInvocation): st
195
232
  * exposed on the `scope` context key. Request-scoped controller instances are
196
233
  * looked up on `scope`, everything else on `services`.
197
234
  */
198
- 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;
199
236
  /**
200
237
  * Execute one compiler-emitted Job descriptor.
201
238
  *
@@ -203,7 +240,7 @@ export declare function createModulePlugin(compiled: CompiledModule, services: R
203
240
  * accepted for command-like job implementations. The scope factory and
204
241
  * destruction hooks are generated statically by the compiler.
205
242
  */
206
- 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>;
207
244
  export declare function defaultErrorResponse(error: unknown, frameworkCode?: string | number): Response;
208
245
  /**
209
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)
@@ -405,6 +515,28 @@ class ApplicationError extends Error {
405
515
  this.details = options.details;
406
516
  }
407
517
  }
518
+ function validatedJsonResponse(validate, value, init) {
519
+ if (init?.status === 204 || init?.status === 205 || init?.status === 304) {
520
+ throw new TypeError("JSON responses cannot use a null-body status");
521
+ }
522
+ let body;
523
+ try {
524
+ const serialized = JSON.stringify(value);
525
+ if (serialized === undefined || validate(JSON.parse(serialized)) !== true) {
526
+ throw new Error("Invalid JSON response");
527
+ }
528
+ body = serialized;
529
+ } catch {
530
+ throw new ApplicationError("Response validation failed", {
531
+ status: 500,
532
+ code: "RESPONSE_VALIDATION_ERROR"
533
+ });
534
+ }
535
+ const headers = new Headers(init?.headers);
536
+ if (!headers.has("content-type"))
537
+ headers.set("content-type", "application/json");
538
+ return new Response(body, { ...init, headers });
539
+ }
408
540
  function safeHeaderValue(value, maxLength) {
409
541
  if (!value || value.length > maxLength || /[\u0000-\u001f\u007f]/.test(value)) {
410
542
  return;
@@ -460,9 +592,31 @@ function missingGovernanceAdapter(command, adapter) {
460
592
  code: codes[adapter]
461
593
  });
462
594
  }
463
- function createCommandExecutor(governance) {
595
+ function createCommandExecutor(governance, observer) {
464
596
  return async (invocation, next) => {
465
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
+ }
466
620
  const audit = command.audit ? governance.audit : undefined;
467
621
  const transaction = command.transaction === "required" ? governance.transaction : undefined;
468
622
  const idempotency = command.idempotency === "required" ? governance.idempotency : undefined;
@@ -476,24 +630,31 @@ function createCommandExecutor(governance) {
476
630
  throw missingGovernanceAdapter(command, "idempotency");
477
631
  }
478
632
  try {
479
- await governance.authorize(invocation);
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));
480
640
  let execute = async () => {
481
- const result = await next();
641
+ const result = await observe("handler", invokeOnce);
482
642
  if (audit)
483
- await audit.succeeded.call(audit, invocation, result);
643
+ await observe("audit", () => audit.succeeded.call(audit, invocation, result));
484
644
  return result;
485
645
  };
646
+ const invokeOnce = once(next);
486
647
  if (command.transaction === "required") {
487
648
  if (!transaction)
488
649
  throw missingGovernanceAdapter(command, "transaction");
489
650
  const inner = execute;
490
- execute = () => Promise.resolve(transaction.call(governance, invocation, inner));
651
+ execute = () => observe("transaction", () => transaction.call(governance, invocation, once(inner)));
491
652
  }
492
653
  if (command.idempotency === "required") {
493
654
  if (!idempotency)
494
655
  throw missingGovernanceAdapter(command, "idempotency");
495
656
  const inner = execute;
496
- execute = () => Promise.resolve(idempotency.call(governance, invocation, inner));
657
+ execute = () => observe("idempotency", () => idempotency.call(governance, invocation, once(inner)));
497
658
  }
498
659
  return await execute();
499
660
  } catch (error) {
@@ -503,6 +664,44 @@ function createCommandExecutor(governance) {
503
664
  }
504
665
  };
505
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
+ }
506
705
  function requireTrustedIdentity(requestContext) {
507
706
  const context = isRecord(requestContext) ? requestContext : {};
508
707
  const identity = isTrustedIdentity(context.identity) ? context.identity : undefined;
@@ -548,7 +747,7 @@ function createModulePlugin(compiled, services, ctxFactory = defaultRequestConte
548
747
  }
549
748
  }
550
749
  const requestContexts = new WeakMap;
551
- const governanceExecutor = options.commandGovernance ? createCommandExecutor(options.commandGovernance) : undefined;
750
+ const governanceExecutor = options.commandGovernance ? createCommandExecutor(options.commandGovernance, options.onExecution) : undefined;
552
751
  const commandExecutor = options.commandExecutor && governanceExecutor ? composeCommandExecutors(options.commandExecutor, governanceExecutor) : options.commandExecutor ?? governanceExecutor;
553
752
  const plugin = new Elysia({ name: `supacloud:${compiled.name}` }).decorate("services", services);
554
753
  const createRequestScope = compiled.createRequestScope;
@@ -614,7 +813,13 @@ function createModulePlugin(compiled, services, ctxFactory = defaultRequestConte
614
813
  scope: requestScope,
615
814
  requestContext
616
815
  };
617
- const invoke = () => route.invoker ? route.invoker(instance, input) : Reflect.apply(method, instance, [input]);
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));
618
823
  const routeContext = {
619
824
  kind: "route",
620
825
  name: `${route.method} ${path}`,
@@ -637,9 +842,9 @@ function createModulePlugin(compiled, services, ctxFactory = defaultRequestConte
637
842
  metadata: command ?? route
638
843
  };
639
844
  const commandAspects = route.command ? commandsByClassName.get(route.command)?.aspects ?? [] : [];
640
- const routePipeline = composeAspects(...route.aspects ?? []);
641
- const commandPipeline = composeAspects(...commandAspects);
642
- const modulePipeline = composeAspects(...compiled.aspects ?? []);
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);
643
848
  const invokeRoute = () => routePipeline(routeContext, () => route.command ? commandPipeline(commandContext, () => invokeCommand()) : invoke());
644
849
  const invokeCommand = () => {
645
850
  if (!route.command)
@@ -663,7 +868,12 @@ function createModulePlugin(compiled, services, ctxFactory = defaultRequestConte
663
868
  scope: requestScope,
664
869
  services
665
870
  };
666
- return commandExecutor(invocation, invoke);
871
+ return observeExecution(options.onExecution, {
872
+ kind: "command",
873
+ operation: command.name,
874
+ stage: "commandExecutor",
875
+ requestId: executionRequestId(requestContext)
876
+ }, () => commandExecutor(invocation, invoke));
667
877
  };
668
878
  return modulePipeline(route.command ? commandContext : routeContext, invokeRoute);
669
879
  };
@@ -694,7 +904,7 @@ function createModulePlugin(compiled, services, ctxFactory = defaultRequestConte
694
904
  }
695
905
  return plugin;
696
906
  }
697
- async function executeJob(compiled, services, job, input, requestContext, imported = {}, executor) {
907
+ async function executeJob(compiled, services, job, input, requestContext, imported = {}, executor, observer) {
698
908
  const jobScope = job.scope === "job" && compiled.createJobScope ? await compiled.createJobScope(services, requestContext, imported) : undefined;
699
909
  try {
700
910
  const source = job.scope === "job" ? jobScope : services;
@@ -719,9 +929,20 @@ async function executeJob(compiled, services, job, input, requestContext, import
719
929
  services,
720
930
  metadata: job
721
931
  };
722
- const invoke = () => Reflect.apply(method, instance, [input]);
723
- const pipeline = composeAspects(...compiled.aspects ?? [], ...job.aspects ?? []);
724
- return await pipeline(context, executor ? () => executor(invocation, invoke) : invoke);
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)));
725
946
  } finally {
726
947
  if (jobScope && compiled.destroyJobScope) {
727
948
  await compiled.destroyJobScope(jobScope);
@@ -781,7 +1002,8 @@ function createApplication(options) {
781
1002
  app.use(createModulePlugin(module, services, ctxFactory, {
782
1003
  commandGovernance: options.commandGovernance,
783
1004
  commandExecutor: options.commandExecutor,
784
- errorMapper: options.errorMapper
1005
+ errorMapper: options.errorMapper,
1006
+ onExecution: options.onExecution
785
1007
  }, imported));
786
1008
  }
787
1009
  return app;
@@ -802,10 +1024,13 @@ export {
802
1024
  createMemoryPolicy,
803
1025
  createMemorySandbox,
804
1026
  createModulePlugin,
1027
+ createSupAuthRequestContext,
805
1028
  createSupaCloudRequestContext,
806
1029
  createTestApp,
807
1030
  defaultErrorResponse,
1031
+ executeCompiledCommand,
808
1032
  executeJob,
809
1033
  requireIdempotencyKey,
810
- requireTrustedIdentity
1034
+ requireTrustedIdentity,
1035
+ validatedJsonResponse
811
1036
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supacloud/elysia",
3
- "version": "0.8.1",
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
  },