handoff-app 2.0.0-2 → 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 (161) hide show
  1. package/README.md +140 -83
  2. package/dist/app-builder/build.js +21 -4
  3. package/dist/app-builder/client-config.js +9 -8
  4. package/dist/cli/auth/browser.d.ts +2 -0
  5. package/dist/cli/auth/browser.js +33 -0
  6. package/dist/cli/auth/device.d.ts +11 -0
  7. package/dist/cli/auth/device.js +155 -0
  8. package/dist/cli/auth/store.d.ts +29 -0
  9. package/dist/cli/auth/store.js +146 -0
  10. package/dist/cli/scaffold.js +20 -154
  11. package/dist/commands/index.js +4 -0
  12. package/dist/commands/login/index.d.ts +8 -0
  13. package/dist/commands/login/index.js +53 -0
  14. package/dist/commands/logout/index.d.ts +7 -0
  15. package/dist/commands/logout/index.js +54 -0
  16. package/dist/config/entries.d.ts +47 -0
  17. package/dist/config/entries.js +162 -0
  18. package/dist/config/index.d.ts +2 -0
  19. package/dist/config/index.js +5 -1
  20. package/dist/config/runtime.d.ts +9 -9
  21. package/dist/config/runtime.js +15 -15
  22. package/dist/registry/auth/access-tokens.d.ts +46 -0
  23. package/dist/registry/auth/access-tokens.js +165 -0
  24. package/dist/registry/auth/action-tokens.d.ts +66 -0
  25. package/dist/registry/auth/action-tokens.js +178 -0
  26. package/dist/registry/auth/crypto.d.ts +4 -0
  27. package/dist/registry/auth/crypto.js +16 -0
  28. package/dist/registry/auth/database.d.ts +7 -0
  29. package/dist/registry/auth/database.js +22 -0
  30. package/dist/registry/auth/device-authorizations.d.ts +51 -0
  31. package/dist/registry/auth/device-authorizations.js +170 -0
  32. package/dist/registry/auth/gravatar.d.ts +1 -0
  33. package/dist/registry/auth/gravatar.js +8 -0
  34. package/dist/registry/auth/index.d.ts +11 -0
  35. package/dist/registry/auth/index.js +72 -0
  36. package/dist/registry/auth/installation.d.ts +42 -0
  37. package/dist/registry/auth/installation.js +105 -0
  38. package/dist/registry/auth/password.d.ts +12 -0
  39. package/dist/registry/auth/password.js +58 -0
  40. package/dist/registry/auth/rate-limits.d.ts +22 -0
  41. package/dist/registry/auth/rate-limits.js +75 -0
  42. package/dist/registry/auth/types.d.ts +26 -0
  43. package/dist/registry/auth/types.js +9 -0
  44. package/dist/registry/auth/user-record.d.ts +13 -0
  45. package/dist/registry/auth/user-record.js +16 -0
  46. package/dist/registry/auth/users.d.ts +31 -0
  47. package/dist/registry/auth/users.js +135 -0
  48. package/dist/registry/auth/validation.d.ts +3 -0
  49. package/dist/registry/auth/validation.js +28 -0
  50. package/dist/registry/checkout/assets.js +4 -2
  51. package/dist/registry/checkout/index.d.ts +1 -1
  52. package/dist/registry/checkout/index.js +101 -23
  53. package/dist/registry/checkout/tokens.js +4 -2
  54. package/dist/registry/connection.d.ts +9 -0
  55. package/dist/registry/connection.js +29 -1
  56. package/dist/registry/db/driver.d.ts +5 -4
  57. package/dist/registry/db/driver.js +5 -4
  58. package/dist/registry/db/index.d.ts +1 -1
  59. package/dist/registry/db/index.js +7 -1
  60. package/dist/registry/db/schema.d.ts +2521 -750
  61. package/dist/registry/db/schema.js +126 -1
  62. package/dist/registry/publish/assets.js +1 -1
  63. package/dist/registry/publish/errors.js +3 -3
  64. package/dist/registry/publish/index.d.ts +1 -1
  65. package/dist/registry/publish/index.js +9 -9
  66. package/dist/registry/publish/tokens.js +1 -1
  67. package/dist/types/config.d.ts +4 -1
  68. package/docs/cli.md +4 -0
  69. package/drizzle/0005_add_registry_auth.sql +95 -0
  70. package/drizzle/meta/0005_snapshot.json +1926 -0
  71. package/drizzle/meta/_journal.json +8 -1
  72. package/package.json +5 -4
  73. package/src/app/components/Auth/AccountLayout.tsx +87 -0
  74. package/src/app/components/Auth/AuthControls.tsx +99 -0
  75. package/src/app/components/Auth/AuthShell.tsx +73 -0
  76. package/src/app/components/Auth/RegistryInstallGate.tsx +23 -0
  77. package/src/app/components/Auth/RegistrySessionProvider.tsx +15 -0
  78. package/src/app/components/Auth/api.ts +20 -0
  79. package/src/app/components/Layout/Header.tsx +2 -0
  80. package/src/app/components/Navigation/MobileNav.tsx +2 -0
  81. package/src/app/lib/auth/api.ts +113 -0
  82. package/src/app/lib/auth/config.ts +175 -0
  83. package/src/app/lib/auth/email.ts +34 -0
  84. package/src/app/lib/docs-api/runtime-config.ts +0 -14
  85. package/src/app/lib/registry-api/auth.ts +29 -46
  86. package/src/app/lib/registry-api/errors.ts +2 -2
  87. package/src/app/lib/registry-api/handler.ts +19 -14
  88. package/src/app/lib/registry-api/token-transfer.ts +1 -1
  89. package/src/app/lib/registry-api/transfer.ts +4 -4
  90. package/src/app/next.config.mjs +12 -3
  91. package/src/app/pages/_app.tsx +10 -4
  92. package/src/app/pages/account/index.tsx +153 -0
  93. package/src/app/pages/account/tokens.tsx +230 -0
  94. package/src/app/pages/account/users.tsx +310 -0
  95. package/src/app/pages/api/account/index.ts +26 -0
  96. package/src/app/pages/api/account/tokens/[id].ts +17 -0
  97. package/src/app/pages/api/account/tokens/index.ts +44 -0
  98. package/src/app/pages/api/admin/users/[id]/[action].ts +89 -0
  99. package/src/app/pages/api/admin/users/index.ts +53 -0
  100. package/src/app/pages/api/auth/[...nextauth].ts +4 -0
  101. package/src/app/pages/api/auth/request-reset.ts +53 -0
  102. package/src/app/pages/api/auth/reset-password.ts +29 -0
  103. package/src/app/pages/api/install.ts +102 -0
  104. package/src/app/pages/api/oauth/device/approve.ts +23 -0
  105. package/src/app/pages/api/oauth/device/index.ts +41 -0
  106. package/src/app/pages/api/oauth/revoke.ts +24 -0
  107. package/src/app/pages/api/oauth/token.ts +31 -0
  108. package/src/app/pages/api/registry/health.ts +4 -3
  109. package/src/app/pages/api/registry/transfer/[kind]/[id].ts +3 -3
  110. package/src/app/pages/api/registry/transfer/assets/[collection].ts +2 -1
  111. package/src/app/pages/api/registry/transfer/assets/blobs/[hash].ts +2 -1
  112. package/src/app/pages/api/registry/transfer/tokens/[...setId].ts +2 -2
  113. package/src/app/pages/cli/device.tsx +143 -0
  114. package/src/app/pages/install.tsx +336 -0
  115. package/src/app/pages/login.tsx +117 -0
  116. package/src/app/pages/reset-password.tsx +159 -0
  117. package/src/app/types/next-auth.d.ts +27 -0
  118. package/src/app-builder/build.ts +22 -5
  119. package/src/app-builder/client-config.ts +10 -9
  120. package/src/cli/auth/browser.ts +29 -0
  121. package/src/cli/auth/device.ts +181 -0
  122. package/src/cli/auth/store.ts +137 -0
  123. package/src/cli/scaffold.ts +20 -197
  124. package/src/commands/index.ts +4 -0
  125. package/src/commands/login/index.ts +48 -0
  126. package/src/commands/logout/index.ts +51 -0
  127. package/src/config/entries.ts +175 -0
  128. package/src/config/index.ts +4 -0
  129. package/src/config/runtime.ts +15 -14
  130. package/src/registry/auth/access-tokens.ts +198 -0
  131. package/src/registry/auth/action-tokens.ts +225 -0
  132. package/src/registry/auth/crypto.ts +13 -0
  133. package/src/registry/auth/database.ts +15 -0
  134. package/src/registry/auth/device-authorizations.ts +223 -0
  135. package/src/registry/auth/gravatar.ts +6 -0
  136. package/src/registry/auth/index.ts +89 -0
  137. package/src/registry/auth/installation.ts +117 -0
  138. package/src/registry/auth/password.ts +46 -0
  139. package/src/registry/auth/rate-limits.ts +84 -0
  140. package/src/registry/auth/types.ts +34 -0
  141. package/src/registry/auth/user-record.ts +25 -0
  142. package/src/registry/auth/users.ts +145 -0
  143. package/src/registry/auth/validation.ts +24 -0
  144. package/src/registry/checkout/assets.ts +4 -2
  145. package/src/registry/checkout/index.ts +114 -24
  146. package/src/registry/checkout/tokens.ts +4 -2
  147. package/src/registry/connection.ts +23 -0
  148. package/src/registry/db/driver.ts +5 -4
  149. package/src/registry/db/index.ts +12 -0
  150. package/src/registry/db/schema.ts +174 -1
  151. package/src/registry/publish/assets.ts +1 -1
  152. package/src/registry/publish/errors.ts +3 -3
  153. package/src/registry/publish/index.ts +9 -9
  154. package/src/registry/publish/tokens.ts +1 -1
  155. package/src/types/config.ts +4 -1
  156. package/templates/default/README.md.tpl +18 -0
  157. package/templates/default/gitignore.tpl +1 -0
  158. package/templates/default/handoff.config.js.tpl +0 -1
  159. package/templates/default/handoff.config.ts.tpl +0 -1
  160. package/templates/default/handoff.config.with-examples.js.tpl +0 -1
  161. package/templates/default/handoff.config.with-examples.ts.tpl +0 -1
@@ -0,0 +1,198 @@
1
+ import { and, eq, gt, isNull } from 'drizzle-orm';
2
+ import type { RegistryDatabase } from '../db/client';
3
+ import { registryAccessTokens, registryUsers, type RegistryAccessScope } from '../db/schema';
4
+ import { createOpaqueSecret, hashSecret, secretHashMatches } from './crypto';
5
+ import { withRegistryTransaction } from './database';
6
+ import { REGISTRY_READ_SCOPE, REGISTRY_WRITE_SCOPE, type RegistryPrincipal, type RegistryUserRole } from './types';
7
+
8
+ const ACCESS_TOKEN_PREFIX = 'hnd';
9
+ const DEFAULT_ACCESS_TOKEN_LIFETIME_MS = 365 * 24 * 60 * 60 * 1000;
10
+
11
+ export interface RegistryAccessTokenSummary {
12
+ id: string;
13
+ name: string;
14
+ scopes: RegistryAccessScope[];
15
+ expiresAt: Date;
16
+ lastUsedAt: Date | null;
17
+ revokedAt: Date | null;
18
+ createdAt: Date;
19
+ }
20
+
21
+ export type CreateRegistryAccessTokenResult =
22
+ | { ok: true; token: string; record: RegistryAccessTokenSummary }
23
+ | { ok: false; reason: 'user_not_found' | 'user_inactive' | 'scope_forbidden' | 'invalid_name' | 'invalid_expiry' };
24
+
25
+ const isRegistryScope = (value: unknown): value is RegistryAccessScope => value === REGISTRY_READ_SCOPE || value === REGISTRY_WRITE_SCOPE;
26
+
27
+ export const normalizeRegistryScopes = (scopes: readonly RegistryAccessScope[]): RegistryAccessScope[] => {
28
+ const unique = new Set(scopes.filter(isRegistryScope));
29
+ return [REGISTRY_READ_SCOPE, REGISTRY_WRITE_SCOPE].filter((scope) => unique.has(scope));
30
+ };
31
+
32
+ export const registryRoleAllowsScopes = (role: RegistryUserRole, scopes: readonly RegistryAccessScope[]): boolean =>
33
+ role === 'admin' || !scopes.includes(REGISTRY_WRITE_SCOPE);
34
+
35
+ export const formatRegistryAccessToken = (id: string, secret: string): string => `${ACCESS_TOKEN_PREFIX}_${id}_${secret}`;
36
+
37
+ export const parseRegistryAccessToken = (token: string): { id: string; secret: string } | null => {
38
+ const match = /^hnd_([A-Za-z0-9_-]{16})_([A-Za-z0-9_-]{43})$/.exec(token.trim());
39
+ return match ? { id: match[1], secret: match[2] } : null;
40
+ };
41
+
42
+ const createRegistryAccessTokenInTransaction = async (
43
+ db: RegistryDatabase,
44
+ input: {
45
+ userId: string;
46
+ name: string;
47
+ scopes: RegistryAccessScope[];
48
+ expiresAt?: Date;
49
+ now?: Date;
50
+ }
51
+ ): Promise<CreateRegistryAccessTokenResult> => {
52
+ const name = input.name.trim();
53
+ if (!name || name.length > 120) return { ok: false, reason: 'invalid_name' };
54
+
55
+ const now = input.now ?? new Date();
56
+ const expiresAt = input.expiresAt ?? new Date(now.getTime() + DEFAULT_ACCESS_TOKEN_LIFETIME_MS);
57
+ if (!(expiresAt instanceof Date) || !Number.isFinite(expiresAt.getTime()) || expiresAt <= now) {
58
+ return { ok: false, reason: 'invalid_expiry' };
59
+ }
60
+
61
+ const [user] = await db
62
+ .select({ id: registryUsers.id, role: registryUsers.role, status: registryUsers.status })
63
+ .from(registryUsers)
64
+ .where(eq(registryUsers.id, input.userId))
65
+ .limit(1)
66
+ .for('update');
67
+ if (!user) return { ok: false, reason: 'user_not_found' };
68
+ if (user.status !== 'active') return { ok: false, reason: 'user_inactive' };
69
+
70
+ const scopes = normalizeRegistryScopes(input.scopes);
71
+ if (!scopes.length) scopes.push(REGISTRY_READ_SCOPE);
72
+ if (!registryRoleAllowsScopes(user.role, scopes)) return { ok: false, reason: 'scope_forbidden' };
73
+
74
+ const id = createOpaqueSecret(12);
75
+ const secret = createOpaqueSecret(32);
76
+ const [record] = await db
77
+ .insert(registryAccessTokens)
78
+ .values({
79
+ id,
80
+ userId: user.id,
81
+ name,
82
+ secretHash: hashSecret(secret),
83
+ scopes,
84
+ expiresAt,
85
+ })
86
+ .returning({
87
+ id: registryAccessTokens.id,
88
+ name: registryAccessTokens.name,
89
+ scopes: registryAccessTokens.scopes,
90
+ expiresAt: registryAccessTokens.expiresAt,
91
+ lastUsedAt: registryAccessTokens.lastUsedAt,
92
+ revokedAt: registryAccessTokens.revokedAt,
93
+ createdAt: registryAccessTokens.createdAt,
94
+ });
95
+
96
+ return { ok: true, token: formatRegistryAccessToken(id, secret), record };
97
+ };
98
+
99
+ export const createRegistryAccessToken = async (
100
+ db: RegistryDatabase,
101
+ input: {
102
+ userId: string;
103
+ name: string;
104
+ scopes: RegistryAccessScope[];
105
+ expiresAt?: Date;
106
+ now?: Date;
107
+ }
108
+ ): Promise<CreateRegistryAccessTokenResult> => withRegistryTransaction(db, (tx) => createRegistryAccessTokenInTransaction(tx, input));
109
+
110
+ export const listRegistryAccessTokens = async (db: RegistryDatabase, userId: string): Promise<RegistryAccessTokenSummary[]> =>
111
+ db
112
+ .select({
113
+ id: registryAccessTokens.id,
114
+ name: registryAccessTokens.name,
115
+ scopes: registryAccessTokens.scopes,
116
+ expiresAt: registryAccessTokens.expiresAt,
117
+ lastUsedAt: registryAccessTokens.lastUsedAt,
118
+ revokedAt: registryAccessTokens.revokedAt,
119
+ createdAt: registryAccessTokens.createdAt,
120
+ })
121
+ .from(registryAccessTokens)
122
+ .where(eq(registryAccessTokens.userId, userId))
123
+ .orderBy(registryAccessTokens.createdAt);
124
+
125
+ export const revokeRegistryAccessToken = async (
126
+ db: RegistryDatabase,
127
+ input: { tokenId: string; userId: string; now?: Date }
128
+ ): Promise<boolean> => {
129
+ const rows = await db
130
+ .update(registryAccessTokens)
131
+ .set({ revokedAt: input.now ?? new Date() })
132
+ .where(
133
+ and(eq(registryAccessTokens.id, input.tokenId), eq(registryAccessTokens.userId, input.userId), isNull(registryAccessTokens.revokedAt))
134
+ )
135
+ .returning({ id: registryAccessTokens.id });
136
+ return rows.length > 0;
137
+ };
138
+
139
+ export const revokeAllRegistryAccessTokens = async (db: RegistryDatabase, userId: string, now = new Date()): Promise<number> => {
140
+ const rows = await db
141
+ .update(registryAccessTokens)
142
+ .set({ revokedAt: now })
143
+ .where(and(eq(registryAccessTokens.userId, userId), isNull(registryAccessTokens.revokedAt)))
144
+ .returning({ id: registryAccessTokens.id });
145
+ return rows.length;
146
+ };
147
+
148
+ /**
149
+ * Resolve an opaque bearer credential into a live principal. User state is joined on every call,
150
+ * making deactivation effective even if a defensive token-revocation write was interrupted.
151
+ */
152
+ export const authenticateRegistryAccessToken = async (
153
+ db: RegistryDatabase,
154
+ token: string,
155
+ now = new Date()
156
+ ): Promise<RegistryPrincipal | null> => {
157
+ const parsed = parseRegistryAccessToken(token);
158
+ if (!parsed) return null;
159
+
160
+ const [row] = await db
161
+ .select({
162
+ tokenId: registryAccessTokens.id,
163
+ secretHash: registryAccessTokens.secretHash,
164
+ scopes: registryAccessTokens.scopes,
165
+ expiresAt: registryAccessTokens.expiresAt,
166
+ revokedAt: registryAccessTokens.revokedAt,
167
+ userId: registryUsers.id,
168
+ email: registryUsers.email,
169
+ name: registryUsers.name,
170
+ role: registryUsers.role,
171
+ status: registryUsers.status,
172
+ })
173
+ .from(registryAccessTokens)
174
+ .innerJoin(registryUsers, eq(registryUsers.id, registryAccessTokens.userId))
175
+ .where(eq(registryAccessTokens.id, parsed.id))
176
+ .limit(1);
177
+
178
+ if (!row || row.status !== 'active' || row.revokedAt || row.expiresAt <= now || !secretHashMatches(parsed.secret, row.secretHash)) {
179
+ return null;
180
+ }
181
+
182
+ const scopes = normalizeRegistryScopes(Array.isArray(row.scopes) ? row.scopes : []);
183
+ const updated = await db
184
+ .update(registryAccessTokens)
185
+ .set({ lastUsedAt: now })
186
+ .where(and(eq(registryAccessTokens.id, row.tokenId), isNull(registryAccessTokens.revokedAt), gt(registryAccessTokens.expiresAt, now)))
187
+ .returning({ id: registryAccessTokens.id });
188
+ if (!updated.length) return null;
189
+
190
+ return {
191
+ tokenId: row.tokenId,
192
+ userId: row.userId,
193
+ email: row.email,
194
+ name: row.name,
195
+ role: row.role,
196
+ scopes,
197
+ };
198
+ };
@@ -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`;