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,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
+ };
@@ -0,0 +1,46 @@
1
+ import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
2
+ import { promisify } from 'node:util';
3
+
4
+ const scryptAsync = promisify(scrypt);
5
+ const PASSWORD_HASH_VERSION = 'scrypt-v1';
6
+ const PASSWORD_KEY_LENGTH = 64;
7
+ export const MINIMUM_PASSWORD_LENGTH = 12;
8
+ export const MAXIMUM_PASSWORD_LENGTH = 1024;
9
+
10
+ export type PasswordValidationResult = { valid: true } | { valid: false; error: string };
11
+
12
+ export const validatePassword = (password: string): PasswordValidationResult => {
13
+ if (password.length < MINIMUM_PASSWORD_LENGTH) {
14
+ return { valid: false, error: `Password must be at least ${MINIMUM_PASSWORD_LENGTH} characters.` };
15
+ }
16
+ if (password.length > MAXIMUM_PASSWORD_LENGTH) {
17
+ return { valid: false, error: `Password must be at most ${MAXIMUM_PASSWORD_LENGTH} characters.` };
18
+ }
19
+ return { valid: true };
20
+ };
21
+
22
+ /** Versioned scrypt encoding: `scrypt-v1:<base64url salt>:<base64url derived key>`. */
23
+ export const hashPassword = async (password: string): Promise<string> => {
24
+ const validation = validatePassword(password);
25
+ if ('error' in validation) throw new Error(validation.error);
26
+
27
+ const salt = randomBytes(16);
28
+ const derived = (await scryptAsync(password, salt, PASSWORD_KEY_LENGTH)) as Buffer;
29
+ return `${PASSWORD_HASH_VERSION}:${salt.toString('base64url')}:${derived.toString('base64url')}`;
30
+ };
31
+
32
+ export const verifyPassword = async (password: string, encoded: string | null | undefined): Promise<boolean> => {
33
+ if (!encoded || password.length > MAXIMUM_PASSWORD_LENGTH) return false;
34
+ const [version, saltText, hashText, ...rest] = encoded.split(':');
35
+ if (version !== PASSWORD_HASH_VERSION || !saltText || !hashText || rest.length) return false;
36
+
37
+ try {
38
+ const salt = Buffer.from(saltText, 'base64url');
39
+ const expected = Buffer.from(hashText, 'base64url');
40
+ if (salt.length !== 16 || expected.length !== PASSWORD_KEY_LENGTH) return false;
41
+ const actual = (await scryptAsync(password, salt, PASSWORD_KEY_LENGTH)) as Buffer;
42
+ return actual.length === expected.length && timingSafeEqual(actual, expected);
43
+ } catch {
44
+ return false;
45
+ }
46
+ };
@@ -0,0 +1,84 @@
1
+ import { and, eq, lt, sql } from 'drizzle-orm';
2
+ import type { RegistryDatabase } from '../db/client';
3
+ import { registryAuthRateLimits, type RegistryRateLimitBucket } from '../db/schema';
4
+ import { hashSecret } from './crypto';
5
+
6
+ export interface ConsumeAuthRateLimitInput {
7
+ bucket: RegistryRateLimitBucket;
8
+ identifier: string;
9
+ limit: number;
10
+ windowMs: number;
11
+ now?: Date;
12
+ }
13
+
14
+ export interface AuthRateLimitResult {
15
+ allowed: boolean;
16
+ attempts: number;
17
+ limit: number;
18
+ remaining: number;
19
+ resetsAt: Date;
20
+ retryAfterSeconds: number;
21
+ }
22
+
23
+ export const hashRateLimitIdentifier = (bucket: RegistryRateLimitBucket, identifier: string): string =>
24
+ hashSecret(`${bucket}:${identifier.trim().toLowerCase()}`);
25
+
26
+ /** Atomic fixed-window counter shared by all registry instances. */
27
+ export const consumeAuthRateLimit = async (db: RegistryDatabase, input: ConsumeAuthRateLimitInput): Promise<AuthRateLimitResult> => {
28
+ if (!Number.isInteger(input.limit) || input.limit < 1) throw new Error('Rate-limit limit must be a positive integer.');
29
+ if (!Number.isInteger(input.windowMs) || input.windowMs < 1) {
30
+ throw new Error('Rate-limit windowMs must be a positive integer.');
31
+ }
32
+
33
+ const now = input.now ?? new Date();
34
+ const windowStartedAt = new Date(Math.floor(now.getTime() / input.windowMs) * input.windowMs);
35
+ const resetsAt = new Date(windowStartedAt.getTime() + input.windowMs);
36
+ const identifierHash = hashRateLimitIdentifier(input.bucket, input.identifier);
37
+ const [counter] = await db
38
+ .insert(registryAuthRateLimits)
39
+ .values({
40
+ bucket: input.bucket,
41
+ identifierHash,
42
+ windowStartedAt,
43
+ attempts: 1,
44
+ expiresAt: resetsAt,
45
+ updatedAt: now,
46
+ })
47
+ .onConflictDoUpdate({
48
+ target: [registryAuthRateLimits.bucket, registryAuthRateLimits.identifierHash, registryAuthRateLimits.windowStartedAt],
49
+ set: {
50
+ attempts: sql`${registryAuthRateLimits.attempts} + 1`,
51
+ expiresAt: resetsAt,
52
+ updatedAt: now,
53
+ },
54
+ })
55
+ .returning({ attempts: registryAuthRateLimits.attempts });
56
+
57
+ const attempts = counter.attempts;
58
+ return {
59
+ allowed: attempts <= input.limit,
60
+ attempts,
61
+ limit: input.limit,
62
+ remaining: Math.max(0, input.limit - attempts),
63
+ resetsAt,
64
+ retryAfterSeconds: attempts > input.limit ? Math.max(1, Math.ceil((resetsAt.getTime() - now.getTime()) / 1000)) : 0,
65
+ };
66
+ };
67
+
68
+ export const clearAuthRateLimit = async (db: RegistryDatabase, bucket: RegistryRateLimitBucket, identifier: string): Promise<number> => {
69
+ const rows = await db
70
+ .delete(registryAuthRateLimits)
71
+ .where(
72
+ and(eq(registryAuthRateLimits.bucket, bucket), eq(registryAuthRateLimits.identifierHash, hashRateLimitIdentifier(bucket, identifier)))
73
+ )
74
+ .returning({ identifierHash: registryAuthRateLimits.identifierHash });
75
+ return rows.length;
76
+ };
77
+
78
+ export const purgeExpiredAuthRateLimits = async (db: RegistryDatabase, now = new Date()): Promise<number> => {
79
+ const rows = await db
80
+ .delete(registryAuthRateLimits)
81
+ .where(lt(registryAuthRateLimits.expiresAt, now))
82
+ .returning({ identifierHash: registryAuthRateLimits.identifierHash });
83
+ return rows.length;
84
+ };
@@ -0,0 +1,34 @@
1
+ import type { RegistryAccessScope, RegistryUserRole, RegistryUserStatus } from '../db/schema';
2
+
3
+ export type { RegistryAccessScope, RegistryUserRole, RegistryUserStatus };
4
+
5
+ export const REGISTRY_READ_SCOPE = 'registry:read' as const;
6
+ export const REGISTRY_WRITE_SCOPE = 'registry:write' as const;
7
+
8
+ export interface RegistryUser {
9
+ id: string;
10
+ email: string;
11
+ name: string | null;
12
+ image: string | null;
13
+ role: RegistryUserRole;
14
+ status: RegistryUserStatus;
15
+ emailVerifiedAt: Date | null;
16
+ authVersion: number;
17
+ createdAt: Date;
18
+ updatedAt: Date;
19
+ }
20
+
21
+ export interface RegistryPrincipal {
22
+ tokenId: string;
23
+ userId: string;
24
+ email: string;
25
+ name: string | null;
26
+ role: RegistryUserRole;
27
+ scopes: RegistryAccessScope[];
28
+ }
29
+
30
+ export const scopesForRegistryRole = (role: RegistryUserRole): RegistryAccessScope[] =>
31
+ role === 'admin' ? [REGISTRY_READ_SCOPE, REGISTRY_WRITE_SCOPE] : [REGISTRY_READ_SCOPE];
32
+
33
+ export const registryPrincipalHasScope = (principal: RegistryPrincipal, scope: RegistryAccessScope): boolean =>
34
+ principal.scopes.includes(scope);
@@ -0,0 +1,25 @@
1
+ import type { RegistryUser } from './types';
2
+
3
+ export const toRegistryUser = (row: {
4
+ id: string;
5
+ email: string;
6
+ name: string | null;
7
+ image: string | null;
8
+ role: RegistryUser['role'];
9
+ status: RegistryUser['status'];
10
+ emailVerifiedAt: Date | null;
11
+ authVersion: number;
12
+ createdAt: Date;
13
+ updatedAt: Date;
14
+ }): RegistryUser => ({
15
+ id: row.id,
16
+ email: row.email,
17
+ name: row.name,
18
+ image: row.image,
19
+ role: row.role,
20
+ status: row.status,
21
+ emailVerifiedAt: row.emailVerifiedAt,
22
+ authVersion: row.authVersion,
23
+ createdAt: row.createdAt,
24
+ updatedAt: row.updatedAt,
25
+ });
@@ -0,0 +1,145 @@
1
+ import { and, count, eq, sql } from 'drizzle-orm';
2
+ import type { RegistryDatabase } from '../db/client';
3
+ import { registryUsers, type RegistryUserRole } from '../db/schema';
4
+ import { revokeAllRegistryAccessTokens } from './access-tokens';
5
+ import { acquireRegistryAdvisoryLock, REGISTRY_ADMIN_LOCK_ID, withRegistryTransaction } from './database';
6
+ import { normalizeEmail } from './crypto';
7
+ import { verifyPassword } from './password';
8
+ import type { RegistryUser, RegistryUserStatus } from './types';
9
+ import { toRegistryUser } from './user-record';
10
+ import { validateRegistryDisplayName, validateRegistryImageUrl } from './validation';
11
+
12
+ const DUMMY_PASSWORD_HASH =
13
+ 'scrypt-v1:AAAAAAAAAAAAAAAAAAAAAA:yK99vlYIaV57oeErq8gHqx18DxRfiUoHwETrTFUjt5JasWeMsu62stPgY-fO0TUeUc6uAu1hge_vuSQY15vvuQ';
14
+
15
+ export type RegistryUserMutationResult =
16
+ | { ok: true; user: RegistryUser }
17
+ | {
18
+ ok: false;
19
+ reason: 'not_found' | 'invalid_name' | 'invalid_image' | 'invalid_role' | 'last_admin' | 'self_deactivation' | 'invalid_status';
20
+ };
21
+
22
+ export const getRegistryUserById = async (db: RegistryDatabase, userId: string): Promise<RegistryUser | null> => {
23
+ const [row] = await db.select().from(registryUsers).where(eq(registryUsers.id, userId)).limit(1);
24
+ return row ? toRegistryUser(row) : null;
25
+ };
26
+
27
+ export const listRegistryUsers = async (db: RegistryDatabase): Promise<RegistryUser[]> => {
28
+ const rows = await db.select().from(registryUsers).orderBy(registryUsers.createdAt);
29
+ return rows.map(toRegistryUser);
30
+ };
31
+
32
+ /** Credentials authentication is intentionally enumeration-safe: all failures return null. */
33
+ export const authenticateRegistryCredentials = async (
34
+ db: RegistryDatabase,
35
+ email: string,
36
+ password: string
37
+ ): Promise<RegistryUser | null> => {
38
+ const normalizedEmail = normalizeEmail(email);
39
+ const [row] = await db.select().from(registryUsers).where(eq(registryUsers.email, normalizedEmail)).limit(1);
40
+ const passwordMatches = await verifyPassword(password, row?.passwordHash ?? DUMMY_PASSWORD_HASH);
41
+ return row && row.status === 'active' && row.passwordHash && passwordMatches ? toRegistryUser(row) : null;
42
+ };
43
+
44
+ export const updateRegistryUserProfile = async (
45
+ db: RegistryDatabase,
46
+ input: { userId: string; name: string; image?: string | null; now?: Date }
47
+ ): Promise<RegistryUserMutationResult> => {
48
+ const name = validateRegistryDisplayName(input.name);
49
+ if (!name) return { ok: false, reason: 'invalid_name' };
50
+ const image = validateRegistryImageUrl(input.image);
51
+ if (image === false) return { ok: false, reason: 'invalid_image' };
52
+
53
+ const [row] = await db
54
+ .update(registryUsers)
55
+ .set({ name, image, updatedAt: input.now ?? new Date() })
56
+ .where(eq(registryUsers.id, input.userId))
57
+ .returning();
58
+ return row ? { ok: true, user: toRegistryUser(row) } : { ok: false, reason: 'not_found' };
59
+ };
60
+
61
+ export const updateRegistryUserRole = async (
62
+ db: RegistryDatabase,
63
+ input: { userId: string; role: RegistryUserRole; now?: Date }
64
+ ): Promise<RegistryUserMutationResult> => {
65
+ if (input.role !== 'admin' && input.role !== 'member') return { ok: false, reason: 'invalid_role' };
66
+ return withRegistryTransaction(db, async (tx) => {
67
+ await acquireRegistryAdvisoryLock(tx, REGISTRY_ADMIN_LOCK_ID);
68
+ const [target] = await tx.select().from(registryUsers).where(eq(registryUsers.id, input.userId)).limit(1);
69
+ if (!target) return { ok: false, reason: 'not_found' };
70
+ if (target.role === input.role) return { ok: true, user: toRegistryUser(target) };
71
+
72
+ if (target.role === 'admin' && target.status === 'active' && input.role === 'member') {
73
+ const [{ value }] = await tx
74
+ .select({ value: count() })
75
+ .from(registryUsers)
76
+ .where(and(eq(registryUsers.role, 'admin'), eq(registryUsers.status, 'active')));
77
+ if (Number(value) <= 1) return { ok: false, reason: 'last_admin' };
78
+ }
79
+
80
+ const now = input.now ?? new Date();
81
+ const [row] = await tx
82
+ .update(registryUsers)
83
+ .set({
84
+ role: input.role,
85
+ authVersion: sql`${registryUsers.authVersion} + 1`,
86
+ updatedAt: now,
87
+ })
88
+ .where(eq(registryUsers.id, input.userId))
89
+ .returning();
90
+ if (target.role === 'admin' && input.role === 'member') {
91
+ await revokeAllRegistryAccessTokens(tx, input.userId, now);
92
+ }
93
+ return { ok: true, user: toRegistryUser(row) };
94
+ });
95
+ };
96
+
97
+ export const setRegistryUserStatus = async (
98
+ db: RegistryDatabase,
99
+ input: {
100
+ userId: string;
101
+ status: Extract<RegistryUserStatus, 'active' | 'deactivated'>;
102
+ actorUserId?: string;
103
+ now?: Date;
104
+ }
105
+ ): Promise<RegistryUserMutationResult> => {
106
+ if (input.status !== 'active' && input.status !== 'deactivated') {
107
+ return { ok: false, reason: 'invalid_status' };
108
+ }
109
+ if (input.status === 'deactivated' && input.actorUserId === input.userId) {
110
+ return { ok: false, reason: 'self_deactivation' };
111
+ }
112
+
113
+ return withRegistryTransaction(db, async (tx) => {
114
+ await acquireRegistryAdvisoryLock(tx, REGISTRY_ADMIN_LOCK_ID);
115
+ const [target] = await tx.select().from(registryUsers).where(eq(registryUsers.id, input.userId)).limit(1);
116
+ if (!target) return { ok: false, reason: 'not_found' };
117
+ if (target.status === input.status) return { ok: true, user: toRegistryUser(target) };
118
+ if (input.status === 'active' && target.status === 'invited') {
119
+ return { ok: false, reason: 'invalid_status' };
120
+ }
121
+
122
+ if (target.role === 'admin' && target.status === 'active' && input.status === 'deactivated') {
123
+ const [{ value }] = await tx
124
+ .select({ value: count() })
125
+ .from(registryUsers)
126
+ .where(and(eq(registryUsers.role, 'admin'), eq(registryUsers.status, 'active')));
127
+ if (Number(value) <= 1) return { ok: false, reason: 'last_admin' };
128
+ }
129
+
130
+ const now = input.now ?? new Date();
131
+ const [row] = await tx
132
+ .update(registryUsers)
133
+ .set({
134
+ status: input.status,
135
+ authVersion: sql`${registryUsers.authVersion} + 1`,
136
+ updatedAt: now,
137
+ })
138
+ .where(eq(registryUsers.id, input.userId))
139
+ .returning();
140
+ if (input.status === 'deactivated') {
141
+ await revokeAllRegistryAccessTokens(tx, input.userId, now);
142
+ }
143
+ return { ok: true, user: toRegistryUser(row) };
144
+ });
145
+ };
@@ -0,0 +1,24 @@
1
+ import { normalizeEmail } from './crypto';
2
+
3
+ const SIMPLE_EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
4
+
5
+ export const validateRegistryEmail = (email: string): string | null => {
6
+ const normalized = normalizeEmail(email);
7
+ return normalized.length <= 320 && SIMPLE_EMAIL_PATTERN.test(normalized) ? normalized : null;
8
+ };
9
+
10
+ export const validateRegistryDisplayName = (name: string): string | null => {
11
+ const normalized = name.trim();
12
+ return normalized && normalized.length <= 120 ? normalized : null;
13
+ };
14
+
15
+ export const validateRegistryImageUrl = (image: string | null | undefined): string | null | false => {
16
+ const normalized = image?.trim();
17
+ if (!normalized) return null;
18
+ try {
19
+ const parsed = new URL(normalized);
20
+ return parsed.protocol === 'https:' && normalized.length <= 2048 ? normalized : false;
21
+ } catch {
22
+ return false;
23
+ }
24
+ };
@@ -28,7 +28,9 @@ const describeFetchFailure = (error: RegistryClientError, target: string, regist
28
28
  case 'runtime_mode_conflict':
29
29
  return `The registry at ${registryUrl} is not running in registry mode, so it cannot serve a checkout: ${error.message}`;
30
30
  case 'unauthorized':
31
- return `The registry rejected the access token (401). Check the configured access token matches the registry's token.`;
31
+ return `The registry rejected the access token (401). Run \`handoff-app login --url ${registryUrl}\` again, or replace the user-issued CI token.`;
32
+ case 'forbidden':
33
+ return `The registry token does not have permission to checkout assets (403). Authorize a token with registry:read access.`;
32
34
  default:
33
35
  return error.message;
34
36
  }
@@ -55,7 +57,7 @@ const wouldChange = (absolutePath: string, bytes: Buffer): boolean => {
55
57
  * Recreates workspace asset files; prompts before overwriting changed local files unless `--force`.
56
58
  */
57
59
  export const checkoutAssets = async (handoff: Handoff, collection?: string): Promise<void> => {
58
- const connection = resolveConnectionOrThrow(handoff);
60
+ const connection = await resolveConnectionOrThrow(handoff);
59
61
  if (collection && !isAssetCollection(collection)) {
60
62
  throw new CheckoutError(`Unknown asset collection "${collection}". Supported collections: ${ASSET_COLLECTIONS.join(', ')}.`);
61
63
  }