@byline/host-tanstack-start 6.7.2 → 6.9.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.
Files changed (74) hide show
  1. package/dist/admin-shell/admin-roles/container_module.css +1 -1
  2. package/dist/admin-shell/admin-roles/delete.js +1 -1
  3. package/dist/admin-shell/admin-users/container.js +181 -159
  4. package/dist/admin-shell/admin-users/container.module.js +2 -1
  5. package/dist/admin-shell/admin-users/container_module.css +5 -1
  6. package/dist/admin-shell/admin-users/delete.js +1 -1
  7. package/dist/admin-shell/chrome/admin-app-bar.d.ts +0 -7
  8. package/dist/admin-shell/collections/api.d.ts +6 -2
  9. package/dist/admin-shell/collections/api.js +109 -3
  10. package/dist/admin-shell/collections/api.test.d.ts +8 -0
  11. package/dist/admin-shell/collections/edit.js +2 -0
  12. package/dist/admin-shell/collections/preview-link.js +17 -13
  13. package/dist/admin-shell/collections/view-menu.d.ts +8 -1
  14. package/dist/admin-shell/collections/view-menu.js +124 -107
  15. package/dist/admin-shell/collections/view-menu.module.js +1 -0
  16. package/dist/admin-shell/collections/view-menu_module.css +15 -1
  17. package/dist/admin-shell/scheduled-publications/list.js +2 -2
  18. package/dist/admin-shell/singletons/preview-link.js +17 -13
  19. package/dist/admin-shell/singletons/view-menu.js +78 -63
  20. package/dist/admin-shell/singletons/view.js +2 -0
  21. package/dist/integrations/byline-admin-services.js +2 -0
  22. package/dist/integrations/passkey-admin-services.d.ts +9 -0
  23. package/dist/integrations/passkey-admin-services.js +53 -0
  24. package/dist/integrations/session-coordination.d.ts +3 -1
  25. package/dist/integrations/session-coordination.js +3 -1
  26. package/dist/integrations/session-middleware.d.ts +3 -1
  27. package/dist/integrations/session-middleware.js +87 -79
  28. package/dist/integrations/sign-in-body.d.ts +3 -0
  29. package/dist/integrations/sign-in-body.js +19 -4
  30. package/dist/integrations/sign-in-middleware.js +30 -5
  31. package/dist/server-fns/auth/passkey-admission.d.ts +24 -0
  32. package/dist/server-fns/auth/passkey-admission.js +88 -0
  33. package/dist/server-fns/auth/passkeys.d.ts +96 -0
  34. package/dist/server-fns/auth/passkeys.js +238 -0
  35. package/dist/server-fns/auth/reconcile-locale.d.ts +20 -0
  36. package/dist/server-fns/auth/reconcile-locale.js +16 -0
  37. package/dist/server-fns/auth/sign-in.js +1 -15
  38. package/dist/server-fns/collections/get.d.ts +1 -0
  39. package/dist/server-fns/collections/get.js +4 -0
  40. package/package.json +11 -11
  41. package/src/admin-shell/admin-roles/container.module.css +1 -1
  42. package/src/admin-shell/admin-roles/delete.tsx +1 -1
  43. package/src/admin-shell/admin-users/container.module.css +7 -1
  44. package/src/admin-shell/admin-users/container.tsx +22 -2
  45. package/src/admin-shell/admin-users/delete.tsx +1 -1
  46. package/src/admin-shell/chrome/admin-app-bar.tsx +2 -2
  47. package/src/admin-shell/chrome/dashboard.test.tsx +2 -2
  48. package/src/admin-shell/collections/api.test.tsx +223 -0
  49. package/src/admin-shell/collections/api.tsx +98 -3
  50. package/src/admin-shell/collections/edit.tsx +9 -1
  51. package/src/admin-shell/collections/preview-link.tsx +13 -12
  52. package/src/admin-shell/collections/view-menu.module.css +29 -3
  53. package/src/admin-shell/collections/view-menu.tsx +114 -96
  54. package/src/admin-shell/scheduled-publications/list.tsx +3 -4
  55. package/src/admin-shell/singletons/history.test.tsx +1 -1
  56. package/src/admin-shell/singletons/preview-link.tsx +13 -12
  57. package/src/admin-shell/singletons/view-menu.tsx +65 -59
  58. package/src/admin-shell/singletons/view.test.tsx +4 -4
  59. package/src/admin-shell/singletons/view.tsx +3 -1
  60. package/src/integrations/byline-admin-services.ts +3 -0
  61. package/src/integrations/passkey-admin-services.ts +85 -0
  62. package/src/integrations/session-client.test.node.ts +73 -0
  63. package/src/integrations/session-coordination.ts +3 -1
  64. package/src/integrations/session-middleware.ts +86 -72
  65. package/src/integrations/sign-in-body.test.node.ts +31 -1
  66. package/src/integrations/sign-in-body.ts +27 -3
  67. package/src/integrations/sign-in-middleware.ts +42 -9
  68. package/src/server-fns/auth/passkey-admission.test.node.ts +173 -0
  69. package/src/server-fns/auth/passkey-admission.ts +125 -0
  70. package/src/server-fns/auth/passkeys.test.node.ts +247 -0
  71. package/src/server-fns/auth/passkeys.ts +252 -0
  72. package/src/server-fns/auth/reconcile-locale.ts +40 -0
  73. package/src/server-fns/auth/sign-in.ts +1 -32
  74. package/src/server-fns/collections/get.ts +3 -0
@@ -0,0 +1,125 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import type {
10
+ PasskeyAdmissionReason,
11
+ PasskeyOperation,
12
+ PasskeyProvider,
13
+ PasskeyRequestProtection,
14
+ PasswordSignInProtection,
15
+ } from '@byline/auth'
16
+ import { AuthError, AuthErrorCodes } from '@byline/auth'
17
+
18
+ import { normalizeClientIp } from '../../integrations/client-ip.js'
19
+
20
+ /** Host boundary shared by the passkey POST handlers. Body and CSRF guards run first. */
21
+ export async function withPasskeyAdmission<T>(input: {
22
+ provider: PasskeyProvider
23
+ purpose: PasskeyOperation
24
+ request: Request
25
+ bodyChecked: boolean
26
+ protection: PasskeyRequestProtection | undefined
27
+ /** Required only for password proof; lookup must authenticate the expected login. */
28
+ password?: {
29
+ protection: PasswordSignInProtection | undefined
30
+ resolveActorEmail(): Promise<string>
31
+ }
32
+ work(metadata: { ip: string }): Promise<T>
33
+ }): Promise<T> {
34
+ const recordDenial = (reason: PasskeyAdmissionReason) => {
35
+ try {
36
+ void Promise.resolve(input.provider.recordAdmissionDenied?.(input.purpose, reason)).catch(
37
+ () => {}
38
+ )
39
+ } catch {
40
+ /* Observability cannot change admission or strand capacity. */
41
+ }
42
+ }
43
+ const denied = (reason: PasskeyAdmissionReason, status: number, retry?: number): never => {
44
+ recordDenial(reason)
45
+ throw new Response(
46
+ status === 429
47
+ ? 'Too many authentication requests'
48
+ : 'Authentication temporarily unavailable',
49
+ {
50
+ status,
51
+ headers: {
52
+ 'Cache-Control': 'no-store',
53
+ ...(retry ? { 'Retry-After': String(retry) } : {}),
54
+ },
55
+ }
56
+ )
57
+ }
58
+ const available = async <R>(work: () => Promise<R>): Promise<R> => {
59
+ try {
60
+ return await work()
61
+ } catch {
62
+ return denied('unavailable', 503)
63
+ }
64
+ }
65
+ const protection = input.protection
66
+ if (!input.bodyChecked || !protection) return denied('unavailable', 503)
67
+ const ip = await available(async () =>
68
+ normalizeClientIp(await protection.resolveClientIp(input.request))
69
+ )
70
+ if (!ip) return denied('unavailable', 503)
71
+ const release = await available(() => protection.limiter.acquire())
72
+ if (!release) return denied('capacity', 429, 1)
73
+ try {
74
+ const admission = await available(() => protection.limiter.consume({ ip }))
75
+ if (!admission.allowed) return denied('network', 429, admission.retryAfterSeconds)
76
+ if (input.purpose === 'password-proof' && !input.password) return denied('unavailable', 503)
77
+ if (!input.password) return await input.work({ ip })
78
+ const password = input.password
79
+ const passwordProtection = password.protection
80
+ if (!passwordProtection) return denied('unavailable', 503)
81
+ // Resolve the account from the authenticated expected login, never from submitted email.
82
+ let email: string
83
+ try {
84
+ email = await password.resolveActorEmail()
85
+ } catch (error) {
86
+ recordDenial('unavailable')
87
+ throw error
88
+ }
89
+ const passwordIp = await available(async () =>
90
+ normalizeClientIp(await passwordProtection.resolveClientIp(input.request))
91
+ )
92
+ if (!passwordIp) return denied('unavailable', 503)
93
+ const releasePassword = await available(() => passwordProtection.limiter.acquire())
94
+ if (!releasePassword) return denied('capacity', 429, 1)
95
+ try {
96
+ const passwordAdmission = await available(() =>
97
+ passwordProtection.limiter.consume({ email, ip: passwordIp })
98
+ )
99
+ if (!passwordAdmission.allowed)
100
+ return denied('network', 429, passwordAdmission.retryAfterSeconds)
101
+ const record = (outcome: 'success' | 'failure') => {
102
+ try {
103
+ void Promise.resolve(
104
+ passwordProtection.limiter.recordResult?.({ email, ip: passwordIp }, outcome)
105
+ ).catch(() => {})
106
+ } catch {
107
+ /* Best effort. */
108
+ }
109
+ }
110
+ try {
111
+ const result = await input.work({ ip })
112
+ record('success')
113
+ return result
114
+ } catch (error) {
115
+ if (error instanceof AuthError && error.code === AuthErrorCodes.INVALID_CREDENTIALS)
116
+ record('failure')
117
+ throw error
118
+ }
119
+ } finally {
120
+ releasePassword()
121
+ }
122
+ } finally {
123
+ release()
124
+ }
125
+ }
@@ -0,0 +1,247 @@
1
+ import { AdminAuth, ERR_SESSION_CHANGED } from '@byline/auth'
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+
4
+ const mocks = vi.hoisted(() => ({
5
+ config: {} as any,
6
+ request: null as unknown as Request,
7
+ auth: vi.fn(),
8
+ user: vi.fn(),
9
+ setCookies: vi.fn(),
10
+ clearCookies: vi.fn(),
11
+ clearPreview: vi.fn(),
12
+ header: vi.fn(),
13
+ release: vi.fn(),
14
+ consume: vi.fn(),
15
+ acquire: vi.fn(),
16
+ passwordConsume: vi.fn(),
17
+ passwordRelease: vi.fn(),
18
+ }))
19
+ vi.mock('@tanstack/react-start', () => ({
20
+ createServerFn: () => {
21
+ const result: any = {
22
+ middleware: () => result,
23
+ validator: (validate: any) => {
24
+ result.validate = validate
25
+ return result
26
+ },
27
+ handler: (handler: any) => {
28
+ result.handler = handler
29
+ return result
30
+ },
31
+ }
32
+ return result
33
+ },
34
+ }))
35
+ vi.mock('@tanstack/react-start/server', () => ({
36
+ getRequest: () => mocks.request,
37
+ getRequestHeader: () => undefined,
38
+ setResponseHeader: mocks.header,
39
+ }))
40
+ vi.mock('@byline/core', () => ({ getServerConfig: () => mocks.config }))
41
+ vi.mock('@byline/client/server', () => ({
42
+ getAdminRequestContext: mocks.auth,
43
+ readAccessTokenCookie: () => 'server-access',
44
+ readRefreshTokenCookie: () => 'server-refresh',
45
+ setSessionCookies: mocks.setCookies,
46
+ clearSessionCookies: mocks.clearCookies,
47
+ clearPreviewCookie: mocks.clearPreview,
48
+ }))
49
+ vi.mock('../../integrations/session-middleware.js', () => ({
50
+ adminSessionMiddleware: {},
51
+ adminSessionMutationMiddleware: {},
52
+ }))
53
+ vi.mock('../../integrations/byline-core.js', () => ({
54
+ bylineCore: () => ({ adminStore: { adminUsers: { getById: mocks.user } } }),
55
+ }))
56
+ vi.mock('../../i18n/locale-cookie.js', () => ({ readAdminLocaleCookie: () => null }))
57
+
58
+ import { checkPasskeyBody } from '../../integrations/sign-in-body.js'
59
+ import * as endpoints from './passkeys.js'
60
+
61
+ const sid = '00000000-0000-4000-8000-000000000001'
62
+ const userId = '00000000-0000-4000-8000-000000000002'
63
+ const bearer = 'a'.repeat(43)
64
+ const methods = [
65
+ 'signInOptions',
66
+ 'verifySignIn',
67
+ 'passwordProof',
68
+ 'proofOptions',
69
+ 'verifyProof',
70
+ 'enrollmentOptions',
71
+ 'verifyEnrollment',
72
+ 'listCredentials',
73
+ 'renameCredential',
74
+ 'removeCredential',
75
+ ]
76
+ let provider: Record<string, ReturnType<typeof vi.fn>>
77
+ const invoke = (endpoint: unknown, data: unknown = {}) => {
78
+ const fn = endpoint as {
79
+ validate?: (data: unknown) => unknown
80
+ handler: (args: any) => Promise<any>
81
+ }
82
+ return fn.handler({ data: fn.validate ? fn.validate(data) : data })
83
+ }
84
+ async function request(password = false) {
85
+ mocks.request = new Request('https://app.test/passkeys', {
86
+ method: 'POST',
87
+ headers: { 'content-type': 'application/json' },
88
+ body: '{}',
89
+ })
90
+ await checkPasskeyBody(mocks.request, password)
91
+ }
92
+ beforeEach(async () => {
93
+ vi.resetAllMocks()
94
+ provider = Object.fromEntries(methods.map((name) => [name, vi.fn().mockResolvedValue({})]))
95
+ mocks.auth.mockResolvedValue({
96
+ actor: new AdminAuth({ id: userId, abilities: [] }),
97
+ sessionId: sid,
98
+ })
99
+ mocks.user.mockResolvedValue({ id: userId, email: 'actor@example.test' })
100
+ mocks.acquire.mockResolvedValue(mocks.release)
101
+ mocks.consume.mockResolvedValue({ allowed: true, retryAfterSeconds: 0 })
102
+ mocks.passwordConsume.mockResolvedValue({ allowed: true, retryAfterSeconds: 0 })
103
+ mocks.config = {
104
+ passkeys: {},
105
+ sessionProvider: { capabilities: { passkeys: true }, passkeys: provider },
106
+ passkeyRequests: {
107
+ resolveClientIp: () => '192.0.2.1',
108
+ limiter: { acquire: mocks.acquire, consume: mocks.consume },
109
+ },
110
+ passwordSignIn: {
111
+ resolveClientIp: () => '192.0.2.1',
112
+ limiter: { acquire: async () => mocks.passwordRelease, consume: mocks.passwordConsume },
113
+ },
114
+ }
115
+ await request()
116
+ })
117
+ describe('passkey host endpoints', () => {
118
+ it('discovers only an enabled capability without starting a ceremony', async () => {
119
+ expect(await invoke(endpoints.getPasskeyCapability)).toEqual({ enabled: true })
120
+ delete mocks.config.passkeys
121
+ expect(await invoke(endpoints.getPasskeyCapability)).toEqual({ enabled: false })
122
+ expect(provider.signInOptions).not.toHaveBeenCalled()
123
+ })
124
+ it('keeps tokens server-side and writes cookies only after verified issuance', async () => {
125
+ const result = {
126
+ actor: { id: userId },
127
+ sessionId: sid,
128
+ accessToken: 'secret',
129
+ refreshToken: 'secret',
130
+ }
131
+ provider.verifySignIn.mockResolvedValue(result)
132
+ const response = { raw: 'untrusted' }
133
+ expect(await invoke(endpoints.passkeySignIn, { ceremonyId: bearer, response })).toEqual({
134
+ userId,
135
+ sessionId: sid,
136
+ })
137
+ expect(provider.verifySignIn).toHaveBeenCalledWith(
138
+ expect.objectContaining({
139
+ previousAccessToken: 'server-access',
140
+ previousRefreshToken: 'server-refresh',
141
+ response,
142
+ })
143
+ )
144
+ expect(mocks.setCookies).toHaveBeenCalledWith(result)
145
+ expect(mocks.header).toHaveBeenCalledWith('Cache-Control', 'no-store')
146
+ provider.verifySignIn.mockRejectedValue(new Error('uncertain commit'))
147
+ await expect(invoke(endpoints.passkeySignIn, { ceremonyId: bearer, response })).rejects.toThrow(
148
+ 'uncertain commit'
149
+ )
150
+ expect(mocks.setCookies).toHaveBeenCalledTimes(1)
151
+ })
152
+ it.each([undefined, false, true, 'false'])(
153
+ 'forwards crossOrigin %s without rewriting signed response bytes',
154
+ async (crossOrigin) => {
155
+ const response = {
156
+ response: {
157
+ clientDataJSON: Buffer.from(JSON.stringify({ crossOrigin })).toString('base64url'),
158
+ },
159
+ }
160
+ await invoke(endpoints.passkeyProof, { expectedSessionId: sid, ceremonyId: bearer, response })
161
+ expect(provider.verifyProof.mock.calls[0][0].response).toBe(response)
162
+ }
163
+ )
164
+ it('applies both limiters with server-resolved email for password proof', async () => {
165
+ await request(true)
166
+ await invoke(endpoints.passkeyPasswordProof, {
167
+ expectedSessionId: sid,
168
+ password: 'legacy',
169
+ action: { purpose: 'enroll' },
170
+ })
171
+ expect(mocks.passwordConsume).toHaveBeenCalledWith({
172
+ email: 'actor@example.test',
173
+ ip: '192.0.2.1',
174
+ })
175
+ expect(mocks.consume).toHaveBeenCalledOnce()
176
+ expect(mocks.passwordRelease).toHaveBeenCalledOnce()
177
+ expect(provider.passwordProof).toHaveBeenCalledWith({
178
+ expectedSessionId: sid,
179
+ accessToken: 'server-access',
180
+ password: 'legacy',
181
+ action: { purpose: 'enroll' },
182
+ })
183
+ })
184
+ it('rejects a substituted expected login before proof or password lookup', async () => {
185
+ await request(true)
186
+ await expect(
187
+ invoke(endpoints.passkeyPasswordProof, {
188
+ expectedSessionId: userId,
189
+ password: 'legacy',
190
+ action: { purpose: 'enroll' },
191
+ })
192
+ ).rejects.toMatchObject({ code: 'ERR_SESSION_CHANGED' })
193
+ expect(mocks.user).not.toHaveBeenCalled()
194
+ expect(provider.passwordProof).not.toHaveBeenCalled()
195
+ })
196
+ it('does not let a client supply an email or access credential', () => {
197
+ expect(() =>
198
+ invoke(endpoints.passkeyPasswordProof, {
199
+ expectedSessionId: sid,
200
+ password: 'legacy',
201
+ action: { purpose: 'enroll' },
202
+ email: 'other@example.test',
203
+ })
204
+ ).toThrow()
205
+ expect(() =>
206
+ invoke(endpoints.passkeyList, { expectedSessionId: sid, accessToken: 'client-token' })
207
+ ).toThrow()
208
+ })
209
+ it.each([false, true])(
210
+ 'clears cookies only when removal signs out the actor: %s',
211
+ async (signedOut) => {
212
+ provider.removeCredential.mockResolvedValue({ signedOut })
213
+ expect(
214
+ await invoke(endpoints.passkeyRemove, { expectedSessionId: sid, grantId: bearer })
215
+ ).toEqual({ signedOut })
216
+ expect(mocks.clearCookies).toHaveBeenCalledTimes(signedOut ? 1 : 0)
217
+ expect(mocks.clearPreview).toHaveBeenCalledTimes(signedOut ? 1 : 0)
218
+ }
219
+ )
220
+ it('does not clear cookies or retry an ambiguous removal', async () => {
221
+ provider.removeCredential.mockRejectedValue(new Error('connection lost'))
222
+ await expect(
223
+ invoke(endpoints.passkeyRemove, { expectedSessionId: sid, grantId: bearer })
224
+ ).rejects.toThrow('connection lost')
225
+ expect(provider.removeCredential).toHaveBeenCalledOnce()
226
+ expect(mocks.clearCookies).not.toHaveBeenCalled()
227
+ })
228
+ it.each(['middleware', 'protection', 'capability', 'capacity'])(
229
+ 'fails closed before provider work without %s',
230
+ async (failure) => {
231
+ if (failure === 'middleware') mocks.request = new Request('https://app.test')
232
+ if (failure === 'protection') delete mocks.config.passkeyRequests
233
+ if (failure === 'capability') mocks.config.sessionProvider.capabilities.passkeys = false
234
+ if (failure === 'capacity') mocks.acquire.mockResolvedValue(null)
235
+ const error = await invoke(endpoints.passkeySignInOptions).catch((error) => error)
236
+ expect(error.status).toBe(failure === 'capability' ? 404 : failure === 'capacity' ? 429 : 503)
237
+ expect(provider.signInOptions).not.toHaveBeenCalled()
238
+ }
239
+ )
240
+ it('never retries provider session-change errors', async () => {
241
+ provider.verifyProof.mockRejectedValue(ERR_SESSION_CHANGED({ message: 'authority changed' }))
242
+ await expect(
243
+ invoke(endpoints.passkeyProof, { expectedSessionId: sid, ceremonyId: bearer, response: {} })
244
+ ).rejects.toMatchObject({ code: 'ERR_SESSION_CHANGED' })
245
+ expect(provider.verifyProof).toHaveBeenCalledOnce()
246
+ })
247
+ })
@@ -0,0 +1,252 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import { createServerFn } from '@tanstack/react-start'
10
+ import { getRequest, setResponseHeader } from '@tanstack/react-start/server'
11
+
12
+ import {
13
+ ERR_SESSION_CHANGED,
14
+ ERR_UNAUTHENTICATED,
15
+ isAdminAuth,
16
+ type PasskeyOperation,
17
+ type PasskeyProvider,
18
+ } from '@byline/auth'
19
+ import {
20
+ clearPreviewCookie,
21
+ clearSessionCookies,
22
+ getAdminRequestContext,
23
+ readAccessTokenCookie,
24
+ readRefreshTokenCookie,
25
+ setSessionCookies,
26
+ } from '@byline/client/server'
27
+ import { getServerConfig } from '@byline/core'
28
+ import { z } from 'zod'
29
+
30
+ import { bylineCore } from '../../integrations/byline-core.js'
31
+ import {
32
+ adminSessionMiddleware,
33
+ adminSessionMutationMiddleware,
34
+ } from '../../integrations/session-middleware.js'
35
+ import { wasPasskeyBodyChecked } from '../../integrations/sign-in-body.js'
36
+ import { withPasskeyAdmission } from './passkey-admission.js'
37
+ import { reconcileLocaleAfterSignIn } from './reconcile-locale.js'
38
+
39
+ const session = z.object({ expectedSessionId: z.uuid() })
40
+ const bearer = z.string().regex(/^[A-Za-z0-9_-]{43}$/)
41
+ const action = z.discriminatedUnion('purpose', [
42
+ z.object({ purpose: z.literal('enroll') }).strict(),
43
+ z
44
+ .object({ purpose: z.literal('remove'), targetUserId: z.uuid(), credentialRecordId: z.uuid() })
45
+ .strict(),
46
+ ])
47
+ const name = z.string().trim().min(1).max(80)
48
+ function validate<T>(schema: z.ZodType<T>) {
49
+ return (input: unknown): T => {
50
+ const parsed = schema.safeParse(input)
51
+ if (!parsed.success)
52
+ throw new Response('Invalid passkey input', {
53
+ status: 400,
54
+ headers: { 'Cache-Control': 'no-store' },
55
+ })
56
+ return parsed.data
57
+ }
58
+ }
59
+
60
+ function configuredProvider(): PasskeyProvider {
61
+ setResponseHeader('Cache-Control', 'no-store')
62
+ const config = getServerConfig()
63
+ const provider = config.sessionProvider
64
+ if (!config.passkeys || provider?.capabilities?.passkeys !== true || !provider.passkeys)
65
+ throw new Response('Passkeys are not enabled', {
66
+ status: 404,
67
+ headers: { 'Cache-Control': 'no-store' },
68
+ })
69
+ return provider.passkeys
70
+ }
71
+
72
+ async function currentSession(expectedSessionId: string) {
73
+ const auth = await getAdminRequestContext()
74
+ if (auth.sessionId !== expectedSessionId)
75
+ throw ERR_SESSION_CHANGED({ message: 'session changed before passkey operation' })
76
+ const accessToken = readAccessTokenCookie()
77
+ if (!accessToken) throw ERR_UNAUTHENTICATED({ message: 'access credential missing' })
78
+ return { accessToken, expectedSessionId }
79
+ }
80
+
81
+ function admitted<T>(
82
+ purpose: PasskeyOperation,
83
+ work: (provider: PasskeyProvider, metadata: { ip: string; userAgent?: string }) => Promise<T>,
84
+ expectedSessionId?: string
85
+ ) {
86
+ const provider = configuredProvider()
87
+ const config = getServerConfig()
88
+ const request = getRequest()
89
+ return withPasskeyAdmission({
90
+ provider,
91
+ purpose,
92
+ request,
93
+ bodyChecked: wasPasskeyBodyChecked(request, purpose === 'password-proof'),
94
+ protection: config.passkeyRequests,
95
+ ...(purpose === 'password-proof'
96
+ ? {
97
+ password: {
98
+ protection: config.passwordSignIn,
99
+ resolveActorEmail: async () => {
100
+ const auth = await getAdminRequestContext()
101
+ if (!auth.actor || !expectedSessionId || auth.sessionId !== expectedSessionId)
102
+ throw ERR_SESSION_CHANGED({ message: 'session changed before password proof' })
103
+ const user = await bylineCore().adminStore?.adminUsers.getById(auth.actor.id)
104
+ if (!user) throw ERR_UNAUTHENTICATED({ message: 'account unavailable' })
105
+ return user.email
106
+ },
107
+ },
108
+ }
109
+ : {}),
110
+ work: ({ ip }) =>
111
+ work(provider, { ip, userAgent: request.headers.get('user-agent')?.slice(0, 512) }),
112
+ })
113
+ }
114
+
115
+ /** Cosmetic capability discovery creates no ceremony and reads no account data. */
116
+ export const getPasskeyCapability = createServerFn({ method: 'GET' }).handler(async () => {
117
+ setResponseHeader('Cache-Control', 'no-store')
118
+ const config = getServerConfig()
119
+ return {
120
+ enabled: !!(
121
+ config.passkeys &&
122
+ config.sessionProvider?.capabilities?.passkeys &&
123
+ config.sessionProvider.passkeys
124
+ ),
125
+ }
126
+ })
127
+
128
+ export const passkeySignInOptions = createServerFn({ method: 'POST' })
129
+ .validator(validate(z.object({}).strict()))
130
+ .handler(async () =>
131
+ admitted('sign-in-options', (provider, metadata) => provider.signInOptions(metadata))
132
+ )
133
+
134
+ export const passkeySignIn = createServerFn({ method: 'POST' })
135
+ .validator(validate(z.object({ ceremonyId: bearer, response: z.unknown() }).strict()))
136
+ .handler(async ({ data }) =>
137
+ admitted('sign-in', async (provider, metadata) => {
138
+ const result = await provider.verifySignIn({
139
+ ...data,
140
+ ...metadata,
141
+ previousAccessToken: readAccessTokenCookie(),
142
+ previousRefreshToken: readRefreshTokenCookie(),
143
+ })
144
+ setSessionCookies(result)
145
+ try {
146
+ await reconcileLocaleAfterSignIn(result.actor.id)
147
+ } catch {
148
+ /* Locale is best effort. */
149
+ }
150
+ return { userId: result.actor.id, sessionId: result.sessionId }
151
+ })
152
+ )
153
+
154
+ export const passkeyPasswordProof = createServerFn({ method: 'POST' })
155
+ .middleware([adminSessionMiddleware])
156
+ .validator(validate(session.extend({ password: z.string().min(1).max(128), action }).strict()))
157
+ .handler(async ({ data }) =>
158
+ admitted(
159
+ 'password-proof',
160
+ async (provider) =>
161
+ provider.passwordProof({ ...data, ...(await currentSession(data.expectedSessionId)) }),
162
+ data.expectedSessionId
163
+ )
164
+ )
165
+
166
+ export const passkeyProofOptions = createServerFn({ method: 'POST' })
167
+ .middleware([adminSessionMiddleware])
168
+ .validator(validate(session.extend({ action }).strict()))
169
+ .handler(async ({ data }) =>
170
+ admitted('proof-options', async (provider) =>
171
+ provider.proofOptions({ ...data, ...(await currentSession(data.expectedSessionId)) })
172
+ )
173
+ )
174
+
175
+ export const passkeyProof = createServerFn({ method: 'POST' })
176
+ .middleware([adminSessionMiddleware])
177
+ .validator(validate(session.extend({ ceremonyId: bearer, response: z.unknown() }).strict()))
178
+ .handler(async ({ data }) =>
179
+ admitted('proof', async (provider) =>
180
+ provider.verifyProof({ ...data, ...(await currentSession(data.expectedSessionId)) })
181
+ )
182
+ )
183
+
184
+ export const passkeyEnrollmentOptions = createServerFn({ method: 'POST' })
185
+ .middleware([adminSessionMiddleware])
186
+ .validator(validate(session.extend({ grantId: bearer }).strict()))
187
+ .handler(async ({ data }) =>
188
+ admitted('enrollment-options', async (provider) =>
189
+ provider.enrollmentOptions({ ...data, ...(await currentSession(data.expectedSessionId)) })
190
+ )
191
+ )
192
+
193
+ export const passkeyEnrollment = createServerFn({ method: 'POST' })
194
+ .middleware([adminSessionMiddleware])
195
+ .validator(validate(session.extend({ ceremonyId: bearer, response: z.unknown(), name }).strict()))
196
+ .handler(async ({ data }) =>
197
+ admitted('enrollment', async (provider) =>
198
+ provider.verifyEnrollment({ ...data, ...(await currentSession(data.expectedSessionId)) })
199
+ )
200
+ )
201
+
202
+ export const passkeyList = createServerFn({ method: 'POST' })
203
+ .middleware([adminSessionMiddleware])
204
+ .validator(validate(session.extend({ targetUserId: z.uuid().optional() }).strict()))
205
+ .handler(async ({ data }) =>
206
+ admitted('list', async (provider) =>
207
+ provider.listCredentials({ ...data, ...(await currentSession(data.expectedSessionId)) })
208
+ )
209
+ )
210
+
211
+ export const passkeyRename = createServerFn({ method: 'POST' })
212
+ .middleware([adminSessionMiddleware])
213
+ .validator(validate(session.extend({ credentialRecordId: z.uuid(), name }).strict()))
214
+ .handler(async ({ data }) =>
215
+ admitted('rename', async (provider) =>
216
+ provider.renameCredential({ ...data, ...(await currentSession(data.expectedSessionId)) })
217
+ )
218
+ )
219
+
220
+ export const passkeyRemove = createServerFn({ method: 'POST' })
221
+ .middleware([adminSessionMutationMiddleware])
222
+ .validator(validate(session.extend({ grantId: bearer }).strict()))
223
+ .handler(async ({ data }) =>
224
+ admitted('remove', async (provider) => {
225
+ const result = await provider.removeCredential({
226
+ ...data,
227
+ ...(await currentSession(data.expectedSessionId)),
228
+ })
229
+ if (result.signedOut) {
230
+ clearSessionCookies()
231
+ clearPreviewCookie()
232
+ }
233
+ return result
234
+ })
235
+ )
236
+
237
+ /** Cosmetic permissions for the manager; every mutation still rechecks authority. */
238
+ export const passkeyManagementContext = createServerFn({ method: 'POST' })
239
+ .middleware([adminSessionMiddleware])
240
+ .validator(validate(session.strict()))
241
+ .handler(async ({ data }) =>
242
+ admitted('list', async () => {
243
+ await currentSession(data.expectedSessionId)
244
+ const { actor } = await getAdminRequestContext()
245
+ if (!isAdminAuth(actor)) throw ERR_UNAUTHENTICATED({ message: 'admin login required' })
246
+ return {
247
+ id: actor.id,
248
+ isSuperAdmin: actor.isSuperAdmin,
249
+ canManagePasskeys: actor.hasAbility('admin.users.managePasskeys'),
250
+ }
251
+ })
252
+ )
@@ -0,0 +1,40 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import { readAdminLocaleCookie } from '../../i18n/locale-cookie.js'
10
+ import { bylineCore } from '../../integrations/byline-core.js'
11
+
12
+ /**
13
+ * Apply the `byline_admin_lng` cookie to `admin_users.preferred_locale`
14
+ * when the two diverge after sign-in. No-op when:
15
+ *
16
+ * - The cookie is unset (the cascade falls through to the existing
17
+ * column / Accept-Language / default anyway).
18
+ * - The cookie carries a locale outside `i18n.admin.locales`
19
+ * (stale value pointing at a removed locale — let the resolver
20
+ * fall through cleanly).
21
+ * - No admin store is configured (headless tooling paths).
22
+ * - The stored value already matches the cookie.
23
+ */
24
+ export async function reconcileLocaleAfterSignIn(adminUserId: string): Promise<void> {
25
+ const cookieLocale = readAdminLocaleCookie()
26
+ if (cookieLocale == null) return
27
+
28
+ const core = bylineCore()
29
+ const locales = core.config.i18n.admin.locales
30
+ if (!locales.includes(cookieLocale)) return
31
+
32
+ const adminStore = core.adminStore
33
+ if (adminStore == null) return
34
+
35
+ const row = await adminStore.adminUsers.getById(adminUserId)
36
+ if (!row) return
37
+ if (row.preferred_locale === cookieLocale) return
38
+
39
+ await adminStore.adminUsers.setPreferredLocale(adminUserId, cookieLocale)
40
+ }