@supacloud/elysia 0.17.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 +345 -44
- package/dist/index.js +870 -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 +17 -2
- package/package.json +8 -7
package/README.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @supacloud/elysia
|
|
2
2
|
|
|
3
|
+
For a cross-module composition shared by HTTP, event/scheduled workers and a
|
|
4
|
+
trusted CLI, see the [fulfillment example](src/examples/fulfillment.ts) and
|
|
5
|
+
[developer guide](../../docs/framework-composition.md). It reuses bound commands,
|
|
6
|
+
explicit aspects and durable receipts, not a new workflow engine. Compensation
|
|
7
|
+
is a separately authorized business command, never an assumed rollback.
|
|
8
|
+
|
|
3
9
|
## Compatibility and Acceptance Boundary
|
|
4
10
|
|
|
5
11
|
The dependency range is not a claim that every allowed version has been tested.
|
|
@@ -89,6 +95,81 @@ These gates prove the stated scenarios, not a full historical npm upgrade matrix
|
|
|
89
95
|
or all business-domain isolation. See [framework acceptance](../../docs/framework-acceptance.md)
|
|
90
96
|
for the evidence boundaries and upgrade policy.
|
|
91
97
|
|
|
98
|
+
## Native HTTP Context And Static DI
|
|
99
|
+
|
|
100
|
+
Pass a native Elysia plugin as `http` to `createApplication`, `createTestApp`,
|
|
101
|
+
or the options argument of `createModulePlugin`. `decorate` shares existing
|
|
102
|
+
instances; `derive` runs before validation; `resolve` runs after validation.
|
|
103
|
+
Use scoped/global hooks, or finish a context plugin with `.as("scoped")`.
|
|
104
|
+
Local hooks retain native encapsulation and do not extend the consuming routes.
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
import { Elysia, t } from "elysia";
|
|
108
|
+
import { createApplication } from "@supacloud/elysia";
|
|
109
|
+
|
|
110
|
+
const http = new Elysia({ name: "application-context" })
|
|
111
|
+
.decorate("clock", { now: () => Date.now() })
|
|
112
|
+
.derive(({ clock }) => ({ startedAt: clock.now() }))
|
|
113
|
+
.guard({ query: t.Object({ locale: t.Optional(t.String()) }) })
|
|
114
|
+
.resolve(({ query }) => ({ locale: query.locale ?? "en" }))
|
|
115
|
+
.as("scoped");
|
|
116
|
+
|
|
117
|
+
const app = createApplication({
|
|
118
|
+
http,
|
|
119
|
+
modules: compiledModules,
|
|
120
|
+
requestContext: (request, context) => ({
|
|
121
|
+
request,
|
|
122
|
+
startedAt: context.startedAt,
|
|
123
|
+
locale: context.locale,
|
|
124
|
+
}),
|
|
125
|
+
}).get("/locale", ({ locale, clock }) => ({
|
|
126
|
+
locale,
|
|
127
|
+
now: clock.now(),
|
|
128
|
+
}));
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
The second `requestContext` argument contains the validated HTTP inputs and
|
|
132
|
+
the inferred native plugin extensions. Existing one-argument factories remain
|
|
133
|
+
valid. Its result is passed to generated request-scoped constructors and the
|
|
134
|
+
controller's `context`/`requestContext` input. It is built once per request;
|
|
135
|
+
early resolver responses and validation failures do not construct DI scopes.
|
|
136
|
+
Request scopes are created inside the existing governed handler and released
|
|
137
|
+
after the response, including handler failures. They are not application
|
|
138
|
+
singletons or native `resolve` hooks.
|
|
139
|
+
|
|
140
|
+
Native routes added to the returned app retain the HTTP plugin's decorator,
|
|
141
|
+
derive and resolve types. `createModulePlugin` also preserves the concrete
|
|
142
|
+
`services` type supplied by its caller. Module service bags are attached by
|
|
143
|
+
the module-local resolver, not merged into a root `decorator.services` bag;
|
|
144
|
+
the service instances themselves remain shared. This prevents same-named
|
|
145
|
+
services in sibling modules from overwriting each other's values or types.
|
|
146
|
+
|
|
147
|
+
Fresh compiler output preserves literal module names and inferred application
|
|
148
|
+
service factory results. Narrow a generated module by its `name`, call its
|
|
149
|
+
`createServices`, and pass that result to `createModulePlugin` to retain the
|
|
150
|
+
service types. Regenerate older artifacts to obtain this inference; explicitly
|
|
151
|
+
annotating them as `CompiledModule[]` still intentionally widens the types.
|
|
152
|
+
Compiled route schemas are runtime
|
|
153
|
+
descriptors: their body/params/query/header/cookie fields in the application-wide
|
|
154
|
+
context factory remain `unknown`-based instead of pretending to infer one
|
|
155
|
+
route's schema for every route. Use shared guards for schema-typed native
|
|
156
|
+
resolvers and the existing generated contracts for individual compiled routes.
|
|
157
|
+
Do not store per-request identity or transaction handles in decorated singletons.
|
|
158
|
+
|
|
159
|
+
The `http` plugin is composed into each compiled module and subsequently into
|
|
160
|
+
the root for native routes. Keep it focused on reusable context extensions;
|
|
161
|
+
register unrelated endpoints on the returned app. Hook execution is tested
|
|
162
|
+
for named/anonymous plugins and scoped/global hooks without duplicate work.
|
|
163
|
+
Anonymous extensions receive a stable internal plugin identity for native
|
|
164
|
+
hook deduplication; the caller's plugin configuration is not mutated.
|
|
165
|
+
This is native HTTP composition, not a replacement runtime DI container or
|
|
166
|
+
an arbitrary native-hook passthrough in compiled route descriptors.
|
|
167
|
+
|
|
168
|
+
`src/http-context.test.ts` covers lifecycle ordering, decoded inputs, failure
|
|
169
|
+
short-circuiting, context/service type inference, cross-module composition and
|
|
170
|
+
concurrent request isolation. Run it with `bun run typecheck:test` as well as
|
|
171
|
+
`bun test src/http-context.test.ts`; runtime tests alone do not verify inference.
|
|
172
|
+
|
|
92
173
|
## Persistent Command Adapters
|
|
93
174
|
|
|
94
175
|
`createPersistentCommandAdapter(command, { identity, input })` binds a
|
|
@@ -112,6 +193,67 @@ authorization infrastructure failure is 503, and redacted-input lookup is 410.
|
|
|
112
193
|
See [the migration plan](../../docs/command-migration.md) for compiler policy,
|
|
113
194
|
authentication replay changes, deployment order and rollback limitations.
|
|
114
195
|
|
|
196
|
+
## Bind A Command Once
|
|
197
|
+
|
|
198
|
+
`bindCompiledCommand` is an optional convenience layer over
|
|
199
|
+
`executeCompiledCommand` and `previewCompiledCommand`. Register static wiring
|
|
200
|
+
once and supply fresh trusted host context for each invocation:
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
import { bindCompiledCommand } from "@supacloud/elysia";
|
|
204
|
+
|
|
205
|
+
const approve = bindCompiledCommand({
|
|
206
|
+
module: generatedApprovalModule,
|
|
207
|
+
command: "ApproveCommand", // compiled class name
|
|
208
|
+
governance,
|
|
209
|
+
handler: (input: ApproveInput, call) =>
|
|
210
|
+
approvalService.execute(input, call.requestContext),
|
|
211
|
+
decode: decodeApprovalResult,
|
|
212
|
+
preview: (input, call) =>
|
|
213
|
+
approvalService.preview(input, call.requestContext),
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// In a trusted HTTP controller, Worker job, or server-side CLI adapter:
|
|
217
|
+
const result = await approve.execute(input, {
|
|
218
|
+
request,
|
|
219
|
+
requestContext: verifiedContext,
|
|
220
|
+
services,
|
|
221
|
+
scope,
|
|
222
|
+
});
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
The generated module, domain types, service, decoder and governance above are
|
|
226
|
+
supplied by the application. The wrapper does not implement a second business
|
|
227
|
+
model, permission system, transaction mechanism or identity provider.
|
|
228
|
+
|
|
229
|
+
- A binding does not execute business code or capture a request identity.
|
|
230
|
+
Calls still resolve the descriptor and authorize each execution, including
|
|
231
|
+
idempotent replays. Static dependencies should be application-scoped; resolve
|
|
232
|
+
request/job-scoped services from the current `call.scope` instead of capturing
|
|
233
|
+
them in the binding.
|
|
234
|
+
- `preview` is present only when a domain preview function was supplied.
|
|
235
|
+
An explicit callback makes it callable without a presence check; dynamically
|
|
236
|
+
optional configuration still requires checking `approve.preview`. It reuses the existing read-only
|
|
237
|
+
preview API: authorization and domain preview only, no command execution,
|
|
238
|
+
aspects, transaction, idempotency, RPC or audit.
|
|
239
|
+
- Keep decoding/validating untrusted input at the existing ingress/domain
|
|
240
|
+
boundary. A TypeScript input type alone is not runtime validation.
|
|
241
|
+
- An HTTP handler calling `approve.execute` must not also bind that same
|
|
242
|
+
command through route-level `command:` metadata. Choose one execution
|
|
243
|
+
boundary to avoid duplicate authorization or aspect execution. Likewise,
|
|
244
|
+
place command aspects on the business module, not a duplicate entry module.
|
|
245
|
+
- Workers must resolve trusted identity in the host and explicitly construct
|
|
246
|
+
the call's `Request`, cancellation signal and idempotency context where
|
|
247
|
+
applicable. Do not infer identity from queue payloads. The binding adds no
|
|
248
|
+
retry, acknowledgement or scope-cleanup policy.
|
|
249
|
+
- The result decoder still runs after execution or receipt replay. A decoding
|
|
250
|
+
failure does not prove rollback and must not trigger a blind retry.
|
|
251
|
+
|
|
252
|
+
Existing direct APIs, route bindings, custom executors, RPC adapters and Worker
|
|
253
|
+
transports remain available without adopting the binding. Local parity tests
|
|
254
|
+
cover HTTP and Worker ingress with fake governance adapters; real database
|
|
255
|
+
atomicity remains covered by the separate PostgreSQL acceptance gates.
|
|
256
|
+
|
|
115
257
|
Runtime adapter that turns `@supacloud/compiler` output into a production-ready
|
|
116
258
|
[Elysia](https://elysiajs.com/) application.
|
|
117
259
|
|
|
@@ -526,3 +668,180 @@ registration instead of silently disabling response validation.
|
|
|
526
668
|
|
|
527
669
|
See [type safety and migration](../../docs/type-safety.md) and [command migration](../../docs/command-migration.md) for examples and
|
|
528
670
|
the distinction between contract declarations and runtime verification.
|
|
671
|
+
|
|
672
|
+
## Declarative HTTP Policies
|
|
673
|
+
|
|
674
|
+
Business dependency wiring remains generated constructors and factories. HTTP
|
|
675
|
+
policies are selected using existing compiler-preserved route metadata:
|
|
676
|
+
|
|
677
|
+
```ts
|
|
678
|
+
@Get("/:id", {
|
|
679
|
+
data: { httpPolicies: [{ name: "authenticated" }] },
|
|
680
|
+
})
|
|
681
|
+
getItem() { /* domain handler */ }
|
|
682
|
+
```
|
|
683
|
+
|
|
684
|
+
Register implementations at the HTTP composition root:
|
|
685
|
+
|
|
686
|
+
```ts
|
|
687
|
+
const app = createApplication({
|
|
688
|
+
modules: createCompiledModules(),
|
|
689
|
+
http: identityPlugin,
|
|
690
|
+
httpPolicies: {
|
|
691
|
+
authenticated: (options, route) => {
|
|
692
|
+
// Validate options here. This factory runs once per declared route policy.
|
|
693
|
+
return async ({ http }) => {
|
|
694
|
+
if (!http.identity) {
|
|
695
|
+
return new Response("Unauthorized", { status: 401 });
|
|
696
|
+
}
|
|
697
|
+
};
|
|
698
|
+
},
|
|
699
|
+
},
|
|
700
|
+
});
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
`identityPlugin` is an application-owned scoped/global Elysia plugin that verifies
|
|
704
|
+
credentials and resolves `identity`; the adapter does not trust a raw user/tenant
|
|
705
|
+
header as identity. Policy callbacks preserve its native context types.
|
|
706
|
+
|
|
707
|
+
Policies become native route-local `beforeHandle` hooks. They execute sequentially
|
|
708
|
+
after schema validation, native resolvers and the application request-context
|
|
709
|
+
factory, but before compiled request-scope construction and command execution.
|
|
710
|
+
Return `undefined` to continue or a `Response` to stop; thrown errors use the
|
|
711
|
+
application error mapper. Native earlier hooks can still short-circuit the request.
|
|
712
|
+
Unknown policies, malformed declarations and invalid factories reject startup.
|
|
713
|
+
Routes without declarations do not install a policy hook.
|
|
714
|
+
|
|
715
|
+
Use the registry for custom resource access or application-owned HTTP policies.
|
|
716
|
+
For bundled security, rate limiting, caching and tracing, use the suite below. Shared factories
|
|
717
|
+
must not retain mutable per-request state; use the callback's request/context.
|
|
718
|
+
Cleanup belongs to native lifecycle hooks. Transactions, durable audit, idempotency
|
|
719
|
+
and recovery remain in command governance, not HTTP policies.
|
|
720
|
+
|
|
721
|
+
Policy metadata can appear in generated clients: never put secrets in options.
|
|
722
|
+
Changes to declarations or registry configuration require creating a new application.
|
|
723
|
+
|
|
724
|
+
### Built-In Security And Governance
|
|
725
|
+
|
|
726
|
+
```ts
|
|
727
|
+
import {
|
|
728
|
+
createApplication, createHttpPolicySuite, createHttpTelemetry,
|
|
729
|
+
createMemoryHttpRateLimitStore, createMemoryHttpCacheStore,
|
|
730
|
+
} from "@supacloud/elysia";
|
|
731
|
+
|
|
732
|
+
const suite = createHttpPolicySuite({
|
|
733
|
+
auth: supAuthOptions,
|
|
734
|
+
cacheNamespace: "release-2026-09-25",
|
|
735
|
+
rateLimitStore: createMemoryHttpRateLimitStore({ maxEntries: 10_000 }),
|
|
736
|
+
cacheStore: createMemoryHttpCacheStore({ maxEntries: 1_000, maxBytes: 8 * 1024 * 1024 }),
|
|
737
|
+
});
|
|
738
|
+
const app = createApplication({
|
|
739
|
+
...suite,
|
|
740
|
+
modules: createCompiledModules(),
|
|
741
|
+
http: createHttpTelemetry((event) => logger.info(event)),
|
|
742
|
+
});
|
|
743
|
+
```
|
|
744
|
+
|
|
745
|
+
`supAuthOptions` uses `createSupAuthRequestContext`'s existing configuration:
|
|
746
|
+
trusted issuer, audience, application client ID, project ID, HTTPS JWKS endpoint
|
|
747
|
+
and `resolveAccess` for current server-side tenant membership/permissions.
|
|
748
|
+
JWT verification and membership resolution run on every credentialed request,
|
|
749
|
+
including cache hits. They are not replaced by a tenant or user header.
|
|
750
|
+
Requests without Authorization get an anonymous identity; public routes may
|
|
751
|
+
remain anonymous. Invalid supplied credentials are rejected even on public routes.
|
|
752
|
+
Use the suite's paired `requestContext`; the registry cannot trust fabricated
|
|
753
|
+
context objects or forwarded subjects. Additional native context plugins may be
|
|
754
|
+
composed with the telemetry plugin using ordinary Elysia `.use(...)`.
|
|
755
|
+
|
|
756
|
+
Routes select built-ins through metadata (use `BuiltinHttpPolicyDeclaration`
|
|
757
|
+
with TypeScript `satisfies` for author-time option checking):
|
|
758
|
+
|
|
759
|
+
```ts
|
|
760
|
+
@Get("/tenants/:tenant/items", {
|
|
761
|
+
data: {
|
|
762
|
+
httpPolicies: [
|
|
763
|
+
{ name: "authenticated" },
|
|
764
|
+
{ name: "tenant", options: { param: "tenant" } },
|
|
765
|
+
{ name: "permission", options: { allOf: ["items.read"] } },
|
|
766
|
+
{ name: "rateLimit", options: { limit: 120, windowMs: 60_000 } },
|
|
767
|
+
{ name: "cache", options: { ttlMs: 5_000, maxBodyBytes: 262_144 } },
|
|
768
|
+
],
|
|
769
|
+
},
|
|
770
|
+
})
|
|
771
|
+
listItems() { /* use the verified tenant in repository queries */ }
|
|
772
|
+
```
|
|
773
|
+
|
|
774
|
+
- `authenticated`: requires successful JWT verification and active application access.
|
|
775
|
+
- `tenant`: matches a validated route parameter to that access record's tenant.
|
|
776
|
+
It is an HTTP boundary check, not automatic repository filtering or database RLS.
|
|
777
|
+
- `permission`: requires every exact permission in `allOf`; no wildcard inference.
|
|
778
|
+
- `rateLimit`: fixed-window quota scoped to route, issuer, application, actor and
|
|
779
|
+
tenant. It does not trust forwarded IP headers. Denials return 429 with
|
|
780
|
+
`Retry-After`; unavailable storage returns a sanitized 503. Anonymous/IP abuse
|
|
781
|
+
protection belongs at the trusted proxy or an explicitly configured native hook.
|
|
782
|
+
- `cache`: authenticated, private GET query caching only; commands are rejected.
|
|
783
|
+
It must be last, so cache hits cannot skip declared permission or quota checks.
|
|
784
|
+
Keys hash trusted identity, permissions, complete URL and request headers
|
|
785
|
+
except the correlation ID. Credentials and query contents are not stored as keys.
|
|
786
|
+
Only successful plain JSON object/array results are stored. Native responses,
|
|
787
|
+
streams, errors, oversized output, cookies, Vary, custom response headers and
|
|
788
|
+
cache-control prohibitions are conservatively excluded. Conditional/range
|
|
789
|
+
requests and request no-cache/no-store bypass caching. Cache-read outages return
|
|
790
|
+
503; post-response write failures notify `onCacheWriteError` (sanitized warning
|
|
791
|
+
by default) without changing a completed response.
|
|
792
|
+
- `createHttpTelemetry`: emits immutable request ID, method, static route template,
|
|
793
|
+
final status and duration after responses, including denied and invalid requests.
|
|
794
|
+
The response header and command/request context share the same correlation ID.
|
|
795
|
+
It never emits raw paths, query parameters, tokens, bodies or errors. Observer
|
|
796
|
+
failures cannot change business results. Connect the observer to your logger or
|
|
797
|
+
telemetry exporter; this is request tracing, not an OpenTelemetry backend.
|
|
798
|
+
|
|
799
|
+
The memory stores are explicitly **single-process**. Quota capacity exhaustion
|
|
800
|
+
fails closed rather than evicting active quotas; local cache storage is bounded
|
|
801
|
+
by entry count and byte budget. They do not coordinate replicas.
|
|
802
|
+
|
|
803
|
+
### Shared PostgreSQL Stores
|
|
804
|
+
|
|
805
|
+
Apply `HTTP_POLICY_STORE_SQL` through normal migrations, then use
|
|
806
|
+
`createPostgresHttpPolicyStores(database)`. Its database port takes a parameterized
|
|
807
|
+
`query(text, parameters)` function; it does not own a pool or transaction scope:
|
|
808
|
+
|
|
809
|
+
```ts
|
|
810
|
+
const stores = createPostgresHttpPolicyStores({
|
|
811
|
+
query: async (text, parameters) => Array.from(await sql.unsafe(text, [...parameters])),
|
|
812
|
+
});
|
|
813
|
+
const suite = createHttpPolicySuite({ auth: supAuthOptions, cacheNamespace: deploymentId, ...stores });
|
|
814
|
+
```
|
|
815
|
+
|
|
816
|
+
Concurrent replicas share an atomic row-locked quota and persistent
|
|
817
|
+
cache entries. Tables live in `supacloud_http` with no PUBLIC privileges. Grant
|
|
818
|
+
only the server runtime's database role access; never expose store credentials to
|
|
819
|
+
clients. Different applications/issuers/tenants/users have distinct keys.
|
|
820
|
+
|
|
821
|
+
Schedule `stores.prune()` to remove expired rows and monitor database size.
|
|
822
|
+
`cacheNamespace` is mandatory when a cache policy is declared. Use the same
|
|
823
|
+
namespace across replicas of one release and a different namespace for every
|
|
824
|
+
representation/schema/security-rule revision. This prevents old in-flight requests
|
|
825
|
+
from repopulating the current release's cache; reusing a namespace opts into reuse.
|
|
826
|
+
Use TTLs appropriate for stale-read tolerance and invoke `cacheStore.clear()` only
|
|
827
|
+
after a confirmed write when explicit broad invalidation is desired. Clear advances
|
|
828
|
+
a shared generation atomically; fills from older generations are rejected. Already
|
|
829
|
+
in-flight HTTP responses are not cancelled. Custom stores must implement the same
|
|
830
|
+
generation/check-and-write contract. Do not cache responses
|
|
831
|
+
containing per-request IDs, nonces or time-sensitive authorization decisions.
|
|
832
|
+
These stores do not provide business transactions or durable audit.
|
|
833
|
+
|
|
834
|
+
### Reproducible Performance Checks
|
|
835
|
+
|
|
836
|
+
Run `bun run bench:http-policy [output.json]`. Optional environment settings:
|
|
837
|
+
`BENCH_REQUESTS`, `BENCH_ROUNDS`, `BENCH_CONCURRENCY`. It compares native static
|
|
838
|
+
Elysia, compiled static DI, one no-op policy, and a full verified policy pipeline.
|
|
839
|
+
The harness warms each case, rotates case order, validates every response and
|
|
840
|
+
records throughput, P50/P95/P99, live heap deltas and RSS. Cache hits and misses
|
|
841
|
+
are separate scenarios with asserted handler/hit/fill counts; sorting is outside
|
|
842
|
+
the throughput timer.
|
|
843
|
+
|
|
844
|
+
Loopback results include the same-process fetch client; heap deltas are affected
|
|
845
|
+
by GC and are **not total allocation counts**. Full-policy results include local
|
|
846
|
+
ES256 verification, not remote identity/database latency. See
|
|
847
|
+
[acceptance evidence](../../docs/http-policy-acceptance.md) for the measured scope.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { CommandPreview } from "@supacloud/contracts";
|
|
2
|
+
import { type CommandGovernance, type CompiledModule, type ExecutionObserver } from "./index";
|
|
3
|
+
/** Supplied by the trusted host for each call, never captured as binding configuration. */
|
|
4
|
+
export interface CompiledCommandCallContext {
|
|
5
|
+
request: Request;
|
|
6
|
+
requestContext: unknown;
|
|
7
|
+
services?: Record<string, unknown>;
|
|
8
|
+
scope?: Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
export interface CompiledCommandBindingOptions<Input, Result> {
|
|
11
|
+
module: Pick<CompiledModule, "name" | "commands" | "aspects" | "aspectPipeline">;
|
|
12
|
+
/** Compiled command class name, as required by executeCompiledCommand. */
|
|
13
|
+
command: string;
|
|
14
|
+
governance: CommandGovernance;
|
|
15
|
+
observer?: ExecutionObserver;
|
|
16
|
+
handler(input: Input, context: CompiledCommandCallContext): Result | Promise<Result>;
|
|
17
|
+
decode(value: unknown): Result;
|
|
18
|
+
preview?(input: Input, context: CompiledCommandCallContext): unknown;
|
|
19
|
+
}
|
|
20
|
+
export interface CompiledCommandBinding<Input, Result> {
|
|
21
|
+
execute(input: Input, context: CompiledCommandCallContext): Promise<Result>;
|
|
22
|
+
preview?(input: Input, context: CompiledCommandCallContext): Promise<CommandPreview>;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Binds static wiring, not an identity or an execution outcome.
|
|
26
|
+
* All policy, replay, aspects and decoding remain in the existing command APIs.
|
|
27
|
+
*/
|
|
28
|
+
export declare function bindCompiledCommand<Input, Result>(options: CompiledCommandBindingOptions<Input, Result> & {
|
|
29
|
+
preview: NonNullable<CompiledCommandBindingOptions<Input, Result>["preview"]>;
|
|
30
|
+
}): CompiledCommandBinding<Input, Result> & {
|
|
31
|
+
preview(input: Input, context: CompiledCommandCallContext): Promise<CommandPreview>;
|
|
32
|
+
};
|
|
33
|
+
export declare function bindCompiledCommand<Input, Result>(options: CompiledCommandBindingOptions<Input, Result>): CompiledCommandBinding<Input, Result>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { DurableCommandReceipt } from "@supacloud/contracts";
|
|
2
|
+
import type { CompiledCommandBinding, CompiledCommandCallContext } from "../command-binding";
|
|
3
|
+
export interface OrderInput {
|
|
4
|
+
orderId: string;
|
|
5
|
+
}
|
|
6
|
+
export interface ReservationInput extends OrderInput {
|
|
7
|
+
reservationId: string;
|
|
8
|
+
}
|
|
9
|
+
export interface ReservationResult {
|
|
10
|
+
reservationId: string;
|
|
11
|
+
}
|
|
12
|
+
export interface PaymentResult {
|
|
13
|
+
outcome: "paid" | "declined";
|
|
14
|
+
}
|
|
15
|
+
export interface OrderResult {
|
|
16
|
+
orderId: string;
|
|
17
|
+
}
|
|
18
|
+
export interface FulfillmentSteps {
|
|
19
|
+
reserve: CompiledCommandBinding<OrderInput, DurableCommandReceipt<ReservationResult>>;
|
|
20
|
+
charge: CompiledCommandBinding<ReservationInput, DurableCommandReceipt<PaymentResult>>;
|
|
21
|
+
confirm: CompiledCommandBinding<ReservationInput, DurableCommandReceipt<OrderResult>>;
|
|
22
|
+
release: CompiledCommandBinding<ReservationInput, DurableCommandReceipt<OrderResult>>;
|
|
23
|
+
}
|
|
24
|
+
export type FulfillmentOutcome = {
|
|
25
|
+
status: "completed" | "declined";
|
|
26
|
+
orderId: string;
|
|
27
|
+
} | {
|
|
28
|
+
status: "pending";
|
|
29
|
+
step: keyof FulfillmentSteps;
|
|
30
|
+
operationId: string;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Application-owned composition, not a scheduler or a distributed transaction.
|
|
34
|
+
* Each bound step owns authorization, durable receipts and its local transaction.
|
|
35
|
+
*/
|
|
36
|
+
export declare function createFulfillment(steps: FulfillmentSteps): {
|
|
37
|
+
execute(input: OrderInput, context: CompiledCommandCallContext): Promise<FulfillmentOutcome>;
|
|
38
|
+
};
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { UpdateWebhook } from "../webhook/update.command";
|
|
1
2
|
export interface CompiledRoute {
|
|
3
|
+
parse?: "none";
|
|
4
|
+
allowDeleteBody?: true;
|
|
2
5
|
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS";
|
|
3
6
|
path: string;
|
|
4
7
|
handler: string;
|
|
@@ -101,7 +104,14 @@ export interface CompiledModule {
|
|
|
101
104
|
}
|
|
102
105
|
type CompiledAspectObserver = (stage: string, run: () => unknown | Promise<unknown>) => unknown | Promise<unknown>;
|
|
103
106
|
type CompiledAspectPipeline = (context: CompiledAspectContext, next: () => unknown | Promise<unknown>, observe?: CompiledAspectObserver) => unknown | Promise<unknown>;
|
|
104
|
-
export
|
|
107
|
+
export type CompiledApplicationModule = Omit<CompiledModule, "name" | "createServices"> & ({
|
|
108
|
+
name: "webhook";
|
|
109
|
+
createServices: typeof createWebhookServices;
|
|
110
|
+
});
|
|
111
|
+
export declare function createCompiledModules(): CompiledApplicationModule[];
|
|
105
112
|
export declare function initializeApplication(services: Record<string, unknown>): Promise<void>;
|
|
106
113
|
export declare function destroyApplication(services: Record<string, unknown>): Promise<void>;
|
|
114
|
+
declare function createWebhookServices(deps: Record<string, unknown>, imported: Record<string, Record<string, unknown>>): {
|
|
115
|
+
updateWebhook: UpdateWebhook;
|
|
116
|
+
};
|
|
107
117
|
export {};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { HttpCacheStore, HttpRateLimitStore } from "./http-policy-stores";
|
|
2
|
+
export interface HttpPolicyDatabase {
|
|
3
|
+
query(text: string, parameters: readonly unknown[]): Promise<readonly Record<string, unknown>[]>;
|
|
4
|
+
}
|
|
5
|
+
/** Apply through migrations, never from a request. Credentials must be server-only. */
|
|
6
|
+
export declare const HTTP_POLICY_STORE_SQL = "\nCREATE SCHEMA IF NOT EXISTS supacloud_http;\nREVOKE ALL ON SCHEMA supacloud_http FROM PUBLIC;\nCREATE TABLE IF NOT EXISTS supacloud_http.rate_limits (\n key text PRIMARY KEY CHECK (key ~ '^[a-f0-9]{64}$'),\n used bigint NOT NULL CHECK (used > 0),\n reset_at timestamptz NOT NULL\n);\nCREATE INDEX IF NOT EXISTS http_rate_limit_expiry ON supacloud_http.rate_limits(reset_at);\nCREATE TABLE IF NOT EXISTS supacloud_http.response_cache (\n key text PRIMARY KEY CHECK (key ~ '^[a-f0-9]{64}$'),\n body text NOT NULL CHECK (octet_length(body) <= 16777216),\n expires_at timestamptz NOT NULL,\n generation uuid NOT NULL\n);\nALTER TABLE supacloud_http.response_cache ADD COLUMN IF NOT EXISTS generation uuid\n NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000';\nCREATE TABLE IF NOT EXISTS supacloud_http.cache_generation (\n singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),\n generation uuid NOT NULL DEFAULT gen_random_uuid()\n);\nINSERT INTO supacloud_http.cache_generation(singleton) VALUES (true) ON CONFLICT DO NOTHING;\nCREATE INDEX IF NOT EXISTS http_response_cache_expiry ON supacloud_http.response_cache(expires_at);\nREVOKE ALL ON ALL TABLES IN SCHEMA supacloud_http FROM PUBLIC;\nCREATE OR REPLACE FUNCTION supacloud_http.consume_rate_limit(p_key text, p_limit bigint, p_window_ms integer)\nRETURNS TABLE(allowed boolean, remaining bigint, reset_at_ms numeric)\nLANGUAGE plpgsql SET search_path = pg_catalog, supacloud_http AS $rate$\nDECLARE\n entry supacloud_http.rate_limits%ROWTYPE;\n observed_at timestamptz;\nBEGIN\n IF p_limit < 1 OR p_limit > 1000000000 OR p_window_ms < 1 OR p_window_ms > 86400000 THEN\n RAISE EXCEPTION 'Invalid rate limit window';\n END IF;\n LOOP\n INSERT INTO supacloud_http.rate_limits(key, used, reset_at)\n VALUES (p_key, 1, '-infinity') ON CONFLICT DO NOTHING;\n SELECT * INTO entry FROM supacloud_http.rate_limits WHERE key=p_key FOR UPDATE;\n EXIT WHEN FOUND;\n -- Expiry pruning may remove a conflicting row before we acquire its lock.\n END LOOP;\n observed_at := clock_timestamp();\n IF entry.reset_at <= observed_at THEN\n entry.used := 1;\n entry.reset_at := observed_at + p_window_ms * interval '1 millisecond';\n ELSE\n entry.used := least(entry.used + 1, p_limit + 1);\n END IF;\n UPDATE supacloud_http.rate_limits SET used=entry.used, reset_at=entry.reset_at WHERE key=p_key;\n RETURN QUERY SELECT entry.used <= p_limit, greatest(p_limit-entry.used,0),\n extract(epoch FROM entry.reset_at)*1000;\nEND\n$rate$;\nREVOKE ALL ON FUNCTION supacloud_http.consume_rate_limit(text,bigint,integer) FROM PUBLIC;\n";
|
|
7
|
+
/** Independent instances sharing a database share atomic quotas and private cache entries. */
|
|
8
|
+
export declare function createPostgresHttpPolicyStores(database: HttpPolicyDatabase): {
|
|
9
|
+
rateLimitStore: HttpRateLimitStore;
|
|
10
|
+
cacheStore: HttpCacheStore;
|
|
11
|
+
prune(): Promise<void>;
|
|
12
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface HttpRateLimitResult {
|
|
2
|
+
allowed: boolean;
|
|
3
|
+
remaining: number;
|
|
4
|
+
resetAt: number;
|
|
5
|
+
}
|
|
6
|
+
export interface HttpRateLimitStore {
|
|
7
|
+
/** Must atomically consume one request across every process sharing this store. */
|
|
8
|
+
consume(key: string, limit: number, windowMs: number): HttpRateLimitResult | Promise<HttpRateLimitResult>;
|
|
9
|
+
}
|
|
10
|
+
export interface HttpCacheEntry {
|
|
11
|
+
body: string;
|
|
12
|
+
contentType: string;
|
|
13
|
+
expiresAt: number;
|
|
14
|
+
}
|
|
15
|
+
export interface HttpCacheStore {
|
|
16
|
+
generation(): string | Promise<string>;
|
|
17
|
+
get(key: string, generation: string): HttpCacheEntry | undefined | Promise<HttpCacheEntry | undefined>;
|
|
18
|
+
/** Atomically refuse fills from an invalidated generation. */
|
|
19
|
+
set(key: string, value: HttpCacheEntry, generation: string): void | Promise<void>;
|
|
20
|
+
/** Invalidation is explicit, never inferred from a write that may have failed. */
|
|
21
|
+
clear(): void | Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
/** Single-process fixed windows. Capacity exhaustion fails closed, not quota eviction. */
|
|
24
|
+
export declare function createMemoryHttpRateLimitStore(options?: {
|
|
25
|
+
maxEntries?: number;
|
|
26
|
+
now?: () => number;
|
|
27
|
+
}): HttpRateLimitStore;
|
|
28
|
+
/** Bounded local LRU; use a shared store for cross-process caching/invalidation. */
|
|
29
|
+
export declare function createMemoryHttpCacheStore(options?: {
|
|
30
|
+
maxEntries?: number;
|
|
31
|
+
maxBytes?: number;
|
|
32
|
+
now?: () => number;
|
|
33
|
+
}): HttpCacheStore;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { type SupaCloudRequestContext } from "./index";
|
|
2
|
+
import { type SupAuthContextOptions, type SupAuthRequestContext } from "./identity";
|
|
3
|
+
import type { HttpPolicy } from "./http-policy";
|
|
4
|
+
import type { HttpCacheStore, HttpRateLimitStore } from "./http-policy-stores";
|
|
5
|
+
export type BuiltinHttpPolicyDeclaration = {
|
|
6
|
+
name: "authenticated";
|
|
7
|
+
options?: never;
|
|
8
|
+
} | {
|
|
9
|
+
name: "tenant";
|
|
10
|
+
options: {
|
|
11
|
+
param: string;
|
|
12
|
+
};
|
|
13
|
+
} | {
|
|
14
|
+
name: "permission";
|
|
15
|
+
options: {
|
|
16
|
+
allOf: readonly string[];
|
|
17
|
+
};
|
|
18
|
+
} | {
|
|
19
|
+
name: "rateLimit";
|
|
20
|
+
options: {
|
|
21
|
+
limit: number;
|
|
22
|
+
windowMs: number;
|
|
23
|
+
};
|
|
24
|
+
} | {
|
|
25
|
+
name: "cache";
|
|
26
|
+
options: {
|
|
27
|
+
ttlMs: number;
|
|
28
|
+
maxBodyBytes?: number;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
export interface HttpPolicySuiteOptions {
|
|
32
|
+
auth: SupAuthContextOptions;
|
|
33
|
+
rateLimitStore?: HttpRateLimitStore;
|
|
34
|
+
cacheStore?: HttpCacheStore;
|
|
35
|
+
/** Required for cache policies. Change for each representation/deployment version. */
|
|
36
|
+
cacheNamespace?: string;
|
|
37
|
+
/** Sanitized operational notification; no exception, key, user or payload. */
|
|
38
|
+
onCacheWriteError?: () => void;
|
|
39
|
+
}
|
|
40
|
+
export declare function policyOptions(value: unknown, allowed: readonly string[]): Record<string, unknown>;
|
|
41
|
+
export declare function positiveInteger(value: unknown, max: number): number;
|
|
42
|
+
export declare function policyKey(parts: unknown[]): Promise<string>;
|
|
43
|
+
export declare function principalKey(context: SupAuthRequestContext): unknown[];
|
|
44
|
+
/** Pair the returned factory and registry; only this verifier can populate policy identity. */
|
|
45
|
+
export declare function createHttpPolicySuite(options: HttpPolicySuiteOptions): {
|
|
46
|
+
requestContext: (request: Request) => Promise<SupaCloudRequestContext | SupAuthRequestContext>;
|
|
47
|
+
httpPolicies: Readonly<Record<string, (options: unknown, route: Readonly<Pick<import("./index").CompiledRoute, "method" | "path" | "command">>) => HttpPolicy<import("elysia").default<"", {
|
|
48
|
+
decorator: {};
|
|
49
|
+
store: {};
|
|
50
|
+
derive: {};
|
|
51
|
+
resolve: {};
|
|
52
|
+
}, {
|
|
53
|
+
typebox: {};
|
|
54
|
+
error: {};
|
|
55
|
+
}, {
|
|
56
|
+
schema: {};
|
|
57
|
+
standaloneSchema: {};
|
|
58
|
+
macro: {};
|
|
59
|
+
macroFn: {};
|
|
60
|
+
parser: {};
|
|
61
|
+
response: {};
|
|
62
|
+
}, {}, {
|
|
63
|
+
derive: {};
|
|
64
|
+
resolve: {};
|
|
65
|
+
schema: {};
|
|
66
|
+
standaloneSchema: {};
|
|
67
|
+
response: {};
|
|
68
|
+
}, {
|
|
69
|
+
derive: {};
|
|
70
|
+
resolve: {};
|
|
71
|
+
schema: {};
|
|
72
|
+
standaloneSchema: {};
|
|
73
|
+
response: {};
|
|
74
|
+
}>>>>;
|
|
75
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { AnyElysia, Elysia } from "elysia";
|
|
2
|
+
import type { ApplicationHttpContext, CompiledRoute } from "./index";
|
|
3
|
+
export interface HttpPolicyDeclaration {
|
|
4
|
+
name: string;
|
|
5
|
+
options?: unknown;
|
|
6
|
+
}
|
|
7
|
+
export interface HttpPolicyContext<Http extends AnyElysia = Elysia> {
|
|
8
|
+
http: ApplicationHttpContext<Http>;
|
|
9
|
+
requestContext: unknown;
|
|
10
|
+
}
|
|
11
|
+
export interface HttpPolicyResponseContext<Http extends AnyElysia = Elysia> extends HttpPolicyContext<Http> {
|
|
12
|
+
response: unknown;
|
|
13
|
+
}
|
|
14
|
+
export type HttpPolicy<Http extends AnyElysia = Elysia> = ((context: HttpPolicyContext<Http>) => void | Response | Promise<void | Response>) & {
|
|
15
|
+
/** Terminal policies (cache hits) must not skip later security checks. */
|
|
16
|
+
terminal?: boolean;
|
|
17
|
+
mapResponse?: (context: HttpPolicyResponseContext<Http>) => void | Response | Promise<void | Response>;
|
|
18
|
+
afterResponse?: (context: HttpPolicyResponseContext<Http>) => void | Promise<void>;
|
|
19
|
+
};
|
|
20
|
+
/** Factories validate configuration once at startup, never per request. */
|
|
21
|
+
export type HttpPolicyRegistry<Http extends AnyElysia = Elysia> = Readonly<Record<string, (options: unknown, route: Readonly<Pick<CompiledRoute, "method" | "path" | "command">>) => HttpPolicy<Http>>>;
|
|
22
|
+
export declare class HttpPolicyConfigurationError extends Error {
|
|
23
|
+
readonly code = "HTTP_POLICY_CONFIGURATION_INVALID";
|
|
24
|
+
}
|
|
25
|
+
export declare function compileHttpPolicies<Http extends AnyElysia>(route: CompiledRoute, path: string, registry?: HttpPolicyRegistry<Http>): HttpPolicy<Http>[];
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { SupAuthRequestContext } from "./identity";
|
|
2
|
+
import type { HttpPolicy } from "./http-policy";
|
|
3
|
+
import type { HttpCacheStore } from "./http-policy-stores";
|
|
4
|
+
interface CachePolicyOptions {
|
|
5
|
+
store: HttpCacheStore;
|
|
6
|
+
requireAccess(request: Request): SupAuthRequestContext;
|
|
7
|
+
ttlMs: number;
|
|
8
|
+
maxBodyBytes: number;
|
|
9
|
+
route: string;
|
|
10
|
+
namespace: string;
|
|
11
|
+
onWriteError(): void;
|
|
12
|
+
}
|
|
13
|
+
export declare function createCachePolicy(options: CachePolicyOptions): HttpPolicy;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Elysia } from "elysia";
|
|
2
|
+
export declare function httpRequestId(request: Request): string | undefined;
|
|
3
|
+
export interface HttpTelemetryEvent {
|
|
4
|
+
requestId: string;
|
|
5
|
+
method: string;
|
|
6
|
+
/** Static route template only. Query strings and concrete paths are excluded. */
|
|
7
|
+
route: string;
|
|
8
|
+
status: number;
|
|
9
|
+
durationMs: number;
|
|
10
|
+
}
|
|
11
|
+
export type HttpTelemetryObserver = (event: Readonly<HttpTelemetryEvent>) => void | Promise<void>;
|
|
12
|
+
/** Request tracing is best effort, not a durable business audit. */
|
|
13
|
+
export declare function createHttpTelemetry(observe: HttpTelemetryObserver): Elysia<"", {
|
|
14
|
+
decorator: {};
|
|
15
|
+
store: {};
|
|
16
|
+
derive: {};
|
|
17
|
+
resolve: {};
|
|
18
|
+
}, {
|
|
19
|
+
typebox: {};
|
|
20
|
+
error: {};
|
|
21
|
+
}, {
|
|
22
|
+
schema: {};
|
|
23
|
+
standaloneSchema: {};
|
|
24
|
+
macro: {};
|
|
25
|
+
macroFn: {};
|
|
26
|
+
parser: {};
|
|
27
|
+
response: {};
|
|
28
|
+
}, {}, {
|
|
29
|
+
derive: {};
|
|
30
|
+
resolve: {};
|
|
31
|
+
schema: {};
|
|
32
|
+
standaloneSchema: {};
|
|
33
|
+
response: {};
|
|
34
|
+
}, {
|
|
35
|
+
derive: {};
|
|
36
|
+
resolve: {};
|
|
37
|
+
schema: {};
|
|
38
|
+
standaloneSchema: {};
|
|
39
|
+
response: {};
|
|
40
|
+
}>;
|