@byline/host-tanstack-start 6.7.2 → 6.8.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 (43) 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/integrations/byline-admin-services.js +2 -0
  8. package/dist/integrations/passkey-admin-services.d.ts +9 -0
  9. package/dist/integrations/passkey-admin-services.js +53 -0
  10. package/dist/integrations/session-coordination.d.ts +3 -1
  11. package/dist/integrations/session-coordination.js +3 -1
  12. package/dist/integrations/session-middleware.d.ts +3 -1
  13. package/dist/integrations/session-middleware.js +87 -79
  14. package/dist/integrations/sign-in-body.d.ts +3 -0
  15. package/dist/integrations/sign-in-body.js +19 -4
  16. package/dist/integrations/sign-in-middleware.js +30 -5
  17. package/dist/server-fns/auth/passkey-admission.d.ts +24 -0
  18. package/dist/server-fns/auth/passkey-admission.js +88 -0
  19. package/dist/server-fns/auth/passkeys.d.ts +96 -0
  20. package/dist/server-fns/auth/passkeys.js +238 -0
  21. package/dist/server-fns/auth/reconcile-locale.d.ts +20 -0
  22. package/dist/server-fns/auth/reconcile-locale.js +16 -0
  23. package/dist/server-fns/auth/sign-in.js +1 -15
  24. package/package.json +11 -11
  25. package/src/admin-shell/admin-roles/container.module.css +1 -1
  26. package/src/admin-shell/admin-roles/delete.tsx +1 -1
  27. package/src/admin-shell/admin-users/container.module.css +7 -1
  28. package/src/admin-shell/admin-users/container.tsx +22 -2
  29. package/src/admin-shell/admin-users/delete.tsx +1 -1
  30. package/src/integrations/byline-admin-services.ts +3 -0
  31. package/src/integrations/passkey-admin-services.ts +85 -0
  32. package/src/integrations/session-client.test.node.ts +73 -0
  33. package/src/integrations/session-coordination.ts +3 -1
  34. package/src/integrations/session-middleware.ts +86 -72
  35. package/src/integrations/sign-in-body.test.node.ts +31 -1
  36. package/src/integrations/sign-in-body.ts +27 -3
  37. package/src/integrations/sign-in-middleware.ts +42 -9
  38. package/src/server-fns/auth/passkey-admission.test.node.ts +173 -0
  39. package/src/server-fns/auth/passkey-admission.ts +125 -0
  40. package/src/server-fns/auth/passkeys.test.node.ts +247 -0
  41. package/src/server-fns/auth/passkeys.ts +252 -0
  42. package/src/server-fns/auth/reconcile-locale.ts +40 -0
  43. package/src/server-fns/auth/sign-in.ts +1 -32
@@ -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
+ }
@@ -29,10 +29,9 @@ import {
29
29
  import { getServerConfig } from '@byline/core'
30
30
  import { passwordSignInSchema } from '@byline/core/validation'
31
31
 
32
- import { readAdminLocaleCookie } from '../../i18n/locale-cookie.js'
33
- import { bylineCore } from '../../integrations/byline-core.js'
34
32
  import { normalizeClientIp } from '../../integrations/client-ip.js'
35
33
  import { wasSignInBodyChecked } from '../../integrations/sign-in-body.js'
34
+ import { reconcileLocaleAfterSignIn } from './reconcile-locale.js'
36
35
 
37
36
  export interface SignInInput {
38
37
  email: string
@@ -137,33 +136,3 @@ export const adminSignIn = createServerFn({ method: 'POST' })
137
136
 
138
137
  return { userId: result.actor.id, sessionId: result.sessionId }
139
138
  })
140
-
141
- /**
142
- * Apply the `byline_admin_lng` cookie to `admin_users.preferred_locale`
143
- * when the two diverge after sign-in. No-op when:
144
- *
145
- * - The cookie is unset (the cascade falls through to the existing
146
- * column / Accept-Language / default anyway).
147
- * - The cookie carries a locale outside `i18n.admin.locales`
148
- * (stale value pointing at a removed locale — let the resolver
149
- * fall through cleanly).
150
- * - No admin store is configured (headless tooling paths).
151
- * - The stored value already matches the cookie.
152
- */
153
- async function reconcileLocaleAfterSignIn(adminUserId: string): Promise<void> {
154
- const cookieLocale = readAdminLocaleCookie()
155
- if (cookieLocale == null) return
156
-
157
- const core = bylineCore()
158
- const locales = core.config.i18n.admin.locales
159
- if (!locales.includes(cookieLocale)) return
160
-
161
- const adminStore = core.adminStore
162
- if (adminStore == null) return
163
-
164
- const row = await adminStore.adminUsers.getById(adminUserId)
165
- if (!row) return
166
- if (row.preferred_locale === cookieLocale) return
167
-
168
- await adminStore.adminUsers.setPreferredLocale(adminUserId, cookieLocale)
169
- }