@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 +96 -2
- package/dist/execution.d.ts +12 -0
- package/dist/identity.d.ts +37 -0
- package/dist/index.d.ts +41 -4
- package/dist/index.js +243 -18
- package/package.json +5 -2
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
|
-
|
|
95
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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 = () =>
|
|
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 = () =>
|
|
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
|
|
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 =
|
|
641
|
-
const commandPipeline =
|
|
642
|
-
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);
|
|
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
|
|
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 = () =>
|
|
723
|
-
|
|
724
|
-
|
|
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.
|
|
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
|
},
|