@supacloud/elysia 0.18.0 → 0.19.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 +319 -0
- package/dist/command-binding.d.ts +33 -0
- package/dist/examples/fulfillment.d.ts +38 -0
- package/dist/fixtures/webhook-generated/application.d.ts +11 -1
- package/dist/http-policy-postgres.d.ts +12 -0
- package/dist/http-policy-stores.d.ts +33 -0
- package/dist/http-policy-suite.d.ts +75 -0
- package/dist/http-policy.d.ts +25 -0
- package/dist/http-response-cache.d.ts +14 -0
- package/dist/http-telemetry.d.ts +40 -0
- package/dist/index.d.ts +302 -11
- package/dist/index.js +771 -101
- package/dist/memory.d.ts +4 -3
- package/dist/schema_contract.d.ts +2 -2
- package/dist/testing.d.ts +3 -2
- package/dist/webhook-migration-example.d.ts +7 -1
- package/package.json +8 -7
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import { Elysia as
|
|
2
|
+
import { Elysia as Elysia3 } from "elysia";
|
|
3
3
|
import { decodeCommandPreview } from "@supacloud/contracts";
|
|
4
4
|
|
|
5
5
|
// src/command-errors.ts
|
|
@@ -36,6 +36,628 @@ function commandErrorStatus(code) {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
// src/http-policy.ts
|
|
40
|
+
class HttpPolicyConfigurationError extends Error {
|
|
41
|
+
code = "HTTP_POLICY_CONFIGURATION_INVALID";
|
|
42
|
+
}
|
|
43
|
+
function compileHttpPolicies(route, path, registry = {}) {
|
|
44
|
+
const declarations = route.data?.httpPolicies;
|
|
45
|
+
if (declarations === undefined)
|
|
46
|
+
return [];
|
|
47
|
+
const fail = () => {
|
|
48
|
+
throw new HttpPolicyConfigurationError(`Invalid HTTP policy configuration for ${route.method} ${path}`);
|
|
49
|
+
};
|
|
50
|
+
if (!Array.isArray(declarations))
|
|
51
|
+
return fail();
|
|
52
|
+
return Array.from(declarations, (value, index) => {
|
|
53
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
54
|
+
return fail();
|
|
55
|
+
const declaration = value;
|
|
56
|
+
if (typeof declaration.name !== "string" || Object.keys(declaration).some((key) => key !== "name" && key !== "options") || !Object.hasOwn(registry, declaration.name))
|
|
57
|
+
return fail();
|
|
58
|
+
const factory = registry[declaration.name];
|
|
59
|
+
if (typeof factory !== "function")
|
|
60
|
+
return fail();
|
|
61
|
+
const policy = factory(declaration.options, Object.freeze({
|
|
62
|
+
method: route.method,
|
|
63
|
+
path,
|
|
64
|
+
...route.command === undefined ? {} : { command: route.command }
|
|
65
|
+
}));
|
|
66
|
+
if (typeof policy !== "function")
|
|
67
|
+
return fail();
|
|
68
|
+
if (policy.terminal && index !== declarations.length - 1)
|
|
69
|
+
return fail();
|
|
70
|
+
if (policy.afterResponse !== undefined && typeof policy.afterResponse !== "function")
|
|
71
|
+
return fail();
|
|
72
|
+
if (policy.mapResponse !== undefined && typeof policy.mapResponse !== "function")
|
|
73
|
+
return fail();
|
|
74
|
+
return policy;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// src/http-telemetry.ts
|
|
79
|
+
import { Elysia, StatusMap } from "elysia";
|
|
80
|
+
var requestIds = new WeakMap;
|
|
81
|
+
function httpRequestId(request) {
|
|
82
|
+
return requestIds.get(request);
|
|
83
|
+
}
|
|
84
|
+
function createHttpTelemetry(observe) {
|
|
85
|
+
const starts = new WeakMap;
|
|
86
|
+
return new Elysia().onRequest(({ request, set }) => {
|
|
87
|
+
const supplied = request.headers.get("x-request-id");
|
|
88
|
+
const requestId = supplied && /^[A-Za-z0-9._:-]{1,128}$/.test(supplied) ? supplied : crypto.randomUUID();
|
|
89
|
+
starts.set(request, { time: performance.now(), requestId });
|
|
90
|
+
requestIds.set(request, requestId);
|
|
91
|
+
set.headers["x-request-id"] = requestId;
|
|
92
|
+
}).onError(() => {
|
|
93
|
+
return;
|
|
94
|
+
}).onAfterResponse(async ({ request, response, set, route }) => {
|
|
95
|
+
const start = starts.get(request);
|
|
96
|
+
if (!start)
|
|
97
|
+
return;
|
|
98
|
+
starts.delete(request);
|
|
99
|
+
const status = response instanceof Response ? response.status : typeof set.status === "number" ? set.status : StatusMap[set.status ?? "OK"];
|
|
100
|
+
try {
|
|
101
|
+
await observe(Object.freeze({
|
|
102
|
+
requestId: start.requestId,
|
|
103
|
+
method: request.method,
|
|
104
|
+
route: route ?? "<unmatched>",
|
|
105
|
+
status,
|
|
106
|
+
durationMs: performance.now() - start.time
|
|
107
|
+
}));
|
|
108
|
+
} catch {}
|
|
109
|
+
}).as("global");
|
|
110
|
+
}
|
|
111
|
+
// src/identity.ts
|
|
112
|
+
import { createRemoteJWKSet, jwtVerify, errors } from "jose";
|
|
113
|
+
function httpsUrl(value) {
|
|
114
|
+
const url = new URL(value);
|
|
115
|
+
if (url.protocol !== "https:" || url.username || url.password || url.hash) {
|
|
116
|
+
throw new TypeError("Identity endpoints must be HTTPS URLs without credentials or fragments");
|
|
117
|
+
}
|
|
118
|
+
return url;
|
|
119
|
+
}
|
|
120
|
+
function createSupAuthRequestContext(options) {
|
|
121
|
+
httpsUrl(options.issuer);
|
|
122
|
+
const jwksUrl = httpsUrl(options.jwksUrl);
|
|
123
|
+
if (!options.audience.trim() || !options.projectId.trim() || !options.clientId?.trim()) {
|
|
124
|
+
throw new TypeError("SupAuth audience, clientId and projectId are required");
|
|
125
|
+
}
|
|
126
|
+
const algorithms = [...options.algorithms ?? ["ES256", "RS256"]];
|
|
127
|
+
if (!algorithms.length || algorithms.some((value) => value !== "ES256" && value !== "RS256")) {
|
|
128
|
+
throw new TypeError("SupAuth requires ES256 or RS256");
|
|
129
|
+
}
|
|
130
|
+
const { issuer, audience, clientId, projectId, resolveAccess } = options;
|
|
131
|
+
const keyResolver = options.keyResolver ?? createRemoteJWKSet(jwksUrl, { timeoutDuration: 5000 });
|
|
132
|
+
return async (request) => {
|
|
133
|
+
const match = request.headers.get("authorization")?.match(/^Bearer ([^\s]+)$/i);
|
|
134
|
+
const token = match?.[1];
|
|
135
|
+
if (!token || token.length > 16384)
|
|
136
|
+
throw unauthenticated();
|
|
137
|
+
let subject;
|
|
138
|
+
try {
|
|
139
|
+
const { payload } = await jwtVerify(token, keyResolver, {
|
|
140
|
+
issuer,
|
|
141
|
+
audience,
|
|
142
|
+
algorithms,
|
|
143
|
+
requiredClaims: ["sub", "exp", "iat"]
|
|
144
|
+
});
|
|
145
|
+
if (typeof payload.sub !== "string" || !payload.sub.trim() || payload.sub.length > 1024 || /[\u0000-\u001f\u007f]/.test(payload.sub)) {
|
|
146
|
+
throw unauthenticated();
|
|
147
|
+
}
|
|
148
|
+
const application = payload.client_id ?? payload.azp;
|
|
149
|
+
if (application !== clientId || payload.role !== "authenticated" || payload.client_id !== undefined && payload.client_id !== clientId || payload.azp !== undefined && payload.azp !== clientId) {
|
|
150
|
+
throw unauthenticated();
|
|
151
|
+
}
|
|
152
|
+
subject = payload.sub;
|
|
153
|
+
} catch (error) {
|
|
154
|
+
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)
|
|
155
|
+
throw unauthenticated();
|
|
156
|
+
throw new ApplicationError("Identity verification service unavailable", {
|
|
157
|
+
status: 503,
|
|
158
|
+
code: "AUTHENTICATION_UNAVAILABLE"
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
const identity = { authenticated: true, subject, issuer, clientId };
|
|
162
|
+
Object.defineProperty(identity, "accessToken", { value: token, enumerable: false });
|
|
163
|
+
Object.freeze(identity);
|
|
164
|
+
let access;
|
|
165
|
+
try {
|
|
166
|
+
access = await resolveAccess(identity, request);
|
|
167
|
+
} catch {
|
|
168
|
+
throw new ApplicationError("Application access service unavailable", {
|
|
169
|
+
status: 503,
|
|
170
|
+
code: "APPLICATION_ACCESS_UNAVAILABLE"
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
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())) {
|
|
174
|
+
throw new ApplicationError("Application access denied", { status: 403, code: "APPLICATION_ACCESS_DENIED" });
|
|
175
|
+
}
|
|
176
|
+
const context = createSupaCloudRequestContext(request);
|
|
177
|
+
return {
|
|
178
|
+
...context,
|
|
179
|
+
identity,
|
|
180
|
+
access: Object.freeze({
|
|
181
|
+
projectId,
|
|
182
|
+
tenantId: access.tenantId,
|
|
183
|
+
permissions: Object.freeze([...new Set(access.permissions)])
|
|
184
|
+
})
|
|
185
|
+
};
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
function unauthenticated() {
|
|
189
|
+
return new ApplicationError("Authenticated user context is required", {
|
|
190
|
+
status: 401,
|
|
191
|
+
code: "AUTHENTICATION_REQUIRED"
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// src/http-response-cache.ts
|
|
196
|
+
import { mapResponse } from "elysia/adapter/web-standard/handler";
|
|
197
|
+
function mayStore({ http, response }) {
|
|
198
|
+
if (response === null || typeof response !== "object" || response instanceof Response || !Array.isArray(response) && Object.getPrototypeOf(response) !== Object.prototype)
|
|
199
|
+
return false;
|
|
200
|
+
if (http.set.status !== undefined && http.set.status !== 200 && http.set.status !== "OK")
|
|
201
|
+
return false;
|
|
202
|
+
if (http.set.cookie && Object.keys(http.set.cookie).length)
|
|
203
|
+
return false;
|
|
204
|
+
const headers = new Headers(http.set.headers);
|
|
205
|
+
for (const name of headers.keys()) {
|
|
206
|
+
if (!["content-type", "x-request-id", "ratelimit-limit", "ratelimit-remaining"].includes(name))
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
return !headers.has("content-type") || headers.get("content-type").split(";")[0].trim() === "application/json";
|
|
210
|
+
}
|
|
211
|
+
async function boundedBody(response, maxBytes) {
|
|
212
|
+
const reader = response.body?.getReader();
|
|
213
|
+
if (!reader)
|
|
214
|
+
return;
|
|
215
|
+
const chunks = [];
|
|
216
|
+
let size = 0;
|
|
217
|
+
try {
|
|
218
|
+
while (true) {
|
|
219
|
+
const next = await reader.read();
|
|
220
|
+
if (next.done)
|
|
221
|
+
break;
|
|
222
|
+
size += next.value.byteLength;
|
|
223
|
+
if (size > maxBytes) {
|
|
224
|
+
reader.cancel().catch(() => {});
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
chunks.push(next.value);
|
|
228
|
+
}
|
|
229
|
+
} finally {
|
|
230
|
+
reader.releaseLock();
|
|
231
|
+
}
|
|
232
|
+
const bytes = new Uint8Array(size);
|
|
233
|
+
let offset = 0;
|
|
234
|
+
for (const chunk of chunks) {
|
|
235
|
+
bytes.set(chunk, offset);
|
|
236
|
+
offset += chunk.byteLength;
|
|
237
|
+
}
|
|
238
|
+
return new TextDecoder().decode(bytes);
|
|
239
|
+
}
|
|
240
|
+
function createCachePolicy(options) {
|
|
241
|
+
const misses = new WeakMap;
|
|
242
|
+
const snapshots = new WeakMap;
|
|
243
|
+
const policy = async ({ http }) => {
|
|
244
|
+
const context = options.requireAccess(http.request);
|
|
245
|
+
if (http.request.headers.has("range") || http.request.headers.has("if-none-match") || http.request.headers.has("if-modified-since") || /(?:no-cache|no-store|max-age\s*=\s*0)/i.test(http.request.headers.get("cache-control") ?? ""))
|
|
246
|
+
return;
|
|
247
|
+
const headers = [...http.request.headers].filter(([name]) => name !== "x-request-id").sort(([a], [b]) => a.localeCompare(b));
|
|
248
|
+
const key = await policyKey([
|
|
249
|
+
"cache",
|
|
250
|
+
options.namespace,
|
|
251
|
+
options.route,
|
|
252
|
+
...principalKey(context),
|
|
253
|
+
[...context.access.permissions].sort(),
|
|
254
|
+
http.request.url,
|
|
255
|
+
headers
|
|
256
|
+
]);
|
|
257
|
+
let entry, generation;
|
|
258
|
+
try {
|
|
259
|
+
generation = await options.store.generation();
|
|
260
|
+
if (typeof generation !== "string" || !generation.length || generation.length > 128)
|
|
261
|
+
throw new Error("Invalid cache generation");
|
|
262
|
+
entry = await options.store.get(key, generation);
|
|
263
|
+
} catch {
|
|
264
|
+
throw new ApplicationError("Cache service unavailable", { status: 503, code: "HTTP_CACHE_UNAVAILABLE" });
|
|
265
|
+
}
|
|
266
|
+
if (entry && entry.expiresAt > Date.now()) {
|
|
267
|
+
if (typeof entry.body !== "string" || entry.contentType !== "application/json" || new TextEncoder().encode(entry.body).byteLength > options.maxBodyBytes) {
|
|
268
|
+
throw new ApplicationError("Cache service unavailable", { status: 503, code: "HTTP_CACHE_UNAVAILABLE" });
|
|
269
|
+
}
|
|
270
|
+
return new Response(entry.body, {
|
|
271
|
+
headers: { "content-type": "application/json", "cache-control": "private, no-store" }
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
misses.set(http.request, { key, generation });
|
|
275
|
+
};
|
|
276
|
+
policy.terminal = true;
|
|
277
|
+
policy.mapResponse = async (context) => {
|
|
278
|
+
if (!misses.has(context.http.request) || !mayStore(context))
|
|
279
|
+
return;
|
|
280
|
+
const response = await mapResponse(context.response, context.http.set, context.http.request);
|
|
281
|
+
const body = boundedBody(response.clone(), options.maxBodyBytes).catch(() => {
|
|
282
|
+
try {
|
|
283
|
+
options.onWriteError();
|
|
284
|
+
} catch {}
|
|
285
|
+
return;
|
|
286
|
+
});
|
|
287
|
+
snapshots.set(context.http.request, { response, body });
|
|
288
|
+
return response;
|
|
289
|
+
};
|
|
290
|
+
policy.afterResponse = async (context) => {
|
|
291
|
+
const miss = misses.get(context.http.request);
|
|
292
|
+
const snapshot = snapshots.get(context.http.request);
|
|
293
|
+
misses.delete(context.http.request);
|
|
294
|
+
snapshots.delete(context.http.request);
|
|
295
|
+
if (!miss || !snapshot || context.response !== snapshot.response || snapshot.response.status !== 200)
|
|
296
|
+
return;
|
|
297
|
+
const headers = snapshot.response.headers;
|
|
298
|
+
if ([...headers.keys()].some((name) => !["content-type", "x-request-id", "ratelimit-limit", "ratelimit-remaining"].includes(name)))
|
|
299
|
+
return;
|
|
300
|
+
try {
|
|
301
|
+
const body = await snapshot.body;
|
|
302
|
+
if (body === undefined)
|
|
303
|
+
return;
|
|
304
|
+
await options.store.set(miss.key, { body, contentType: "application/json", expiresAt: Date.now() + options.ttlMs }, miss.generation);
|
|
305
|
+
} catch {
|
|
306
|
+
try {
|
|
307
|
+
options.onWriteError();
|
|
308
|
+
} catch {}
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
return policy;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// src/http-policy-suite.ts
|
|
315
|
+
function policyOptions(value, allowed) {
|
|
316
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).some((key) => !allowed.includes(key))) {
|
|
317
|
+
throw new HttpPolicyConfigurationError("Invalid built-in HTTP policy options");
|
|
318
|
+
}
|
|
319
|
+
return value;
|
|
320
|
+
}
|
|
321
|
+
function positiveInteger(value, max) {
|
|
322
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0 || value > max) {
|
|
323
|
+
throw new HttpPolicyConfigurationError("HTTP policy numeric option is out of range");
|
|
324
|
+
}
|
|
325
|
+
return value;
|
|
326
|
+
}
|
|
327
|
+
async function policyKey(parts) {
|
|
328
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(parts)));
|
|
329
|
+
return Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join("");
|
|
330
|
+
}
|
|
331
|
+
function principalKey(context) {
|
|
332
|
+
return [
|
|
333
|
+
context.identity.issuer,
|
|
334
|
+
context.identity.clientId,
|
|
335
|
+
context.identity.subject,
|
|
336
|
+
context.access.projectId,
|
|
337
|
+
context.access.tenantId
|
|
338
|
+
];
|
|
339
|
+
}
|
|
340
|
+
function createHttpPolicySuite(options) {
|
|
341
|
+
const verify = createSupAuthRequestContext(options.auth);
|
|
342
|
+
const verified = new WeakMap;
|
|
343
|
+
const requestContext = async (request) => {
|
|
344
|
+
verified.delete(request);
|
|
345
|
+
if (!request.headers.has("authorization")) {
|
|
346
|
+
const base = createSupaCloudRequestContext(request);
|
|
347
|
+
return { ...base, identity: Object.freeze({ authenticated: false }) };
|
|
348
|
+
}
|
|
349
|
+
const context = await verify(request);
|
|
350
|
+
verified.set(request, context);
|
|
351
|
+
return context;
|
|
352
|
+
};
|
|
353
|
+
const requireAccess = (request) => {
|
|
354
|
+
const context = verified.get(request);
|
|
355
|
+
if (!context)
|
|
356
|
+
throw new ApplicationError("Authentication required", {
|
|
357
|
+
status: 401,
|
|
358
|
+
code: "AUTHENTICATION_REQUIRED"
|
|
359
|
+
});
|
|
360
|
+
return context;
|
|
361
|
+
};
|
|
362
|
+
const registry = {
|
|
363
|
+
authenticated: (value) => {
|
|
364
|
+
if (value !== undefined)
|
|
365
|
+
throw new HttpPolicyConfigurationError("authenticated does not accept options");
|
|
366
|
+
return ({ http }) => {
|
|
367
|
+
requireAccess(http.request);
|
|
368
|
+
};
|
|
369
|
+
},
|
|
370
|
+
tenant: (value) => {
|
|
371
|
+
const { param } = policyOptions(value, ["param"]);
|
|
372
|
+
if (typeof param !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(param)) {
|
|
373
|
+
throw new HttpPolicyConfigurationError("tenant requires a route parameter name");
|
|
374
|
+
}
|
|
375
|
+
return ({ http }) => {
|
|
376
|
+
const context = requireAccess(http.request);
|
|
377
|
+
if (http.params[param] !== context.access.tenantId) {
|
|
378
|
+
throw new ApplicationError("Tenant access denied", { status: 403, code: "TENANT_ACCESS_DENIED" });
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
},
|
|
382
|
+
permission: (value) => {
|
|
383
|
+
const { allOf } = policyOptions(value, ["allOf"]);
|
|
384
|
+
if (!Array.isArray(allOf) || !allOf.length || Array.from(allOf).some((permission) => typeof permission !== "string" || !permission.trim())) {
|
|
385
|
+
throw new HttpPolicyConfigurationError("permission requires non-empty allOf permissions");
|
|
386
|
+
}
|
|
387
|
+
const required = [...new Set(allOf)];
|
|
388
|
+
return ({ http }) => {
|
|
389
|
+
const access = requireAccess(http.request).access;
|
|
390
|
+
if (!required.every((permission) => access.permissions.includes(permission))) {
|
|
391
|
+
throw new ApplicationError("Permission denied", { status: 403, code: "PERMISSION_DENIED" });
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
},
|
|
395
|
+
rateLimit: (value, route) => {
|
|
396
|
+
const config = policyOptions(value, ["limit", "windowMs"]);
|
|
397
|
+
const limit = positiveInteger(config.limit, 1e9);
|
|
398
|
+
const windowMs = positiveInteger(config.windowMs, 86400000);
|
|
399
|
+
const store = options.rateLimitStore;
|
|
400
|
+
if (!store)
|
|
401
|
+
throw new HttpPolicyConfigurationError("rateLimit requires an explicit store");
|
|
402
|
+
return async ({ http }) => {
|
|
403
|
+
const context = requireAccess(http.request);
|
|
404
|
+
const key = await policyKey(["rate", route.method, route.path, ...principalKey(context), limit, windowMs]);
|
|
405
|
+
let result;
|
|
406
|
+
try {
|
|
407
|
+
result = await store.consume(key, limit, windowMs);
|
|
408
|
+
} catch {
|
|
409
|
+
throw new ApplicationError("Rate limit service unavailable", { status: 503, code: "RATE_LIMIT_UNAVAILABLE" });
|
|
410
|
+
}
|
|
411
|
+
if (typeof result?.allowed !== "boolean" || !Number.isSafeInteger(result.remaining) || result.remaining < 0 || result.remaining > limit || !Number.isFinite(result.resetAt)) {
|
|
412
|
+
throw new ApplicationError("Rate limit service unavailable", { status: 503, code: "RATE_LIMIT_UNAVAILABLE" });
|
|
413
|
+
}
|
|
414
|
+
http.set.headers["ratelimit-limit"] = String(limit);
|
|
415
|
+
http.set.headers["ratelimit-remaining"] = String(result.remaining);
|
|
416
|
+
if (!result.allowed) {
|
|
417
|
+
const retryAfter = Math.max(1, Math.ceil((result.resetAt - Date.now()) / 1000));
|
|
418
|
+
return new Response("Too Many Requests", { status: 429, headers: { "retry-after": String(retryAfter) } });
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
},
|
|
422
|
+
cache: (value, route) => {
|
|
423
|
+
if (route.method !== "GET" || route.command) {
|
|
424
|
+
throw new HttpPolicyConfigurationError("cache is restricted to GET queries, never commands");
|
|
425
|
+
}
|
|
426
|
+
const config = policyOptions(value, ["ttlMs", "maxBodyBytes"]);
|
|
427
|
+
const ttlMs = positiveInteger(config.ttlMs, 86400000);
|
|
428
|
+
const maxBodyBytes = positiveInteger(config.maxBodyBytes ?? 262144, 16777216);
|
|
429
|
+
if (!options.cacheStore)
|
|
430
|
+
throw new HttpPolicyConfigurationError("cache requires an explicit store");
|
|
431
|
+
if (typeof options.cacheNamespace !== "string" || !/^[A-Za-z0-9._:-]{1,128}$/.test(options.cacheNamespace)) {
|
|
432
|
+
throw new HttpPolicyConfigurationError("cache requires an explicit deployment cacheNamespace");
|
|
433
|
+
}
|
|
434
|
+
return createCachePolicy({
|
|
435
|
+
store: options.cacheStore,
|
|
436
|
+
requireAccess,
|
|
437
|
+
ttlMs,
|
|
438
|
+
maxBodyBytes,
|
|
439
|
+
route: `${route.method} ${route.path}`,
|
|
440
|
+
namespace: options.cacheNamespace,
|
|
441
|
+
onWriteError: options.onCacheWriteError ?? (() => console.warn("supacloud: HTTP cache write failed"))
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
return { requestContext, httpPolicies: registry };
|
|
446
|
+
}
|
|
447
|
+
// src/http-policy-stores.ts
|
|
448
|
+
function capacity(value) {
|
|
449
|
+
if (!Number.isSafeInteger(value) || value <= 0)
|
|
450
|
+
throw new TypeError("Store capacity must be a positive integer");
|
|
451
|
+
}
|
|
452
|
+
function createMemoryHttpRateLimitStore(options = {}) {
|
|
453
|
+
const maxEntries = options.maxEntries ?? 1e4;
|
|
454
|
+
capacity(maxEntries);
|
|
455
|
+
const now = options.now ?? Date.now;
|
|
456
|
+
const entries = new Map;
|
|
457
|
+
return {
|
|
458
|
+
consume(key, limit, windowMs) {
|
|
459
|
+
capacity(limit);
|
|
460
|
+
capacity(windowMs);
|
|
461
|
+
const time = now();
|
|
462
|
+
let entry = entries.get(key);
|
|
463
|
+
if (!entry || entry.resetAt <= time) {
|
|
464
|
+
if (!entry && entries.size >= maxEntries) {
|
|
465
|
+
for (const [expiredKey, value] of entries) {
|
|
466
|
+
if (value.resetAt <= time)
|
|
467
|
+
entries.delete(expiredKey);
|
|
468
|
+
}
|
|
469
|
+
if (entries.size >= maxEntries)
|
|
470
|
+
throw new Error("Rate limit store capacity exhausted");
|
|
471
|
+
}
|
|
472
|
+
entry = { used: 0, resetAt: time + windowMs };
|
|
473
|
+
entries.set(key, entry);
|
|
474
|
+
}
|
|
475
|
+
const allowed = entry.used < limit;
|
|
476
|
+
if (allowed)
|
|
477
|
+
entry.used++;
|
|
478
|
+
return { allowed, remaining: Math.max(0, limit - entry.used), resetAt: entry.resetAt };
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
function createMemoryHttpCacheStore(options = {}) {
|
|
483
|
+
const maxEntries = options.maxEntries ?? 1000, maxBytes = options.maxBytes ?? 8 * 1024 * 1024;
|
|
484
|
+
capacity(maxEntries);
|
|
485
|
+
capacity(maxBytes);
|
|
486
|
+
const now = options.now ?? Date.now;
|
|
487
|
+
const entries = new Map;
|
|
488
|
+
let generation = crypto.randomUUID();
|
|
489
|
+
let bytes = 0;
|
|
490
|
+
const remove = (key) => {
|
|
491
|
+
bytes -= entries.get(key)?.bytes ?? 0;
|
|
492
|
+
entries.delete(key);
|
|
493
|
+
};
|
|
494
|
+
return {
|
|
495
|
+
generation: () => generation,
|
|
496
|
+
get(key, expected) {
|
|
497
|
+
if (expected !== generation)
|
|
498
|
+
return;
|
|
499
|
+
const entry = entries.get(key);
|
|
500
|
+
if (!entry)
|
|
501
|
+
return;
|
|
502
|
+
if (entry.value.expiresAt <= now()) {
|
|
503
|
+
remove(key);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
entries.delete(key);
|
|
507
|
+
entries.set(key, entry);
|
|
508
|
+
return { ...entry.value };
|
|
509
|
+
},
|
|
510
|
+
set(key, value, expected) {
|
|
511
|
+
if (expected !== generation)
|
|
512
|
+
return;
|
|
513
|
+
const size = new TextEncoder().encode(key + value.body + value.contentType).byteLength;
|
|
514
|
+
if (size > maxBytes || value.expiresAt <= now())
|
|
515
|
+
return;
|
|
516
|
+
remove(key);
|
|
517
|
+
while (entries.size >= maxEntries || bytes + size > maxBytes) {
|
|
518
|
+
const oldest = entries.keys().next().value;
|
|
519
|
+
if (oldest === undefined)
|
|
520
|
+
break;
|
|
521
|
+
remove(oldest);
|
|
522
|
+
}
|
|
523
|
+
entries.set(key, { value: { ...value }, bytes: size });
|
|
524
|
+
bytes += size;
|
|
525
|
+
},
|
|
526
|
+
clear() {
|
|
527
|
+
generation = crypto.randomUUID();
|
|
528
|
+
entries.clear();
|
|
529
|
+
bytes = 0;
|
|
530
|
+
}
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
// src/http-policy-postgres.ts
|
|
534
|
+
var HTTP_POLICY_STORE_SQL = `
|
|
535
|
+
CREATE SCHEMA IF NOT EXISTS supacloud_http;
|
|
536
|
+
REVOKE ALL ON SCHEMA supacloud_http FROM PUBLIC;
|
|
537
|
+
CREATE TABLE IF NOT EXISTS supacloud_http.rate_limits (
|
|
538
|
+
key text PRIMARY KEY CHECK (key ~ '^[a-f0-9]{64}$'),
|
|
539
|
+
used bigint NOT NULL CHECK (used > 0),
|
|
540
|
+
reset_at timestamptz NOT NULL
|
|
541
|
+
);
|
|
542
|
+
CREATE INDEX IF NOT EXISTS http_rate_limit_expiry ON supacloud_http.rate_limits(reset_at);
|
|
543
|
+
CREATE TABLE IF NOT EXISTS supacloud_http.response_cache (
|
|
544
|
+
key text PRIMARY KEY CHECK (key ~ '^[a-f0-9]{64}$'),
|
|
545
|
+
body text NOT NULL CHECK (octet_length(body) <= 16777216),
|
|
546
|
+
expires_at timestamptz NOT NULL,
|
|
547
|
+
generation uuid NOT NULL
|
|
548
|
+
);
|
|
549
|
+
ALTER TABLE supacloud_http.response_cache ADD COLUMN IF NOT EXISTS generation uuid
|
|
550
|
+
NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000';
|
|
551
|
+
CREATE TABLE IF NOT EXISTS supacloud_http.cache_generation (
|
|
552
|
+
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
|
|
553
|
+
generation uuid NOT NULL DEFAULT gen_random_uuid()
|
|
554
|
+
);
|
|
555
|
+
INSERT INTO supacloud_http.cache_generation(singleton) VALUES (true) ON CONFLICT DO NOTHING;
|
|
556
|
+
CREATE INDEX IF NOT EXISTS http_response_cache_expiry ON supacloud_http.response_cache(expires_at);
|
|
557
|
+
REVOKE ALL ON ALL TABLES IN SCHEMA supacloud_http FROM PUBLIC;
|
|
558
|
+
CREATE OR REPLACE FUNCTION supacloud_http.consume_rate_limit(p_key text, p_limit bigint, p_window_ms integer)
|
|
559
|
+
RETURNS TABLE(allowed boolean, remaining bigint, reset_at_ms numeric)
|
|
560
|
+
LANGUAGE plpgsql SET search_path = pg_catalog, supacloud_http AS $rate$
|
|
561
|
+
DECLARE
|
|
562
|
+
entry supacloud_http.rate_limits%ROWTYPE;
|
|
563
|
+
observed_at timestamptz;
|
|
564
|
+
BEGIN
|
|
565
|
+
IF p_limit < 1 OR p_limit > 1000000000 OR p_window_ms < 1 OR p_window_ms > 86400000 THEN
|
|
566
|
+
RAISE EXCEPTION 'Invalid rate limit window';
|
|
567
|
+
END IF;
|
|
568
|
+
LOOP
|
|
569
|
+
INSERT INTO supacloud_http.rate_limits(key, used, reset_at)
|
|
570
|
+
VALUES (p_key, 1, '-infinity') ON CONFLICT DO NOTHING;
|
|
571
|
+
SELECT * INTO entry FROM supacloud_http.rate_limits WHERE key=p_key FOR UPDATE;
|
|
572
|
+
EXIT WHEN FOUND;
|
|
573
|
+
-- Expiry pruning may remove a conflicting row before we acquire its lock.
|
|
574
|
+
END LOOP;
|
|
575
|
+
observed_at := clock_timestamp();
|
|
576
|
+
IF entry.reset_at <= observed_at THEN
|
|
577
|
+
entry.used := 1;
|
|
578
|
+
entry.reset_at := observed_at + p_window_ms * interval '1 millisecond';
|
|
579
|
+
ELSE
|
|
580
|
+
entry.used := least(entry.used + 1, p_limit + 1);
|
|
581
|
+
END IF;
|
|
582
|
+
UPDATE supacloud_http.rate_limits SET used=entry.used, reset_at=entry.reset_at WHERE key=p_key;
|
|
583
|
+
RETURN QUERY SELECT entry.used <= p_limit, greatest(p_limit-entry.used,0),
|
|
584
|
+
extract(epoch FROM entry.reset_at)*1000;
|
|
585
|
+
END
|
|
586
|
+
$rate$;
|
|
587
|
+
REVOKE ALL ON FUNCTION supacloud_http.consume_rate_limit(text,bigint,integer) FROM PUBLIC;
|
|
588
|
+
`;
|
|
589
|
+
function validKey(key) {
|
|
590
|
+
if (!/^[a-f0-9]{64}$/.test(key))
|
|
591
|
+
throw new TypeError("HTTP store keys must be SHA-256 digests");
|
|
592
|
+
}
|
|
593
|
+
function createPostgresHttpPolicyStores(database) {
|
|
594
|
+
const rateLimitStore = {
|
|
595
|
+
async consume(key, limit, windowMs) {
|
|
596
|
+
validKey(key);
|
|
597
|
+
if (!Number.isSafeInteger(limit) || limit <= 0 || limit > 1e9 || !Number.isSafeInteger(windowMs) || windowMs <= 0 || windowMs > 86400000) {
|
|
598
|
+
throw new TypeError("Invalid rate limit window");
|
|
599
|
+
}
|
|
600
|
+
const [row] = await database.query("SELECT * FROM supacloud_http.consume_rate_limit($1,$2,$3)", [key, limit, windowMs]);
|
|
601
|
+
if (!row || typeof row.allowed !== "boolean")
|
|
602
|
+
throw new Error("Invalid rate limit storage result");
|
|
603
|
+
return { allowed: row.allowed, remaining: Number(row.remaining), resetAt: Number(row.reset_at_ms) };
|
|
604
|
+
}
|
|
605
|
+
};
|
|
606
|
+
const cacheStore = {
|
|
607
|
+
async generation() {
|
|
608
|
+
const [row] = await database.query("SELECT generation FROM supacloud_http.cache_generation WHERE singleton", []);
|
|
609
|
+
if (!row || typeof row.generation !== "string")
|
|
610
|
+
throw new Error("Invalid cache generation");
|
|
611
|
+
return row.generation;
|
|
612
|
+
},
|
|
613
|
+
async get(key, generation) {
|
|
614
|
+
validKey(key);
|
|
615
|
+
const [row] = await database.query(`
|
|
616
|
+
SELECT body, extract(epoch FROM expires_at) * 1000 AS expires_at_ms
|
|
617
|
+
FROM supacloud_http.response_cache AS cache
|
|
618
|
+
JOIN supacloud_http.cache_generation AS current ON cache.generation = current.generation
|
|
619
|
+
WHERE key = $1 AND cache.generation = $2::uuid AND expires_at > clock_timestamp()
|
|
620
|
+
`, [key, generation]);
|
|
621
|
+
if (!row)
|
|
622
|
+
return;
|
|
623
|
+
if (typeof row.body !== "string")
|
|
624
|
+
throw new Error("Invalid cache storage result");
|
|
625
|
+
return { body: row.body, contentType: "application/json", expiresAt: Number(row.expires_at_ms) };
|
|
626
|
+
},
|
|
627
|
+
async set(key, value, generation) {
|
|
628
|
+
validKey(key);
|
|
629
|
+
if (value.contentType !== "application/json" || typeof value.body !== "string" || new TextEncoder().encode(value.body).byteLength > 16777216 || !Number.isSafeInteger(value.expiresAt) || value.expiresAt <= 0) {
|
|
630
|
+
throw new TypeError("Invalid cache entry");
|
|
631
|
+
}
|
|
632
|
+
await database.query(`
|
|
633
|
+
WITH current AS MATERIALIZED (
|
|
634
|
+
SELECT generation FROM supacloud_http.cache_generation
|
|
635
|
+
WHERE singleton AND generation=$4::uuid FOR SHARE
|
|
636
|
+
)
|
|
637
|
+
INSERT INTO supacloud_http.response_cache (key, body, expires_at, generation)
|
|
638
|
+
SELECT $1, $2, to_timestamp($3 / 1000.0), generation FROM current
|
|
639
|
+
ON CONFLICT (key) DO UPDATE SET body = excluded.body,
|
|
640
|
+
expires_at = excluded.expires_at, generation = excluded.generation
|
|
641
|
+
`, [key, value.body, value.expiresAt, generation]);
|
|
642
|
+
},
|
|
643
|
+
async clear() {
|
|
644
|
+
await database.query(`
|
|
645
|
+
WITH advanced AS (
|
|
646
|
+
UPDATE supacloud_http.cache_generation SET generation=gen_random_uuid() WHERE singleton RETURNING generation
|
|
647
|
+
)
|
|
648
|
+
DELETE FROM supacloud_http.response_cache WHERE EXISTS(SELECT 1 FROM advanced)
|
|
649
|
+
`, []);
|
|
650
|
+
}
|
|
651
|
+
};
|
|
652
|
+
return {
|
|
653
|
+
rateLimitStore,
|
|
654
|
+
cacheStore,
|
|
655
|
+
async prune() {
|
|
656
|
+
await database.query("DELETE FROM supacloud_http.rate_limits WHERE reset_at <= clock_timestamp()", []);
|
|
657
|
+
await database.query("DELETE FROM supacloud_http.response_cache WHERE expires_at <= clock_timestamp()", []);
|
|
658
|
+
}
|
|
659
|
+
};
|
|
660
|
+
}
|
|
39
661
|
// src/execution.ts
|
|
40
662
|
async function observeExecution(observer, event, next) {
|
|
41
663
|
const emit = (phase, durationMs) => {
|
|
@@ -74,7 +696,7 @@ function executionTrace(context) {
|
|
|
74
696
|
// src/schema_contract.ts
|
|
75
697
|
import {
|
|
76
698
|
getSchemaValidator,
|
|
77
|
-
StatusMap,
|
|
699
|
+
StatusMap as StatusMap2,
|
|
78
700
|
ElysiaCustomStatusResponse
|
|
79
701
|
} from "elysia";
|
|
80
702
|
function defineRouteContract(schemas) {
|
|
@@ -156,7 +778,7 @@ function responseStatusOf(value, configuredStatus) {
|
|
|
156
778
|
if (typeof configuredStatus === "number")
|
|
157
779
|
return configuredStatus;
|
|
158
780
|
if (typeof configuredStatus === "string") {
|
|
159
|
-
const mapped =
|
|
781
|
+
const mapped = StatusMap2[configuredStatus];
|
|
160
782
|
if (typeof mapped === "number")
|
|
161
783
|
return mapped;
|
|
162
784
|
}
|
|
@@ -296,7 +918,7 @@ function defineJsonContract(schemas, request, options = {}) {
|
|
|
296
918
|
}
|
|
297
919
|
|
|
298
920
|
// src/documentation.ts
|
|
299
|
-
import { Elysia } from "elysia";
|
|
921
|
+
import { Elysia as Elysia2 } from "elysia";
|
|
300
922
|
function normalizePath(value, fallback) {
|
|
301
923
|
const path = value ?? fallback;
|
|
302
924
|
if (!path.trim() || !path.startsWith("/") || path.includes("?") || path.includes("#")) {
|
|
@@ -373,7 +995,7 @@ function renderViewerPage(title, resourcePath, mode) {
|
|
|
373
995
|
</html>`;
|
|
374
996
|
}
|
|
375
997
|
function createDocumentationPlugin(options = {}) {
|
|
376
|
-
const app = new
|
|
998
|
+
const app = new Elysia2({ name: "supacloud:documentation" });
|
|
377
999
|
const paths = new Set;
|
|
378
1000
|
const reserve = (path) => {
|
|
379
1001
|
if (paths.has(path))
|
|
@@ -408,6 +1030,39 @@ function createDocumentationPlugin(options = {}) {
|
|
|
408
1030
|
}
|
|
409
1031
|
return app;
|
|
410
1032
|
}
|
|
1033
|
+
|
|
1034
|
+
// src/command-binding.ts
|
|
1035
|
+
function bindCompiledCommand(options) {
|
|
1036
|
+
const { module, command, governance, observer, handler, decode, preview } = options;
|
|
1037
|
+
return {
|
|
1038
|
+
execute: (input, context) => executeCompiledCommand({
|
|
1039
|
+
module,
|
|
1040
|
+
command,
|
|
1041
|
+
governance,
|
|
1042
|
+
observer,
|
|
1043
|
+
decode,
|
|
1044
|
+
input,
|
|
1045
|
+
request: context.request,
|
|
1046
|
+
requestContext: context.requestContext,
|
|
1047
|
+
services: context.services,
|
|
1048
|
+
scope: context.scope,
|
|
1049
|
+
handler: (value) => handler(value, context)
|
|
1050
|
+
}),
|
|
1051
|
+
...preview ? {
|
|
1052
|
+
preview: (input, context) => previewCompiledCommand({
|
|
1053
|
+
module,
|
|
1054
|
+
command,
|
|
1055
|
+
governance,
|
|
1056
|
+
input,
|
|
1057
|
+
request: context.request,
|
|
1058
|
+
requestContext: context.requestContext,
|
|
1059
|
+
services: context.services,
|
|
1060
|
+
scope: context.scope,
|
|
1061
|
+
preview: (value) => preview(value, context)
|
|
1062
|
+
})
|
|
1063
|
+
} : {}
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
411
1066
|
// src/persistent-command.ts
|
|
412
1067
|
import {
|
|
413
1068
|
CommandError,
|
|
@@ -434,81 +1089,6 @@ function createPersistentCommandAdapter(command, options) {
|
|
|
434
1089
|
}
|
|
435
1090
|
};
|
|
436
1091
|
}
|
|
437
|
-
// src/identity.ts
|
|
438
|
-
import { createRemoteJWKSet, jwtVerify, errors } from "jose";
|
|
439
|
-
function httpsUrl(value) {
|
|
440
|
-
const url = new URL(value);
|
|
441
|
-
if (url.protocol !== "https:" || url.username || url.password || url.hash) {
|
|
442
|
-
throw new TypeError("Identity endpoints must be HTTPS URLs without credentials or fragments");
|
|
443
|
-
}
|
|
444
|
-
return url;
|
|
445
|
-
}
|
|
446
|
-
function createSupAuthRequestContext(options) {
|
|
447
|
-
httpsUrl(options.issuer);
|
|
448
|
-
const jwksUrl = httpsUrl(options.jwksUrl);
|
|
449
|
-
if (!options.audience.trim() || !options.projectId.trim() || !options.clientId?.trim()) {
|
|
450
|
-
throw new TypeError("SupAuth audience, clientId and projectId are required");
|
|
451
|
-
}
|
|
452
|
-
const algorithms = [...options.algorithms ?? ["ES256", "RS256"]];
|
|
453
|
-
if (!algorithms.length || algorithms.some((value) => value !== "ES256" && value !== "RS256")) {
|
|
454
|
-
throw new TypeError("SupAuth requires ES256 or RS256");
|
|
455
|
-
}
|
|
456
|
-
const { issuer, audience, clientId, projectId, resolveAccess } = options;
|
|
457
|
-
const keyResolver = options.keyResolver ?? createRemoteJWKSet(jwksUrl, { timeoutDuration: 5000 });
|
|
458
|
-
return async (request) => {
|
|
459
|
-
const match = request.headers.get("authorization")?.match(/^Bearer ([^\s]+)$/i);
|
|
460
|
-
const token = match?.[1];
|
|
461
|
-
if (!token || token.length > 16384)
|
|
462
|
-
throw unauthenticated();
|
|
463
|
-
let subject;
|
|
464
|
-
try {
|
|
465
|
-
const { payload } = await jwtVerify(token, keyResolver, {
|
|
466
|
-
issuer,
|
|
467
|
-
audience,
|
|
468
|
-
algorithms,
|
|
469
|
-
requiredClaims: ["sub", "exp", "iat"]
|
|
470
|
-
});
|
|
471
|
-
if (typeof payload.sub !== "string" || !payload.sub.trim() || payload.sub.length > 1024 || /[\u0000-\u001f\u007f]/.test(payload.sub)) {
|
|
472
|
-
throw unauthenticated();
|
|
473
|
-
}
|
|
474
|
-
const application = payload.client_id ?? payload.azp;
|
|
475
|
-
if (application !== clientId || payload.role !== "authenticated" || payload.client_id !== undefined && payload.client_id !== clientId || payload.azp !== undefined && payload.azp !== clientId) {
|
|
476
|
-
throw unauthenticated();
|
|
477
|
-
}
|
|
478
|
-
subject = payload.sub;
|
|
479
|
-
} catch (error) {
|
|
480
|
-
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)
|
|
481
|
-
throw unauthenticated();
|
|
482
|
-
throw new ApplicationError("Identity verification service unavailable", {
|
|
483
|
-
status: 503,
|
|
484
|
-
code: "AUTHENTICATION_UNAVAILABLE"
|
|
485
|
-
});
|
|
486
|
-
}
|
|
487
|
-
const identity = { authenticated: true, subject, issuer, clientId };
|
|
488
|
-
Object.defineProperty(identity, "accessToken", { value: token, enumerable: false });
|
|
489
|
-
Object.freeze(identity);
|
|
490
|
-
const access = await resolveAccess(identity, request);
|
|
491
|
-
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())) {
|
|
492
|
-
throw new ApplicationError("Application access denied", { status: 403, code: "APPLICATION_ACCESS_DENIED" });
|
|
493
|
-
}
|
|
494
|
-
const context = createSupaCloudRequestContext(request);
|
|
495
|
-
return {
|
|
496
|
-
...context,
|
|
497
|
-
identity,
|
|
498
|
-
access: Object.freeze({
|
|
499
|
-
projectId,
|
|
500
|
-
tenantId: access.tenantId,
|
|
501
|
-
permissions: Object.freeze([...new Set(access.permissions)])
|
|
502
|
-
})
|
|
503
|
-
};
|
|
504
|
-
};
|
|
505
|
-
}
|
|
506
|
-
function unauthenticated() {
|
|
507
|
-
return new ApplicationError("Authenticated user context is required", {
|
|
508
|
-
status: 401,
|
|
509
|
-
code: "AUTHENTICATION_REQUIRED"
|
|
510
|
-
});
|
|
511
|
-
}
|
|
512
1092
|
// src/feature.ts
|
|
513
1093
|
function assertFeatureTransition(spec, state, event) {
|
|
514
1094
|
const transition = Object.hasOwn(spec.transitions, event) ? spec.transitions[event] : undefined;
|
|
@@ -1386,6 +1966,19 @@ function validatedJsonResponse(validate, value, init) {
|
|
|
1386
1966
|
headers.set("content-type", "application/json");
|
|
1387
1967
|
return new Response(body, { ...init, headers });
|
|
1388
1968
|
}
|
|
1969
|
+
var httpPluginIds = new WeakMap;
|
|
1970
|
+
var nextHttpPluginId = 0;
|
|
1971
|
+
function contextPlugin(http) {
|
|
1972
|
+
let seed = http ? httpPluginIds.get(http) : 0;
|
|
1973
|
+
if (http && seed === undefined) {
|
|
1974
|
+
seed = ++nextHttpPluginId;
|
|
1975
|
+
httpPluginIds.set(http, seed);
|
|
1976
|
+
}
|
|
1977
|
+
return new Elysia3({ name: "supacloud:http-context", seed }).use(http ?? new Elysia3).as("scoped");
|
|
1978
|
+
}
|
|
1979
|
+
function mountHttp(http, name, normalize = true) {
|
|
1980
|
+
return new Elysia3({ name, normalize }).use(contextPlugin(http));
|
|
1981
|
+
}
|
|
1389
1982
|
function decodeJobContract(schema, value, boundary) {
|
|
1390
1983
|
if (schema === undefined)
|
|
1391
1984
|
return value;
|
|
@@ -1422,7 +2015,7 @@ function bearerToken(request) {
|
|
|
1422
2015
|
var createSupaCloudRequestContext = (request) => {
|
|
1423
2016
|
const subject = safeHeaderValue(request.headers.get(VERIFIED_JWT_SUBJECT_HEADER), 1024);
|
|
1424
2017
|
const accessToken = subject ? bearerToken(request) : undefined;
|
|
1425
|
-
const requestId = safeHeaderValue(request.headers.get(EXECUTION_ID_HEADER), 256) ?? safeHeaderValue(request.headers.get("x-request-id"), 256) ?? crypto.randomUUID();
|
|
2018
|
+
const requestId = httpRequestId(request) ?? safeHeaderValue(request.headers.get(EXECUTION_ID_HEADER), 256) ?? safeHeaderValue(request.headers.get("x-request-id"), 256) ?? crypto.randomUUID();
|
|
1426
2019
|
const idempotencyKey = safeHeaderValue(request.headers.get(IDEMPOTENCY_KEY_HEADER), 512);
|
|
1427
2020
|
const identity = {
|
|
1428
2021
|
authenticated: subject !== undefined,
|
|
@@ -1737,7 +2330,9 @@ function createModulePlugin(compiled, services, ctxFactory = defaultRequestConte
|
|
|
1737
2330
|
"data",
|
|
1738
2331
|
"input",
|
|
1739
2332
|
"result",
|
|
1740
|
-
"request"
|
|
2333
|
+
"request",
|
|
2334
|
+
"parse",
|
|
2335
|
+
"allowDeleteBody"
|
|
1741
2336
|
]);
|
|
1742
2337
|
for (const controller of compiled.controllers) {
|
|
1743
2338
|
for (const route of controller.routes) {
|
|
@@ -1745,6 +2340,9 @@ function createModulePlugin(compiled, services, ctxFactory = defaultRequestConte
|
|
|
1745
2340
|
if (!supportedMethods.has(route.method) || unsupported.length > 0) {
|
|
1746
2341
|
throw new ApplicationError(`Unsupported compiled route ${route.method} ${controller.path}${route.path}` + (unsupported.length > 0 ? `: ${unsupported.join(", ")}` : ""), { code: "ROUTE_DESCRIPTOR_UNSUPPORTED" });
|
|
1747
2342
|
}
|
|
2343
|
+
if (route.parse !== undefined && route.parse !== "none" || route.parse === "none" && (route.body !== undefined || route.contract?.body !== "domain" || !route.contract.evidence?.trim()) || route.allowDeleteBody !== undefined && (route.allowDeleteBody !== true || route.method !== "DELETE" || route.body === undefined)) {
|
|
2344
|
+
throw new ApplicationError(`Invalid compiled body policy on ${route.method} ${controller.path}${route.path}`, { code: "ROUTE_DESCRIPTOR_UNSUPPORTED" });
|
|
2345
|
+
}
|
|
1748
2346
|
}
|
|
1749
2347
|
}
|
|
1750
2348
|
const hasCommandRoutes = compiled.controllers.some((controller) => controller.routes.some((route) => route.command !== undefined));
|
|
@@ -1765,7 +2363,11 @@ function createModulePlugin(compiled, services, ctxFactory = defaultRequestConte
|
|
|
1765
2363
|
const requestContexts = new WeakMap;
|
|
1766
2364
|
const governanceExecutor = options.commandGovernance ? createCommandExecutor(options.commandGovernance, options.onExecution) : undefined;
|
|
1767
2365
|
const commandExecutor = options.commandExecutor && governanceExecutor ? composeCommandExecutors(options.commandExecutor, governanceExecutor) : options.commandExecutor ?? governanceExecutor;
|
|
1768
|
-
const plugin =
|
|
2366
|
+
const plugin = mountHttp(options.http, `supacloud:${compiled.name}`, options.normalize ?? true).resolve(async (context) => {
|
|
2367
|
+
const requestContext = await ctxFactory(context.request, context);
|
|
2368
|
+
requestContexts.set(context.request, requestContext);
|
|
2369
|
+
return { services, requestContext };
|
|
2370
|
+
});
|
|
1769
2371
|
const createRequestScope = compiled.createRequestScope;
|
|
1770
2372
|
const requestScopes = new WeakMap;
|
|
1771
2373
|
if (compiled.destroyRequestScope) {
|
|
@@ -1782,11 +2384,6 @@ function createModulePlugin(compiled, services, ctxFactory = defaultRequestConte
|
|
|
1782
2384
|
}
|
|
1783
2385
|
});
|
|
1784
2386
|
}
|
|
1785
|
-
plugin.resolve(async ({ request }) => {
|
|
1786
|
-
const requestContext = await ctxFactory(request);
|
|
1787
|
-
requestContexts.set(request, requestContext);
|
|
1788
|
-
return { requestContext };
|
|
1789
|
-
});
|
|
1790
2387
|
plugin.onError(async ({ code, error, request }) => {
|
|
1791
2388
|
const context = {
|
|
1792
2389
|
request,
|
|
@@ -1796,12 +2393,77 @@ function createModulePlugin(compiled, services, ctxFactory = defaultRequestConte
|
|
|
1796
2393
|
const mapped = await options.errorMapper?.(error, context);
|
|
1797
2394
|
return mapped ?? defaultErrorResponse(error, code);
|
|
1798
2395
|
});
|
|
2396
|
+
const routeRegistrar = plugin;
|
|
1799
2397
|
for (const controller of compiled.controllers) {
|
|
1800
2398
|
for (const route of controller.routes) {
|
|
1801
2399
|
const path = joinPaths(controller.path, route.path);
|
|
1802
|
-
const
|
|
2400
|
+
const documentation = route.data?.openapi;
|
|
2401
|
+
const hidden = documentation !== null && typeof documentation === "object" && "hide" in documentation && documentation.hide === true;
|
|
2402
|
+
const schema = {
|
|
2403
|
+
...toElysiaRouteSchema(route),
|
|
2404
|
+
...route.parse === "none" ? { parse: "none" } : {},
|
|
2405
|
+
...route.title || hidden ? { detail: {
|
|
2406
|
+
...route.title ? { summary: route.title } : {},
|
|
2407
|
+
...hidden ? { hide: true } : {}
|
|
2408
|
+
} } : {}
|
|
2409
|
+
};
|
|
2410
|
+
const policies = compileHttpPolicies(route, path, options.httpPolicies);
|
|
2411
|
+
if (policies.length > 0) {
|
|
2412
|
+
Object.assign(schema, {
|
|
2413
|
+
beforeHandle: async (context) => {
|
|
2414
|
+
for (const policy of policies) {
|
|
2415
|
+
const result = await policy({
|
|
2416
|
+
http: context,
|
|
2417
|
+
requestContext: requestContexts.get(context.request)
|
|
2418
|
+
});
|
|
2419
|
+
if (result instanceof Response)
|
|
2420
|
+
return result;
|
|
2421
|
+
if (result !== undefined) {
|
|
2422
|
+
throw new Error("HTTP policies must return Response or undefined");
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
});
|
|
2427
|
+
}
|
|
2428
|
+
const responsePolicies = policies.filter((policy) => policy.afterResponse);
|
|
2429
|
+
const mappingPolicies = policies.filter((policy) => policy.mapResponse);
|
|
2430
|
+
if (mappingPolicies.length > 0) {
|
|
2431
|
+
Object.assign(schema, {
|
|
2432
|
+
mapResponse: async (context) => {
|
|
2433
|
+
let response = context.response;
|
|
2434
|
+
let mapped;
|
|
2435
|
+
for (const policy of mappingPolicies) {
|
|
2436
|
+
const result = await policy.mapResponse({
|
|
2437
|
+
http: context,
|
|
2438
|
+
requestContext: requestContexts.get(context.request),
|
|
2439
|
+
response
|
|
2440
|
+
});
|
|
2441
|
+
if (result !== undefined) {
|
|
2442
|
+
if (!(result instanceof Response))
|
|
2443
|
+
throw new Error("HTTP response policies must return Response or undefined");
|
|
2444
|
+
mapped = result;
|
|
2445
|
+
response = result;
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2448
|
+
return mapped;
|
|
2449
|
+
}
|
|
2450
|
+
});
|
|
2451
|
+
}
|
|
2452
|
+
if (responsePolicies.length > 0) {
|
|
2453
|
+
Object.assign(schema, {
|
|
2454
|
+
afterResponse: async (context) => {
|
|
2455
|
+
for (const policy of responsePolicies) {
|
|
2456
|
+
await policy.afterResponse({
|
|
2457
|
+
http: context,
|
|
2458
|
+
requestContext: requestContexts.get(context.request),
|
|
2459
|
+
response: context.response
|
|
2460
|
+
});
|
|
2461
|
+
}
|
|
2462
|
+
}
|
|
2463
|
+
});
|
|
2464
|
+
}
|
|
1803
2465
|
const handler = async (ctx) => {
|
|
1804
|
-
const requestContext =
|
|
2466
|
+
const requestContext = requestContexts.get(ctx.request);
|
|
1805
2467
|
const execute = async () => {
|
|
1806
2468
|
const requestScope = createRequestScope ? await createRequestScope(services, requestContext, imported) : undefined;
|
|
1807
2469
|
if (requestScope && compiled.destroyRequestScope)
|
|
@@ -1893,25 +2555,25 @@ function createModulePlugin(compiled, services, ctxFactory = defaultRequestConte
|
|
|
1893
2555
|
};
|
|
1894
2556
|
switch (route.method) {
|
|
1895
2557
|
case "GET":
|
|
1896
|
-
|
|
2558
|
+
routeRegistrar.get(path, handler, schema);
|
|
1897
2559
|
break;
|
|
1898
2560
|
case "POST":
|
|
1899
|
-
|
|
2561
|
+
routeRegistrar.post(path, handler, schema);
|
|
1900
2562
|
break;
|
|
1901
2563
|
case "PUT":
|
|
1902
|
-
|
|
2564
|
+
routeRegistrar.put(path, handler, schema);
|
|
1903
2565
|
break;
|
|
1904
2566
|
case "PATCH":
|
|
1905
|
-
|
|
2567
|
+
routeRegistrar.patch(path, handler, schema);
|
|
1906
2568
|
break;
|
|
1907
2569
|
case "DELETE":
|
|
1908
|
-
|
|
2570
|
+
routeRegistrar.delete(path, handler, schema);
|
|
1909
2571
|
break;
|
|
1910
2572
|
case "HEAD":
|
|
1911
|
-
|
|
2573
|
+
routeRegistrar.head(path, handler, schema);
|
|
1912
2574
|
break;
|
|
1913
2575
|
case "OPTIONS":
|
|
1914
|
-
|
|
2576
|
+
routeRegistrar.options(path, handler, schema);
|
|
1915
2577
|
break;
|
|
1916
2578
|
}
|
|
1917
2579
|
}
|
|
@@ -2013,16 +2675,16 @@ function isPublicApplicationError(error) {
|
|
|
2013
2675
|
return error.expose === true && typeof error.status === "number" && Number.isInteger(error.status) && error.status >= 400 && error.status <= 599 && typeof error.code === "string" && error.code.length > 0;
|
|
2014
2676
|
}
|
|
2015
2677
|
function createApplication(options) {
|
|
2016
|
-
const app = new
|
|
2678
|
+
const app = new Elysia3({ name: options.name ?? "supacloud:app", normalize: options.normalize ?? true });
|
|
2017
2679
|
if (options.documentation)
|
|
2018
2680
|
app.use(createDocumentationPlugin(options.documentation));
|
|
2019
2681
|
const configuredContextFactory = options.requestContext ?? defaultRequestContext;
|
|
2020
2682
|
const contextCache = new WeakMap;
|
|
2021
|
-
const ctxFactory = (request) => {
|
|
2683
|
+
const ctxFactory = (request, context) => {
|
|
2022
2684
|
const cached = contextCache.get(request);
|
|
2023
2685
|
if (cached)
|
|
2024
2686
|
return cached;
|
|
2025
|
-
const pending = Promise.resolve(configuredContextFactory(request));
|
|
2687
|
+
const pending = Promise.resolve(configuredContextFactory(request, context));
|
|
2026
2688
|
contextCache.set(request, pending);
|
|
2027
2689
|
return pending;
|
|
2028
2690
|
};
|
|
@@ -2032,7 +2694,7 @@ function createApplication(options) {
|
|
|
2032
2694
|
imported[module.name] = services;
|
|
2033
2695
|
app.use(createModulePlugin(module, services, ctxFactory, options, imported));
|
|
2034
2696
|
}
|
|
2035
|
-
return app;
|
|
2697
|
+
return app.use(contextPlugin(options.http));
|
|
2036
2698
|
}
|
|
2037
2699
|
function createTestApp(options) {
|
|
2038
2700
|
return createApplication(options);
|
|
@@ -2040,6 +2702,8 @@ function createTestApp(options) {
|
|
|
2040
2702
|
export {
|
|
2041
2703
|
ApplicationError,
|
|
2042
2704
|
EXECUTION_ID_HEADER,
|
|
2705
|
+
HTTP_POLICY_STORE_SQL,
|
|
2706
|
+
HttpPolicyConfigurationError,
|
|
2043
2707
|
IDEMPOTENCY_KEY_HEADER,
|
|
2044
2708
|
SchemaContractError,
|
|
2045
2709
|
SupaCloudWorker,
|
|
@@ -2048,16 +2712,22 @@ export {
|
|
|
2048
2712
|
WorkerRegistrationError,
|
|
2049
2713
|
assertFeatureTransition,
|
|
2050
2714
|
assertResponseStatusDeclared,
|
|
2715
|
+
bindCompiledCommand,
|
|
2051
2716
|
composeAspects,
|
|
2052
2717
|
composeCommandExecutors,
|
|
2053
2718
|
createApplication,
|
|
2054
2719
|
createCommandAuthorizationAdapter,
|
|
2055
2720
|
createCommandExecutor,
|
|
2056
2721
|
createDocumentationPlugin,
|
|
2722
|
+
createHttpPolicySuite,
|
|
2723
|
+
createHttpTelemetry,
|
|
2724
|
+
createMemoryHttpCacheStore,
|
|
2725
|
+
createMemoryHttpRateLimitStore,
|
|
2057
2726
|
createMemoryPolicy,
|
|
2058
2727
|
createMemorySandbox,
|
|
2059
2728
|
createModulePlugin,
|
|
2060
2729
|
createPersistentCommandAdapter,
|
|
2730
|
+
createPostgresHttpPolicyStores,
|
|
2061
2731
|
createQueueWorkerTransport,
|
|
2062
2732
|
createSchemaDecoder,
|
|
2063
2733
|
createSupAuthRequestContext,
|