@avelonjs/conformance 0.1.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.
@@ -0,0 +1,253 @@
1
+ import {
2
+ Conflict,
3
+ Invalid,
4
+ Unauthenticated,
5
+ type IdentityDriver,
6
+ type IdentityOrganization,
7
+ type MagicLinkIdentitySurface,
8
+ type MfaChallenge,
9
+ type MfaIdentitySurface,
10
+ type OAuthIdentitySurface,
11
+ type OrganizationIdentitySurface,
12
+ type PasswordIdentitySurface,
13
+ type RequestCookies,
14
+ } from '@avelonjs/core'
15
+
16
+ const capabilities = {
17
+ passwords: true,
18
+ magicLinks: true,
19
+ oauth: true,
20
+ organizations: true,
21
+ mfa: ['totp', 'recovery'] as const,
22
+ } as const
23
+
24
+ type Factor = (typeof capabilities.mfa)[number]
25
+
26
+ /** Actor returned by the in-memory identity implementation. */
27
+ export interface FakeIdentityActor {
28
+ /** Stable actor identifier. */
29
+ id: string
30
+ /** Sign-in address. */
31
+ email: string
32
+ }
33
+
34
+ /** Session returned by the in-memory identity implementation. */
35
+ export interface FakeIdentitySession {
36
+ /** Stable session identifier. */
37
+ id: string
38
+ /** Authenticated actor identifier. */
39
+ actorId: string
40
+ /** Time after which the session is invalid. */
41
+ expiresAt: Date
42
+ }
43
+
44
+ interface StoredActor extends FakeIdentityActor {
45
+ password: string
46
+ }
47
+
48
+ interface StoredSession extends FakeIdentitySession {
49
+ active: boolean
50
+ }
51
+
52
+ interface FakeIdentityRaw {
53
+ users: number
54
+ sessions: number
55
+ }
56
+
57
+ const sessionCookie = 'avelon_assay_session'
58
+
59
+ function unauthenticated(message: string): Unauthenticated {
60
+ return new Unauthenticated(message, { metadata: { guard: 'identity' } })
61
+ }
62
+
63
+ function invalid(field: string, message: string): Invalid {
64
+ return new Invalid(message, { metadata: { fields: { [field]: [message] } } })
65
+ }
66
+
67
+ function isFactor(value: string): value is Factor {
68
+ return capabilities.mfa.some((factor) => factor === value)
69
+ }
70
+
71
+ /** In-memory identity reference implementation used by conformance and application tests. */
72
+ export class FakeIdentity
73
+ implements
74
+ IdentityDriver<typeof capabilities, FakeIdentityRaw, FakeIdentityActor, FakeIdentitySession>,
75
+ PasswordIdentitySurface<FakeIdentityActor>,
76
+ MagicLinkIdentitySurface,
77
+ OAuthIdentitySurface,
78
+ OrganizationIdentitySurface,
79
+ MfaIdentitySurface<Factor>
80
+ {
81
+ /** Driver implementation name. */
82
+ readonly name = 'fake'
83
+
84
+ /** Configured identity connection name. */
85
+ readonly instance = 'default'
86
+
87
+ /** Exact optional-feature declaration. */
88
+ readonly capabilities = capabilities
89
+
90
+ readonly #cookies: RequestCookies
91
+ readonly #actors = new Map<string, StoredActor>()
92
+ readonly #sessions = new Map<string, StoredSession>()
93
+ readonly #resetTokens = new Map<string, string>()
94
+ readonly #challenges = new Map<string, { actorId: string; challenge: MfaChallenge<Factor> }>()
95
+ #lastSession: FakeIdentitySession | null = null
96
+ #nextActor = 1
97
+ #nextSession = 1
98
+ #nextChallenge = 1
99
+
100
+ /** Creates an identity driver bound to one request-scoped cookie store. */
101
+ constructor(cookies: RequestCookies) {
102
+ this.#cookies = cookies
103
+ }
104
+
105
+ /** Returns observable in-memory client counts. */
106
+ raw(): FakeIdentityRaw {
107
+ return { users: this.#actors.size, sessions: this.#sessions.size }
108
+ }
109
+
110
+ /** Returns the current actor or null for an anonymous request. */
111
+ async user(): Promise<FakeIdentityActor | null> {
112
+ const session = this.#currentSession()
113
+ if (!session) return null
114
+ const actor = [...this.#actors.values()].find((candidate) => candidate.id === session.actorId)
115
+ return actor ? { id: actor.id, email: actor.email } : null
116
+ }
117
+
118
+ /** Returns the current session or null when it is absent or expired. */
119
+ async session(): Promise<FakeIdentitySession | null> {
120
+ const session = this.#currentSession()
121
+ return session ? this.#copySession(session) : null
122
+ }
123
+
124
+ /** Ends the current session and deletes its request cookie. */
125
+ async signOut(): Promise<void> {
126
+ const session = this.#requireSession()
127
+ session.active = false
128
+ this.#cookies.delete(sessionCookie, { path: '/' })
129
+ }
130
+
131
+ /** Registers an actor without changing the current request session. */
132
+ async register(email: string, password: string): Promise<FakeIdentityActor> {
133
+ if (this.#actors.has(email)) {
134
+ throw new Conflict('An actor with this email already exists.', {
135
+ metadata: { resource: 'actor', key: 'email' },
136
+ })
137
+ }
138
+ const actor: StoredActor = { id: `actor-${this.#nextActor++}`, email, password }
139
+ this.#actors.set(email, actor)
140
+ return { id: actor.id, email: actor.email }
141
+ }
142
+
143
+ /** Authenticates an actor and writes a request-scoped session cookie. */
144
+ async signInWithPassword(email: string, password: string): Promise<FakeIdentityActor> {
145
+ const actor = this.#actors.get(email)
146
+ if (!actor || actor.password !== password) throw unauthenticated('Invalid credentials.')
147
+
148
+ const session: StoredSession = {
149
+ id: `session-${this.#nextSession++}`,
150
+ actorId: actor.id,
151
+ expiresAt: new Date(Date.now() + 60 * 60 * 1000),
152
+ active: true,
153
+ }
154
+ this.#sessions.set(session.id, session)
155
+ this.#lastSession = this.#copySession(session)
156
+ this.#cookies.set(sessionCookie, session.id, {
157
+ httpOnly: true,
158
+ maxAge: 60 * 60,
159
+ path: '/',
160
+ sameSite: 'lax',
161
+ secure: true,
162
+ })
163
+ return { id: actor.id, email: actor.email }
164
+ }
165
+
166
+ /** Records a deterministic recovery token for a known actor without revealing account absence. */
167
+ async sendPasswordReset(email: string): Promise<void> {
168
+ if (this.#actors.has(email)) this.#resetTokens.set('assay-reset-token', email)
169
+ }
170
+
171
+ /** Replaces a password after validating a recovery token. */
172
+ async resetPassword(token: string, password: string): Promise<void> {
173
+ const email = this.#resetTokens.get(token)
174
+ const actor = email ? this.#actors.get(email) : undefined
175
+ if (!email || !actor) throw unauthenticated('Invalid password recovery token.')
176
+ actor.password = password
177
+ this.#resetTokens.delete(token)
178
+ }
179
+
180
+ /** Changes the current actor's password. */
181
+ async updatePassword(password: string): Promise<void> {
182
+ const session = this.#requireSession()
183
+ const actor = [...this.#actors.values()].find((candidate) => candidate.id === session.actorId)
184
+ if (!actor) throw unauthenticated('The authenticated actor no longer exists.')
185
+ actor.password = password
186
+ }
187
+
188
+ /** Accepts a magic-link request without revealing account existence. */
189
+ async sendMagicLink(_email: string, _redirectTo?: string): Promise<void> {}
190
+
191
+ /** Links a completed authorization to the authenticated actor. */
192
+ async linkOAuthIdentity(_provider: string, _code: string): Promise<void> {
193
+ this.#requireSession()
194
+ }
195
+
196
+ /** Lists organizations available to the authenticated actor. */
197
+ async organizations(): Promise<readonly IdentityOrganization[]> {
198
+ this.#requireSession()
199
+ return [{ id: 'organization-1', name: 'Assay Organization' }]
200
+ }
201
+
202
+ /** Selects an organization available to the authenticated actor. */
203
+ async useOrganization(id: string): Promise<void> {
204
+ this.#requireSession()
205
+ if (id !== 'organization-1') throw invalid('organization', 'Unknown organization.')
206
+ }
207
+
208
+ /** Begins a challenge for one declared factor. */
209
+ async challengeMfa(factor?: string): Promise<MfaChallenge<Factor>> {
210
+ const session = this.#requireSession()
211
+ const selected = factor ?? capabilities.mfa[0]
212
+ if (!isFactor(selected)) throw invalid('factor', 'Unsupported multi-factor method.')
213
+ const challenge: MfaChallenge<Factor> = {
214
+ id: `challenge-${this.#nextChallenge++}`,
215
+ factor: selected,
216
+ expiresAt: new Date(Date.now() + 5 * 60 * 1000),
217
+ }
218
+ this.#challenges.set(challenge.id, { actorId: session.actorId, challenge })
219
+ return { ...challenge }
220
+ }
221
+
222
+ /** Verifies the deterministic code accepted by the in-memory implementation. */
223
+ async verifyMfa(challengeId: string, code: string): Promise<void> {
224
+ const pending = this.#challenges.get(challengeId)
225
+ if (!pending || pending.challenge.expiresAt.getTime() <= Date.now() || code !== '123456') {
226
+ throw unauthenticated('Invalid multi-factor challenge.')
227
+ }
228
+ this.#challenges.delete(challengeId)
229
+ }
230
+
231
+ /** Returns the last issued session for conformance stubs that model stale-session defects. */
232
+ protected lastIssuedSession(): FakeIdentitySession | null {
233
+ return this.#lastSession ? this.#copySession(this.#lastSession) : null
234
+ }
235
+
236
+ #copySession(session: FakeIdentitySession): FakeIdentitySession {
237
+ return { ...session, expiresAt: new Date(session.expiresAt) }
238
+ }
239
+
240
+ #currentSession(): StoredSession | null {
241
+ const id = this.#cookies.get(sessionCookie)
242
+ if (!id) return null
243
+ const session = this.#sessions.get(id)
244
+ if (!session || !session.active || session.expiresAt.getTime() <= Date.now()) return null
245
+ return session
246
+ }
247
+
248
+ #requireSession(): StoredSession {
249
+ const session = this.#currentSession()
250
+ if (!session) throw unauthenticated('An active session is required.')
251
+ return session
252
+ }
253
+ }
@@ -0,0 +1,101 @@
1
+ import { type LogDriver, type LogRecord, type TraceLogSurface, type TraceSpan } from '@avelonjs/core'
2
+
3
+ const capabilities = { traces: true } as const
4
+
5
+ /** Observable trace state retained by the in-memory logging reference implementation. */
6
+ export interface FakeTraceRecord {
7
+ /** Trace operation name. */
8
+ readonly name: string
9
+ /** Stable trace identifier. */
10
+ readonly traceId: string
11
+ /** Stable span identifier. */
12
+ readonly spanId: string
13
+ /** Structured attributes attached to the span. */
14
+ readonly attributes: Record<string, string | number | boolean>
15
+ /** Errors recorded by the span. */
16
+ readonly errors: unknown[]
17
+ /** Whether the span has ended. */
18
+ ended: boolean
19
+ }
20
+
21
+ interface FakeLogsRaw {
22
+ readonly records: readonly LogRecord[]
23
+ readonly spans: readonly FakeTraceRecord[]
24
+ }
25
+
26
+ class FakeTraceSpan implements TraceSpan {
27
+ readonly traceId: string
28
+ readonly spanId: string
29
+ readonly #record: FakeTraceRecord
30
+
31
+ constructor(record: FakeTraceRecord) {
32
+ this.#record = record
33
+ this.traceId = record.traceId
34
+ this.spanId = record.spanId
35
+ }
36
+
37
+ attribute(name: string, value: string | number | boolean): void {
38
+ this.#record.attributes[name] = value
39
+ }
40
+
41
+ recordError(error: unknown): void {
42
+ this.#record.errors.push(error)
43
+ }
44
+
45
+ end(): void {
46
+ this.#record.ended = true
47
+ }
48
+ }
49
+
50
+ /** In-memory structured logging reference implementation with trace support. */
51
+ export class FakeLogs implements LogDriver<typeof capabilities, FakeLogsRaw>, TraceLogSurface {
52
+ /** Driver implementation name. */
53
+ readonly name = 'fake'
54
+
55
+ /** Configured log connection name. */
56
+ readonly instance: string
57
+
58
+ /** Exact optional-feature declaration. */
59
+ readonly capabilities = capabilities
60
+
61
+ /** Structured records accepted by the fake sink. */
62
+ readonly records: LogRecord[] = []
63
+
64
+ /** Trace records accepted by the fake sink. */
65
+ readonly spans: FakeTraceRecord[] = []
66
+
67
+ #nextSpan = 1
68
+
69
+ /** Creates an isolated log connection. */
70
+ constructor(instance = 'default') {
71
+ this.instance = instance
72
+ }
73
+
74
+ /** Returns the observable fake sink. */
75
+ raw(): FakeLogsRaw {
76
+ return { records: this.records, spans: this.spans }
77
+ }
78
+
79
+ /** Writes one structured record and supplies its omitted timestamp. */
80
+ async write(record: LogRecord): Promise<void> {
81
+ this.records.push({ ...record, timestamp: record.timestamp ?? new Date() })
82
+ }
83
+
84
+ /** Starts an observable trace span. */
85
+ startSpan(
86
+ name: string,
87
+ attributes: Readonly<Record<string, string | number | boolean>> = {},
88
+ ): TraceSpan {
89
+ const sequence = this.#nextSpan++
90
+ const record: FakeTraceRecord = {
91
+ name,
92
+ traceId: `trace-${sequence}`,
93
+ spanId: `span-${sequence}`,
94
+ attributes: { ...attributes },
95
+ errors: [],
96
+ ended: false,
97
+ }
98
+ this.spans.push(record)
99
+ return new FakeTraceSpan(record)
100
+ }
101
+ }
@@ -0,0 +1,140 @@
1
+ import {
2
+ Invalid,
3
+ type MailAttachment,
4
+ type MailDriver,
5
+ type MailMessage,
6
+ type MailReceipt,
7
+ type TemplateMailSurface,
8
+ } from '@avelonjs/core'
9
+
10
+ const capabilities = {
11
+ templates: true,
12
+ } as const
13
+
14
+ interface FakeMailRaw {
15
+ messages: number
16
+ templates: number
17
+ }
18
+
19
+ interface TemplateDelivery {
20
+ template: string
21
+ to: string | readonly string[]
22
+ variables: Readonly<Record<string, unknown>>
23
+ }
24
+
25
+ function addressList(value: string | readonly string[] | undefined): readonly string[] {
26
+ if (value === undefined) return []
27
+ return typeof value === 'string' ? [value] : value
28
+ }
29
+
30
+ function validAddress(address: string): boolean {
31
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address)
32
+ }
33
+
34
+ function validateAddresses(message: MailMessage): void {
35
+ const fields: readonly [string, readonly string[]][] = [
36
+ ['to', addressList(message.to)],
37
+ ['from', addressList(message.from)],
38
+ ['replyTo', addressList(message.replyTo)],
39
+ ['cc', addressList(message.cc)],
40
+ ['bcc', addressList(message.bcc)],
41
+ ]
42
+ for (const [field, addresses] of fields) {
43
+ const malformed = addresses.find((address) => !validAddress(address))
44
+ if (malformed) {
45
+ throw new Invalid(`Invalid email address in ${field}.`, {
46
+ metadata: { fields: { [field]: [`${malformed} is not a valid email address.`] } },
47
+ })
48
+ }
49
+ }
50
+ }
51
+
52
+ function copyAddresses(value: string | readonly string[]): string | readonly string[] {
53
+ return typeof value === 'string' ? value : [...value]
54
+ }
55
+
56
+ function copyAttachments(
57
+ attachments: readonly MailAttachment[] | undefined,
58
+ ): readonly MailAttachment[] | undefined {
59
+ return attachments?.map((attachment) => ({
60
+ filename: attachment.filename,
61
+ content: attachment.content.slice(),
62
+ ...(attachment.contentType === undefined ? {} : { contentType: attachment.contentType }),
63
+ }))
64
+ }
65
+
66
+ function copyMessage(message: MailMessage, defaultFrom: string): MailMessage {
67
+ const attachments = copyAttachments(message.attachments)
68
+ return {
69
+ to: copyAddresses(message.to),
70
+ from: message.from ?? defaultFrom,
71
+ ...(message.replyTo === undefined ? {} : { replyTo: copyAddresses(message.replyTo) }),
72
+ ...(message.cc === undefined ? {} : { cc: [...message.cc] }),
73
+ ...(message.bcc === undefined ? {} : { bcc: [...message.bcc] }),
74
+ subject: message.subject,
75
+ ...(message.text === undefined ? {} : { text: message.text }),
76
+ ...(message.html === undefined ? {} : { html: message.html }),
77
+ ...(attachments === undefined ? {} : { attachments }),
78
+ }
79
+ }
80
+
81
+ /** In-memory mail reference implementation used by conformance and application tests. */
82
+ export class FakeMail implements MailDriver<typeof capabilities, FakeMailRaw>, TemplateMailSurface {
83
+ /** Driver implementation name. */
84
+ readonly name = 'fake'
85
+
86
+ /** Configured mailer name. */
87
+ readonly instance: string
88
+
89
+ /** Exact optional-feature declaration. */
90
+ readonly capabilities = capabilities
91
+
92
+ readonly #defaultFrom: string
93
+ readonly #sent: MailMessage[] = []
94
+ readonly #templates: TemplateDelivery[] = []
95
+ #nextId = 1
96
+
97
+ /** Creates an isolated mailer with an instance-specific default sender. */
98
+ constructor(instance = 'default', defaultFrom = 'no-reply@example.test') {
99
+ this.instance = instance
100
+ this.#defaultFrom = defaultFrom
101
+ }
102
+
103
+ /** Returns copies of messages accepted by this fake mailer. */
104
+ get sent(): readonly MailMessage[] {
105
+ return this.#sent.map((message) => copyMessage(message, this.#defaultFrom))
106
+ }
107
+
108
+ /** Returns observable accepted-message and template counts. */
109
+ raw(): FakeMailRaw {
110
+ return { messages: this.#sent.length, templates: this.#templates.length }
111
+ }
112
+
113
+ /** Validates and records a transport-neutral message. */
114
+ async send(message: MailMessage): Promise<MailReceipt> {
115
+ const normalized = copyMessage(message, this.#defaultFrom)
116
+ validateAddresses(normalized)
117
+ this.#sent.push(normalized)
118
+ // Every to, cc, and bcc address is an envelope recipient; reply-to is intentionally excluded.
119
+ const accepted = [
120
+ ...addressList(normalized.to),
121
+ ...addressList(normalized.cc),
122
+ ...addressList(normalized.bcc),
123
+ ]
124
+ return { id: `message-${this.#nextId++}`, accepted }
125
+ }
126
+
127
+ /** Records a hosted-template delivery after validating all recipients. */
128
+ async sendTemplate(
129
+ template: string,
130
+ to: string | readonly string[],
131
+ variables: Readonly<Record<string, unknown>>,
132
+ ): Promise<MailReceipt> {
133
+ validateAddresses({ to, subject: template })
134
+ this.#templates.push({ template, to: copyAddresses(to), variables: { ...variables } })
135
+ return {
136
+ id: `message-${this.#nextId++}`,
137
+ accepted: [...addressList(to)],
138
+ }
139
+ }
140
+ }
@@ -0,0 +1,73 @@
1
+ import {
2
+ Invalid,
3
+ type NotificationCapabilities,
4
+ type NotificationChannel,
5
+ type NotificationDriver,
6
+ type NotificationMessage,
7
+ type NotificationReceipt,
8
+ } from '@avelonjs/core'
9
+
10
+ /** One notification accepted by the in-memory reference implementation. */
11
+ export interface FakeSentNotification {
12
+ /** Delivery channel used for the message. */
13
+ readonly channel: NotificationChannel
14
+ /** Message retained without transport-specific conversion. */
15
+ readonly message: NotificationMessage
16
+ }
17
+
18
+ interface FakeNotificationsRaw {
19
+ readonly sent: readonly FakeSentNotification[]
20
+ }
21
+
22
+ class FakeNotificationsBase<TChannel extends NotificationChannel> implements NotificationDriver<
23
+ NotificationCapabilities<TChannel>,
24
+ FakeNotificationsRaw
25
+ > {
26
+ readonly name = 'fake'
27
+ readonly instance: string
28
+ readonly capabilities: NotificationCapabilities<TChannel>
29
+ readonly sent: FakeSentNotification[] = []
30
+ #nextId = 1
31
+
32
+ constructor(channels: readonly TChannel[], instance = 'default') {
33
+ this.capabilities = { channels }
34
+ this.instance = instance
35
+ }
36
+
37
+ raw(): FakeNotificationsRaw {
38
+ return { sent: this.sent }
39
+ }
40
+
41
+ async send<TData>(
42
+ channel: NotificationChannel,
43
+ message: NotificationMessage<TData>,
44
+ ): Promise<NotificationReceipt> {
45
+ if (!this.capabilities.channels.some((declared) => declared === channel)) {
46
+ throw new Invalid(`Notification channel ${channel} is not configured.`, {
47
+ metadata: { fields: { channel: [`${channel} is not declared by this driver.`] } },
48
+ })
49
+ }
50
+ this.sent.push({ channel, message })
51
+ return { id: `notification-${this.#nextId++}`, channel }
52
+ }
53
+ }
54
+
55
+ const allChannels = ['push', 'sms', 'inApp'] as const
56
+
57
+ /** In-memory notification reference implementation supporting every portable channel. */
58
+ export class FakeNotifications extends FakeNotificationsBase<(typeof allChannels)[number]> {
59
+ /** Creates an isolated all-channel notification connection. */
60
+ constructor(instance = 'default') {
61
+ super(allChannels, instance)
62
+ }
63
+ }
64
+
65
+ const noChannels = [] as const
66
+
67
+ /** In-memory notification reference implementation with every channel disabled. */
68
+ export class FakeNotificationsWithoutChannels extends FakeNotificationsBase<never> {
69
+ /** Creates an isolated notification connection with no configured channels. */
70
+ constructor(instance = 'default') {
71
+ super(noChannels, instance)
72
+ }
73
+ }