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
@@ -16,12 +16,13 @@
16
16
  import * as p from '@clack/prompts';
17
17
  import fs from 'fs-extra';
18
18
  import path from 'path';
19
- import { resolveComponentDeclaration } from '../../config/runtime';
19
+ import { type EntryKind, isEntryCovered, writeEntries } from '../../config/entries';
20
+ import { isComponentDirectory, resolveComponentDeclaration } from '../../config/runtime';
20
21
  import Handoff from '../../index';
21
22
  import type { DeclarationFormat } from '../../types/config';
22
23
  import { Logger } from '../../utils/logger';
23
24
  import { createRegistryClient, type RegistryClient, RegistryClientError } from '../client';
24
- import { resolveRegistryConnection } from '../connection';
25
+ import { resolveAuthenticatedRegistryConnection } from '../connection';
25
26
  import { isSafePathSegment, isSafeRelativePath, resolvePathWithin } from '../path';
26
27
  import type { CheckoutPayload, TransferEntityKind, TransferFile } from '../transfer';
27
28
 
@@ -51,7 +52,7 @@ const COMPONENT_EXTENSION = /\.(tsx|jsx|ts|js|cjs|mjs)$/i;
51
52
  * host has no local workspace to write into) and a resolved registry URL + access token. Throws an
52
53
  * actionable {@link CheckoutError} naming the exact misconfiguration.
53
54
  */
54
- export const resolveConnectionOrThrow = (handoff: Handoff) => {
55
+ export const resolveConnectionOrThrow = async (handoff: Handoff) => {
55
56
  const mode = handoff.config?.runtime?.mode ?? 'workspace';
56
57
  if (mode !== 'workspace') {
57
58
  throw new CheckoutError(
@@ -60,17 +61,17 @@ export const resolveConnectionOrThrow = (handoff: Handoff) => {
60
61
  );
61
62
  }
62
63
 
63
- const connection = resolveRegistryConnection(handoff.config);
64
+ const connection = await resolveAuthenticatedRegistryConnection(handoff.config, handoff.workingPath);
64
65
  if (!connection.url) {
65
66
  throw new CheckoutError(
66
- `No registry URL is configured. Set runtime.registryConnection.url, or the "${connection.urlEnv}" environment variable, ` +
67
- 'to the base URL of the registry to checkout from.'
67
+ `No registry is configured. Run \`handoff-app login --url <registry-url>\`, set runtime.registryConnection.url, ` +
68
+ `or set the "${connection.urlEnv}" environment variable to the base URL of the registry to checkout from.`
68
69
  );
69
70
  }
70
71
  if (!connection.accessToken) {
71
72
  throw new CheckoutError(
72
- `No registry access token is configured. Set the "${connection.accessTokenEnv}" environment variable to the ` +
73
- "registry's bearer token to authorize checkout."
73
+ `No registry access token is configured. Run \`handoff-app login --url ${connection.url}\`, or set the ` +
74
+ `"${connection.accessTokenEnv}" environment variable to a user-issued token for CI.`
74
75
  );
75
76
  }
76
77
  return connection;
@@ -84,16 +85,68 @@ const describeFetchFailure = (error: RegistryClientError, kind: TransferEntityKi
84
85
  case 'runtime_mode_conflict':
85
86
  return `The registry at ${registryUrl} is not running in registry mode, so it cannot serve a checkout: ${error.message}`;
86
87
  case 'unauthorized':
87
- return `The registry rejected the access token (401). Check the configured access token matches the registry's token.`;
88
+ return `The registry rejected the access token (401). Run \`handoff-app login --url ${registryUrl}\` again, or replace the user-issued CI token.`;
89
+ case 'forbidden':
90
+ return `The registry token does not have permission to checkout this content (403). Authorize a token with registry:read access.`;
88
91
  default:
89
92
  return error.message;
90
93
  }
91
94
  };
92
95
 
93
96
  /**
94
- * Resolve the local directory the entity is written into: an already-checked-out / locally-declared
95
- * entity keeps its existing source directory (matched by stable `id`); otherwise it lands under the
96
- * first configured `entries.{components|patterns}` root (falling back to `components`/`patterns`).
97
+ * Ask the user which collection directory a new entity should land in when the config is ambiguous.
98
+ * Under `--force` (or any non-interactive run) there's no one to ask, so we fail with an actionable
99
+ * message rather than guessing a root.
100
+ */
101
+ const promptForCollectionRoot = async (handoff: Handoff, kind: TransferEntityKind, roots: string[]): Promise<string> => {
102
+ const relative = (root: string) => path.relative(handoff.workingPath, root) || '.';
103
+ if (handoff.force) {
104
+ throw new CheckoutError(
105
+ `Cannot determine where to checkout the ${kind}: entries.${kind}s declares individual ${kind}s under different ` +
106
+ `directories (${roots.map(relative).join(', ')}). Declare a single collection directory in handoff.config, ` +
107
+ `or run checkout without --force to choose interactively.`
108
+ );
109
+ }
110
+ const choice = await p.select({
111
+ message: `Where should the new ${kind} be checked out?`,
112
+ options: roots.map((root) => ({ value: root, label: relative(root) })),
113
+ initialValue: roots[0],
114
+ });
115
+ if (p.isCancel(choice)) {
116
+ throw new CheckoutError('Checkout cancelled; no target directory chosen.');
117
+ }
118
+ return choice as string;
119
+ };
120
+
121
+ /**
122
+ * Resolve the collection directory a new entity is cloned into from the configured
123
+ * `entries.{components|patterns}`. Each entry is either a collection directory (used as-is) or a
124
+ * single declared entity directory, in which case its parent is the collection root so the new
125
+ * entity lands as a sibling rather than nested inside. Different parents mean the config is
126
+ * ambiguous, so we ask the user; with nothing configured we fall back to the default subdir.
127
+ */
128
+ const resolveCollectionRoot = async (handoff: Handoff, kind: TransferEntityKind): Promise<string> => {
129
+ const configuredRoots = kind === 'component' ? handoff.config?.entries?.components : handoff.config?.entries?.patterns;
130
+ if (!configuredRoots?.length) {
131
+ return path.resolve(handoff.workingPath, DEFAULT_ENTITY_DIR[kind]);
132
+ }
133
+
134
+ const roots = [
135
+ ...new Set(
136
+ configuredRoots.map((entry) => {
137
+ const resolved = path.resolve(handoff.workingPath, entry);
138
+ return isComponentDirectory(resolved) ? path.dirname(resolved) : resolved;
139
+ })
140
+ ),
141
+ ];
142
+
143
+ return roots.length === 1 ? roots[0] : promptForCollectionRoot(handoff, kind, roots);
144
+ };
145
+
146
+ /**
147
+ * Resolve the local directory the entity is written into. An already-checked-out or locally-declared
148
+ * entity keeps its existing source directory (matched by stable `id`); otherwise it lands as a
149
+ * sibling under its configured collection root (see {@link resolveCollectionRoot}).
97
150
  */
98
151
  const resolveTargetDir = async (handoff: Handoff, kind: TransferEntityKind, id: string): Promise<string> => {
99
152
  const store = kind === 'component' ? handoff.store.components : handoff.store.patterns;
@@ -103,10 +156,7 @@ const resolveTargetDir = async (handoff: Handoff, kind: TransferEntityKind, id:
103
156
  return existingDir;
104
157
  }
105
158
 
106
- const configuredRoots = kind === 'component' ? handoff.config?.entries?.components : handoff.config?.entries?.patterns;
107
- const root = configuredRoots?.length
108
- ? path.resolve(handoff.workingPath, configuredRoots[0])
109
- : path.resolve(handoff.workingPath, DEFAULT_ENTITY_DIR[kind]);
159
+ const root = await resolveCollectionRoot(handoff, kind);
110
160
  const targetDir = resolvePathWithin(root, id);
111
161
  if (!targetDir) {
112
162
  throw new CheckoutError(`Cannot checkout ${kind} with unsafe id "${id}".`);
@@ -542,7 +592,7 @@ const checkoutSingle = async (
542
592
  id: string,
543
593
  client: RegistryClient,
544
594
  registryUrl: string
545
- ): Promise<void> => {
595
+ ): Promise<string | null> => {
546
596
  const validId = kind === 'page' ? isSafeRelativePath(id) : isSafePathSegment(id);
547
597
  if (!validId) {
548
598
  throw new CheckoutError(`Cannot checkout ${kind} with unsafe id "${id}".`);
@@ -559,10 +609,11 @@ const checkoutSingle = async (
559
609
  throw error;
560
610
  }
561
611
 
562
- // Pages round-trip as a single verbatim `.md` with no declaration synthesis.
612
+ // Pages round-trip as a single verbatim `.md` with no declaration synthesis, and aren't
613
+ // declared in `entries`, so there's nothing to register.
563
614
  if (kind === 'page') {
564
615
  await checkoutPage(handoff, id, payload);
565
- return;
616
+ return null;
566
617
  }
567
618
 
568
619
  const targetDir = await resolveTargetDir(handoff, kind, id);
@@ -581,7 +632,7 @@ const checkoutSingle = async (
581
632
  const conflicts = [...sourceTargets.map((entry) => entry.absolutePath), declarationPath].filter((file) => fs.existsSync(file));
582
633
  if (!(await confirmOverwrite(handoff, conflicts))) {
583
634
  Logger.warn(`Checkout of ${kind} "${id}" cancelled; no files were written.`);
584
- return;
635
+ return null;
585
636
  }
586
637
 
587
638
  await fs.ensureDir(targetDir);
@@ -598,6 +649,35 @@ const checkoutSingle = async (
598
649
  `Checked out ${kind} "${id}" into ${path.relative(handoff.workingPath, targetDir) || '.'} ` +
599
650
  `(${payload.files.length} source file(s) + ${declarationFileName}).`
600
651
  );
652
+ return targetDir;
653
+ };
654
+
655
+ /**
656
+ * Declare freshly checked-out entities in `entries.{components|patterns}` so the workspace build
657
+ * picks them up. Ones already covered by a collection directory load on their own and are left
658
+ * alone; the rest are added to the config automatically. If the config can't be edited (a computed
659
+ * or unusual `entries` array), we print the paths for the user to add so nothing is silently orphaned.
660
+ */
661
+ const registerCheckedOut = async (handoff: Handoff, kind: TransferEntityKind, targetDirs: string[]): Promise<void> => {
662
+ if (kind === 'page') {
663
+ return;
664
+ }
665
+ const entryKind: EntryKind = kind === 'component' ? 'components' : 'patterns';
666
+ const uncovered = targetDirs.filter((dir) => !isEntryCovered(handoff, entryKind, dir));
667
+ if (uncovered.length === 0) {
668
+ return;
669
+ }
670
+
671
+ const result = await writeEntries(handoff, entryKind, uncovered);
672
+ if (result.status === 'added') {
673
+ const where = result.configPath ? path.relative(handoff.workingPath, result.configPath) || path.basename(result.configPath) : 'handoff.config';
674
+ Logger.success(`Updated ${where} with ${result.added.length} ${entryKind} path(s).`);
675
+ return;
676
+ }
677
+
678
+ const where = result.configPath ? path.relative(handoff.workingPath, result.configPath) : 'handoff.config';
679
+ Logger.warn(`Could not update ${where} automatically. Add these to entries.${entryKind} manually:`);
680
+ result.pending.forEach((rel) => Logger.warn(` - ${rel}`));
601
681
  };
602
682
 
603
683
  /**
@@ -605,9 +685,12 @@ const checkoutSingle = async (
605
685
  * Overwriting existing local files requires `--force` or an interactive confirmation.
606
686
  */
607
687
  export const checkoutEntity = async (handoff: Handoff, kind: TransferEntityKind, id: string): Promise<void> => {
608
- const connection = resolveConnectionOrThrow(handoff);
688
+ const connection = await resolveConnectionOrThrow(handoff);
609
689
  const client = createRegistryClient({ baseUrl: connection.url, accessToken: connection.accessToken });
610
- await checkoutSingle(handoff, kind, id, client, connection.url);
690
+ const targetDir = await checkoutSingle(handoff, kind, id, client, connection.url);
691
+ if (targetDir) {
692
+ await registerCheckedOut(handoff, kind, [targetDir]);
693
+ }
611
694
  };
612
695
 
613
696
  /**
@@ -617,7 +700,7 @@ export const checkoutEntity = async (handoff: Handoff, kind: TransferEntityKind,
617
700
  * prompting is per entity (skipped under `--force`).
618
701
  */
619
702
  export const checkoutEntities = async (handoff: Handoff, kind: TransferEntityKind): Promise<void> => {
620
- const connection = resolveConnectionOrThrow(handoff);
703
+ const connection = await resolveConnectionOrThrow(handoff);
621
704
  const client = createRegistryClient({ baseUrl: connection.url, accessToken: connection.accessToken });
622
705
 
623
706
  let summaries;
@@ -637,15 +720,22 @@ export const checkoutEntities = async (handoff: Handoff, kind: TransferEntityKin
637
720
 
638
721
  let checkedOut = 0;
639
722
  const failed: { id: string; message: string }[] = [];
723
+ const targetDirs: string[] = [];
640
724
  for (const { id } of summaries) {
641
725
  try {
642
- await checkoutSingle(handoff, kind, id, client, connection.url);
726
+ const targetDir = await checkoutSingle(handoff, kind, id, client, connection.url);
727
+ if (targetDir) {
728
+ targetDirs.push(targetDir);
729
+ }
643
730
  checkedOut += 1;
644
731
  } catch (error) {
645
732
  failed.push({ id, message: error instanceof Error ? error.message : String(error) });
646
733
  }
647
734
  }
648
735
 
736
+ // Register everything checked out in one pass, so the config is edited (and confirmed) once.
737
+ await registerCheckedOut(handoff, kind, targetDirs);
738
+
649
739
  Logger.success(
650
740
  `${kind[0].toUpperCase()}${kind.slice(1)}s checkout complete — ${checkedOut} checked out${failed.length ? `, ${failed.length} failed` : ''}.`
651
741
  );
@@ -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 design tokens (403). Authorize a token with registry:read access.`;
32
34
  default:
33
35
  return error.message;
34
36
  }
@@ -73,7 +75,7 @@ export const checkoutTokens = async (handoff: Handoff, setId?: string): Promise<
73
75
  if (setId && !isTokenSetId(setId)) {
74
76
  throw new CheckoutError(`Unknown or unsafe token set id "${setId}".`);
75
77
  }
76
- const connection = resolveConnectionOrThrow(handoff);
78
+ const connection = await resolveConnectionOrThrow(handoff);
77
79
  const client = createRegistryClient({ baseUrl: connection.url, accessToken: connection.accessToken });
78
80
 
79
81
  let ids: string[];
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import type { Config } from '../types/config';
12
+ import { cliAuthMatchesRegistry, cliAuthTokenIsValid, readCliAuth } from '../cli/auth/store';
12
13
 
13
14
  /** Default env-var name holding the remote registry base URL. */
14
15
  export const DEFAULT_REGISTRY_URL_ENV = 'HANDOFF_REGISTRY_URL';
@@ -48,3 +49,25 @@ export const resolveRegistryConnection = (config: Config | null | undefined): Re
48
49
  accessTokenEnv,
49
50
  };
50
51
  };
52
+
53
+ /**
54
+ * Resolve the same connection for an authenticated CLI operation, letting a saved device login fill
55
+ * in what the environment does not provide. A valid login supplies both the registry URL and the
56
+ * access token, so `handoff-app login` alone is enough to publish and checkout. Environment values
57
+ * keep precedence for deterministic CI: `HANDOFF_REGISTRY_URL` (or inline config) wins over the
58
+ * saved URL, and `HANDOFF_REGISTRY_ACCESS_TOKEN` wins over the saved token. The saved token only
59
+ * applies to the URL it was issued for.
60
+ */
61
+ export const resolveAuthenticatedRegistryConnection = async (
62
+ config: Config | null | undefined,
63
+ workingPath = process.cwd()
64
+ ): Promise<ResolvedRegistryConnection> => {
65
+ const connection = resolveRegistryConnection(config);
66
+
67
+ const auth = await readCliAuth(workingPath);
68
+ if (!cliAuthTokenIsValid(auth)) return connection;
69
+
70
+ const url = connection.url || auth!.remoteUrl;
71
+ const accessToken = connection.accessToken || (cliAuthMatchesRegistry(auth, url) ? auth!.accessToken : '');
72
+ return { ...connection, url, accessToken };
73
+ };
@@ -18,7 +18,7 @@ export type RegistryDatabaseDriver = 'pg' | 'neon';
18
18
  /** Default env-var name holding the database connection string. */
19
19
  export const DEFAULT_DATABASE_URL_ENV = 'DATABASE_URL';
20
20
 
21
- /** Default env-var name holding the registry management API bearer token. */
21
+ /** @deprecated Fixed registry server secrets are no longer authorized. */
22
22
  export const DEFAULT_REGISTRY_API_TOKEN_ENV = 'HANDOFF_REGISTRY_API_TOKEN';
23
23
 
24
24
  /** Default registry database driver when none is configured. */
@@ -41,9 +41,10 @@ export const resolveDatabaseUrlEnv = (config: Config | null | undefined): string
41
41
  };
42
42
 
43
43
  /**
44
- * Resolve the env-var *name* holding the registry management API bearer token (defaults to
45
- * `HANDOFF_REGISTRY_API_TOKEN`). Like the database URL, only the name is ever persisted — the token
46
- * value is resolved from the environment at request time and never written to config.
44
+ * Resolve the legacy fixed-token env-var name for configuration compatibility.
45
+ *
46
+ * @deprecated The returned env var is intentionally ignored by registry authorization. Use a
47
+ * user-issued access token or `handoff-app login`.
47
48
  */
48
49
  export const resolveApiTokenEnv = (config: Config | null | undefined): string => {
49
50
  const configured = config?.runtime?.registry?.apiTokenEnv?.trim();
@@ -5,10 +5,22 @@ export {
5
5
  docsArtifacts,
6
6
  patternFiles,
7
7
  patterns,
8
+ registryAccessTokens,
9
+ registryAuthActionTokens,
10
+ registryAuthRateLimits,
11
+ registryDeviceAuthorizations,
12
+ registryInstallations,
8
13
  registrySchema,
14
+ registryUsers,
9
15
  type BuildMetadataEntityKind,
10
16
  type DocsArtifactEntityKind,
17
+ type RegistryAccessScope,
18
+ type RegistryAuthActionPurpose,
19
+ type RegistryDeviceAuthorizationStatus,
20
+ type RegistryRateLimitBucket,
11
21
  type RegistryReviewMetadata,
22
+ type RegistryUserRole,
23
+ type RegistryUserStatus,
12
24
  } from './schema';
13
25
 
14
26
  export type { ArtifactBuildStatus as RegistryBuildStatus } from '../../artifacts/types';
@@ -23,7 +23,7 @@
23
23
  */
24
24
 
25
25
  import { sql } from 'drizzle-orm';
26
- import { check, customType, index, integer, jsonb, pgTable, primaryKey, text, timestamp } from 'drizzle-orm/pg-core';
26
+ import { check, customType, index, integer, jsonb, pgTable, primaryKey, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
27
27
  import type { ArtifactBuildStatus, ArtifactKind, ArtifactOwnerKind, ArtifactReference } from '../../artifacts/types';
28
28
  import type { RegistryTextFileKind } from '../../store/types';
29
29
  import type { TokenSetKind } from '../tokens/sets';
@@ -43,6 +43,24 @@ const bytea = customType<{ data: Buffer; driverData: Buffer }>({
43
43
  export type AssetStorageProvider = 'database' | 'vercel-blob' | (string & {});
44
44
  import type { ComponentListObject, PageListObject, PatternComponentEntry, PatternListObject } from '../../transformers/preview/types';
45
45
 
46
+ /** Registry account role. Administrators may issue write-scoped credentials. */
47
+ export type RegistryUserRole = 'admin' | 'member';
48
+
49
+ /** Registry account lifecycle. Invited and deactivated accounts cannot authenticate. */
50
+ export type RegistryUserStatus = 'invited' | 'active' | 'deactivated';
51
+
52
+ /** One-time account action represented by a hashed secret. */
53
+ export type RegistryAuthActionPurpose = 'invite' | 'password_reset';
54
+
55
+ /** Capability granted to a registry access token. */
56
+ export type RegistryAccessScope = 'registry:read' | 'registry:write';
57
+
58
+ /** State machine for an RFC 8628-style CLI device authorization. */
59
+ export type RegistryDeviceAuthorizationStatus = 'pending' | 'approved' | 'denied' | 'consumed';
60
+
61
+ /** Public endpoint family protected by a database-backed throttling counter. */
62
+ export type RegistryRateLimitBucket = 'login' | 'password_reset' | 'device';
63
+
46
64
  /**
47
65
  * Entity a docs read-model artifact is associated with. `page` is included only for type-compat with
48
66
  * the generic management delete path (which filters `docs_artifacts.entityKind` by kind); pages emit
@@ -426,6 +444,155 @@ export const assetBlobs = pgTable('asset_blobs', {
426
444
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
427
445
  });
428
446
 
447
+ /**
448
+ * Registry users. Browser sessions carry the id and auth version, while protected requests re-read
449
+ * this row so role/status changes take effect without waiting for a cookie to expire.
450
+ */
451
+ export const registryUsers = pgTable(
452
+ 'registry_users',
453
+ {
454
+ id: text('id')
455
+ .primaryKey()
456
+ .$defaultFn(() => crypto.randomUUID()),
457
+ email: text('email').notNull(),
458
+ name: text('name'),
459
+ image: text('image'),
460
+ role: text('role').$type<RegistryUserRole>().notNull().default('member'),
461
+ status: text('status').$type<RegistryUserStatus>().notNull().default('invited'),
462
+ passwordHash: text('password_hash'),
463
+ emailVerifiedAt: timestamp('email_verified_at', { withTimezone: true }),
464
+ authVersion: integer('auth_version').notNull().default(1),
465
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
466
+ updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
467
+ },
468
+ (table) => [
469
+ uniqueIndex('registry_users_normalized_email_idx').on(sql`lower(btrim(${table.email}))`),
470
+ index('registry_users_status_role_idx').on(table.status, table.role),
471
+ check('registry_users_email_normalized', sql`${table.email} = lower(btrim(${table.email}))`),
472
+ check('registry_users_role_valid', sql`${table.role} in ('admin', 'member')`),
473
+ check('registry_users_status_valid', sql`${table.status} in ('invited', 'active', 'deactivated')`),
474
+ check('registry_users_auth_version_positive', sql`${table.authVersion} > 0`),
475
+ ]
476
+ );
477
+
478
+ /**
479
+ * Permanent one-row installation marker. There is deliberately no reset path: deleting or
480
+ * deactivating users must never reopen the public first-visitor installer.
481
+ */
482
+ export const registryInstallations = pgTable(
483
+ 'registry_installations',
484
+ {
485
+ id: text('id').primaryKey().default('default'),
486
+ status: text('status').notNull().default('installed'),
487
+ schemaVersion: integer('schema_version').notNull(),
488
+ initialAdminUserId: text('initial_admin_user_id')
489
+ .notNull()
490
+ .references(() => registryUsers.id, { onDelete: 'restrict' }),
491
+ installedAt: timestamp('installed_at', { withTimezone: true }).notNull().defaultNow(),
492
+ },
493
+ (table) => [
494
+ check('registry_installations_singleton', sql`${table.id} = 'default'`),
495
+ check('registry_installations_status_valid', sql`${table.status} = 'installed'`),
496
+ check('registry_installations_schema_version_positive', sql`${table.schemaVersion} > 0`),
497
+ ]
498
+ );
499
+
500
+ /** Hash-only invitation and password-reset secrets. */
501
+ export const registryAuthActionTokens = pgTable(
502
+ 'registry_auth_action_tokens',
503
+ {
504
+ id: text('id')
505
+ .primaryKey()
506
+ .$defaultFn(() => crypto.randomUUID()),
507
+ userId: text('user_id')
508
+ .notNull()
509
+ .references(() => registryUsers.id, { onDelete: 'cascade' }),
510
+ purpose: text('purpose').$type<RegistryAuthActionPurpose>().notNull(),
511
+ tokenHash: text('token_hash').notNull(),
512
+ expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
513
+ usedAt: timestamp('used_at', { withTimezone: true }),
514
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
515
+ },
516
+ (table) => [
517
+ uniqueIndex('registry_auth_action_tokens_hash_idx').on(table.tokenHash),
518
+ index('registry_auth_action_tokens_user_purpose_idx').on(table.userId, table.purpose),
519
+ check('registry_auth_action_tokens_purpose_valid', sql`${table.purpose} in ('invite', 'password_reset')`),
520
+ ]
521
+ );
522
+
523
+ /** Revocable, hash-only registry API credentials owned by one user. */
524
+ export const registryAccessTokens = pgTable(
525
+ 'registry_access_tokens',
526
+ {
527
+ /** Public lookup id embedded in the opaque token; not itself a credential. */
528
+ id: text('id').primaryKey(),
529
+ userId: text('user_id')
530
+ .notNull()
531
+ .references(() => registryUsers.id, { onDelete: 'cascade' }),
532
+ name: text('name').notNull(),
533
+ secretHash: text('secret_hash').notNull(),
534
+ scopes: jsonb('scopes').$type<RegistryAccessScope[]>().notNull(),
535
+ expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
536
+ lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
537
+ revokedAt: timestamp('revoked_at', { withTimezone: true }),
538
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
539
+ },
540
+ (table) => [
541
+ index('registry_access_tokens_user_idx').on(table.userId),
542
+ index('registry_access_tokens_expires_idx').on(table.expiresAt),
543
+ check('registry_access_tokens_name_not_blank', sql`length(btrim(${table.name})) > 0`),
544
+ check('registry_access_tokens_scopes_array', sql`jsonb_typeof(${table.scopes}) = 'array'`),
545
+ ]
546
+ );
547
+
548
+ /** Short-lived authorization used by `handoff-app login`. Plaintext device codes are never stored. */
549
+ export const registryDeviceAuthorizations = pgTable(
550
+ 'registry_device_authorizations',
551
+ {
552
+ id: text('id')
553
+ .primaryKey()
554
+ .$defaultFn(() => crypto.randomUUID()),
555
+ deviceCodeHash: text('device_code_hash').notNull(),
556
+ userCode: text('user_code').notNull(),
557
+ status: text('status').$type<RegistryDeviceAuthorizationStatus>().notNull().default('pending'),
558
+ userId: text('user_id').references(() => registryUsers.id, { onDelete: 'set null' }),
559
+ scopes: jsonb('scopes').$type<RegistryAccessScope[]>().notNull(),
560
+ expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
561
+ approvedAt: timestamp('approved_at', { withTimezone: true }),
562
+ consumedAt: timestamp('consumed_at', { withTimezone: true }),
563
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
564
+ },
565
+ (table) => [
566
+ uniqueIndex('registry_device_authorizations_device_hash_idx').on(table.deviceCodeHash),
567
+ uniqueIndex('registry_device_authorizations_user_code_idx').on(table.userCode),
568
+ index('registry_device_authorizations_expires_idx').on(table.expiresAt),
569
+ check('registry_device_authorizations_status_valid', sql`${table.status} in ('pending', 'approved', 'denied', 'consumed')`),
570
+ check('registry_device_authorizations_scopes_array', sql`jsonb_typeof(${table.scopes}) = 'array'`),
571
+ ]
572
+ );
573
+
574
+ /**
575
+ * Fixed-window throttling counters. Callers pass a hash of the identifying value so raw emails and
576
+ * IP addresses are not retained.
577
+ */
578
+ export const registryAuthRateLimits = pgTable(
579
+ 'registry_auth_rate_limits',
580
+ {
581
+ bucket: text('bucket').$type<RegistryRateLimitBucket>().notNull(),
582
+ identifierHash: text('identifier_hash').notNull(),
583
+ windowStartedAt: timestamp('window_started_at', { withTimezone: true }).notNull(),
584
+ attempts: integer('attempts').notNull().default(1),
585
+ expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
586
+ updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
587
+ },
588
+ (table) => [
589
+ primaryKey({ columns: [table.bucket, table.identifierHash, table.windowStartedAt] }),
590
+ index('registry_auth_rate_limits_expires_idx').on(table.expiresAt),
591
+ check('registry_auth_rate_limits_bucket_valid', sql`${table.bucket} in ('login', 'password_reset', 'device')`),
592
+ check('registry_auth_rate_limits_attempts_positive', sql`${table.attempts} > 0`),
593
+ ]
594
+ );
595
+
429
596
  /** All registry tables, for typed Drizzle clients and migration tooling. */
430
597
  export const registrySchema = {
431
598
  components,
@@ -441,4 +608,10 @@ export const registrySchema = {
441
608
  assetCollections,
442
609
  assets,
443
610
  assetBlobs,
611
+ registryUsers,
612
+ registryInstallations,
613
+ registryAuthActionTokens,
614
+ registryAccessTokens,
615
+ registryDeviceAuthorizations,
616
+ registryAuthRateLimits,
444
617
  };
@@ -42,7 +42,7 @@ const toManifestEntry = (asset: AssetMetadata): AssetManifestEntry => ({
42
42
  * throws at the end if any collection failed.
43
43
  */
44
44
  export const publishAssets = async (handoff: Handoff, collection?: string): Promise<void> => {
45
- const connection = resolveConnectionOrThrow(handoff);
45
+ const connection = await resolveConnectionOrThrow(handoff);
46
46
  if (collection && !isAssetCollection(collection)) {
47
47
  throw new PublishError(`Unknown asset collection "${collection}". Supported collections: ${ASSET_COLLECTIONS.join(', ')}.`);
48
48
  }
@@ -11,10 +11,10 @@ export const describeUploadFailure = (
11
11
  switch (error.code) {
12
12
  case 'runtime_mode_conflict':
13
13
  return `The registry at ${registryUrl} is not running in registry mode, so it cannot accept publishes: ${error.message}`;
14
- case 'token_not_configured':
15
- return `The registry at ${registryUrl} has no management token configured, so it is rejecting mutations: ${error.message}`;
16
14
  case 'unauthorized':
17
- return `The registry rejected the access token (401). Check the configured access token matches the registry's token.`;
15
+ return `The registry rejected the access token (401). Run \`handoff-app login --url ${registryUrl}\` again, or replace the user-issued CI token.`;
16
+ case 'forbidden':
17
+ return `The registry token does not have permission to publish (403). Sign in as an administrator and authorize a token with registry:write access.`;
18
18
  case 'bad_request':
19
19
  return `The registry rejected the ${rejectedPayload} (400): ${error.message}`;
20
20
  default:
@@ -16,7 +16,7 @@ import processComponents from '../../transformers/preview/component/builder';
16
16
  import { processPatterns } from '../../transformers/preview/pattern/builder';
17
17
  import { Logger } from '../../utils/logger';
18
18
  import { createRegistryClient, RegistryClientError } from '../client';
19
- import { resolveRegistryConnection } from '../connection';
19
+ import { resolveAuthenticatedRegistryConnection } from '../connection';
20
20
  import type { EntitySummary, TransferEntityKind } from '../transfer';
21
21
  import { describeUploadFailure } from './errors';
22
22
  import { assertRequiredArtifactsPresent, buildPublishPackage, PublishPackageError } from './package';
@@ -34,7 +34,7 @@ export class PublishError extends Error {
34
34
  * hosts do not publish) and a resolved registry URL + access token. Throws an actionable
35
35
  * {@link PublishError} naming the exact misconfiguration.
36
36
  */
37
- export const resolveConnectionOrThrow = (handoff: Handoff) => {
37
+ export const resolveConnectionOrThrow = async (handoff: Handoff) => {
38
38
  const mode = handoff.config?.runtime?.mode ?? 'workspace';
39
39
  if (mode !== 'workspace') {
40
40
  throw new PublishError(
@@ -43,17 +43,17 @@ export const resolveConnectionOrThrow = (handoff: Handoff) => {
43
43
  );
44
44
  }
45
45
 
46
- const connection = resolveRegistryConnection(handoff.config);
46
+ const connection = await resolveAuthenticatedRegistryConnection(handoff.config, handoff.workingPath);
47
47
  if (!connection.url) {
48
48
  throw new PublishError(
49
- `No registry URL is configured. Set runtime.registryConnection.url, or the "${connection.urlEnv}" environment variable, ` +
50
- 'to the base URL of the registry to publish to.'
49
+ `No registry is configured. Run \`handoff-app login --url <registry-url>\`, set runtime.registryConnection.url, ` +
50
+ `or set the "${connection.urlEnv}" environment variable to the base URL of the registry to publish to.`
51
51
  );
52
52
  }
53
53
  if (!connection.accessToken) {
54
54
  throw new PublishError(
55
- `No registry access token is configured. Set the "${connection.accessTokenEnv}" environment variable to the ` +
56
- "registry's bearer token to authorize publishing."
55
+ `No registry access token is configured. Run \`handoff-app login --url ${connection.url}\`, or set the ` +
56
+ `"${connection.accessTokenEnv}" environment variable to a user-issued token for CI.`
57
57
  );
58
58
  }
59
59
  return connection;
@@ -103,7 +103,7 @@ const runTargetedBuild = async (handoff: Handoff, kind: TransferEntityKind, id:
103
103
  * receive a targeted build, while pages are packaged directly from their source.
104
104
  */
105
105
  export const publishEntity = async (handoff: Handoff, kind: TransferEntityKind, id: string): Promise<void> => {
106
- const connection = resolveConnectionOrThrow(handoff);
106
+ const connection = await resolveConnectionOrThrow(handoff);
107
107
 
108
108
  Logger.info(kind === 'page' ? `Preparing page "${id}" for publish…` : `Building ${kind} "${id}" for publish…`);
109
109
  await runTargetedBuild(handoff, kind, id);
@@ -160,7 +160,7 @@ const listWorkspaceEntityIds = async (handoff: Handoff, kind: TransferEntityKind
160
160
  * collected and never aborts the rest; the run throws at the end if any entity failed.
161
161
  */
162
162
  export const publishEntities = async (handoff: Handoff, kind: TransferEntityKind): Promise<void> => {
163
- const connection = resolveConnectionOrThrow(handoff);
163
+ const connection = await resolveConnectionOrThrow(handoff);
164
164
 
165
165
  Logger.info(`Building ${kind}s for publish…`);
166
166
  await runBulkBuild(handoff, kind);
@@ -58,7 +58,7 @@ const buildTokenSetPackage = async (handoff: Handoff, set: TokenSetRecord): Prom
58
58
  * committed atomically server-side); the run throws at the end if any set failed.
59
59
  */
60
60
  export const publishTokens = async (handoff: Handoff, setId?: string): Promise<void> => {
61
- const connection = resolveConnectionOrThrow(handoff);
61
+ const connection = await resolveConnectionOrThrow(handoff);
62
62
 
63
63
  Logger.info(setId ? `Building tokens to publish "${setId}"…` : 'Building tokens for publish…');
64
64
  await handoff.fetch();
@@ -135,7 +135,10 @@ export interface HandoffRuntimeConfig {
135
135
  registry?: {
136
136
  /** Name of the env var holding the database URL. @default "DATABASE_URL" */
137
137
  databaseUrlEnv?: string;
138
- /** Name of the env var holding the registry API token. @default "HANDOFF_REGISTRY_API_TOKEN" */
138
+ /**
139
+ * @deprecated Fixed registry secrets are no longer authorized. This option remains parseable
140
+ * only so existing configuration files do not break.
141
+ */
139
142
  apiTokenEnv?: string;
140
143
  database?: {
141
144
  /**