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,105 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.installRegistry = exports.getRegistryInstallationState = exports.getRegistryInstallation = exports.REGISTRY_INSTALLATION_ID = exports.REGISTRY_AUTH_SCHEMA_VERSION = void 0;
13
+ const drizzle_orm_1 = require("drizzle-orm");
14
+ const node_crypto_1 = require("node:crypto");
15
+ const schema_1 = require("../db/schema");
16
+ const database_1 = require("./database");
17
+ const password_1 = require("./password");
18
+ const user_record_1 = require("./user-record");
19
+ const validation_1 = require("./validation");
20
+ /** Migration number that introduced the registry authentication schema. */
21
+ exports.REGISTRY_AUTH_SCHEMA_VERSION = 5;
22
+ exports.REGISTRY_INSTALLATION_ID = 'default';
23
+ const getRegistryInstallation = (db) => __awaiter(void 0, void 0, void 0, function* () {
24
+ const [installation] = yield db
25
+ .select()
26
+ .from(schema_1.registryInstallations)
27
+ .where((0, drizzle_orm_1.eq)(schema_1.registryInstallations.id, exports.REGISTRY_INSTALLATION_ID))
28
+ .limit(1);
29
+ return installation !== null && installation !== void 0 ? installation : null;
30
+ });
31
+ exports.getRegistryInstallation = getRegistryInstallation;
32
+ const getRegistryInstallationState = (db) => __awaiter(void 0, void 0, void 0, function* () {
33
+ const installation = yield (0, exports.getRegistryInstallation)(db);
34
+ const [{ value }] = yield db.select({ value: (0, drizzle_orm_1.count)() }).from(schema_1.registryUsers);
35
+ const userCount = Number(value);
36
+ if (installation)
37
+ return { status: 'installed', installation, userCount };
38
+ return userCount === 0
39
+ ? { status: 'ready', installation: null, userCount: 0 }
40
+ : { status: 'inconsistent', installation: null, userCount };
41
+ });
42
+ exports.getRegistryInstallationState = getRegistryInstallationState;
43
+ /**
44
+ * Complete first-visitor installation under a transaction-scoped advisory lock. Password hashing is
45
+ * intentionally performed before taking the lock; all state checks are repeated inside it.
46
+ */
47
+ const installRegistry = (db, input) => __awaiter(void 0, void 0, void 0, function* () {
48
+ var _a;
49
+ const email = (0, validation_1.validateRegistryEmail)(input.email);
50
+ if (!email)
51
+ return { ok: false, reason: 'invalid_email' };
52
+ const name = (0, validation_1.validateRegistryDisplayName)(input.name);
53
+ if (!name)
54
+ return { ok: false, reason: 'invalid_name' };
55
+ const passwordValidation = (0, password_1.validatePassword)(input.password);
56
+ if ('error' in passwordValidation) {
57
+ return { ok: false, reason: 'invalid_password', error: passwordValidation.error };
58
+ }
59
+ const passwordHash = yield (0, password_1.hashPassword)(input.password);
60
+ const now = (_a = input.now) !== null && _a !== void 0 ? _a : new Date();
61
+ return (0, database_1.withRegistryTransaction)(db, (tx) => __awaiter(void 0, void 0, void 0, function* () {
62
+ yield (0, database_1.acquireRegistryAdvisoryLock)(tx, database_1.REGISTRY_INSTALL_LOCK_ID);
63
+ const [installation] = yield tx
64
+ .select()
65
+ .from(schema_1.registryInstallations)
66
+ .where((0, drizzle_orm_1.eq)(schema_1.registryInstallations.id, exports.REGISTRY_INSTALLATION_ID))
67
+ .limit(1);
68
+ if (installation)
69
+ return { ok: false, reason: 'already_installed' };
70
+ const [{ value }] = yield tx.select({ value: (0, drizzle_orm_1.count)() }).from(schema_1.registryUsers);
71
+ if (Number(value) > 0)
72
+ return { ok: false, reason: 'users_exist' };
73
+ const userId = (0, node_crypto_1.randomUUID)();
74
+ const [userRow] = yield tx
75
+ .insert(schema_1.registryUsers)
76
+ .values({
77
+ id: userId,
78
+ email,
79
+ name,
80
+ role: 'admin',
81
+ status: 'active',
82
+ passwordHash,
83
+ emailVerifiedAt: now,
84
+ createdAt: now,
85
+ updatedAt: now,
86
+ })
87
+ .returning();
88
+ const [createdInstallation] = yield tx
89
+ .insert(schema_1.registryInstallations)
90
+ .values({
91
+ id: exports.REGISTRY_INSTALLATION_ID,
92
+ status: 'installed',
93
+ schemaVersion: exports.REGISTRY_AUTH_SCHEMA_VERSION,
94
+ initialAdminUserId: userId,
95
+ installedAt: now,
96
+ })
97
+ .returning();
98
+ return {
99
+ ok: true,
100
+ user: (0, user_record_1.toRegistryUser)(userRow),
101
+ installation: createdInstallation,
102
+ };
103
+ }));
104
+ });
105
+ exports.installRegistry = installRegistry;
@@ -0,0 +1,12 @@
1
+ export declare const MINIMUM_PASSWORD_LENGTH = 12;
2
+ export declare const MAXIMUM_PASSWORD_LENGTH = 1024;
3
+ export type PasswordValidationResult = {
4
+ valid: true;
5
+ } | {
6
+ valid: false;
7
+ error: string;
8
+ };
9
+ export declare const validatePassword: (password: string) => PasswordValidationResult;
10
+ /** Versioned scrypt encoding: `scrypt-v1:<base64url salt>:<base64url derived key>`. */
11
+ export declare const hashPassword: (password: string) => Promise<string>;
12
+ export declare const verifyPassword: (password: string, encoded: string | null | undefined) => Promise<boolean>;
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.verifyPassword = exports.hashPassword = exports.validatePassword = exports.MAXIMUM_PASSWORD_LENGTH = exports.MINIMUM_PASSWORD_LENGTH = void 0;
13
+ const node_crypto_1 = require("node:crypto");
14
+ const node_util_1 = require("node:util");
15
+ const scryptAsync = (0, node_util_1.promisify)(node_crypto_1.scrypt);
16
+ const PASSWORD_HASH_VERSION = 'scrypt-v1';
17
+ const PASSWORD_KEY_LENGTH = 64;
18
+ exports.MINIMUM_PASSWORD_LENGTH = 12;
19
+ exports.MAXIMUM_PASSWORD_LENGTH = 1024;
20
+ const validatePassword = (password) => {
21
+ if (password.length < exports.MINIMUM_PASSWORD_LENGTH) {
22
+ return { valid: false, error: `Password must be at least ${exports.MINIMUM_PASSWORD_LENGTH} characters.` };
23
+ }
24
+ if (password.length > exports.MAXIMUM_PASSWORD_LENGTH) {
25
+ return { valid: false, error: `Password must be at most ${exports.MAXIMUM_PASSWORD_LENGTH} characters.` };
26
+ }
27
+ return { valid: true };
28
+ };
29
+ exports.validatePassword = validatePassword;
30
+ /** Versioned scrypt encoding: `scrypt-v1:<base64url salt>:<base64url derived key>`. */
31
+ const hashPassword = (password) => __awaiter(void 0, void 0, void 0, function* () {
32
+ const validation = (0, exports.validatePassword)(password);
33
+ if ('error' in validation)
34
+ throw new Error(validation.error);
35
+ const salt = (0, node_crypto_1.randomBytes)(16);
36
+ const derived = (yield scryptAsync(password, salt, PASSWORD_KEY_LENGTH));
37
+ return `${PASSWORD_HASH_VERSION}:${salt.toString('base64url')}:${derived.toString('base64url')}`;
38
+ });
39
+ exports.hashPassword = hashPassword;
40
+ const verifyPassword = (password, encoded) => __awaiter(void 0, void 0, void 0, function* () {
41
+ if (!encoded || password.length > exports.MAXIMUM_PASSWORD_LENGTH)
42
+ return false;
43
+ const [version, saltText, hashText, ...rest] = encoded.split(':');
44
+ if (version !== PASSWORD_HASH_VERSION || !saltText || !hashText || rest.length)
45
+ return false;
46
+ try {
47
+ const salt = Buffer.from(saltText, 'base64url');
48
+ const expected = Buffer.from(hashText, 'base64url');
49
+ if (salt.length !== 16 || expected.length !== PASSWORD_KEY_LENGTH)
50
+ return false;
51
+ const actual = (yield scryptAsync(password, salt, PASSWORD_KEY_LENGTH));
52
+ return actual.length === expected.length && (0, node_crypto_1.timingSafeEqual)(actual, expected);
53
+ }
54
+ catch (_a) {
55
+ return false;
56
+ }
57
+ });
58
+ exports.verifyPassword = verifyPassword;
@@ -0,0 +1,22 @@
1
+ import type { RegistryDatabase } from '../db/client';
2
+ import { type RegistryRateLimitBucket } from '../db/schema';
3
+ export interface ConsumeAuthRateLimitInput {
4
+ bucket: RegistryRateLimitBucket;
5
+ identifier: string;
6
+ limit: number;
7
+ windowMs: number;
8
+ now?: Date;
9
+ }
10
+ export interface AuthRateLimitResult {
11
+ allowed: boolean;
12
+ attempts: number;
13
+ limit: number;
14
+ remaining: number;
15
+ resetsAt: Date;
16
+ retryAfterSeconds: number;
17
+ }
18
+ export declare const hashRateLimitIdentifier: (bucket: RegistryRateLimitBucket, identifier: string) => string;
19
+ /** Atomic fixed-window counter shared by all registry instances. */
20
+ export declare const consumeAuthRateLimit: (db: RegistryDatabase, input: ConsumeAuthRateLimitInput) => Promise<AuthRateLimitResult>;
21
+ export declare const clearAuthRateLimit: (db: RegistryDatabase, bucket: RegistryRateLimitBucket, identifier: string) => Promise<number>;
22
+ export declare const purgeExpiredAuthRateLimits: (db: RegistryDatabase, now?: Date) => Promise<number>;
@@ -0,0 +1,75 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.purgeExpiredAuthRateLimits = exports.clearAuthRateLimit = exports.consumeAuthRateLimit = exports.hashRateLimitIdentifier = void 0;
13
+ const drizzle_orm_1 = require("drizzle-orm");
14
+ const schema_1 = require("../db/schema");
15
+ const crypto_1 = require("./crypto");
16
+ const hashRateLimitIdentifier = (bucket, identifier) => (0, crypto_1.hashSecret)(`${bucket}:${identifier.trim().toLowerCase()}`);
17
+ exports.hashRateLimitIdentifier = hashRateLimitIdentifier;
18
+ /** Atomic fixed-window counter shared by all registry instances. */
19
+ const consumeAuthRateLimit = (db, input) => __awaiter(void 0, void 0, void 0, function* () {
20
+ var _a;
21
+ if (!Number.isInteger(input.limit) || input.limit < 1)
22
+ throw new Error('Rate-limit limit must be a positive integer.');
23
+ if (!Number.isInteger(input.windowMs) || input.windowMs < 1) {
24
+ throw new Error('Rate-limit windowMs must be a positive integer.');
25
+ }
26
+ const now = (_a = input.now) !== null && _a !== void 0 ? _a : new Date();
27
+ const windowStartedAt = new Date(Math.floor(now.getTime() / input.windowMs) * input.windowMs);
28
+ const resetsAt = new Date(windowStartedAt.getTime() + input.windowMs);
29
+ const identifierHash = (0, exports.hashRateLimitIdentifier)(input.bucket, input.identifier);
30
+ const [counter] = yield db
31
+ .insert(schema_1.registryAuthRateLimits)
32
+ .values({
33
+ bucket: input.bucket,
34
+ identifierHash,
35
+ windowStartedAt,
36
+ attempts: 1,
37
+ expiresAt: resetsAt,
38
+ updatedAt: now,
39
+ })
40
+ .onConflictDoUpdate({
41
+ target: [schema_1.registryAuthRateLimits.bucket, schema_1.registryAuthRateLimits.identifierHash, schema_1.registryAuthRateLimits.windowStartedAt],
42
+ set: {
43
+ attempts: (0, drizzle_orm_1.sql) `${schema_1.registryAuthRateLimits.attempts} + 1`,
44
+ expiresAt: resetsAt,
45
+ updatedAt: now,
46
+ },
47
+ })
48
+ .returning({ attempts: schema_1.registryAuthRateLimits.attempts });
49
+ const attempts = counter.attempts;
50
+ return {
51
+ allowed: attempts <= input.limit,
52
+ attempts,
53
+ limit: input.limit,
54
+ remaining: Math.max(0, input.limit - attempts),
55
+ resetsAt,
56
+ retryAfterSeconds: attempts > input.limit ? Math.max(1, Math.ceil((resetsAt.getTime() - now.getTime()) / 1000)) : 0,
57
+ };
58
+ });
59
+ exports.consumeAuthRateLimit = consumeAuthRateLimit;
60
+ const clearAuthRateLimit = (db, bucket, identifier) => __awaiter(void 0, void 0, void 0, function* () {
61
+ const rows = yield db
62
+ .delete(schema_1.registryAuthRateLimits)
63
+ .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.registryAuthRateLimits.bucket, bucket), (0, drizzle_orm_1.eq)(schema_1.registryAuthRateLimits.identifierHash, (0, exports.hashRateLimitIdentifier)(bucket, identifier))))
64
+ .returning({ identifierHash: schema_1.registryAuthRateLimits.identifierHash });
65
+ return rows.length;
66
+ });
67
+ exports.clearAuthRateLimit = clearAuthRateLimit;
68
+ const purgeExpiredAuthRateLimits = (db_1, ...args_1) => __awaiter(void 0, [db_1, ...args_1], void 0, function* (db, now = new Date()) {
69
+ const rows = yield db
70
+ .delete(schema_1.registryAuthRateLimits)
71
+ .where((0, drizzle_orm_1.lt)(schema_1.registryAuthRateLimits.expiresAt, now))
72
+ .returning({ identifierHash: schema_1.registryAuthRateLimits.identifierHash });
73
+ return rows.length;
74
+ });
75
+ exports.purgeExpiredAuthRateLimits = purgeExpiredAuthRateLimits;
@@ -0,0 +1,26 @@
1
+ import type { RegistryAccessScope, RegistryUserRole, RegistryUserStatus } from '../db/schema';
2
+ export type { RegistryAccessScope, RegistryUserRole, RegistryUserStatus };
3
+ export declare const REGISTRY_READ_SCOPE: "registry:read";
4
+ export declare const REGISTRY_WRITE_SCOPE: "registry:write";
5
+ export interface RegistryUser {
6
+ id: string;
7
+ email: string;
8
+ name: string | null;
9
+ image: string | null;
10
+ role: RegistryUserRole;
11
+ status: RegistryUserStatus;
12
+ emailVerifiedAt: Date | null;
13
+ authVersion: number;
14
+ createdAt: Date;
15
+ updatedAt: Date;
16
+ }
17
+ export interface RegistryPrincipal {
18
+ tokenId: string;
19
+ userId: string;
20
+ email: string;
21
+ name: string | null;
22
+ role: RegistryUserRole;
23
+ scopes: RegistryAccessScope[];
24
+ }
25
+ export declare const scopesForRegistryRole: (role: RegistryUserRole) => RegistryAccessScope[];
26
+ export declare const registryPrincipalHasScope: (principal: RegistryPrincipal, scope: RegistryAccessScope) => boolean;
@@ -0,0 +1,9 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registryPrincipalHasScope = exports.scopesForRegistryRole = exports.REGISTRY_WRITE_SCOPE = exports.REGISTRY_READ_SCOPE = void 0;
4
+ exports.REGISTRY_READ_SCOPE = 'registry:read';
5
+ exports.REGISTRY_WRITE_SCOPE = 'registry:write';
6
+ const scopesForRegistryRole = (role) => role === 'admin' ? [exports.REGISTRY_READ_SCOPE, exports.REGISTRY_WRITE_SCOPE] : [exports.REGISTRY_READ_SCOPE];
7
+ exports.scopesForRegistryRole = scopesForRegistryRole;
8
+ const registryPrincipalHasScope = (principal, scope) => principal.scopes.includes(scope);
9
+ exports.registryPrincipalHasScope = registryPrincipalHasScope;
@@ -0,0 +1,13 @@
1
+ import type { RegistryUser } from './types';
2
+ export declare const toRegistryUser: (row: {
3
+ id: string;
4
+ email: string;
5
+ name: string | null;
6
+ image: string | null;
7
+ role: RegistryUser["role"];
8
+ status: RegistryUser["status"];
9
+ emailVerifiedAt: Date | null;
10
+ authVersion: number;
11
+ createdAt: Date;
12
+ updatedAt: Date;
13
+ }) => RegistryUser;
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.toRegistryUser = void 0;
4
+ const toRegistryUser = (row) => ({
5
+ id: row.id,
6
+ email: row.email,
7
+ name: row.name,
8
+ image: row.image,
9
+ role: row.role,
10
+ status: row.status,
11
+ emailVerifiedAt: row.emailVerifiedAt,
12
+ authVersion: row.authVersion,
13
+ createdAt: row.createdAt,
14
+ updatedAt: row.updatedAt,
15
+ });
16
+ exports.toRegistryUser = toRegistryUser;
@@ -0,0 +1,31 @@
1
+ import type { RegistryDatabase } from '../db/client';
2
+ import { type RegistryUserRole } from '../db/schema';
3
+ import type { RegistryUser, RegistryUserStatus } from './types';
4
+ export type RegistryUserMutationResult = {
5
+ ok: true;
6
+ user: RegistryUser;
7
+ } | {
8
+ ok: false;
9
+ reason: 'not_found' | 'invalid_name' | 'invalid_image' | 'invalid_role' | 'last_admin' | 'self_deactivation' | 'invalid_status';
10
+ };
11
+ export declare const getRegistryUserById: (db: RegistryDatabase, userId: string) => Promise<RegistryUser | null>;
12
+ export declare const listRegistryUsers: (db: RegistryDatabase) => Promise<RegistryUser[]>;
13
+ /** Credentials authentication is intentionally enumeration-safe: all failures return null. */
14
+ export declare const authenticateRegistryCredentials: (db: RegistryDatabase, email: string, password: string) => Promise<RegistryUser | null>;
15
+ export declare const updateRegistryUserProfile: (db: RegistryDatabase, input: {
16
+ userId: string;
17
+ name: string;
18
+ image?: string | null;
19
+ now?: Date;
20
+ }) => Promise<RegistryUserMutationResult>;
21
+ export declare const updateRegistryUserRole: (db: RegistryDatabase, input: {
22
+ userId: string;
23
+ role: RegistryUserRole;
24
+ now?: Date;
25
+ }) => Promise<RegistryUserMutationResult>;
26
+ export declare const setRegistryUserStatus: (db: RegistryDatabase, input: {
27
+ userId: string;
28
+ status: Extract<RegistryUserStatus, "active" | "deactivated">;
29
+ actorUserId?: string;
30
+ now?: Date;
31
+ }) => Promise<RegistryUserMutationResult>;
@@ -0,0 +1,135 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.setRegistryUserStatus = exports.updateRegistryUserRole = exports.updateRegistryUserProfile = exports.authenticateRegistryCredentials = exports.listRegistryUsers = exports.getRegistryUserById = void 0;
13
+ const drizzle_orm_1 = require("drizzle-orm");
14
+ const schema_1 = require("../db/schema");
15
+ const access_tokens_1 = require("./access-tokens");
16
+ const database_1 = require("./database");
17
+ const crypto_1 = require("./crypto");
18
+ const password_1 = require("./password");
19
+ const user_record_1 = require("./user-record");
20
+ const validation_1 = require("./validation");
21
+ const DUMMY_PASSWORD_HASH = 'scrypt-v1:AAAAAAAAAAAAAAAAAAAAAA:yK99vlYIaV57oeErq8gHqx18DxRfiUoHwETrTFUjt5JasWeMsu62stPgY-fO0TUeUc6uAu1hge_vuSQY15vvuQ';
22
+ const getRegistryUserById = (db, userId) => __awaiter(void 0, void 0, void 0, function* () {
23
+ const [row] = yield db.select().from(schema_1.registryUsers).where((0, drizzle_orm_1.eq)(schema_1.registryUsers.id, userId)).limit(1);
24
+ return row ? (0, user_record_1.toRegistryUser)(row) : null;
25
+ });
26
+ exports.getRegistryUserById = getRegistryUserById;
27
+ const listRegistryUsers = (db) => __awaiter(void 0, void 0, void 0, function* () {
28
+ const rows = yield db.select().from(schema_1.registryUsers).orderBy(schema_1.registryUsers.createdAt);
29
+ return rows.map(user_record_1.toRegistryUser);
30
+ });
31
+ exports.listRegistryUsers = listRegistryUsers;
32
+ /** Credentials authentication is intentionally enumeration-safe: all failures return null. */
33
+ const authenticateRegistryCredentials = (db, email, password) => __awaiter(void 0, void 0, void 0, function* () {
34
+ var _a;
35
+ const normalizedEmail = (0, crypto_1.normalizeEmail)(email);
36
+ const [row] = yield db.select().from(schema_1.registryUsers).where((0, drizzle_orm_1.eq)(schema_1.registryUsers.email, normalizedEmail)).limit(1);
37
+ const passwordMatches = yield (0, password_1.verifyPassword)(password, (_a = row === null || row === void 0 ? void 0 : row.passwordHash) !== null && _a !== void 0 ? _a : DUMMY_PASSWORD_HASH);
38
+ return row && row.status === 'active' && row.passwordHash && passwordMatches ? (0, user_record_1.toRegistryUser)(row) : null;
39
+ });
40
+ exports.authenticateRegistryCredentials = authenticateRegistryCredentials;
41
+ const updateRegistryUserProfile = (db, input) => __awaiter(void 0, void 0, void 0, function* () {
42
+ var _a;
43
+ const name = (0, validation_1.validateRegistryDisplayName)(input.name);
44
+ if (!name)
45
+ return { ok: false, reason: 'invalid_name' };
46
+ const image = (0, validation_1.validateRegistryImageUrl)(input.image);
47
+ if (image === false)
48
+ return { ok: false, reason: 'invalid_image' };
49
+ const [row] = yield db
50
+ .update(schema_1.registryUsers)
51
+ .set({ name, image, updatedAt: (_a = input.now) !== null && _a !== void 0 ? _a : new Date() })
52
+ .where((0, drizzle_orm_1.eq)(schema_1.registryUsers.id, input.userId))
53
+ .returning();
54
+ return row ? { ok: true, user: (0, user_record_1.toRegistryUser)(row) } : { ok: false, reason: 'not_found' };
55
+ });
56
+ exports.updateRegistryUserProfile = updateRegistryUserProfile;
57
+ const updateRegistryUserRole = (db, input) => __awaiter(void 0, void 0, void 0, function* () {
58
+ if (input.role !== 'admin' && input.role !== 'member')
59
+ return { ok: false, reason: 'invalid_role' };
60
+ return (0, database_1.withRegistryTransaction)(db, (tx) => __awaiter(void 0, void 0, void 0, function* () {
61
+ var _a;
62
+ yield (0, database_1.acquireRegistryAdvisoryLock)(tx, database_1.REGISTRY_ADMIN_LOCK_ID);
63
+ const [target] = yield tx.select().from(schema_1.registryUsers).where((0, drizzle_orm_1.eq)(schema_1.registryUsers.id, input.userId)).limit(1);
64
+ if (!target)
65
+ return { ok: false, reason: 'not_found' };
66
+ if (target.role === input.role)
67
+ return { ok: true, user: (0, user_record_1.toRegistryUser)(target) };
68
+ if (target.role === 'admin' && target.status === 'active' && input.role === 'member') {
69
+ const [{ value }] = yield tx
70
+ .select({ value: (0, drizzle_orm_1.count)() })
71
+ .from(schema_1.registryUsers)
72
+ .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.registryUsers.role, 'admin'), (0, drizzle_orm_1.eq)(schema_1.registryUsers.status, 'active')));
73
+ if (Number(value) <= 1)
74
+ return { ok: false, reason: 'last_admin' };
75
+ }
76
+ const now = (_a = input.now) !== null && _a !== void 0 ? _a : new Date();
77
+ const [row] = yield tx
78
+ .update(schema_1.registryUsers)
79
+ .set({
80
+ role: input.role,
81
+ authVersion: (0, drizzle_orm_1.sql) `${schema_1.registryUsers.authVersion} + 1`,
82
+ updatedAt: now,
83
+ })
84
+ .where((0, drizzle_orm_1.eq)(schema_1.registryUsers.id, input.userId))
85
+ .returning();
86
+ if (target.role === 'admin' && input.role === 'member') {
87
+ yield (0, access_tokens_1.revokeAllRegistryAccessTokens)(tx, input.userId, now);
88
+ }
89
+ return { ok: true, user: (0, user_record_1.toRegistryUser)(row) };
90
+ }));
91
+ });
92
+ exports.updateRegistryUserRole = updateRegistryUserRole;
93
+ const setRegistryUserStatus = (db, input) => __awaiter(void 0, void 0, void 0, function* () {
94
+ if (input.status !== 'active' && input.status !== 'deactivated') {
95
+ return { ok: false, reason: 'invalid_status' };
96
+ }
97
+ if (input.status === 'deactivated' && input.actorUserId === input.userId) {
98
+ return { ok: false, reason: 'self_deactivation' };
99
+ }
100
+ return (0, database_1.withRegistryTransaction)(db, (tx) => __awaiter(void 0, void 0, void 0, function* () {
101
+ var _a;
102
+ yield (0, database_1.acquireRegistryAdvisoryLock)(tx, database_1.REGISTRY_ADMIN_LOCK_ID);
103
+ const [target] = yield tx.select().from(schema_1.registryUsers).where((0, drizzle_orm_1.eq)(schema_1.registryUsers.id, input.userId)).limit(1);
104
+ if (!target)
105
+ return { ok: false, reason: 'not_found' };
106
+ if (target.status === input.status)
107
+ return { ok: true, user: (0, user_record_1.toRegistryUser)(target) };
108
+ if (input.status === 'active' && target.status === 'invited') {
109
+ return { ok: false, reason: 'invalid_status' };
110
+ }
111
+ if (target.role === 'admin' && target.status === 'active' && input.status === 'deactivated') {
112
+ const [{ value }] = yield tx
113
+ .select({ value: (0, drizzle_orm_1.count)() })
114
+ .from(schema_1.registryUsers)
115
+ .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.registryUsers.role, 'admin'), (0, drizzle_orm_1.eq)(schema_1.registryUsers.status, 'active')));
116
+ if (Number(value) <= 1)
117
+ return { ok: false, reason: 'last_admin' };
118
+ }
119
+ const now = (_a = input.now) !== null && _a !== void 0 ? _a : new Date();
120
+ const [row] = yield tx
121
+ .update(schema_1.registryUsers)
122
+ .set({
123
+ status: input.status,
124
+ authVersion: (0, drizzle_orm_1.sql) `${schema_1.registryUsers.authVersion} + 1`,
125
+ updatedAt: now,
126
+ })
127
+ .where((0, drizzle_orm_1.eq)(schema_1.registryUsers.id, input.userId))
128
+ .returning();
129
+ if (input.status === 'deactivated') {
130
+ yield (0, access_tokens_1.revokeAllRegistryAccessTokens)(tx, input.userId, now);
131
+ }
132
+ return { ok: true, user: (0, user_record_1.toRegistryUser)(row) };
133
+ }));
134
+ });
135
+ exports.setRegistryUserStatus = setRegistryUserStatus;
@@ -0,0 +1,3 @@
1
+ export declare const validateRegistryEmail: (email: string) => string | null;
2
+ export declare const validateRegistryDisplayName: (name: string) => string | null;
3
+ export declare const validateRegistryImageUrl: (image: string | null | undefined) => string | null | false;
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.validateRegistryImageUrl = exports.validateRegistryDisplayName = exports.validateRegistryEmail = void 0;
4
+ const crypto_1 = require("./crypto");
5
+ const SIMPLE_EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
6
+ const validateRegistryEmail = (email) => {
7
+ const normalized = (0, crypto_1.normalizeEmail)(email);
8
+ return normalized.length <= 320 && SIMPLE_EMAIL_PATTERN.test(normalized) ? normalized : null;
9
+ };
10
+ exports.validateRegistryEmail = validateRegistryEmail;
11
+ const validateRegistryDisplayName = (name) => {
12
+ const normalized = name.trim();
13
+ return normalized && normalized.length <= 120 ? normalized : null;
14
+ };
15
+ exports.validateRegistryDisplayName = validateRegistryDisplayName;
16
+ const validateRegistryImageUrl = (image) => {
17
+ const normalized = image === null || image === void 0 ? void 0 : image.trim();
18
+ if (!normalized)
19
+ return null;
20
+ try {
21
+ const parsed = new URL(normalized);
22
+ return parsed.protocol === 'https:' && normalized.length <= 2048 ? normalized : false;
23
+ }
24
+ catch (_a) {
25
+ return false;
26
+ }
27
+ };
28
+ exports.validateRegistryImageUrl = validateRegistryImageUrl;
@@ -62,7 +62,9 @@ const describeFetchFailure = (error, target, registryUrl) => {
62
62
  case 'runtime_mode_conflict':
63
63
  return `The registry at ${registryUrl} is not running in registry mode, so it cannot serve a checkout: ${error.message}`;
64
64
  case 'unauthorized':
65
- return `The registry rejected the access token (401). Check the configured access token matches the registry's token.`;
65
+ return `The registry rejected the access token (401). Run \`handoff-app login --url ${registryUrl}\` again, or replace the user-issued CI token.`;
66
+ case 'forbidden':
67
+ return `The registry token does not have permission to checkout assets (403). Authorize a token with registry:read access.`;
66
68
  default:
67
69
  return error.message;
68
70
  }
@@ -83,7 +85,7 @@ const wouldChange = (absolutePath, bytes) => {
83
85
  * Recreates workspace asset files; prompts before overwriting changed local files unless `--force`.
84
86
  */
85
87
  const checkoutAssets = (handoff, collection) => __awaiter(void 0, void 0, void 0, function* () {
86
- const connection = (0, index_1.resolveConnectionOrThrow)(handoff);
88
+ const connection = yield (0, index_1.resolveConnectionOrThrow)(handoff);
87
89
  if (collection && !(0, sets_1.isAssetCollection)(collection)) {
88
90
  throw new index_1.CheckoutError(`Unknown asset collection "${collection}". Supported collections: ${sets_1.ASSET_COLLECTIONS.join(', ')}.`);
89
91
  }
@@ -23,7 +23,7 @@ export declare class CheckoutError extends Error {
23
23
  * host has no local workspace to write into) and a resolved registry URL + access token. Throws an
24
24
  * actionable {@link CheckoutError} naming the exact misconfiguration.
25
25
  */
26
- export declare const resolveConnectionOrThrow: (handoff: Handoff) => import("../connection").ResolvedRegistryConnection;
26
+ export declare const resolveConnectionOrThrow: (handoff: Handoff) => Promise<import("../connection").ResolvedRegistryConnection>;
27
27
  /**
28
28
  * Checkout a single component, pattern, or page from the connected registry into this workspace.
29
29
  * Overwriting existing local files requires `--force` or an interactive confirmation.