handoff-app 2.0.0-1 → 2.0.0-3

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 (219) hide show
  1. package/README.md +170 -91
  2. package/config/docs/foundations/colors/guidelines.md +9 -0
  3. package/config/docs/foundations/effects/guidelines.md +9 -0
  4. package/config/docs/foundations/effects/resources.md +9 -0
  5. package/config/docs/foundations/grid.md +10 -0
  6. package/config/docs/foundations/icons/guidelines.md +9 -0
  7. package/config/docs/foundations/images/guidelines.md +9 -0
  8. package/config/docs/foundations/images/resources.md +9 -0
  9. package/config/docs/foundations/images.md +10 -0
  10. package/config/docs/foundations/logo/resources.md +9 -0
  11. package/config/docs/foundations/typography/guidelines.md +9 -0
  12. package/config/docs/foundations/typography/resources.md +9 -0
  13. package/config/docs/foundations.md +5 -3
  14. package/dist/app-builder/build.js +109 -49
  15. package/dist/app-builder/client-config.js +13 -12
  16. package/dist/app-builder/paths.js +7 -9
  17. package/dist/app-builder/static-export.js +6 -6
  18. package/dist/app-builder/vercel-output.js +2 -2
  19. package/dist/artifacts/url.d.ts +7 -7
  20. package/dist/artifacts/url.js +7 -7
  21. package/dist/cli/auth/browser.d.ts +2 -0
  22. package/dist/cli/auth/browser.js +33 -0
  23. package/dist/cli/auth/device.d.ts +11 -0
  24. package/dist/cli/auth/device.js +155 -0
  25. package/dist/cli/auth/store.d.ts +29 -0
  26. package/dist/cli/auth/store.js +146 -0
  27. package/dist/cli/create.js +48 -3
  28. package/dist/cli/scaffold.js +20 -154
  29. package/dist/commands/checkout/index.d.ts +6 -6
  30. package/dist/commands/checkout/index.js +8 -12
  31. package/dist/commands/index.js +4 -0
  32. package/dist/commands/login/index.d.ts +8 -0
  33. package/dist/commands/login/index.js +53 -0
  34. package/dist/commands/logout/index.d.ts +7 -0
  35. package/dist/commands/logout/index.js +54 -0
  36. package/dist/commands/publish/index.d.ts +6 -5
  37. package/dist/commands/publish/index.js +8 -11
  38. package/dist/commands/utils.d.ts +6 -0
  39. package/dist/commands/utils.js +9 -1
  40. package/dist/config/entries.d.ts +47 -0
  41. package/dist/config/entries.js +162 -0
  42. package/dist/config/index.d.ts +2 -0
  43. package/dist/config/index.js +5 -1
  44. package/dist/config/runtime.d.ts +9 -9
  45. package/dist/config/runtime.js +15 -15
  46. package/dist/index.d.ts +12 -8
  47. package/dist/index.js +26 -10
  48. package/dist/registry/asset-storage/resolve.d.ts +1 -1
  49. package/dist/registry/asset-storage/resolve.js +1 -1
  50. package/dist/registry/auth/access-tokens.d.ts +46 -0
  51. package/dist/registry/auth/access-tokens.js +165 -0
  52. package/dist/registry/auth/action-tokens.d.ts +66 -0
  53. package/dist/registry/auth/action-tokens.js +178 -0
  54. package/dist/registry/auth/crypto.d.ts +4 -0
  55. package/dist/registry/auth/crypto.js +16 -0
  56. package/dist/registry/auth/database.d.ts +7 -0
  57. package/dist/registry/auth/database.js +22 -0
  58. package/dist/registry/auth/device-authorizations.d.ts +51 -0
  59. package/dist/registry/auth/device-authorizations.js +170 -0
  60. package/dist/registry/auth/gravatar.d.ts +1 -0
  61. package/dist/registry/auth/gravatar.js +8 -0
  62. package/dist/registry/auth/index.d.ts +11 -0
  63. package/dist/registry/auth/index.js +72 -0
  64. package/dist/registry/auth/installation.d.ts +42 -0
  65. package/dist/registry/auth/installation.js +105 -0
  66. package/dist/registry/auth/password.d.ts +12 -0
  67. package/dist/registry/auth/password.js +58 -0
  68. package/dist/registry/auth/rate-limits.d.ts +22 -0
  69. package/dist/registry/auth/rate-limits.js +75 -0
  70. package/dist/registry/auth/types.d.ts +26 -0
  71. package/dist/registry/auth/types.js +9 -0
  72. package/dist/registry/auth/user-record.d.ts +13 -0
  73. package/dist/registry/auth/user-record.js +16 -0
  74. package/dist/registry/auth/users.d.ts +31 -0
  75. package/dist/registry/auth/users.js +135 -0
  76. package/dist/registry/auth/validation.d.ts +3 -0
  77. package/dist/registry/auth/validation.js +28 -0
  78. package/dist/registry/checkout/assets.js +4 -2
  79. package/dist/registry/checkout/index.d.ts +11 -6
  80. package/dist/registry/checkout/index.js +159 -30
  81. package/dist/registry/checkout/tokens.js +4 -2
  82. package/dist/registry/client.d.ts +2 -1
  83. package/dist/registry/client.js +8 -0
  84. package/dist/registry/connection.d.ts +9 -0
  85. package/dist/registry/connection.js +29 -1
  86. package/dist/registry/db/client.d.ts +9 -9
  87. package/dist/registry/db/client.js +7 -7
  88. package/dist/registry/db/driver.d.ts +46 -0
  89. package/dist/registry/db/{adapter.js → driver.js} +22 -19
  90. package/dist/registry/db/index.d.ts +2 -2
  91. package/dist/registry/db/index.js +15 -9
  92. package/dist/registry/db/migrate.d.ts +1 -1
  93. package/dist/registry/db/migrate.js +5 -5
  94. package/dist/registry/db/schema.d.ts +2521 -750
  95. package/dist/registry/db/schema.js +126 -1
  96. package/dist/registry/publish/assets.js +1 -1
  97. package/dist/registry/publish/errors.js +3 -3
  98. package/dist/registry/publish/index.d.ts +9 -2
  99. package/dist/registry/publish/index.js +96 -10
  100. package/dist/registry/publish/tokens.js +1 -1
  101. package/dist/registry/transfer.d.ts +13 -0
  102. package/dist/types/config.d.ts +11 -4
  103. package/docs/cli.md +4 -0
  104. package/drizzle/0005_add_registry_auth.sql +95 -0
  105. package/drizzle/meta/0005_snapshot.json +1926 -0
  106. package/drizzle/meta/_journal.json +8 -1
  107. package/drizzle.config.ts +1 -1
  108. package/package.json +5 -4
  109. package/src/app/components/Auth/AccountLayout.tsx +87 -0
  110. package/src/app/components/Auth/AuthControls.tsx +99 -0
  111. package/src/app/components/Auth/AuthShell.tsx +73 -0
  112. package/src/app/components/Auth/RegistryInstallGate.tsx +23 -0
  113. package/src/app/components/Auth/RegistrySessionProvider.tsx +15 -0
  114. package/src/app/components/Auth/api.ts +20 -0
  115. package/src/app/components/Foundations/DisplayLogo.tsx +3 -3
  116. package/src/app/components/Layout/Header.tsx +2 -0
  117. package/src/app/components/Navigation/MobileNav.tsx +2 -0
  118. package/src/app/lib/auth/api.ts +113 -0
  119. package/src/app/lib/auth/config.ts +175 -0
  120. package/src/app/lib/auth/email.ts +34 -0
  121. package/src/app/lib/docs-api/runtime-config.ts +10 -24
  122. package/src/app/lib/registry-api/auth.ts +29 -46
  123. package/src/app/lib/registry-api/errors.ts +2 -2
  124. package/src/app/lib/registry-api/handler.ts +19 -14
  125. package/src/app/lib/registry-api/token-transfer.ts +1 -1
  126. package/src/app/lib/registry-api/transfer.ts +31 -2
  127. package/src/app/lib/registry-connection.ts +2 -2
  128. package/src/app/next.config.mjs +13 -4
  129. package/src/app/pages/_app.tsx +10 -4
  130. package/src/app/pages/account/index.tsx +153 -0
  131. package/src/app/pages/account/tokens.tsx +230 -0
  132. package/src/app/pages/account/users.tsx +310 -0
  133. package/src/app/pages/api/account/index.ts +26 -0
  134. package/src/app/pages/api/account/tokens/[id].ts +17 -0
  135. package/src/app/pages/api/account/tokens/index.ts +44 -0
  136. package/src/app/pages/api/admin/users/[id]/[action].ts +89 -0
  137. package/src/app/pages/api/admin/users/index.ts +53 -0
  138. package/src/app/pages/api/auth/[...nextauth].ts +4 -0
  139. package/src/app/pages/api/auth/request-reset.ts +53 -0
  140. package/src/app/pages/api/auth/reset-password.ts +29 -0
  141. package/src/app/pages/api/install.ts +102 -0
  142. package/src/app/pages/api/oauth/device/approve.ts +23 -0
  143. package/src/app/pages/api/oauth/device/index.ts +41 -0
  144. package/src/app/pages/api/oauth/revoke.ts +24 -0
  145. package/src/app/pages/api/oauth/token.ts +31 -0
  146. package/src/app/pages/api/registry/health.ts +4 -3
  147. package/src/app/pages/api/registry/transfer/[kind]/[id].ts +3 -3
  148. package/src/app/pages/api/registry/transfer/[kind]/index.ts +21 -0
  149. package/src/app/pages/api/registry/transfer/assets/[collection].ts +2 -1
  150. package/src/app/pages/api/registry/transfer/assets/blobs/[hash].ts +2 -1
  151. package/src/app/pages/api/registry/transfer/tokens/[...setId].ts +2 -2
  152. package/src/app/pages/cli/device.tsx +143 -0
  153. package/src/app/pages/foundations/icons/index.tsx +2 -2
  154. package/src/app/pages/foundations/icons/{[name] → library/[name]}/index.tsx +6 -6
  155. package/src/app/pages/install.tsx +336 -0
  156. package/src/app/pages/login.tsx +117 -0
  157. package/src/app/pages/reset-password.tsx +159 -0
  158. package/src/app/types/next-auth.d.ts +27 -0
  159. package/src/app-builder/build.ts +96 -59
  160. package/src/app-builder/client-config.ts +12 -11
  161. package/src/app-builder/paths.ts +7 -9
  162. package/src/app-builder/static-export.ts +6 -6
  163. package/src/app-builder/vercel-output.ts +2 -2
  164. package/src/artifacts/url.ts +7 -7
  165. package/src/cli/auth/browser.ts +29 -0
  166. package/src/cli/auth/device.ts +181 -0
  167. package/src/cli/auth/store.ts +137 -0
  168. package/src/cli/create.ts +58 -3
  169. package/src/cli/scaffold.ts +20 -197
  170. package/src/commands/checkout/index.ts +10 -15
  171. package/src/commands/index.ts +4 -0
  172. package/src/commands/login/index.ts +48 -0
  173. package/src/commands/logout/index.ts +51 -0
  174. package/src/commands/publish/index.ts +10 -14
  175. package/src/commands/utils.ts +12 -0
  176. package/src/config/entries.ts +175 -0
  177. package/src/config/index.ts +4 -0
  178. package/src/config/runtime.ts +15 -14
  179. package/src/index.ts +26 -12
  180. package/src/registry/asset-storage/resolve.ts +1 -1
  181. package/src/registry/auth/access-tokens.ts +198 -0
  182. package/src/registry/auth/action-tokens.ts +225 -0
  183. package/src/registry/auth/crypto.ts +13 -0
  184. package/src/registry/auth/database.ts +15 -0
  185. package/src/registry/auth/device-authorizations.ts +223 -0
  186. package/src/registry/auth/gravatar.ts +6 -0
  187. package/src/registry/auth/index.ts +89 -0
  188. package/src/registry/auth/installation.ts +117 -0
  189. package/src/registry/auth/password.ts +46 -0
  190. package/src/registry/auth/rate-limits.ts +84 -0
  191. package/src/registry/auth/types.ts +34 -0
  192. package/src/registry/auth/user-record.ts +25 -0
  193. package/src/registry/auth/users.ts +145 -0
  194. package/src/registry/auth/validation.ts +24 -0
  195. package/src/registry/checkout/assets.ts +4 -2
  196. package/src/registry/checkout/index.ts +182 -30
  197. package/src/registry/checkout/tokens.ts +4 -2
  198. package/src/registry/client.ts +7 -1
  199. package/src/registry/connection.ts +23 -0
  200. package/src/registry/db/client.ts +12 -12
  201. package/src/registry/db/{adapter.ts → driver.ts} +24 -21
  202. package/src/registry/db/index.ts +16 -4
  203. package/src/registry/db/migrate.ts +5 -5
  204. package/src/registry/db/schema.ts +174 -1
  205. package/src/registry/publish/assets.ts +1 -1
  206. package/src/registry/publish/errors.ts +3 -3
  207. package/src/registry/publish/index.ts +108 -10
  208. package/src/registry/publish/tokens.ts +1 -1
  209. package/src/registry/transfer.ts +14 -0
  210. package/src/types/config.ts +11 -4
  211. package/templates/default/README.md.tpl +58 -1
  212. package/templates/default/gitignore.tpl +19 -3
  213. package/templates/default/handoff.config.js.tpl +13 -0
  214. package/templates/default/handoff.config.ts.tpl +13 -0
  215. package/templates/default/handoff.config.with-examples.js.tpl +13 -0
  216. package/templates/default/handoff.config.with-examples.ts.tpl +13 -0
  217. package/templates/default/package.json.tpl +2 -1
  218. package/templates/default/vercel.json.tpl +4 -0
  219. package/dist/registry/db/adapter.d.ts +0 -43
@@ -0,0 +1,225 @@
1
+ import { and, eq, gt, isNull, sql } from 'drizzle-orm';
2
+ import { randomUUID } from 'node:crypto';
3
+ import type { RegistryDatabase } from '../db/client';
4
+ import { registryAuthActionTokens, registryUsers, type RegistryAuthActionPurpose, type RegistryUserRole } from '../db/schema';
5
+ import { revokeAllRegistryAccessTokens } from './access-tokens';
6
+ import { createOpaqueSecret, hashSecret, normalizeEmail } from './crypto';
7
+ import { withRegistryTransaction } from './database';
8
+ import { hashPassword, validatePassword } from './password';
9
+ import type { RegistryUser } from './types';
10
+ import { toRegistryUser } from './user-record';
11
+ import { validateRegistryDisplayName, validateRegistryEmail } from './validation';
12
+
13
+ export const INVITATION_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000;
14
+ export const PASSWORD_RESET_LIFETIME_MS = 60 * 60 * 1000;
15
+
16
+ export type CreateUserInvitationResult =
17
+ | { ok: true; user: RegistryUser; token: string; expiresAt: Date }
18
+ | { ok: false; reason: 'invalid_email' | 'invalid_name' | 'invalid_role' | 'email_exists' };
19
+
20
+ export type ResendUserInvitationResult =
21
+ | { ok: true; user: RegistryUser; token: string; expiresAt: Date }
22
+ | { ok: false; reason: 'not_found' | 'already_accepted' | 'deactivated' };
23
+
24
+ export type ConsumeAuthActionResult =
25
+ | { ok: true; user: RegistryUser }
26
+ | { ok: false; reason: 'invalid_or_expired_token' | 'invalid_password'; error?: string };
27
+
28
+ const invalidateUnusedActionTokens = async (
29
+ db: RegistryDatabase,
30
+ userId: string,
31
+ purpose: RegistryAuthActionPurpose,
32
+ now: Date
33
+ ): Promise<void> => {
34
+ await db
35
+ .update(registryAuthActionTokens)
36
+ .set({ usedAt: now })
37
+ .where(
38
+ and(
39
+ eq(registryAuthActionTokens.userId, userId),
40
+ eq(registryAuthActionTokens.purpose, purpose),
41
+ isNull(registryAuthActionTokens.usedAt)
42
+ )
43
+ );
44
+ };
45
+
46
+ const insertActionToken = async (
47
+ db: RegistryDatabase,
48
+ userId: string,
49
+ purpose: RegistryAuthActionPurpose,
50
+ expiresAt: Date,
51
+ now: Date
52
+ ): Promise<string> => {
53
+ const token = createOpaqueSecret(32);
54
+ await db.insert(registryAuthActionTokens).values({
55
+ id: randomUUID(),
56
+ userId,
57
+ purpose,
58
+ tokenHash: hashSecret(token),
59
+ expiresAt,
60
+ createdAt: now,
61
+ });
62
+ return token;
63
+ };
64
+
65
+ export const createUserInvitation = async (
66
+ db: RegistryDatabase,
67
+ input: {
68
+ email: string;
69
+ name: string;
70
+ role: RegistryUserRole;
71
+ now?: Date;
72
+ expiresAt?: Date;
73
+ }
74
+ ): Promise<CreateUserInvitationResult> => {
75
+ const email = validateRegistryEmail(input.email);
76
+ if (!email) return { ok: false, reason: 'invalid_email' };
77
+ const name = validateRegistryDisplayName(input.name);
78
+ if (!name) return { ok: false, reason: 'invalid_name' };
79
+ if (input.role !== 'admin' && input.role !== 'member') return { ok: false, reason: 'invalid_role' };
80
+ const now = input.now ?? new Date();
81
+ const expiresAt = input.expiresAt ?? new Date(now.getTime() + INVITATION_LIFETIME_MS);
82
+
83
+ return withRegistryTransaction(db, async (tx) => {
84
+ const userId = randomUUID();
85
+ const rows = await tx
86
+ .insert(registryUsers)
87
+ .values({
88
+ id: userId,
89
+ email,
90
+ name,
91
+ role: input.role,
92
+ status: 'invited',
93
+ createdAt: now,
94
+ updatedAt: now,
95
+ })
96
+ .onConflictDoNothing()
97
+ .returning();
98
+ const user = rows[0];
99
+ if (!user) return { ok: false, reason: 'email_exists' };
100
+
101
+ const token = await insertActionToken(tx, userId, 'invite', expiresAt, now);
102
+ return { ok: true, user: toRegistryUser(user), token, expiresAt };
103
+ });
104
+ };
105
+
106
+ export const resendUserInvitation = async (
107
+ db: RegistryDatabase,
108
+ input: { userId: string; now?: Date; expiresAt?: Date }
109
+ ): Promise<ResendUserInvitationResult> =>
110
+ withRegistryTransaction(db, async (tx) => {
111
+ const [user] = await tx.select().from(registryUsers).where(eq(registryUsers.id, input.userId)).limit(1).for('update');
112
+ if (!user) return { ok: false, reason: 'not_found' };
113
+ if (user.status === 'active') return { ok: false, reason: 'already_accepted' };
114
+ if (user.status === 'deactivated') return { ok: false, reason: 'deactivated' };
115
+
116
+ const now = input.now ?? new Date();
117
+ const expiresAt = input.expiresAt ?? new Date(now.getTime() + INVITATION_LIFETIME_MS);
118
+ await invalidateUnusedActionTokens(tx, user.id, 'invite', now);
119
+ const token = await insertActionToken(tx, user.id, 'invite', expiresAt, now);
120
+ return { ok: true, user: toRegistryUser(user), token, expiresAt };
121
+ });
122
+
123
+ export const acceptUserInvitation = async (
124
+ db: RegistryDatabase,
125
+ input: { token: string; password: string; now?: Date }
126
+ ): Promise<ConsumeAuthActionResult> => {
127
+ const validation = validatePassword(input.password);
128
+ if ('error' in validation) return { ok: false, reason: 'invalid_password', error: validation.error };
129
+ const passwordHash = await hashPassword(input.password);
130
+ const now = input.now ?? new Date();
131
+
132
+ return withRegistryTransaction(db, async (tx) => {
133
+ const [consumed] = await tx
134
+ .update(registryAuthActionTokens)
135
+ .set({ usedAt: now })
136
+ .where(
137
+ and(
138
+ eq(registryAuthActionTokens.tokenHash, hashSecret(input.token)),
139
+ eq(registryAuthActionTokens.purpose, 'invite'),
140
+ isNull(registryAuthActionTokens.usedAt),
141
+ gt(registryAuthActionTokens.expiresAt, now)
142
+ )
143
+ )
144
+ .returning({ userId: registryAuthActionTokens.userId });
145
+ if (!consumed) return { ok: false, reason: 'invalid_or_expired_token' };
146
+
147
+ const [user] = await tx
148
+ .update(registryUsers)
149
+ .set({
150
+ passwordHash,
151
+ status: 'active',
152
+ emailVerifiedAt: now,
153
+ updatedAt: now,
154
+ })
155
+ .where(and(eq(registryUsers.id, consumed.userId), eq(registryUsers.status, 'invited')))
156
+ .returning();
157
+ return user ? { ok: true, user: toRegistryUser(user) } : { ok: false, reason: 'invalid_or_expired_token' };
158
+ });
159
+ };
160
+
161
+ /**
162
+ * Enumeration-safe reset request. The token is returned only to the trusted mail-delivery caller;
163
+ * unknown/inactive emails return the same successful shape with a null token.
164
+ */
165
+ export const createPasswordReset = async (
166
+ db: RegistryDatabase,
167
+ email: string,
168
+ options: { now?: Date; expiresAt?: Date } = {}
169
+ ): Promise<{ ok: true; user: RegistryUser | null; token: string | null; expiresAt: Date | null }> => {
170
+ const normalizedEmail = normalizeEmail(email);
171
+ const now = options.now ?? new Date();
172
+ const expiresAt = options.expiresAt ?? new Date(now.getTime() + PASSWORD_RESET_LIFETIME_MS);
173
+ return withRegistryTransaction(db, async (tx) => {
174
+ const [user] = await tx
175
+ .select()
176
+ .from(registryUsers)
177
+ .where(and(eq(registryUsers.email, normalizedEmail), eq(registryUsers.status, 'active')))
178
+ .limit(1)
179
+ .for('update');
180
+ if (!user) return { ok: true, user: null, token: null, expiresAt: null };
181
+
182
+ await invalidateUnusedActionTokens(tx, user.id, 'password_reset', now);
183
+ const token = await insertActionToken(tx, user.id, 'password_reset', expiresAt, now);
184
+ return { ok: true, user: toRegistryUser(user), token, expiresAt };
185
+ });
186
+ };
187
+
188
+ export const resetPassword = async (
189
+ db: RegistryDatabase,
190
+ input: { token: string; password: string; now?: Date }
191
+ ): Promise<ConsumeAuthActionResult> => {
192
+ const validation = validatePassword(input.password);
193
+ if ('error' in validation) return { ok: false, reason: 'invalid_password', error: validation.error };
194
+ const passwordHash = await hashPassword(input.password);
195
+ const now = input.now ?? new Date();
196
+
197
+ return withRegistryTransaction(db, async (tx) => {
198
+ const [consumed] = await tx
199
+ .update(registryAuthActionTokens)
200
+ .set({ usedAt: now })
201
+ .where(
202
+ and(
203
+ eq(registryAuthActionTokens.tokenHash, hashSecret(input.token)),
204
+ eq(registryAuthActionTokens.purpose, 'password_reset'),
205
+ isNull(registryAuthActionTokens.usedAt),
206
+ gt(registryAuthActionTokens.expiresAt, now)
207
+ )
208
+ )
209
+ .returning({ userId: registryAuthActionTokens.userId });
210
+ if (!consumed) return { ok: false, reason: 'invalid_or_expired_token' };
211
+
212
+ const [user] = await tx
213
+ .update(registryUsers)
214
+ .set({
215
+ passwordHash,
216
+ authVersion: sql`${registryUsers.authVersion} + 1`,
217
+ updatedAt: now,
218
+ })
219
+ .where(and(eq(registryUsers.id, consumed.userId), eq(registryUsers.status, 'active')))
220
+ .returning();
221
+ if (!user) return { ok: false, reason: 'invalid_or_expired_token' };
222
+ await revokeAllRegistryAccessTokens(tx, user.id, now);
223
+ return { ok: true, user: toRegistryUser(user) };
224
+ });
225
+ };
@@ -0,0 +1,13 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
2
+
3
+ export const hashSecret = (secret: string): string => createHash('sha256').update(secret, 'utf8').digest('hex');
4
+
5
+ export const createOpaqueSecret = (byteLength = 32): string => randomBytes(byteLength).toString('base64url');
6
+
7
+ export const secretHashMatches = (secret: string, expectedHash: string): boolean => {
8
+ const actual = Buffer.from(hashSecret(secret), 'hex');
9
+ const expected = Buffer.from(expectedHash, 'hex');
10
+ return actual.length === expected.length && timingSafeEqual(actual, expected);
11
+ };
12
+
13
+ export const normalizeEmail = (email: string): string => email.trim().toLowerCase();
@@ -0,0 +1,15 @@
1
+ import { sql } from 'drizzle-orm';
2
+ import type { RegistryDatabase } from '../db/client';
3
+
4
+ export type RegistryTransaction = RegistryDatabase;
5
+
6
+ export const withRegistryTransaction = async <T>(db: RegistryDatabase, callback: (tx: RegistryTransaction) => Promise<T>): Promise<T> =>
7
+ (db as any).transaction((tx: unknown) => callback(tx as RegistryTransaction));
8
+
9
+ export const acquireRegistryAdvisoryLock = async (tx: RegistryTransaction, lockId: number): Promise<void> => {
10
+ await tx.execute(sql`select pg_advisory_xact_lock(${lockId})`);
11
+ };
12
+
13
+ /** Stable, package-owned Postgres advisory-lock ids. */
14
+ export const REGISTRY_INSTALL_LOCK_ID = 1_841_111_001;
15
+ export const REGISTRY_ADMIN_LOCK_ID = 1_841_111_002;
@@ -0,0 +1,223 @@
1
+ import { and, eq, gt, lt } from 'drizzle-orm';
2
+ import { randomBytes, randomUUID } from 'node:crypto';
3
+ import type { RegistryDatabase } from '../db/client';
4
+ import { registryDeviceAuthorizations, registryUsers } from '../db/schema';
5
+ import {
6
+ createRegistryAccessToken,
7
+ normalizeRegistryScopes,
8
+ registryRoleAllowsScopes,
9
+ type RegistryAccessTokenSummary,
10
+ } from './access-tokens';
11
+ import { hashSecret } from './crypto';
12
+ import { withRegistryTransaction } from './database';
13
+ import { scopesForRegistryRole, type RegistryAccessScope, type RegistryUser } from './types';
14
+ import { toRegistryUser } from './user-record';
15
+
16
+ const USER_CODE_ALPHABET = 'BCDFGHJKLMNPQRSTVWXYZ23456789';
17
+ export const DEVICE_AUTHORIZATION_LIFETIME_SECONDS = 15 * 60;
18
+ export const DEVICE_AUTHORIZATION_POLL_INTERVAL_SECONDS = 5;
19
+
20
+ const randomUserCodeSegment = (length: number): string => {
21
+ const bytes = randomBytes(length);
22
+ let result = '';
23
+ for (let index = 0; index < length; index += 1) {
24
+ result += USER_CODE_ALPHABET[bytes[index] % USER_CODE_ALPHABET.length];
25
+ }
26
+ return result;
27
+ };
28
+
29
+ export const generateRegistryUserCode = (): string => `${randomUserCodeSegment(4)}-${randomUserCodeSegment(4)}`;
30
+
31
+ export const normalizeRegistryUserCode = (value: string): string => {
32
+ const compact = value.toUpperCase().replace(/[^A-Z0-9]/g, '');
33
+ return compact.length === 8 ? `${compact.slice(0, 4)}-${compact.slice(4)}` : value.trim().toUpperCase();
34
+ };
35
+
36
+ export interface CreateRegistryDeviceAuthorizationResult {
37
+ deviceCode: string;
38
+ userCode: string;
39
+ expiresIn: number;
40
+ interval: number;
41
+ expiresAt: Date;
42
+ }
43
+
44
+ export const createRegistryDeviceAuthorization = async (
45
+ db: RegistryDatabase,
46
+ options: { now?: Date; expiresInSeconds?: number } = {}
47
+ ): Promise<CreateRegistryDeviceAuthorizationResult> => {
48
+ const now = options.now ?? new Date();
49
+ const expiresIn = options.expiresInSeconds ?? DEVICE_AUTHORIZATION_LIFETIME_SECONDS;
50
+ const deviceCode = randomBytes(32).toString('base64url');
51
+ const userCode = generateRegistryUserCode();
52
+ const expiresAt = new Date(now.getTime() + expiresIn * 1000);
53
+
54
+ await db.insert(registryDeviceAuthorizations).values({
55
+ id: randomUUID(),
56
+ deviceCodeHash: hashSecret(deviceCode),
57
+ userCode,
58
+ status: 'pending',
59
+ scopes: [],
60
+ expiresAt,
61
+ createdAt: now,
62
+ });
63
+ return {
64
+ deviceCode,
65
+ userCode,
66
+ expiresIn,
67
+ interval: DEVICE_AUTHORIZATION_POLL_INTERVAL_SECONDS,
68
+ expiresAt,
69
+ };
70
+ };
71
+
72
+ export type RegistryDeviceApprovalResult =
73
+ | { ok: true; user: RegistryUser; scopes: RegistryAccessScope[] }
74
+ | { ok: false; reason: 'invalid_or_expired_code' | 'user_inactive' };
75
+
76
+ export const approveRegistryDeviceAuthorization = async (
77
+ db: RegistryDatabase,
78
+ input: { userCode: string; userId: string; now?: Date }
79
+ ): Promise<RegistryDeviceApprovalResult> => {
80
+ const now = input.now ?? new Date();
81
+ const [user] = await db.select().from(registryUsers).where(eq(registryUsers.id, input.userId)).limit(1);
82
+ if (!user || user.status !== 'active') return { ok: false, reason: 'user_inactive' };
83
+
84
+ const scopes = scopesForRegistryRole(user.role);
85
+ const [authorization] = await db
86
+ .update(registryDeviceAuthorizations)
87
+ .set({
88
+ status: 'approved',
89
+ userId: user.id,
90
+ scopes,
91
+ approvedAt: now,
92
+ })
93
+ .where(
94
+ and(
95
+ eq(registryDeviceAuthorizations.userCode, normalizeRegistryUserCode(input.userCode)),
96
+ eq(registryDeviceAuthorizations.status, 'pending'),
97
+ gt(registryDeviceAuthorizations.expiresAt, now)
98
+ )
99
+ )
100
+ .returning({ id: registryDeviceAuthorizations.id });
101
+ return authorization ? { ok: true, user: toRegistryUser(user), scopes } : { ok: false, reason: 'invalid_or_expired_code' };
102
+ };
103
+
104
+ export const denyRegistryDeviceAuthorization = async (db: RegistryDatabase, userCode: string, now = new Date()): Promise<boolean> => {
105
+ const rows = await db
106
+ .update(registryDeviceAuthorizations)
107
+ .set({ status: 'denied' })
108
+ .where(
109
+ and(
110
+ eq(registryDeviceAuthorizations.userCode, normalizeRegistryUserCode(userCode)),
111
+ eq(registryDeviceAuthorizations.status, 'pending'),
112
+ gt(registryDeviceAuthorizations.expiresAt, now)
113
+ )
114
+ )
115
+ .returning({ id: registryDeviceAuthorizations.id });
116
+ return rows.length > 0;
117
+ };
118
+
119
+ export type ExchangeRegistryDeviceAuthorizationResult =
120
+ | {
121
+ ok: true;
122
+ accessToken: string;
123
+ tokenType: 'Bearer';
124
+ expiresIn: number;
125
+ scopes: RegistryAccessScope[];
126
+ user: RegistryUser;
127
+ record: RegistryAccessTokenSummary;
128
+ }
129
+ | {
130
+ ok: false;
131
+ error: 'invalid_grant' | 'expired_token' | 'authorization_pending' | 'access_denied';
132
+ errorDescription: string;
133
+ };
134
+
135
+ export const exchangeRegistryDeviceAuthorization = async (
136
+ db: RegistryDatabase,
137
+ input: { deviceCode: string; tokenName?: string; now?: Date }
138
+ ): Promise<ExchangeRegistryDeviceAuthorizationResult> => {
139
+ const now = input.now ?? new Date();
140
+ const tokenName = input.tokenName?.trim() || 'Handoff CLI';
141
+
142
+ return withRegistryTransaction(db, async (tx) => {
143
+ const [authorization] = await tx
144
+ .select()
145
+ .from(registryDeviceAuthorizations)
146
+ .where(eq(registryDeviceAuthorizations.deviceCodeHash, hashSecret(input.deviceCode)))
147
+ .limit(1);
148
+ if (!authorization) {
149
+ return { ok: false, error: 'invalid_grant', errorDescription: 'Unknown device code.' };
150
+ }
151
+ if (authorization.expiresAt <= now) {
152
+ return { ok: false, error: 'expired_token', errorDescription: 'Device authorization expired.' };
153
+ }
154
+ if (authorization.status === 'pending') {
155
+ return {
156
+ ok: false,
157
+ error: 'authorization_pending',
158
+ errorDescription: 'The authorization request is still pending.',
159
+ };
160
+ }
161
+ if (authorization.status === 'denied') {
162
+ return { ok: false, error: 'access_denied', errorDescription: 'The authorization request was denied.' };
163
+ }
164
+ if (authorization.status === 'consumed') {
165
+ return { ok: false, error: 'invalid_grant', errorDescription: 'Device code has already been used.' };
166
+ }
167
+ if (!authorization.userId) {
168
+ return { ok: false, error: 'invalid_grant', errorDescription: 'Device authorization has no user.' };
169
+ }
170
+
171
+ const [consumed] = await tx
172
+ .update(registryDeviceAuthorizations)
173
+ .set({ status: 'consumed', consumedAt: now })
174
+ .where(
175
+ and(
176
+ eq(registryDeviceAuthorizations.id, authorization.id),
177
+ eq(registryDeviceAuthorizations.status, 'approved'),
178
+ gt(registryDeviceAuthorizations.expiresAt, now)
179
+ )
180
+ )
181
+ .returning({ id: registryDeviceAuthorizations.id });
182
+ if (!consumed) {
183
+ return { ok: false, error: 'invalid_grant', errorDescription: 'Device code has already been used.' };
184
+ }
185
+
186
+ const [user] = await tx.select().from(registryUsers).where(eq(registryUsers.id, authorization.userId)).limit(1);
187
+ if (!user || user.status !== 'active') {
188
+ return { ok: false, error: 'access_denied', errorDescription: 'The approving account is inactive.' };
189
+ }
190
+
191
+ let scopes = normalizeRegistryScopes(authorization.scopes);
192
+ if (!registryRoleAllowsScopes(user.role, scopes)) scopes = scopesForRegistryRole(user.role);
193
+ const expiresAt = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000);
194
+ const token = await createRegistryAccessToken(tx, {
195
+ userId: user.id,
196
+ name: tokenName,
197
+ scopes,
198
+ expiresAt,
199
+ now,
200
+ });
201
+ if (!token.ok) {
202
+ return { ok: false, error: 'access_denied', errorDescription: 'The account cannot issue this token.' };
203
+ }
204
+
205
+ return {
206
+ ok: true,
207
+ accessToken: token.token,
208
+ tokenType: 'Bearer',
209
+ expiresIn: Math.floor((expiresAt.getTime() - now.getTime()) / 1000),
210
+ scopes: token.record.scopes,
211
+ user: toRegistryUser(user),
212
+ record: token.record,
213
+ };
214
+ });
215
+ };
216
+
217
+ export const purgeExpiredRegistryDeviceAuthorizations = async (db: RegistryDatabase, now = new Date()): Promise<number> => {
218
+ const rows = await db
219
+ .delete(registryDeviceAuthorizations)
220
+ .where(lt(registryDeviceAuthorizations.expiresAt, now))
221
+ .returning({ id: registryDeviceAuthorizations.id });
222
+ return rows.length;
223
+ };
@@ -0,0 +1,6 @@
1
+ import { hashSecret, normalizeEmail } from './crypto';
2
+
3
+ // Gravatar resolves an avatar from the SHA-256 hash of the trimmed, lowercased email.
4
+ // `d=identicon` guarantees a generated image renders even when the user has no Gravatar.
5
+ export const buildGravatarUrl = (email: string): string =>
6
+ `https://www.gravatar.com/avatar/${hashSecret(normalizeEmail(email))}?d=identicon`;
@@ -0,0 +1,89 @@
1
+ export {
2
+ createRegistryAccessToken,
3
+ listRegistryAccessTokens,
4
+ revokeRegistryAccessToken,
5
+ revokeAllRegistryAccessTokens,
6
+ authenticateRegistryAccessToken,
7
+ formatRegistryAccessToken,
8
+ parseRegistryAccessToken,
9
+ normalizeRegistryScopes,
10
+ registryRoleAllowsScopes,
11
+ type RegistryAccessTokenSummary,
12
+ type CreateRegistryAccessTokenResult,
13
+ } from './access-tokens';
14
+ export {
15
+ createUserInvitation,
16
+ resendUserInvitation,
17
+ acceptUserInvitation,
18
+ createPasswordReset,
19
+ resetPassword,
20
+ INVITATION_LIFETIME_MS,
21
+ PASSWORD_RESET_LIFETIME_MS,
22
+ type CreateUserInvitationResult,
23
+ type ResendUserInvitationResult,
24
+ type ConsumeAuthActionResult,
25
+ } from './action-tokens';
26
+ export { createOpaqueSecret, hashSecret, normalizeEmail, secretHashMatches } from './crypto';
27
+ export { buildGravatarUrl } from './gravatar';
28
+ export {
29
+ createRegistryDeviceAuthorization,
30
+ approveRegistryDeviceAuthorization,
31
+ denyRegistryDeviceAuthorization,
32
+ exchangeRegistryDeviceAuthorization,
33
+ purgeExpiredRegistryDeviceAuthorizations,
34
+ generateRegistryUserCode,
35
+ normalizeRegistryUserCode,
36
+ DEVICE_AUTHORIZATION_LIFETIME_SECONDS,
37
+ DEVICE_AUTHORIZATION_POLL_INTERVAL_SECONDS,
38
+ type CreateRegistryDeviceAuthorizationResult,
39
+ type RegistryDeviceApprovalResult,
40
+ type ExchangeRegistryDeviceAuthorizationResult,
41
+ } from './device-authorizations';
42
+ export {
43
+ getRegistryInstallation,
44
+ getRegistryInstallationState,
45
+ installRegistry,
46
+ REGISTRY_AUTH_SCHEMA_VERSION,
47
+ REGISTRY_INSTALLATION_ID,
48
+ type RegistryInstallation,
49
+ type RegistryInstallationState,
50
+ type RegistryInstallInput,
51
+ type RegistryInstallResult,
52
+ } from './installation';
53
+ export {
54
+ hashPassword,
55
+ verifyPassword,
56
+ validatePassword,
57
+ MINIMUM_PASSWORD_LENGTH,
58
+ MAXIMUM_PASSWORD_LENGTH,
59
+ type PasswordValidationResult,
60
+ } from './password';
61
+ export {
62
+ consumeAuthRateLimit,
63
+ clearAuthRateLimit,
64
+ purgeExpiredAuthRateLimits,
65
+ hashRateLimitIdentifier,
66
+ type ConsumeAuthRateLimitInput,
67
+ type AuthRateLimitResult,
68
+ } from './rate-limits';
69
+ export {
70
+ getRegistryUserById,
71
+ listRegistryUsers,
72
+ authenticateRegistryCredentials,
73
+ updateRegistryUserProfile,
74
+ updateRegistryUserRole,
75
+ setRegistryUserStatus,
76
+ type RegistryUserMutationResult,
77
+ } from './users';
78
+ export {
79
+ REGISTRY_READ_SCOPE,
80
+ REGISTRY_WRITE_SCOPE,
81
+ scopesForRegistryRole,
82
+ registryPrincipalHasScope,
83
+ type RegistryAccessScope,
84
+ type RegistryPrincipal,
85
+ type RegistryUser,
86
+ type RegistryUserRole,
87
+ type RegistryUserStatus,
88
+ } from './types';
89
+ export { validateRegistryDisplayName, validateRegistryEmail, validateRegistryImageUrl } from './validation';
@@ -0,0 +1,117 @@
1
+ import { count, eq } from 'drizzle-orm';
2
+ import { randomUUID } from 'node:crypto';
3
+ import type { RegistryDatabase } from '../db/client';
4
+ import { registryInstallations, registryUsers } from '../db/schema';
5
+ import { acquireRegistryAdvisoryLock, REGISTRY_INSTALL_LOCK_ID, withRegistryTransaction } from './database';
6
+ import { hashPassword, validatePassword } from './password';
7
+ import type { RegistryUser } from './types';
8
+ import { toRegistryUser } from './user-record';
9
+ import { validateRegistryDisplayName, validateRegistryEmail } from './validation';
10
+
11
+ /** Migration number that introduced the registry authentication schema. */
12
+ export const REGISTRY_AUTH_SCHEMA_VERSION = 5;
13
+ export const REGISTRY_INSTALLATION_ID = 'default';
14
+
15
+ export type RegistryInstallation = typeof registryInstallations.$inferSelect;
16
+ export type RegistryInstallationState =
17
+ | { status: 'ready'; installation: null; userCount: 0 }
18
+ | { status: 'installed'; installation: RegistryInstallation; userCount: number }
19
+ | { status: 'inconsistent'; installation: null; userCount: number };
20
+
21
+ export interface RegistryInstallInput {
22
+ email: string;
23
+ name: string;
24
+ password: string;
25
+ now?: Date;
26
+ }
27
+
28
+ export type RegistryInstallResult =
29
+ | { ok: true; user: RegistryUser; installation: RegistryInstallation }
30
+ | {
31
+ ok: false;
32
+ reason: 'invalid_email' | 'invalid_name' | 'invalid_password' | 'already_installed' | 'users_exist';
33
+ error?: string;
34
+ };
35
+
36
+ export const getRegistryInstallation = async (db: RegistryDatabase): Promise<RegistryInstallation | null> => {
37
+ const [installation] = await db
38
+ .select()
39
+ .from(registryInstallations)
40
+ .where(eq(registryInstallations.id, REGISTRY_INSTALLATION_ID))
41
+ .limit(1);
42
+ return installation ?? null;
43
+ };
44
+
45
+ export const getRegistryInstallationState = async (db: RegistryDatabase): Promise<RegistryInstallationState> => {
46
+ const installation = await getRegistryInstallation(db);
47
+ const [{ value }] = await db.select({ value: count() }).from(registryUsers);
48
+ const userCount = Number(value);
49
+ if (installation) return { status: 'installed', installation, userCount };
50
+ return userCount === 0
51
+ ? { status: 'ready', installation: null, userCount: 0 }
52
+ : { status: 'inconsistent', installation: null, userCount };
53
+ };
54
+
55
+ /**
56
+ * Complete first-visitor installation under a transaction-scoped advisory lock. Password hashing is
57
+ * intentionally performed before taking the lock; all state checks are repeated inside it.
58
+ */
59
+ export const installRegistry = async (db: RegistryDatabase, input: RegistryInstallInput): Promise<RegistryInstallResult> => {
60
+ const email = validateRegistryEmail(input.email);
61
+ if (!email) return { ok: false, reason: 'invalid_email' };
62
+ const name = validateRegistryDisplayName(input.name);
63
+ if (!name) return { ok: false, reason: 'invalid_name' };
64
+ const passwordValidation = validatePassword(input.password);
65
+ if ('error' in passwordValidation) {
66
+ return { ok: false, reason: 'invalid_password', error: passwordValidation.error };
67
+ }
68
+
69
+ const passwordHash = await hashPassword(input.password);
70
+ const now = input.now ?? new Date();
71
+
72
+ return withRegistryTransaction(db, async (tx) => {
73
+ await acquireRegistryAdvisoryLock(tx, REGISTRY_INSTALL_LOCK_ID);
74
+
75
+ const [installation] = await tx
76
+ .select()
77
+ .from(registryInstallations)
78
+ .where(eq(registryInstallations.id, REGISTRY_INSTALLATION_ID))
79
+ .limit(1);
80
+ if (installation) return { ok: false, reason: 'already_installed' };
81
+
82
+ const [{ value }] = await tx.select({ value: count() }).from(registryUsers);
83
+ if (Number(value) > 0) return { ok: false, reason: 'users_exist' };
84
+
85
+ const userId = randomUUID();
86
+ const [userRow] = await tx
87
+ .insert(registryUsers)
88
+ .values({
89
+ id: userId,
90
+ email,
91
+ name,
92
+ role: 'admin',
93
+ status: 'active',
94
+ passwordHash,
95
+ emailVerifiedAt: now,
96
+ createdAt: now,
97
+ updatedAt: now,
98
+ })
99
+ .returning();
100
+ const [createdInstallation] = await tx
101
+ .insert(registryInstallations)
102
+ .values({
103
+ id: REGISTRY_INSTALLATION_ID,
104
+ status: 'installed',
105
+ schemaVersion: REGISTRY_AUTH_SCHEMA_VERSION,
106
+ initialAdminUserId: userId,
107
+ installedAt: now,
108
+ })
109
+ .returning();
110
+
111
+ return {
112
+ ok: true,
113
+ user: toRegistryUser(userRow),
114
+ installation: createdInstallation,
115
+ };
116
+ });
117
+ };