@byline/admin 6.7.1 → 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 (163) hide show
  1. package/dist/modules/admin-account/components/change-password.js +1 -1
  2. package/dist/modules/admin-account/components/container.js +18 -3
  3. package/dist/modules/admin-account/components/container.module.js +2 -0
  4. package/dist/modules/admin-account/components/container_module.css +7 -0
  5. package/dist/modules/admin-account/components/preferences.js +1 -1
  6. package/dist/modules/admin-account/components/update.js +1 -1
  7. package/dist/modules/admin-roles/components/create.js +1 -1
  8. package/dist/modules/admin-roles/components/permissions.js +1 -1
  9. package/dist/modules/admin-roles/components/update.js +1 -1
  10. package/dist/modules/admin-users/abilities.d.ts +1 -0
  11. package/dist/modules/admin-users/abilities.js +8 -1
  12. package/dist/modules/admin-users/components/create.js +1 -1
  13. package/dist/modules/admin-users/components/roles.js +1 -1
  14. package/dist/modules/admin-users/components/set-password.js +1 -1
  15. package/dist/modules/admin-users/components/update.js +1 -1
  16. package/dist/modules/auth/auth-maintenance-repository.d.ts +31 -0
  17. package/dist/modules/auth/auth-maintenance-repository.js +1 -0
  18. package/dist/modules/auth/auth-maintenance.d.ts +11 -0
  19. package/dist/modules/auth/auth-maintenance.js +39 -0
  20. package/dist/modules/auth/auth-maintenance.test.node.d.ts +1 -0
  21. package/dist/modules/auth/components/passkey-browser.d.ts +14 -0
  22. package/dist/modules/auth/components/passkey-browser.js +118 -0
  23. package/dist/modules/auth/components/passkey-browser.test.node.d.ts +1 -0
  24. package/dist/modules/auth/components/passkey-manager.d.ts +26 -0
  25. package/dist/modules/auth/components/passkey-manager.js +406 -0
  26. package/dist/modules/auth/components/passkey-manager.module.js +22 -0
  27. package/dist/modules/auth/components/passkey-manager_module.css +104 -0
  28. package/dist/modules/auth/components/sign-in-form.js +62 -3
  29. package/dist/modules/auth/components/sign-in-form.module.js +5 -1
  30. package/dist/modules/auth/components/sign-in-form_module.css +40 -0
  31. package/dist/modules/auth/index.d.ts +5 -0
  32. package/dist/modules/auth/index.js +3 -0
  33. package/dist/modules/auth/jwt-session-provider.d.ts +12 -4
  34. package/dist/modules/auth/jwt-session-provider.js +135 -94
  35. package/dist/modules/auth/native-passkey-provider.d.ts +72 -0
  36. package/dist/modules/auth/native-passkey-provider.js +618 -0
  37. package/dist/modules/auth/passkey-rate-limiter.d.ts +14 -0
  38. package/dist/modules/auth/passkey-rate-limiter.js +60 -0
  39. package/dist/modules/auth/passkey-rate-limiter.test.node.d.ts +8 -0
  40. package/dist/modules/auth/passkey-storage-validation.d.ts +14 -0
  41. package/dist/modules/auth/passkey-storage-validation.js +59 -0
  42. package/dist/modules/auth/passkeys-repository.d.ts +96 -0
  43. package/dist/modules/auth/passkeys-repository.js +1 -0
  44. package/dist/modules/auth/refresh-tokens-repository.d.ts +1 -1
  45. package/dist/modules/auth/sign-in-rate-limiter.d.ts +2 -0
  46. package/dist/modules/auth/sign-in-rate-limiter.js +1 -1
  47. package/dist/services/admin-services-context.d.ts +1 -1
  48. package/dist/services/admin-services-types.d.ts +60 -0
  49. package/dist/store.d.ts +4 -0
  50. package/package.json +31 -24
  51. package/src/modules/admin-account/components/change-password.tsx +1 -1
  52. package/src/modules/admin-account/components/container.module.css +9 -0
  53. package/src/modules/admin-account/components/container.tsx +12 -3
  54. package/src/modules/admin-account/components/preferences.tsx +1 -1
  55. package/src/modules/admin-account/components/update.tsx +1 -1
  56. package/src/modules/admin-roles/components/create.tsx +1 -1
  57. package/src/modules/admin-roles/components/permissions.tsx +1 -1
  58. package/src/modules/admin-roles/components/update.tsx +1 -1
  59. package/src/modules/admin-users/abilities.ts +7 -0
  60. package/src/modules/admin-users/components/create.tsx +1 -1
  61. package/src/modules/admin-users/components/roles.tsx +1 -1
  62. package/src/modules/admin-users/components/set-password.tsx +1 -1
  63. package/src/modules/admin-users/components/update.tsx +1 -1
  64. package/src/modules/auth/auth-maintenance-repository.ts +26 -0
  65. package/src/modules/auth/auth-maintenance.test.node.ts +110 -0
  66. package/src/modules/auth/auth-maintenance.ts +62 -0
  67. package/src/modules/auth/components/passkey-browser.test.node.ts +188 -0
  68. package/src/modules/auth/components/passkey-browser.ts +139 -0
  69. package/src/modules/auth/components/passkey-manager.module.css +100 -0
  70. package/src/modules/auth/components/passkey-manager.test.tsx +189 -0
  71. package/src/modules/auth/components/passkey-manager.tsx +386 -0
  72. package/src/modules/auth/components/sign-in-form.module.css +37 -0
  73. package/src/modules/auth/components/sign-in-form.test.tsx +92 -0
  74. package/src/modules/auth/components/sign-in-form.tsx +65 -4
  75. package/src/modules/auth/index.ts +15 -0
  76. package/src/modules/auth/jwt-session-provider.ts +116 -44
  77. package/src/modules/auth/native-passkey-provider.ts +710 -0
  78. package/src/modules/auth/passkey-rate-limiter.test.node.ts +83 -0
  79. package/src/modules/auth/passkey-rate-limiter.ts +76 -0
  80. package/src/modules/auth/passkey-storage-validation.ts +108 -0
  81. package/src/modules/auth/passkeys-repository.ts +97 -0
  82. package/src/modules/auth/refresh-tokens-repository.ts +1 -1
  83. package/src/modules/auth/sign-in-rate-limiter.ts +1 -1
  84. package/src/services/admin-services-context.tsx +2 -0
  85. package/src/services/admin-services-types.ts +60 -0
  86. package/src/store.ts +4 -0
  87. package/dist/exports-parity.test.node.js +0 -37
  88. package/dist/fields/array/array-field.jsx +0 -209
  89. package/dist/fields/blocks/blocks-field.jsx +0 -248
  90. package/dist/fields/checkbox/checkbox-field.jsx +0 -24
  91. package/dist/fields/code/code-editor.jsx +0 -211
  92. package/dist/fields/code/code-field.jsx +0 -109
  93. package/dist/fields/column-formatter.jsx +0 -25
  94. package/dist/fields/date-time-formatter.jsx +0 -18
  95. package/dist/fields/datetime/datetime-field.jsx +0 -22
  96. package/dist/fields/draggable-context-menu.jsx +0 -50
  97. package/dist/fields/field-admin.test.node.js +0 -40
  98. package/dist/fields/field-renderer.jsx +0 -136
  99. package/dist/fields/field-services-context.jsx +0 -17
  100. package/dist/fields/file/file-field.jsx +0 -176
  101. package/dist/fields/file/file-upload-field.jsx +0 -89
  102. package/dist/fields/group/group-field.jsx +0 -46
  103. package/dist/fields/image/image-field.jsx +0 -183
  104. package/dist/fields/image/image-upload-field.jsx +0 -157
  105. package/dist/fields/local-date-time.jsx +0 -69
  106. package/dist/fields/locale-badge.jsx +0 -19
  107. package/dist/fields/numerical/numerical-field.jsx +0 -93
  108. package/dist/fields/relation/relation-column-formatter.jsx +0 -74
  109. package/dist/fields/relation/relation-display.jsx +0 -118
  110. package/dist/fields/relation/relation-field.jsx +0 -108
  111. package/dist/fields/relation/relation-many-field.jsx +0 -142
  112. package/dist/fields/relation/relation-picker.jsx +0 -240
  113. package/dist/fields/relation/relation-summary.jsx +0 -45
  114. package/dist/fields/select/select-field.jsx +0 -27
  115. package/dist/fields/sortable-item.jsx +0 -54
  116. package/dist/fields/text/text-field.jsx +0 -79
  117. package/dist/fields/text-area/text-area-field.jsx +0 -79
  118. package/dist/forms/available-locales-reconcile.test.node.js +0 -43
  119. package/dist/forms/available-locales-widget.jsx +0 -63
  120. package/dist/forms/document-actions.jsx +0 -486
  121. package/dist/forms/form-context.jsx +0 -598
  122. package/dist/forms/form-modals.jsx +0 -128
  123. package/dist/forms/form-renderer.jsx +0 -532
  124. package/dist/forms/form-status-display.jsx +0 -69
  125. package/dist/forms/navigation-guard.jsx +0 -68
  126. package/dist/forms/nested-path.test.node.js +0 -115
  127. package/dist/forms/path-widget.jsx +0 -125
  128. package/dist/forms/pending-uploads.test.node.js +0 -19
  129. package/dist/forms/repeating-items.test.node.js +0 -29
  130. package/dist/forms/scheduled-publication-control.jsx +0 -389
  131. package/dist/forms/scheduled-publication-state.test.node.js +0 -121
  132. package/dist/forms/scheduled-publication-time.test.node.js +0 -70
  133. package/dist/forms/status-transitions.test.node.js +0 -76
  134. package/dist/forms/tree-placement-widget.jsx +0 -160
  135. package/dist/forms/upload-executor.test.node.js +0 -368
  136. package/dist/forms/use-form-layout.test.node.js +0 -69
  137. package/dist/modules/admin-account/components/change-password.jsx +0 -169
  138. package/dist/modules/admin-account/components/container.jsx +0 -164
  139. package/dist/modules/admin-account/components/preferences.jsx +0 -148
  140. package/dist/modules/admin-account/components/theme-switch.jsx +0 -52
  141. package/dist/modules/admin-account/components/update.jsx +0 -186
  142. package/dist/modules/admin-permissions/components/inspector.jsx +0 -200
  143. package/dist/modules/admin-preferences/schemas.test.node.js +0 -48
  144. package/dist/modules/admin-roles/components/create.jsx +0 -155
  145. package/dist/modules/admin-roles/components/permissions.jsx +0 -239
  146. package/dist/modules/admin-roles/components/update.jsx +0 -148
  147. package/dist/modules/admin-users/components/create.jsx +0 -206
  148. package/dist/modules/admin-users/components/roles.jsx +0 -126
  149. package/dist/modules/admin-users/components/set-password.jsx +0 -133
  150. package/dist/modules/admin-users/components/update.jsx +0 -215
  151. package/dist/modules/analytics/components/dashboard.jsx +0 -208
  152. package/dist/modules/analytics/components/dashboard.test.node.js +0 -162
  153. package/dist/modules/analytics/components/timeseries.jsx +0 -193
  154. package/dist/modules/auth/components/sign-in-form.jsx +0 -89
  155. package/dist/modules/auth/safe-redirect.test.node.js +0 -40
  156. package/dist/modules/auth/sign-in-form-props.test.node.js +0 -7
  157. package/dist/presentation/group.jsx +0 -26
  158. package/dist/presentation/row.jsx +0 -23
  159. package/dist/presentation/tabs.jsx +0 -39
  160. package/dist/services/admin-services-context.jsx +0 -17
  161. package/dist/widgets/diff-viewer/diff-modal.jsx +0 -110
  162. package/dist/widgets/source-locale-badge/source-locale-badge.jsx +0 -28
  163. package/dist/widgets/status-badge/status-badge.jsx +0 -46
@@ -0,0 +1,83 @@
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 { afterEach, describe, expect, it, vi } from 'vitest'
10
+
11
+ import { createPasskeyRequestLimiter } from './passkey-rate-limiter.js'
12
+ import { createPasswordSignInLimiter, type SignInRateLimitStore } from './sign-in-rate-limiter.js'
13
+
14
+ const secret = 'passkey-counter-secret-at-least-thirty-two-bytes'
15
+ function fixture() {
16
+ const counts = new Map<string, number>()
17
+ const store: SignInRateLimitStore = {
18
+ consume: vi.fn(async (key, limit) => {
19
+ const n = (counts.get(key) ?? 0) + 1
20
+ counts.set(key, n)
21
+ return n <= limit
22
+ }),
23
+ purgeExpired: vi.fn(async () => 0),
24
+ }
25
+ return { store, limiter: createPasskeyRequestLimiter(store, secret, () => 0) }
26
+ }
27
+ afterEach(() => vi.useRealTimers())
28
+ describe('passkey request limiter', () => {
29
+ it('uses four slots without queueing and retains each slot for 100ms including quick denials', async () => {
30
+ vi.useFakeTimers()
31
+ const { limiter } = fixture()
32
+ const leases = await Promise.all(Array.from({ length: 4 }, () => limiter.acquire()))
33
+ expect(leases.every(Boolean)).toBe(true)
34
+ expect(await limiter.acquire()).toBeNull()
35
+ leases[0]?.()
36
+ leases[0]?.()
37
+ await vi.advanceTimersByTimeAsync(99)
38
+ expect(await limiter.acquire()).toBeNull()
39
+ await vi.advanceTimersByTimeAsync(1)
40
+ expect(await limiter.acquire()).toBeTypeOf('function')
41
+ expect(await limiter.acquire()).toBeNull()
42
+ })
43
+ it('shares a 60/minute network bucket across instances with IPv4-mapped equivalence', async () => {
44
+ const { store, limiter } = fixture()
45
+ const another = createPasskeyRequestLimiter(store, secret, () => 0)
46
+ for (let i = 0; i < 60; i++)
47
+ expect((await limiter.consume({ ip: '192.0.2.1' })).allowed).toBe(true)
48
+ expect(await another.consume({ ip: '::ffff:192.0.2.1' })).toEqual({
49
+ allowed: false,
50
+ retryAfterSeconds: 60,
51
+ })
52
+ expect((await another.consume({ ip: '192.0.2.2' })).allowed).toBe(true)
53
+ const key = vi.mocked(store.consume).mock.calls[0]?.[0]
54
+ expect(key).toMatch(/^[a-f0-9]{64}$/)
55
+ expect(key).not.toContain('192.0.2.1')
56
+ })
57
+ it('separates the passkey counter domain and capacity from password sign-in', async () => {
58
+ const { store, limiter } = fixture()
59
+ for (let i = 0; i < 4; i++) await limiter.acquire()
60
+ const password = createPasswordSignInLimiter(store, secret, undefined, () => 0, {
61
+ onEvent: () => {},
62
+ })
63
+ const releasePassword = await password.acquire()
64
+ expect(releasePassword).toBeTypeOf('function')
65
+ await limiter.consume({ ip: '192.0.2.1' })
66
+ const passkeyKey = vi.mocked(store.consume).mock.calls.at(-1)?.[0]
67
+ await password.consume({ email: 'admin@example.test', ip: '192.0.2.1' })
68
+ expect(
69
+ vi
70
+ .mocked(store.consume)
71
+ .mock.calls.slice(1)
72
+ .every(([key]) => key !== passkeyKey)
73
+ ).toBe(true)
74
+ releasePassword?.()
75
+ password.dispose()
76
+ })
77
+ it('fails closed on storage errors and rejects invalid addresses', async () => {
78
+ const { store, limiter } = fixture()
79
+ vi.mocked(store.consume).mockRejectedValue(new Error('offline'))
80
+ await expect(limiter.consume({ ip: '192.0.2.1' })).rejects.toThrow('offline')
81
+ await expect(limiter.consume({ ip: 'untrusted' })).rejects.toThrow('Invalid client address')
82
+ })
83
+ })
@@ -0,0 +1,76 @@
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 { createHmac } from 'node:crypto'
10
+
11
+ import type { PasskeyRequestLimiter } from '@byline/auth'
12
+ import type { RecurringTaskDefinition } from '@byline/core'
13
+
14
+ import { type SignInRateLimitStore, signInNetwork } from './sign-in-rate-limiter.js'
15
+
16
+ /** Separate WebAuthn capacity and key domain; counter storage and cleanup are shared. */
17
+ export function createPasskeyRequestLimiter(
18
+ store: SignInRateLimitStore,
19
+ secret: string | Uint8Array,
20
+ now: () => number = Date.now
21
+ ): PasskeyRequestLimiter & { cleanupTask: RecurringTaskDefinition } {
22
+ const bytes = typeof secret === 'string' ? new TextEncoder().encode(secret) : secret
23
+ if (bytes.byteLength < 32) throw new Error('Passkey HMAC secret must contain at least 32 bytes')
24
+ const key = createHmac('sha256', bytes).update('byline:passkey-requests:v1').digest()
25
+ let active = 0
26
+ const cleanupTask: RecurringTaskDefinition = {
27
+ // Both limiters use the same counter table; register this task only once.
28
+ name: 'auth.sign-in-counters.cleanup',
29
+ intervalMs: 60_000,
30
+ leaseMs: 60_000,
31
+ async run(context) {
32
+ const started = performance.now()
33
+ for (let n = 0; n < 32 && performance.now() - started < 30_000; n++) {
34
+ context.signal.throwIfAborted()
35
+ await context.heartbeat()
36
+ if ((await store.purgeExpired(new Date(now() - 300_000))) < 100)
37
+ return { workRemaining: false }
38
+ }
39
+ return { workRemaining: true }
40
+ },
41
+ }
42
+ return {
43
+ requiredCleanupTask: cleanupTask.name,
44
+ cleanupTask,
45
+ async acquire() {
46
+ if (active >= 4) return null
47
+ active++
48
+ const started = performance.now()
49
+ let released = false
50
+ return () => {
51
+ if (released) return
52
+ released = true
53
+ setTimeout(
54
+ () => {
55
+ active--
56
+ },
57
+ Math.max(0, 100 - (performance.now() - started))
58
+ ).unref()
59
+ }
60
+ },
61
+ async consume({ ip }) {
62
+ const network = signInNetwork(ip)
63
+ const time = now()
64
+ const end = (Math.floor(time / 60_000) + 1) * 60_000
65
+ const digest = createHmac('sha256', key)
66
+ .update(JSON.stringify(['network', network, end]))
67
+ .digest('hex')
68
+ await store.purgeExpired(new Date(time - 300_000))
69
+ const allowed = await store.consume(digest, 60, new Date(end))
70
+ return {
71
+ allowed,
72
+ retryAfterSeconds: allowed ? 0 : Math.max(1, Math.ceil((end - time) / 1000)),
73
+ }
74
+ },
75
+ }
76
+ }
@@ -0,0 +1,108 @@
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 { PasskeyAttemptRow, PasskeyClaimInput } from './passkeys-repository.js'
10
+
11
+ /** Canonical encoding makes string identity exactly equivalent to WebAuthn byte identity. */
12
+ export function assertPasskeyBytes(value: string, maxBytes: number, exactBytes?: number): void {
13
+ if (typeof value !== 'string' || !/^[A-Za-z0-9_-]+$/.test(value)) {
14
+ throw new Error('Invalid passkey byte encoding')
15
+ }
16
+ const bytes = Buffer.from(value, 'base64url')
17
+ if (
18
+ bytes.length > maxBytes ||
19
+ (exactBytes !== undefined && bytes.length !== exactBytes) ||
20
+ bytes.toString('base64url') !== value
21
+ )
22
+ throw new Error('Invalid passkey byte encoding')
23
+ }
24
+
25
+ export function assertPasskeyTokenHash(value: string): void {
26
+ if (!/^[a-f0-9]{64}$/.test(value)) throw new Error('Invalid passkey token hash')
27
+ }
28
+
29
+ export function assertPasskeyAttempt(input: Omit<PasskeyAttemptRow, 'consumed_at'>): void {
30
+ assertPasskeyTokenHash(input.token_hash)
31
+ const lifetime = input.expires_at.getTime() - input.created_at.getTime()
32
+ if (!Number.isFinite(lifetime) || lifetime <= 0 || lifetime > 300_000) {
33
+ throw new Error('Invalid passkey attempt lifetime')
34
+ }
35
+ if (!['sign-in', 'fresh-authentication', 'enrollment', 'grant'].includes(input.purpose)) {
36
+ throw new Error('Invalid passkey attempt purpose')
37
+ }
38
+ if (input.purpose === 'grant') {
39
+ if (input.challenge !== null) throw new Error('Grants cannot contain challenges')
40
+ } else {
41
+ assertPasskeyBytes(input.challenge ?? '', 32, 32)
42
+ }
43
+ if (input.purpose === 'sign-in') {
44
+ if (
45
+ [
46
+ input.actor_user_id,
47
+ input.actor_login_id,
48
+ input.actor_generation,
49
+ input.action,
50
+ input.target_user_id,
51
+ input.credential_record_id,
52
+ input.proof_credential_id,
53
+ ].some((value) => value !== null)
54
+ ) {
55
+ throw new Error('Sign-in attempts cannot carry management authority')
56
+ }
57
+ } else {
58
+ if (
59
+ !input.actor_user_id ||
60
+ !input.actor_login_id ||
61
+ !Number.isSafeInteger(input.actor_generation) ||
62
+ (input.actor_generation ?? -1) < 0
63
+ ) {
64
+ throw new Error('Management attempts require an acting login and generation')
65
+ }
66
+ if (input.action === 'enroll') {
67
+ if (input.target_user_id !== input.actor_user_id || input.credential_record_id !== null) {
68
+ throw new Error('Enrollment must target the actor')
69
+ }
70
+ } else if (input.action === 'remove') {
71
+ if (!input.target_user_id || !input.credential_record_id || input.purpose === 'enrollment') {
72
+ throw new Error('Removal must bind its target account and credential')
73
+ }
74
+ } else throw new Error('Management attempts require an action')
75
+ }
76
+ }
77
+
78
+ /** Runtime callers must obey the same purpose/action boundary as typed callers. */
79
+ export function assertPasskeyClaim(input: PasskeyClaimInput): void {
80
+ assertPasskeyTokenHash(input.tokenHash)
81
+ if (input.purpose === 'sign-in') {
82
+ if (
83
+ input.action !== undefined ||
84
+ [
85
+ input.binding.actor_user_id,
86
+ input.binding.actor_login_id,
87
+ input.binding.actor_generation,
88
+ ].some((value) => value !== null)
89
+ ) {
90
+ throw new Error('Sign-in claims require anonymous binding and no action')
91
+ }
92
+ } else {
93
+ if (!['fresh-authentication', 'enrollment', 'grant'].includes(input.purpose)) {
94
+ throw new Error('Invalid passkey claim purpose')
95
+ }
96
+ if (input.action !== 'enroll' && input.action !== 'remove') {
97
+ throw new Error('Authenticated claims require an action')
98
+ }
99
+ if (
100
+ !input.binding.actor_user_id ||
101
+ !input.binding.actor_login_id ||
102
+ !Number.isSafeInteger(input.binding.actor_generation) ||
103
+ input.binding.actor_generation < 0
104
+ ) {
105
+ throw new Error('Authenticated claims require an acting login binding')
106
+ }
107
+ }
108
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * This Source Code Form 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 https://mozilla.org/MPL/2.0/.
5
+ * Copyright (c) Infonomic Company Limited
6
+ */
7
+
8
+ /** Internal persistence records. Never return these through account/editor DTOs. */
9
+ export interface PasskeyCredentialRow {
10
+ id: string
11
+ /** Null after account deletion; the credential identity remains reserved. */
12
+ admin_user_id: string | null
13
+ /** Canonical unpadded base64url; byte-exact uniqueness includes revoked records. */
14
+ credential_id: string
15
+ public_key: string
16
+ counter: number
17
+ transports: string[]
18
+ backup_eligible: boolean
19
+ backed_up: boolean
20
+ name: string
21
+ created_at: Date
22
+ last_used_at: Date | null
23
+ revoked_at: Date | null
24
+ }
25
+
26
+ export interface PasskeyAttemptRow {
27
+ /** SHA-256 hex of the independent bearer identifier; never the identifier itself. */
28
+ token_hash: string
29
+ purpose: 'sign-in' | 'fresh-authentication' | 'enrollment' | 'grant'
30
+ challenge: string | null
31
+ actor_user_id: string | null
32
+ actor_login_id: string | null
33
+ actor_generation: number | null
34
+ action: 'enroll' | 'remove' | null
35
+ target_user_id: string | null
36
+ credential_record_id: string | null
37
+ /** Credential used for fresh proof, if any; revalidated at grant use. */
38
+ proof_credential_id: string | null
39
+ created_at: Date
40
+ expires_at: Date
41
+ consumed_at: Date | null
42
+ }
43
+
44
+ export type PasskeyClaimInput = { tokenHash: string; now: Date } & (
45
+ | {
46
+ purpose: 'sign-in'
47
+ binding: { actor_user_id: null; actor_login_id: null; actor_generation: null }
48
+ action?: never
49
+ }
50
+ | {
51
+ purpose: 'fresh-authentication' | 'enrollment' | 'grant'
52
+ binding: { actor_user_id: string; actor_login_id: string; actor_generation: number }
53
+ action: 'enroll' | 'remove'
54
+ }
55
+ )
56
+
57
+ export interface PasskeysRepository {
58
+ /** Target lock required; generation and native-login revocation commit together. */
59
+ invalidateAccountLogins(adminUserId: string, at: Date): Promise<void>
60
+ /** Success requires both account locks; denial/failure records may be written separately. */
61
+ appendRemovalAudit(input: {
62
+ actorId: string
63
+ targetId: string
64
+ credentialId: string
65
+ outcome: 'success' | 'denied' | 'failed'
66
+ }): Promise<void>
67
+ getUserHandle(adminUserId: string): Promise<string | null>
68
+ /** Requires the owning account lock. Allocates once; never resets on credential removal. */
69
+ ensureUserHandle(adminUserId: string, candidate: string): Promise<string>
70
+ /** Includes tombstones: authentication must reject revoked or ownerless records. */
71
+ findCredential(id: string): Promise<PasskeyCredentialRow | null>
72
+ /** Includes tombstones: authentication must reject revoked or ownerless records. */
73
+ findCredentialByCredentialId(credentialId: string): Promise<PasskeyCredentialRow | null>
74
+ listCredentials(adminUserId: string): Promise<PasskeyCredentialRow[]>
75
+ /** All credential writes require the owning account lock and share its transaction. */
76
+ createCredential(
77
+ input: Omit<PasskeyCredentialRow, 'admin_user_id' | 'revoked_at'> & {
78
+ admin_user_id: string
79
+ }
80
+ ): Promise<void>
81
+ updateCredential(
82
+ id: string,
83
+ patch: Partial<Pick<PasskeyCredentialRow, 'counter' | 'backed_up' | 'name' | 'last_used_at'>>
84
+ ): Promise<void>
85
+ revokeCredential(id: string, at: Date): Promise<void>
86
+ /** Authenticated attempts require the actor lock; sign-in has no account yet. */
87
+ createAttempt(input: Omit<PasskeyAttemptRow, 'consumed_at'>): Promise<void>
88
+ findAttempt(tokenHash: string): Promise<PasskeyAttemptRow | null>
89
+ countPendingForLogin(loginId: string, now: Date): Promise<number>
90
+ /**
91
+ * Root store only: commits consumption in its own short transaction BEFORE returning.
92
+ * Calling through an account-transaction store must throw, never create a savepoint.
93
+ * Exact purpose/binding, unconsumed state and expiry gate the sole winning claim.
94
+ * No automatic retries, even if commit acknowledgement is uncertain.
95
+ */
96
+ claimAttempt(input: PasskeyClaimInput): Promise<PasskeyAttemptRow | null>
97
+ }
@@ -66,7 +66,7 @@ export interface RefreshTokensRepository {
66
66
  revokeChain(startId: string, at?: Date): Promise<number>
67
67
  /** Revoke every non-revoked token for a user. Used on password change / sign-out everywhere. */
68
68
  revokeAllForUser(adminUserId: string, at?: Date): Promise<number>
69
- /** Remove rows whose `expires_at` is in the past. Housekeeping. */
69
+ /** Legacy housekeeping: at most 100 null-sid rows expired for 30 days. Native login members are retained. */
70
70
  purgeExpired(now?: Date): Promise<number>
71
71
  /** All non-revoked tokens for a user. Primarily for tests. */
72
72
  listActiveForUser(adminUserId: string): Promise<RefreshTokenRow[]>
@@ -48,7 +48,7 @@ const DEFAULT_POLICY: SignInRateLimitPolicy = {
48
48
  const CLEANUP_TASK_NAME = 'auth.sign-in-counters.cleanup'
49
49
 
50
50
  /** IPv4 uses one address; IPv6 uses its /64. Mapped IPv4 shares its native IPv4 bucket. */
51
- function signInNetwork(ip: string): string {
51
+ export function signInNetwork(ip: string): string {
52
52
  const version = isIP(ip)
53
53
  if (version === 4) return ip
54
54
  if (version !== 6 || ip.includes('%')) throw new Error('Invalid client address')
@@ -16,6 +16,8 @@ export type {
16
16
  ChangeAccountPasswordInput,
17
17
  CreateAdminRoleInput,
18
18
  CreateAdminUserInput,
19
+ PasskeyAdminServices,
20
+ PasskeyPageBinding,
19
21
  SetAdminUserPasswordInput,
20
22
  SetRoleAbilitiesInput,
21
23
  SetUserRolesInput,
@@ -25,6 +25,15 @@
25
25
  * in Phase 3 along with the route factories.
26
26
  */
27
27
 
28
+ import type {
29
+ PasskeyAuthenticationOptions,
30
+ PasskeyCeremony,
31
+ PasskeyCredentialSummary,
32
+ PasskeyManagementAction,
33
+ PasskeyManagementGrant,
34
+ PasskeyRegistrationOptions,
35
+ } from '@byline/auth'
36
+
28
37
  import type {
29
38
  AccountResponse,
30
39
  ChangeAccountPasswordRequest,
@@ -59,6 +68,56 @@ export interface SignInInput {
59
68
  */
60
69
  export type SignInResult = unknown
61
70
 
71
+ /** Captured before any prompt; the host checks it again before submission. */
72
+ export interface PasskeyPageBinding {
73
+ expectedSessionId: string | null
74
+ assertCurrent(): void
75
+ }
76
+ interface PasskeyBoundInput {
77
+ expectedSessionId: string
78
+ }
79
+ export interface PasskeyAdminServices {
80
+ capability(): Promise<{ enabled: boolean }>
81
+ captureLogin(signIn?: boolean): PasskeyPageBinding
82
+ managementContext(
83
+ binding: PasskeyPageBinding
84
+ ): Promise<{ id: string; isSuperAdmin: boolean; canManagePasskeys: boolean }>
85
+ signInOptions: AdminServiceCall<
86
+ Record<string, never>,
87
+ PasskeyCeremony<PasskeyAuthenticationOptions>
88
+ >
89
+ signIn(
90
+ args: { data: { ceremonyId: string; response: unknown } },
91
+ binding: PasskeyPageBinding
92
+ ): Promise<unknown>
93
+ passwordProof: AdminServiceCall<
94
+ PasskeyBoundInput & { password: string; action: PasskeyManagementAction },
95
+ PasskeyManagementGrant
96
+ >
97
+ proofOptions: AdminServiceCall<
98
+ PasskeyBoundInput & { action: PasskeyManagementAction },
99
+ PasskeyCeremony<PasskeyAuthenticationOptions>
100
+ >
101
+ proof: AdminServiceCall<
102
+ PasskeyBoundInput & { ceremonyId: string; response: unknown },
103
+ PasskeyManagementGrant
104
+ >
105
+ enrollmentOptions: AdminServiceCall<
106
+ PasskeyBoundInput & { grantId: string },
107
+ PasskeyCeremony<PasskeyRegistrationOptions>
108
+ >
109
+ enrollment: AdminServiceCall<
110
+ PasskeyBoundInput & { ceremonyId: string; response: unknown; name: string },
111
+ PasskeyCredentialSummary
112
+ >
113
+ list: AdminServiceCall<PasskeyBoundInput & { targetUserId?: string }, PasskeyCredentialSummary[]>
114
+ rename: AdminServiceCall<
115
+ PasskeyBoundInput & { credentialRecordId: string; name: string },
116
+ PasskeyCredentialSummary
117
+ >
118
+ remove: AdminServiceCall<PasskeyBoundInput & { grantId: string }, { signedOut: boolean }>
119
+ }
120
+
62
121
  // --- Account self-service -------------------------------------------------
63
122
 
64
123
  /** Same shape as `UpdateAccountRequest` from `@byline/admin/admin-account`. */
@@ -155,6 +214,7 @@ export interface WhoHasAbilityInput {
155
214
  export interface BylineAdminServices {
156
215
  // Auth
157
216
  adminSignIn: AdminServiceCall<SignInInput, SignInResult>
217
+ passkeys?: PasskeyAdminServices
158
218
 
159
219
  // Account self-service
160
220
  updateAccount: AdminServiceCall<UpdateAccountInput, AccountResponse>
package/src/store.ts CHANGED
@@ -13,7 +13,9 @@ import type {
13
13
  AdminUsersRepository,
14
14
  AdminUserWithPasswordRow,
15
15
  } from './modules/admin-users/repository.js'
16
+ import type { AuthMaintenanceRepository } from './modules/auth/auth-maintenance-repository.js'
16
17
  import type { LoginSessionsRepository } from './modules/auth/login-sessions-repository.js'
18
+ import type { PasskeysRepository } from './modules/auth/passkeys-repository.js'
17
19
  import type { RefreshTokensRepository } from './modules/auth/refresh-tokens-repository.js'
18
20
  import type { SignInRateLimitStore } from './modules/auth/sign-in-rate-limiter.js'
19
21
 
@@ -44,6 +46,8 @@ export interface AdminStore {
44
46
  ): Promise<T>
45
47
  /** Lock distinct account IDs in sorted order in one transaction for account-switch sign-in. */
46
48
  withSessionLocks<T>(adminUserIds: string[], work: (store: AdminStore) => Promise<T>): Promise<T>
49
+ authMaintenance: AuthMaintenanceRepository
50
+ passkeys: PasskeysRepository
47
51
  loginSessions: LoginSessionsRepository
48
52
  signInRateLimits: SignInRateLimitStore
49
53
  adminUsers: AdminUsersRepository
@@ -1,37 +0,0 @@
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
- * Guard against `publishConfig.exports` drift.
10
- *
11
- * `@byline/admin` carries a `publishConfig.exports` block that npm uses to
12
- * **override** the top-level `exports` at publish time. The workspace and dev
13
- * builds resolve through the top-level `exports` (or source), so a subpath
14
- * added there but forgotten in `publishConfig.exports` typechecks and builds
15
- * locally yet is **missing from the published package** — surfacing only as a
16
- * downstream consumer's build error ("X is not exported …"). That is exactly
17
- * how `./admin-activity` slipped through in v3.11.0.
18
- *
19
- * This test fails the moment the two blocks drift, so the gap is caught in
20
- * `pnpm test` / CI rather than in a production Docker build.
21
- */
22
- import { readFileSync } from 'node:fs';
23
- import { dirname, resolve } from 'node:path';
24
- import { fileURLToPath } from 'node:url';
25
- import { describe, expect, it } from 'vitest';
26
- const pkgPath = resolve(dirname(fileURLToPath(import.meta.url)), '../package.json');
27
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
28
- describe('package.json export parity', () => {
29
- it('every top-level export subpath is also declared in publishConfig.exports', () => {
30
- const publishExports = pkg.publishConfig?.exports;
31
- // If there is no override, the top-level exports ship as-is — nothing to check.
32
- if (publishExports == null)
33
- return;
34
- const missing = Object.keys(pkg.exports).filter((key) => !(key in publishExports));
35
- expect(missing, `publishConfig.exports is missing subpath(s) present in the top-level exports: ${missing.join(', ')}. The published package would not expose them — add them to BOTH blocks.`).toEqual([]);
36
- });
37
- });