@avelonjs/core 0.1.0 → 0.3.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 +5 -4
- package/package.json +1 -1
- package/src/drivers/identity.ts +19 -3
- package/src/runtime/config.ts +1 -1
- package/src/runtime/gate.ts +29 -7
- package/src/runtime/kernel.ts +57 -17
package/README.md
CHANGED
|
@@ -159,7 +159,7 @@ export const IndexPublishedPost = defineListener<PostPublished, void, 'queued'>(
|
|
|
159
159
|
|
|
160
160
|
## Runtime Facades
|
|
161
161
|
|
|
162
|
-
|
|
162
|
+
The runtime implements the frozen contracts as process-local facades. You wire drivers with `defineConfig`, dispatch events through `Events`, authorize with `Gate`, validate with `defineRequest`, inject wards with `injectWard`, and run background work through `Errands`. Named mailers, disks, and connections never fall back to the default when the name is unknown.
|
|
163
163
|
|
|
164
164
|
```ts
|
|
165
165
|
import {
|
|
@@ -301,6 +301,7 @@ The table lists every public export. Capability surface interfaces describe meth
|
|
|
301
301
|
| `DriverContract` | `interface DriverContract<TCapabilities, TRaw>` | Shared name, instance, capabilities, and raw client contract. |
|
|
302
302
|
| `DriverFault` | `class DriverFault` | Normalized unmapped driver error. |
|
|
303
303
|
| `DriverFaultMetadata` | `interface DriverFaultMetadata` | Driver contract and operation identifiers. |
|
|
304
|
+
| `EmailVerificationIdentitySurface` | `interface EmailVerificationIdentitySurface` | Send-confirmation and token-verify methods. |
|
|
304
305
|
| `EmbeddingAiSurface` | `interface EmbeddingAiSurface` | Text embedding method. |
|
|
305
306
|
| `EmbeddingResult` | `interface EmbeddingResult` | Ordered embedding vectors and token count. |
|
|
306
307
|
| `ErrorCode` | `type ErrorCode` | Fixed machine-readable error code union. |
|
|
@@ -320,8 +321,8 @@ The table lists every public export. Capability surface interfaces describe meth
|
|
|
320
321
|
| `ForbiddenMetadata` | `interface ForbiddenMetadata` | Denied ability and resource identifiers. |
|
|
321
322
|
| `HttpMethod` | `type HttpMethod` | Methods accepted by route manifests. |
|
|
322
323
|
| `HttpRequest` | `interface HttpRequest<TBody>` | Framework-neutral decoded request with access to raw bytes. |
|
|
323
|
-
| `Identity` | `type Identity<TDriver>` | Identity facade narrowed to
|
|
324
|
-
| `IdentityCapabilities` | `interface IdentityCapabilities<TFactor>` | Password, magic-link, OAuth, organization, and
|
|
324
|
+
| `Identity` | `type Identity<TDriver>` | Identity facade narrowed to six auth capabilities. |
|
|
325
|
+
| `IdentityCapabilities` | `interface IdentityCapabilities<TFactor>` | Password, magic-link, OAuth, organization, MFA, and email-verification declaration. |
|
|
325
326
|
| `IdentityDriver` | `interface IdentityDriver<TCapabilities, TRaw, TActor, TSession>` | Current actor, session, and sign-out contract. |
|
|
326
327
|
| `IdentityOrganization` | `interface IdentityOrganization` | Normalized organization summary. |
|
|
327
328
|
| `Invalid` | `class Invalid` | Normalized invalid-input error. |
|
|
@@ -339,7 +340,7 @@ The table lists every public export. Capability surface interfaces describe meth
|
|
|
339
340
|
| `LogLevel` | `type LogLevel` | Portable structured log severity. |
|
|
340
341
|
| `LogRecord` | `interface LogRecord` | Structured application log event. |
|
|
341
342
|
| `Logs` | `type Logs<TDriver>` | Logging facade narrowed to tracing support. |
|
|
342
|
-
| `MagicLinkIdentitySurface` | `interface MagicLinkIdentitySurface
|
|
343
|
+
| `MagicLinkIdentitySurface` | `interface MagicLinkIdentitySurface<TActor>` | Passwordless sign-in: send a link, then redeem its token. |
|
|
343
344
|
| `Mail` | `type Mail<TDriver>` | Mail facade narrowed to hosted templates. |
|
|
344
345
|
| `MailAttachment` | `interface MailAttachment` | Portable attachment filename, bytes, and media type. |
|
|
345
346
|
| `MailCapabilities` | `interface MailCapabilities` | Literal hosted-template capability declaration. |
|
package/package.json
CHANGED
package/src/drivers/identity.ts
CHANGED
|
@@ -12,6 +12,8 @@ export interface IdentityCapabilities<TFactor extends string = string> {
|
|
|
12
12
|
readonly organizations: boolean
|
|
13
13
|
/** Supported factors retained as a literal readonly list; empty means no MFA surface. */
|
|
14
14
|
readonly mfa: readonly TFactor[]
|
|
15
|
+
/** Whether email-address confirmation is available. */
|
|
16
|
+
readonly emailVerification: boolean
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
/** Authentication capability alias matching the application-facing `Auth` vocabulary. */
|
|
@@ -77,9 +79,11 @@ export interface PasswordIdentitySurface<TActor = unknown> {
|
|
|
77
79
|
}
|
|
78
80
|
|
|
79
81
|
/** Passwordless operations exposed only by magic-link-capable identity drivers. */
|
|
80
|
-
export interface MagicLinkIdentitySurface {
|
|
82
|
+
export interface MagicLinkIdentitySurface<TActor = unknown> {
|
|
81
83
|
/** Sends a sign-in link to an email address. */
|
|
82
84
|
sendMagicLink(email: string, redirectTo?: string): Promise<void>
|
|
85
|
+
/** Authenticates an actor with the token a magic link carried. */
|
|
86
|
+
signInWithMagicLink(token: string): Promise<TActor>
|
|
83
87
|
}
|
|
84
88
|
|
|
85
89
|
/** OAuth linking exposed only when the identity driver supports linked identities. */
|
|
@@ -104,6 +108,14 @@ export interface MfaIdentitySurface<TFactor extends string = string> {
|
|
|
104
108
|
verifyMfa(challengeId: string, code: string): Promise<void>
|
|
105
109
|
}
|
|
106
110
|
|
|
111
|
+
/** Email confirmation exposed only by drivers that verify addresses. */
|
|
112
|
+
export interface EmailVerificationIdentitySurface {
|
|
113
|
+
/** Sends a confirmation message without revealing account existence. */
|
|
114
|
+
sendEmailVerification(email?: string): Promise<void>
|
|
115
|
+
/** Confirms an email address after validating a vendor token. */
|
|
116
|
+
verifyEmail(token: string): Promise<void>
|
|
117
|
+
}
|
|
118
|
+
|
|
107
119
|
type ActorOf<TDriver extends IdentityDriver> =
|
|
108
120
|
TDriver extends IdentityDriver<IdentityCapabilities, unknown, infer TActor, unknown>
|
|
109
121
|
? TActor
|
|
@@ -124,10 +136,14 @@ export type Identity<TDriver extends IdentityDriver> = Pick<
|
|
|
124
136
|
TDriver['capabilities']['passwords'],
|
|
125
137
|
PasswordIdentitySurface<ActorOf<TDriver>>
|
|
126
138
|
> &
|
|
127
|
-
CapabilitySurface<
|
|
139
|
+
CapabilitySurface<
|
|
140
|
+
TDriver['capabilities']['magicLinks'],
|
|
141
|
+
MagicLinkIdentitySurface<ActorOf<TDriver>>
|
|
142
|
+
> &
|
|
128
143
|
CapabilitySurface<TDriver['capabilities']['oauth'], OAuthIdentitySurface> &
|
|
129
144
|
CapabilitySurface<TDriver['capabilities']['organizations'], OrganizationIdentitySurface> &
|
|
130
|
-
MfaIdentityFor<TDriver['capabilities']>
|
|
145
|
+
MfaIdentityFor<TDriver['capabilities']> &
|
|
146
|
+
CapabilitySurface<TDriver['capabilities']['emailVerification'], EmailVerificationIdentitySurface>
|
|
131
147
|
|
|
132
148
|
/** Authentication facade alias matching the application-facing `Auth` vocabulary. */
|
|
133
149
|
export type Auth<TDriver extends IdentityDriver> = Identity<TDriver>
|
package/src/runtime/config.ts
CHANGED
|
@@ -36,7 +36,7 @@ export interface AvelonDrivers {
|
|
|
36
36
|
export interface AvelonConfig {
|
|
37
37
|
/** Application name used in diagnostics. */
|
|
38
38
|
name?: string
|
|
39
|
-
/** Optional adapter.
|
|
39
|
+
/** Optional adapter. The Next adapter mounts routes. Core only stores the reference. */
|
|
40
40
|
adapter?: Adapter
|
|
41
41
|
/** Driver wiring for the current environment. */
|
|
42
42
|
drivers: AvelonDrivers
|
package/src/runtime/gate.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
1
2
|
import { Forbidden, Unauthenticated } from '../errors'
|
|
2
3
|
|
|
3
4
|
/** Actor shape accepted by the gate. `null` is an anonymous caller. */
|
|
@@ -12,7 +13,18 @@ export type PolicyAbilities = Readonly<Record<string, PolicyHandler>>
|
|
|
12
13
|
type ResourceKey = string | { readonly name: string }
|
|
13
14
|
|
|
14
15
|
const policies = new Map<string, PolicyAbilities>()
|
|
15
|
-
|
|
16
|
+
const storage = new AsyncLocalStorage<{ actor: GateActor }>()
|
|
17
|
+
let fallbackActor: GateActor = null
|
|
18
|
+
|
|
19
|
+
function activeActor(): GateActor {
|
|
20
|
+
const store = storage.getStore()
|
|
21
|
+
return store === undefined ? fallbackActor : store.actor
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Runs `fn` with `actor` bound for that call and everything it awaits. */
|
|
25
|
+
export async function runWithActor<T>(actor: GateActor, fn: () => Promise<T>): Promise<T> {
|
|
26
|
+
return await storage.run({ actor }, fn)
|
|
27
|
+
}
|
|
16
28
|
|
|
17
29
|
function resourceName(resource: ResourceKey | object): string {
|
|
18
30
|
if (typeof resource === 'string') return resource
|
|
@@ -39,7 +51,7 @@ export function definePolicy(resource: ResourceKey, abilities: PolicyAbilities):
|
|
|
39
51
|
/** Clears registered policies and the request actor. Intended for tests. */
|
|
40
52
|
export function resetPolicies(): void {
|
|
41
53
|
policies.clear()
|
|
42
|
-
|
|
54
|
+
fallbackActor = null
|
|
43
55
|
}
|
|
44
56
|
|
|
45
57
|
async function allows(actor: GateActor, ability: string, resource?: unknown): Promise<boolean> {
|
|
@@ -56,21 +68,31 @@ async function allows(actor: GateActor, ability: string, resource?: unknown): Pr
|
|
|
56
68
|
|
|
57
69
|
/** Authorization helpers used by controllers. */
|
|
58
70
|
export const Gate = {
|
|
59
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* Binds the request actor used when `authorize`/`can` omit one.
|
|
73
|
+
*
|
|
74
|
+
* Inside `runWithActor` the write stays scoped to that call; outside one it writes the
|
|
75
|
+
* process-wide fallback.
|
|
76
|
+
*/
|
|
60
77
|
setActor(actor: GateActor): void {
|
|
61
|
-
|
|
78
|
+
const store = storage.getStore()
|
|
79
|
+
if (store !== undefined) {
|
|
80
|
+
store.actor = actor
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
fallbackActor = actor
|
|
62
84
|
},
|
|
63
85
|
|
|
64
86
|
/** Returns the actor bound for this request, or `null`. */
|
|
65
87
|
actor(): GateActor {
|
|
66
|
-
return
|
|
88
|
+
return activeActor()
|
|
67
89
|
},
|
|
68
90
|
|
|
69
91
|
/** Returns whether the actor may perform the ability. */
|
|
70
92
|
async can(
|
|
71
93
|
ability: string,
|
|
72
94
|
resource?: unknown,
|
|
73
|
-
actor: GateActor =
|
|
95
|
+
actor: GateActor = activeActor(),
|
|
74
96
|
): Promise<boolean> {
|
|
75
97
|
return allows(actor, ability, resource)
|
|
76
98
|
},
|
|
@@ -79,7 +101,7 @@ export const Gate = {
|
|
|
79
101
|
async authorize(
|
|
80
102
|
ability: string,
|
|
81
103
|
resource?: unknown,
|
|
82
|
-
actor: GateActor =
|
|
104
|
+
actor: GateActor = activeActor(),
|
|
83
105
|
): Promise<void> {
|
|
84
106
|
if (actor === null) {
|
|
85
107
|
const handlerAllowsAnonymous = await allows(null, ability, resource)
|
package/src/runtime/kernel.ts
CHANGED
|
@@ -7,8 +7,16 @@ import type {
|
|
|
7
7
|
RouteDefinition,
|
|
8
8
|
ViewResult,
|
|
9
9
|
} from '../adapter'
|
|
10
|
-
import {
|
|
11
|
-
|
|
10
|
+
import {
|
|
11
|
+
Conflict,
|
|
12
|
+
Forbidden,
|
|
13
|
+
Invalid,
|
|
14
|
+
NotFound,
|
|
15
|
+
RateLimited,
|
|
16
|
+
Unauthenticated,
|
|
17
|
+
Unavailable,
|
|
18
|
+
} from '../errors'
|
|
19
|
+
import { Gate, runWithActor, type GateActor } from './gate'
|
|
12
20
|
import { Events } from './dispatcher'
|
|
13
21
|
import { flushAfterErrands } from './errands'
|
|
14
22
|
|
|
@@ -46,7 +54,15 @@ function hasHeader(headers: Readonly<Record<string, string>>, name: string): boo
|
|
|
46
54
|
)
|
|
47
55
|
}
|
|
48
56
|
|
|
49
|
-
/**
|
|
57
|
+
/**
|
|
58
|
+
* Maps framework errors onto transport-neutral kernel results.
|
|
59
|
+
*
|
|
60
|
+
* Every taxonomy member except `DriverFault` has a result here; that one stays unmapped because an
|
|
61
|
+
* unrecognized driver failure is a server fault, not an answer to give the caller. A member left
|
|
62
|
+
* unmapped propagates out of the dispatcher, and the adapter above it turns that into a runtime
|
|
63
|
+
* error whose message a production build hides, which is how a driver reporting "Signups not
|
|
64
|
+
* allowed for this instance" reaches the browser as a blank failure.
|
|
65
|
+
*/
|
|
50
66
|
export function mapKernelException(error: unknown): KernelResult | undefined {
|
|
51
67
|
if (error instanceof Invalid) {
|
|
52
68
|
return {
|
|
@@ -63,6 +79,30 @@ export function mapKernelException(error: unknown): KernelResult | undefined {
|
|
|
63
79
|
status: 302,
|
|
64
80
|
} satisfies RedirectResult
|
|
65
81
|
}
|
|
82
|
+
if (error instanceof Conflict) {
|
|
83
|
+
return {
|
|
84
|
+
type: 'action',
|
|
85
|
+
ok: false,
|
|
86
|
+
errors: { _form: [error.message] },
|
|
87
|
+
status: 409,
|
|
88
|
+
} satisfies ActionResult
|
|
89
|
+
}
|
|
90
|
+
if (error instanceof RateLimited) {
|
|
91
|
+
return {
|
|
92
|
+
type: 'action',
|
|
93
|
+
ok: false,
|
|
94
|
+
errors: { _form: [error.message] },
|
|
95
|
+
status: 429,
|
|
96
|
+
} satisfies ActionResult
|
|
97
|
+
}
|
|
98
|
+
if (error instanceof Unavailable) {
|
|
99
|
+
return {
|
|
100
|
+
type: 'action',
|
|
101
|
+
ok: false,
|
|
102
|
+
errors: { _form: [error.message] },
|
|
103
|
+
status: 503,
|
|
104
|
+
} satisfies ActionResult
|
|
105
|
+
}
|
|
66
106
|
if (error instanceof Forbidden) {
|
|
67
107
|
return {
|
|
68
108
|
type: 'action',
|
|
@@ -92,20 +132,20 @@ export function createKernel(options: CreateKernelOptions = {}): Kernel<Controll
|
|
|
92
132
|
return {
|
|
93
133
|
async dispatch(route, request) {
|
|
94
134
|
try {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
135
|
+
const actor = options.resolveActor ? await options.resolveActor(request) : Gate.actor()
|
|
136
|
+
return await runWithActor(actor, async () => {
|
|
137
|
+
await runMiddleware(route, request, options)
|
|
138
|
+
const bindings = await resolveBindings(route, request, options.resolveBinding)
|
|
139
|
+
const controller = new route.controller()
|
|
140
|
+
const action = Reflect.get(controller, route.action)
|
|
141
|
+
if (typeof action !== 'function') {
|
|
142
|
+
throw new NotFound(`Controller action ${route.action} was not found.`, {
|
|
143
|
+
metadata: { resource: 'controller_action', identifier: route.action },
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
const result = await (action as ControllerMethod).call(controller, request, ...bindings)
|
|
147
|
+
return normalizeResult(result)
|
|
148
|
+
})
|
|
109
149
|
} catch (error) {
|
|
110
150
|
const mapped = options.mapException?.(error) ?? mapKernelException(error)
|
|
111
151
|
if (mapped !== undefined) return mapped
|