handoff-app 2.0.0-2 → 2.0.0-4

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 (163) 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/cli.js +1 -1
  12. package/dist/commands/index.js +4 -0
  13. package/dist/commands/login/index.d.ts +8 -0
  14. package/dist/commands/login/index.js +53 -0
  15. package/dist/commands/logout/index.d.ts +7 -0
  16. package/dist/commands/logout/index.js +54 -0
  17. package/dist/config/entries.d.ts +47 -0
  18. package/dist/config/entries.js +162 -0
  19. package/dist/config/index.d.ts +2 -0
  20. package/dist/config/index.js +5 -1
  21. package/dist/config/runtime.d.ts +9 -9
  22. package/dist/config/runtime.js +15 -15
  23. package/dist/registry/auth/access-tokens.d.ts +46 -0
  24. package/dist/registry/auth/access-tokens.js +165 -0
  25. package/dist/registry/auth/action-tokens.d.ts +66 -0
  26. package/dist/registry/auth/action-tokens.js +178 -0
  27. package/dist/registry/auth/crypto.d.ts +4 -0
  28. package/dist/registry/auth/crypto.js +16 -0
  29. package/dist/registry/auth/database.d.ts +7 -0
  30. package/dist/registry/auth/database.js +22 -0
  31. package/dist/registry/auth/device-authorizations.d.ts +51 -0
  32. package/dist/registry/auth/device-authorizations.js +170 -0
  33. package/dist/registry/auth/gravatar.d.ts +1 -0
  34. package/dist/registry/auth/gravatar.js +8 -0
  35. package/dist/registry/auth/index.d.ts +11 -0
  36. package/dist/registry/auth/index.js +72 -0
  37. package/dist/registry/auth/installation.d.ts +42 -0
  38. package/dist/registry/auth/installation.js +105 -0
  39. package/dist/registry/auth/password.d.ts +12 -0
  40. package/dist/registry/auth/password.js +58 -0
  41. package/dist/registry/auth/rate-limits.d.ts +22 -0
  42. package/dist/registry/auth/rate-limits.js +75 -0
  43. package/dist/registry/auth/types.d.ts +26 -0
  44. package/dist/registry/auth/types.js +9 -0
  45. package/dist/registry/auth/user-record.d.ts +13 -0
  46. package/dist/registry/auth/user-record.js +16 -0
  47. package/dist/registry/auth/users.d.ts +31 -0
  48. package/dist/registry/auth/users.js +135 -0
  49. package/dist/registry/auth/validation.d.ts +3 -0
  50. package/dist/registry/auth/validation.js +28 -0
  51. package/dist/registry/checkout/assets.js +4 -2
  52. package/dist/registry/checkout/index.d.ts +1 -1
  53. package/dist/registry/checkout/index.js +101 -23
  54. package/dist/registry/checkout/tokens.js +4 -2
  55. package/dist/registry/connection.d.ts +9 -0
  56. package/dist/registry/connection.js +29 -1
  57. package/dist/registry/db/driver.d.ts +5 -4
  58. package/dist/registry/db/driver.js +5 -4
  59. package/dist/registry/db/index.d.ts +1 -1
  60. package/dist/registry/db/index.js +7 -1
  61. package/dist/registry/db/schema.d.ts +2521 -750
  62. package/dist/registry/db/schema.js +126 -1
  63. package/dist/registry/publish/assets.js +1 -1
  64. package/dist/registry/publish/errors.js +3 -3
  65. package/dist/registry/publish/index.d.ts +1 -1
  66. package/dist/registry/publish/index.js +9 -9
  67. package/dist/registry/publish/tokens.js +1 -1
  68. package/dist/types/config.d.ts +4 -1
  69. package/docs/cli.md +4 -0
  70. package/drizzle/0005_add_registry_auth.sql +95 -0
  71. package/drizzle/meta/0005_snapshot.json +1926 -0
  72. package/drizzle/meta/_journal.json +8 -1
  73. package/package.json +5 -4
  74. package/src/app/components/Auth/AccountLayout.tsx +87 -0
  75. package/src/app/components/Auth/AuthControls.tsx +99 -0
  76. package/src/app/components/Auth/AuthShell.tsx +73 -0
  77. package/src/app/components/Auth/RegistryInstallGate.tsx +23 -0
  78. package/src/app/components/Auth/RegistrySessionProvider.tsx +15 -0
  79. package/src/app/components/Auth/api.ts +20 -0
  80. package/src/app/components/Layout/Header.tsx +2 -0
  81. package/src/app/components/Navigation/MobileNav.tsx +2 -0
  82. package/src/app/lib/auth/api.ts +113 -0
  83. package/src/app/lib/auth/config.ts +175 -0
  84. package/src/app/lib/auth/email.ts +34 -0
  85. package/src/app/lib/docs-api/runtime-config.ts +0 -14
  86. package/src/app/lib/registry-api/auth.ts +29 -46
  87. package/src/app/lib/registry-api/errors.ts +2 -2
  88. package/src/app/lib/registry-api/handler.ts +19 -14
  89. package/src/app/lib/registry-api/token-transfer.ts +1 -1
  90. package/src/app/lib/registry-api/transfer.ts +4 -4
  91. package/src/app/next.config.mjs +12 -3
  92. package/src/app/pages/_app.tsx +10 -4
  93. package/src/app/pages/account/index.tsx +153 -0
  94. package/src/app/pages/account/tokens.tsx +230 -0
  95. package/src/app/pages/account/users.tsx +310 -0
  96. package/src/app/pages/api/account/index.ts +26 -0
  97. package/src/app/pages/api/account/tokens/[id].ts +17 -0
  98. package/src/app/pages/api/account/tokens/index.ts +44 -0
  99. package/src/app/pages/api/admin/users/[id]/[action].ts +89 -0
  100. package/src/app/pages/api/admin/users/index.ts +53 -0
  101. package/src/app/pages/api/auth/[...nextauth].ts +4 -0
  102. package/src/app/pages/api/auth/request-reset.ts +53 -0
  103. package/src/app/pages/api/auth/reset-password.ts +29 -0
  104. package/src/app/pages/api/install.ts +102 -0
  105. package/src/app/pages/api/oauth/device/approve.ts +23 -0
  106. package/src/app/pages/api/oauth/device/index.ts +41 -0
  107. package/src/app/pages/api/oauth/revoke.ts +24 -0
  108. package/src/app/pages/api/oauth/token.ts +31 -0
  109. package/src/app/pages/api/registry/health.ts +4 -3
  110. package/src/app/pages/api/registry/transfer/[kind]/[id].ts +3 -3
  111. package/src/app/pages/api/registry/transfer/assets/[collection].ts +2 -1
  112. package/src/app/pages/api/registry/transfer/assets/blobs/[hash].ts +2 -1
  113. package/src/app/pages/api/registry/transfer/tokens/[...setId].ts +2 -2
  114. package/src/app/pages/cli/device.tsx +143 -0
  115. package/src/app/pages/install.tsx +336 -0
  116. package/src/app/pages/login.tsx +117 -0
  117. package/src/app/pages/reset-password.tsx +159 -0
  118. package/src/app/types/next-auth.d.ts +27 -0
  119. package/src/app-builder/build.ts +22 -5
  120. package/src/app-builder/client-config.ts +10 -9
  121. package/src/cli/auth/browser.ts +29 -0
  122. package/src/cli/auth/device.ts +181 -0
  123. package/src/cli/auth/store.ts +137 -0
  124. package/src/cli/scaffold.ts +20 -197
  125. package/src/cli.ts +1 -1
  126. package/src/commands/index.ts +4 -0
  127. package/src/commands/login/index.ts +48 -0
  128. package/src/commands/logout/index.ts +51 -0
  129. package/src/config/entries.ts +175 -0
  130. package/src/config/index.ts +4 -0
  131. package/src/config/runtime.ts +15 -14
  132. package/src/registry/auth/access-tokens.ts +198 -0
  133. package/src/registry/auth/action-tokens.ts +225 -0
  134. package/src/registry/auth/crypto.ts +13 -0
  135. package/src/registry/auth/database.ts +15 -0
  136. package/src/registry/auth/device-authorizations.ts +223 -0
  137. package/src/registry/auth/gravatar.ts +6 -0
  138. package/src/registry/auth/index.ts +89 -0
  139. package/src/registry/auth/installation.ts +117 -0
  140. package/src/registry/auth/password.ts +46 -0
  141. package/src/registry/auth/rate-limits.ts +84 -0
  142. package/src/registry/auth/types.ts +34 -0
  143. package/src/registry/auth/user-record.ts +25 -0
  144. package/src/registry/auth/users.ts +145 -0
  145. package/src/registry/auth/validation.ts +24 -0
  146. package/src/registry/checkout/assets.ts +4 -2
  147. package/src/registry/checkout/index.ts +114 -24
  148. package/src/registry/checkout/tokens.ts +4 -2
  149. package/src/registry/connection.ts +23 -0
  150. package/src/registry/db/driver.ts +5 -4
  151. package/src/registry/db/index.ts +12 -0
  152. package/src/registry/db/schema.ts +174 -1
  153. package/src/registry/publish/assets.ts +1 -1
  154. package/src/registry/publish/errors.ts +3 -3
  155. package/src/registry/publish/index.ts +9 -9
  156. package/src/registry/publish/tokens.ts +1 -1
  157. package/src/types/config.ts +4 -1
  158. package/templates/default/README.md.tpl +18 -0
  159. package/templates/default/gitignore.tpl +1 -0
  160. package/templates/default/handoff.config.js.tpl +0 -1
  161. package/templates/default/handoff.config.ts.tpl +0 -1
  162. package/templates/default/handoff.config.with-examples.js.tpl +0 -1
  163. package/templates/default/handoff.config.with-examples.ts.tpl +0 -1
@@ -0,0 +1,175 @@
1
+ import type { NextApiRequest, NextApiResponse } from 'next';
2
+ import type { NextAuthOptions, Session } from 'next-auth';
3
+ import { getServerSession } from 'next-auth';
4
+ import CredentialsProvider from 'next-auth/providers/credentials';
5
+ import {
6
+ authenticateRegistryCredentials,
7
+ clearAuthRateLimit,
8
+ consumeAuthRateLimit,
9
+ getRegistryInstallationState,
10
+ getRegistryUserById,
11
+ normalizeEmail,
12
+ type RegistryUser,
13
+ } from '@handoff/registry/auth';
14
+ import { getRegistryConnection } from '../registry-connection';
15
+ import { getServerRuntimeConfig } from '../docs-api/runtime-config';
16
+
17
+ export const registryAuthSecret = (): string => process.env.AUTH_SECRET?.trim() || process.env.NEXTAUTH_SECRET?.trim() || '';
18
+
19
+ /** The canonical public registry URL (base path included), read from the documented AUTH_URL. */
20
+ export const canonicalRegistryUrl = (): URL | null => {
21
+ const value = process.env.AUTH_URL?.trim() || process.env.NEXTAUTH_URL?.trim();
22
+ if (!value) return null;
23
+ try {
24
+ const url = new URL(value);
25
+ return url.protocol === 'http:' || url.protocol === 'https:' ? url : null;
26
+ } catch {
27
+ return null;
28
+ }
29
+ };
30
+
31
+ // NextAuth v4 only reads NEXTAUTH_URL to resolve callback origins and set the Secure cookie flag,
32
+ // but deployments configure the documented AUTH_URL (the way registryAuthSecret bridges AUTH_SECRET).
33
+ // Mirror it onto NEXTAUTH_URL, keeping the base path and adding the /api/auth mount NextAuth expects,
34
+ // so self-hosted registries get https callbacks and Secure cookies instead of falling back to localhost.
35
+ if (!process.env.NEXTAUTH_URL?.trim()) {
36
+ const canonical = canonicalRegistryUrl();
37
+ if (canonical) {
38
+ canonical.pathname = `${canonical.pathname.replace(/\/+$/, '')}/api/auth`;
39
+ canonical.search = '';
40
+ process.env.NEXTAUTH_URL = canonical.toString();
41
+ }
42
+ }
43
+
44
+ const appBasePath = process.env.HANDOFF_APP_BASE_PATH ?? '';
45
+
46
+ const requestIdentifier = (request: { headers?: Record<string, string | string[] | undefined> }, email: string): string => {
47
+ const forwarded = request.headers?.['x-forwarded-for'];
48
+ const forwardedValue = Array.isArray(forwarded) ? forwarded[0] : forwarded;
49
+ const address = forwardedValue?.split(',')[0]?.trim() || request.headers?.['x-real-ip'] || 'unknown';
50
+ return `${String(address)}:${normalizeEmail(email)}`;
51
+ };
52
+
53
+ const clearUserToken = (token: Record<string, unknown>): Record<string, unknown> => {
54
+ delete token.sub;
55
+ delete token.userId;
56
+ delete token.role;
57
+ delete token.authVersion;
58
+ token.authInvalid = true;
59
+ return token;
60
+ };
61
+
62
+ export const registryAuthOptions: NextAuthOptions = {
63
+ secret: registryAuthSecret() || 'registry-auth-is-not-configured',
64
+ session: {
65
+ strategy: 'jwt',
66
+ maxAge: 30 * 24 * 60 * 60,
67
+ updateAge: 5 * 60,
68
+ },
69
+ pages: {
70
+ signIn: `${appBasePath}/login`,
71
+ error: `${appBasePath}/login`,
72
+ },
73
+ providers: [
74
+ CredentialsProvider({
75
+ id: 'handoff-credentials',
76
+ name: 'Handoff Registry',
77
+ credentials: {
78
+ email: { label: 'Email', type: 'email' },
79
+ password: { label: 'Password', type: 'password' },
80
+ },
81
+ async authorize(credentials, request) {
82
+ if (getServerRuntimeConfig().mode !== 'registry' || !registryAuthSecret()) return null;
83
+ const email = credentials?.email ?? '';
84
+ const password = credentials?.password ?? '';
85
+ if (!email || !password) return null;
86
+
87
+ try {
88
+ const { db } = await getRegistryConnection();
89
+ const installation = await getRegistryInstallationState(db);
90
+ if (installation.status !== 'installed') return null;
91
+
92
+ const identifier = requestIdentifier(request, email);
93
+ const throttle = await consumeAuthRateLimit(db, {
94
+ bucket: 'login',
95
+ identifier,
96
+ limit: 10,
97
+ windowMs: 15 * 60 * 1000,
98
+ });
99
+ if (!throttle.allowed) return null;
100
+
101
+ const user = await authenticateRegistryCredentials(db, email, password);
102
+ if (!user) return null;
103
+ await clearAuthRateLimit(db, 'login', identifier);
104
+ return {
105
+ id: user.id,
106
+ email: user.email,
107
+ name: user.name,
108
+ image: user.image,
109
+ role: user.role,
110
+ status: user.status,
111
+ authVersion: user.authVersion,
112
+ };
113
+ } catch {
114
+ return null;
115
+ }
116
+ },
117
+ }),
118
+ ],
119
+ callbacks: {
120
+ async jwt({ token, user }) {
121
+ const mutable = token as Record<string, unknown>;
122
+ const userId = (user?.id as string | undefined) || (mutable.userId as string | undefined) || token.sub;
123
+ const issuedAuthVersion = user ? (user as { authVersion?: number }).authVersion : (mutable.authVersion as number | undefined);
124
+ if (!userId || getServerRuntimeConfig().mode !== 'registry') return clearUserToken(mutable);
125
+
126
+ try {
127
+ const { db } = await getRegistryConnection();
128
+ const current = await getRegistryUserById(db, userId);
129
+ if (!current || current.status !== 'active') return clearUserToken(mutable);
130
+ if (!user && issuedAuthVersion !== current.authVersion) return clearUserToken(mutable);
131
+ mutable.userId = current.id;
132
+ mutable.email = current.email;
133
+ mutable.name = current.name;
134
+ mutable.picture = current.image;
135
+ mutable.role = current.role;
136
+ mutable.authVersion = current.authVersion;
137
+ mutable.authInvalid = false;
138
+ } catch {
139
+ // Don't invalidate a good session over a transient failure (database down, pool exhausted).
140
+ // Stripping identity here would strand the user in an unrecoverable "authenticated but no
141
+ // user" state that only clearing cookies fixes, so keep the last-known token and re-validate
142
+ // next request. Genuine invalidation (missing/inactive user, authVersion) is handled above.
143
+ return token;
144
+ }
145
+ return token;
146
+ },
147
+ async session({ session, token }) {
148
+ const values = token as Record<string, unknown>;
149
+ if (values.authInvalid || !values.userId) {
150
+ session.user = undefined as unknown as Session['user'];
151
+ return session;
152
+ }
153
+ session.user = {
154
+ id: values.userId as string,
155
+ email: (values.email as string) || '',
156
+ name: (values.name as string | null) ?? null,
157
+ image: (values.picture as string | null) ?? null,
158
+ role: values.role as RegistryUser['role'],
159
+ authVersion: values.authVersion as number,
160
+ };
161
+ return session;
162
+ },
163
+ },
164
+ };
165
+
166
+ /** Resolve the current session and re-read its user so every protected request sees live account state. */
167
+ export const getRegistrySessionUser = async (req: NextApiRequest, res: NextApiResponse): Promise<RegistryUser | null> => {
168
+ if (getServerRuntimeConfig().mode !== 'registry' || !registryAuthSecret()) return null;
169
+ const session = await getServerSession(req, res, registryAuthOptions);
170
+ const userId = (session?.user as { id?: string } | undefined)?.id;
171
+ if (!userId) return null;
172
+ const { db } = await getRegistryConnection();
173
+ const user = await getRegistryUserById(db, userId);
174
+ return user?.status === 'active' ? user : null;
175
+ };
@@ -0,0 +1,34 @@
1
+ export const registryEmailIsConfigured = (): boolean => Boolean(process.env.RESEND_API_KEY?.trim() && process.env.AUTH_FROM_EMAIL?.trim());
2
+
3
+ const escapeHtml = (value: string): string =>
4
+ value.replace(/[&<>"']/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character]!);
5
+
6
+ export const sendRegistryAuthEmail = async (input: {
7
+ to: string;
8
+ subject: string;
9
+ heading: string;
10
+ message: string;
11
+ actionLabel: string;
12
+ actionUrl: string;
13
+ }): Promise<boolean> => {
14
+ const apiKey = process.env.RESEND_API_KEY?.trim();
15
+ const from = process.env.AUTH_FROM_EMAIL?.trim();
16
+ if (!apiKey || !from) return false;
17
+
18
+ const response = await fetch('https://api.resend.com/emails', {
19
+ method: 'POST',
20
+ headers: {
21
+ Authorization: `Bearer ${apiKey}`,
22
+ 'Content-Type': 'application/json',
23
+ },
24
+ body: JSON.stringify({
25
+ from,
26
+ to: [input.to],
27
+ subject: input.subject,
28
+ html:
29
+ `<h1>${escapeHtml(input.heading)}</h1><p>${escapeHtml(input.message)}</p>` +
30
+ `<p><a href="${escapeHtml(input.actionUrl)}">${escapeHtml(input.actionLabel)}</a></p>`,
31
+ }),
32
+ });
33
+ return response.ok;
34
+ };
@@ -4,7 +4,6 @@ import type { RuntimeMode } from '@handoff/types/config';
4
4
  import {
5
5
  DEFAULT_DATABASE_URL_ENV,
6
6
  DEFAULT_REGISTRY_DRIVER,
7
- DEFAULT_REGISTRY_API_TOKEN_ENV,
8
7
  type RegistryDatabaseDriver,
9
8
  } from '@handoff/registry/db/driver';
10
9
  import { DEFAULT_ASSET_STORAGE_ADAPTER, type AssetStorageSettings } from '@handoff/registry/asset-storage/resolve';
@@ -29,11 +28,6 @@ export interface ServerRegistryRuntimeConfig {
29
28
  driver: RegistryDatabaseDriver;
30
29
  /** Name of the env var holding the database connection string (value resolved at request time). */
31
30
  databaseUrlEnv: string;
32
- /**
33
- * Name of the env var holding the registry management API bearer token. The token *value* is
34
- * resolved from the environment at request time by the management API guard — never persisted.
35
- */
36
- apiTokenEnv: string;
37
31
  }
38
32
 
39
33
  /** Resolved server-side runtime configuration for the docs read API. */
@@ -55,7 +49,6 @@ const defaults = (): ServerRuntimeConfig => ({
55
49
  registry: {
56
50
  driver: DEFAULT_REGISTRY_DRIVER,
57
51
  databaseUrlEnv: DEFAULT_DATABASE_URL_ENV,
58
- apiTokenEnv: DEFAULT_REGISTRY_API_TOKEN_ENV,
59
52
  },
60
53
  assetStorage: { adapter: DEFAULT_ASSET_STORAGE_ADAPTER },
61
54
  });
@@ -108,13 +101,11 @@ const fromEnv = (): ServerRuntimeConfig | null => {
108
101
  return null;
109
102
  }
110
103
  const databaseUrlEnv = process.env.HANDOFF_REGISTRY_DATABASE_URL_ENV?.trim() || DEFAULT_DATABASE_URL_ENV;
111
- const apiTokenEnv = process.env.HANDOFF_REGISTRY_API_TOKEN_ENV?.trim() || DEFAULT_REGISTRY_API_TOKEN_ENV;
112
104
  return {
113
105
  mode: mode === 'registry' ? 'registry' : 'workspace',
114
106
  registry: {
115
107
  driver: process.env.HANDOFF_REGISTRY_DRIVER?.trim() === 'neon' ? 'neon' : 'pg',
116
108
  databaseUrlEnv,
117
- apiTokenEnv,
118
109
  },
119
110
  assetStorage: assetStorageFromEnv(),
120
111
  };
@@ -145,10 +136,6 @@ export const getServerRuntimeConfig = (): ServerRuntimeConfig => {
145
136
  typeof parsed?.registry?.databaseUrlEnv === 'string' && parsed.registry.databaseUrlEnv.trim()
146
137
  ? parsed.registry.databaseUrlEnv.trim()
147
138
  : DEFAULT_DATABASE_URL_ENV;
148
- const apiTokenEnv =
149
- typeof parsed?.registry?.apiTokenEnv === 'string' && parsed.registry.apiTokenEnv.trim()
150
- ? parsed.registry.apiTokenEnv.trim()
151
- : DEFAULT_REGISTRY_API_TOKEN_ENV;
152
139
  const assetStorage: AssetStorageSettings =
153
140
  parsed?.assetStorage && typeof parsed.assetStorage === 'object'
154
141
  ? parsed.assetStorage
@@ -158,7 +145,6 @@ export const getServerRuntimeConfig = (): ServerRuntimeConfig => {
158
145
  registry: {
159
146
  driver: parsed?.registry?.driver === 'neon' ? 'neon' : 'pg',
160
147
  databaseUrlEnv,
161
- apiTokenEnv,
162
148
  },
163
149
  assetStorage,
164
150
  };
@@ -1,68 +1,51 @@
1
1
  import type { NextApiRequest } from 'next';
2
- import { getServerRuntimeConfig } from '../docs-api/runtime-config';
2
+ import type { RegistryDatabase } from '@handoff/registry/db/client';
3
+ import {
4
+ authenticateRegistryAccessToken,
5
+ registryPrincipalHasScope,
6
+ type RegistryAccessScope,
7
+ type RegistryPrincipal,
8
+ } from '@handoff/registry/auth';
3
9
 
4
- /**
5
- * Bearer-token guard for registry management mutations.
6
- *
7
- * This is **proof-of-concept protection**, not production auth: a single static token, resolved
8
- * from the configured env-var *name* at request time (never persisted in config). GET reads are
9
- * unauthenticated; only mutations (POST/PUT/DELETE) call this guard.
10
- *
11
- * - Token env var unset/empty → `token_not_configured` (503).
12
- * - `Authorization: Bearer <token>` missing or not an exact match → `unauthorized` (401).
13
- */
14
- /**
15
- * Result of {@link authorizeMutation}. Flat (not a discriminated union) because the app compiles
16
- * with `strictNullChecks` off — `ok` decides, with `code`/`message` set on failure.
17
- */
18
- export interface MutationAuthResult {
10
+ export interface RegistryAuthResult {
19
11
  ok: boolean;
20
- code?: 'token_not_configured' | 'unauthorized';
12
+ code?: 'unauthorized' | 'forbidden';
21
13
  message?: string;
14
+ principal?: RegistryPrincipal;
22
15
  }
23
16
 
24
- /** Length-safe constant-time-ish string comparison to avoid trivial timing leaks. */
25
- const tokensMatch = (provided: string, expected: string): boolean => {
26
- if (provided.length !== expected.length) {
27
- return false;
28
- }
29
- let mismatch = 0;
30
- for (let i = 0; i < expected.length; i += 1) {
31
- mismatch |= provided.charCodeAt(i) ^ expected.charCodeAt(i);
32
- }
33
- return mismatch === 0;
34
- };
35
-
36
- /** Extract a bearer token from the `Authorization` header, or `''` when absent/malformed. */
37
17
  const bearerToken = (req: NextApiRequest): string => {
38
18
  const header = req.headers.authorization;
39
- if (typeof header !== 'string') {
40
- return '';
41
- }
19
+ if (typeof header !== 'string') return '';
42
20
  const match = /^Bearer\s+(.+)$/i.exec(header.trim());
43
21
  return match ? match[1].trim() : '';
44
22
  };
45
23
 
46
- /** Authorize a mutation request against the configured static bearer token. */
47
- export const authorizeMutation = (req: NextApiRequest): MutationAuthResult => {
48
- const { registry } = getServerRuntimeConfig();
49
- const expected = process.env[registry.apiTokenEnv]?.trim();
50
- if (!expected) {
24
+ /**
25
+ * Authorize a registry request with a live, revocable user token carrying the required scope: reads
26
+ * need `registry:read`, mutations need `registry:write`. Every `/api/registry/*` request runs
27
+ * through this — there are no public endpoints behind the guard stack.
28
+ */
29
+ export const authorizeRegistryRequest = async (
30
+ req: NextApiRequest,
31
+ db: RegistryDatabase,
32
+ scope: RegistryAccessScope
33
+ ): Promise<RegistryAuthResult> => {
34
+ const principal = await authenticateRegistryAccessToken(db, bearerToken(req));
35
+ if (!principal) {
51
36
  return {
52
37
  ok: false,
53
- code: 'token_not_configured',
38
+ code: 'unauthorized',
54
39
  message:
55
- `Registry management mutations are disabled because the "${registry.apiTokenEnv}" environment ` +
56
- `variable is not set. Set it to a bearer token to enable mutations.`,
40
+ 'A valid user-issued access token is required. Run `handoff-app login`, or create a CI token in registry Account settings.',
57
41
  };
58
42
  }
59
- const provided = bearerToken(req);
60
- if (!provided || !tokensMatch(provided, expected)) {
43
+ if (!registryPrincipalHasScope(principal, scope)) {
61
44
  return {
62
45
  ok: false,
63
- code: 'unauthorized',
64
- message: 'A valid "Authorization: Bearer <token>" header is required for registry management mutations.',
46
+ code: 'forbidden',
47
+ message: `This access token does not include the ${scope} scope.`,
65
48
  };
66
49
  }
67
- return { ok: true };
50
+ return { ok: true, principal };
68
51
  };
@@ -12,20 +12,20 @@ import { buildMeta, type RegistryMeta } from './meta';
12
12
  export type RegistryErrorCode =
13
13
  | 'bad_request'
14
14
  | 'unauthorized'
15
+ | 'forbidden'
15
16
  | 'not_found'
16
17
  | 'method_not_allowed'
17
18
  | 'runtime_mode_conflict'
18
- | 'token_not_configured'
19
19
  | 'database_unavailable'
20
20
  | 'unexpected_error';
21
21
 
22
22
  const STATUS_BY_CODE: Record<RegistryErrorCode, number> = {
23
23
  bad_request: 400,
24
24
  unauthorized: 401,
25
+ forbidden: 403,
25
26
  not_found: 404,
26
27
  method_not_allowed: 405,
27
28
  runtime_mode_conflict: 409,
28
- token_not_configured: 503,
29
29
  database_unavailable: 503,
30
30
  unexpected_error: 500,
31
31
  };
@@ -1,8 +1,10 @@
1
1
  import type { NextApiRequest, NextApiResponse } from 'next';
2
2
  import type { RegistryDatabase } from '@handoff/registry/db/client';
3
+ import type { RegistryPrincipal } from '@handoff/registry/auth';
3
4
  import { getServerRuntimeConfig } from '../docs-api/runtime-config';
5
+ import { REGISTRY_READ_SCOPE, REGISTRY_WRITE_SCOPE } from '@handoff/registry/auth';
4
6
  import { getRegistryConnection, RegistryConnectionError } from '../registry-connection';
5
- import { authorizeMutation } from './auth';
7
+ import { authorizeRegistryRequest } from './auth';
6
8
  import { sendRegistryError } from './errors';
7
9
  import { buildMeta, type RegistryMeta } from './meta';
8
10
  import { redactSecrets } from '../api/redact';
@@ -12,12 +14,13 @@ import { redactSecrets } from '../api/redact';
12
14
  *
13
15
  * Enforces, in order: the registry-runtime guard (management APIs exist only when
14
16
  * `runtime.mode: registry`, else `409 runtime_mode_conflict`); the per-route method allowlist
15
- * (`405 method_not_allowed`); the bearer-token guard for mutations (`503 token_not_configured` /
16
- * `401 unauthorized`); and database resolution (`503 database_unavailable`). The route body then
17
- * runs against a live connection, with any thrown failure mapped to `unexpected_error`.
17
+ * (`405 method_not_allowed`); database resolution (`503 database_unavailable`); and scoped,
18
+ * revocable bearer-token authorization on every request — reads require `registry:read`, mutations
19
+ * require `registry:write` (`401 unauthorized` / `403 forbidden`). The route body then runs against
20
+ * a live connection, with any thrown failure mapped to `unexpected_error`.
18
21
  */
19
22
 
20
- /** Methods that mutate state and therefore require the bearer token. */
23
+ /** Methods that mutate state and therefore require the `registry:write` scope; all others read. */
21
24
  const MUTATION_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
22
25
 
23
26
  /** Context handed to a registry route body once all guards pass. */
@@ -26,6 +29,8 @@ export interface RegistryRouteContext {
26
29
  res: NextApiResponse;
27
30
  db: RegistryDatabase;
28
31
  method: string;
32
+ /** Authenticated token owner; every request that reaches a route body is authorized. */
33
+ principal: RegistryPrincipal;
29
34
  }
30
35
 
31
36
  /**
@@ -66,14 +71,6 @@ export const handleRegistryRoute = async (
66
71
  return;
67
72
  }
68
73
 
69
- if (MUTATION_METHODS.has(method)) {
70
- const auth = authorizeMutation(req);
71
- if (!auth.ok) {
72
- sendRegistryError(res, auth.code, auth.message);
73
- return;
74
- }
75
- }
76
-
77
74
  let db: RegistryDatabase;
78
75
  try {
79
76
  ({ db } = await getRegistryConnection());
@@ -87,8 +84,16 @@ export const handleRegistryRoute = async (
87
84
  return;
88
85
  }
89
86
 
87
+ const requiredScope = MUTATION_METHODS.has(method) ? REGISTRY_WRITE_SCOPE : REGISTRY_READ_SCOPE;
88
+ const auth = await authorizeRegistryRequest(req, db, requiredScope);
89
+ if (!auth.ok) {
90
+ sendRegistryError(res, auth.code!, auth.message!);
91
+ return;
92
+ }
93
+ const principal = auth.principal!;
94
+
90
95
  try {
91
- await body({ req, res, db, method });
96
+ await body({ req, res, db, method, principal });
92
97
  } catch (error) {
93
98
  console.error('Registry API request failed.', error);
94
99
  sendRegistryError(res, 'unexpected_error', 'Unexpected registry API error.');
@@ -161,7 +161,7 @@ const ingestTokenSet = async (db: RegistryDatabase, pkg: TokenSetTransferPackage
161
161
  });
162
162
  };
163
163
 
164
- /** `GET /api/registry/transfer/tokens` — list set summaries (unauthenticated read behind the guards). */
164
+ /** `GET /api/registry/transfer/tokens` — list set summaries (requires a `registry:read` token). */
165
165
  export const handleTokenSummaryRoute = (req: NextApiRequest, res: NextApiResponse): Promise<void> =>
166
166
  handleRegistryRoute(req, res, ['GET'], async ({ db }) => {
167
167
  const rows = await db
@@ -435,8 +435,8 @@ const upsertBuildMetadata = async (db: RegistryDatabase, kind: TransferEntityKin
435
435
  /**
436
436
  * Handles `GET /api/registry/transfer/{component|pattern|page}`. Lists summaries for one kind of
437
437
  * entity: each id plus its build hashes and status. A connected workspace uses these to skip
438
- * unchanged entities on a bulk publish and to enumerate published ids for a bulk checkout. The read
439
- * is unauthenticated; only the registry-runtime and method guards apply.
438
+ * unchanged entities on a bulk publish and to enumerate published ids for a bulk checkout. Requires
439
+ * a `registry:read` token (enforced by the guard stack).
440
440
  */
441
441
  export const handleEntitySummaryRoute = (req: NextApiRequest, res: NextApiResponse, kind: TransferEntityKind): Promise<void> =>
442
442
  handleRegistryRoute(req, res, ['GET'], async ({ db }) => {
@@ -465,8 +465,8 @@ export const handleEntitySummaryRoute = (req: NextApiRequest, res: NextApiRespon
465
465
  * Handle `GET /api/registry/transfer/{component|pattern}/:id` — checkout read. Returns the
466
466
  * normalized record plus its registry-safe source files so a connected workspace can reconstruct
467
467
  * the entity locally. Declaration files are workspace-only: registry stores never hold them, but
468
- * they are filtered defensively so checkout never receives one. The read is unauthenticated,
469
- * running behind the registry-runtime + method guards only.
468
+ * they are filtered defensively so checkout never receives one. Requires a `registry:read` token
469
+ * (enforced by the guard stack).
470
470
  */
471
471
  export const handleCheckoutRoute = (req: NextApiRequest, res: NextApiResponse, kind: TransferEntityKind): Promise<void> =>
472
472
  handleRegistryRoute(req, res, ['GET'], async ({ db }) => {
@@ -67,11 +67,21 @@ const nextConfig = {
67
67
  reactStrictMode: true,
68
68
  pageExtensions: ['js', 'jsx', 'ts', 'tsx'],
69
69
  trailingSlash: true,
70
+ // Extra safety for the invite/reset flow. The token now lives in the URL fragment, so it never
71
+ // reaches the server or leaks via Referer, but this header hardens the page anyway. Only the
72
+ // dynamic registry server can send response headers; a static export can't.
73
+ ...(handoffBuildTarget === 'registry'
74
+ ? {
75
+ async headers() {
76
+ return [{ source: '/reset-password', headers: [{ key: 'Referrer-Policy', value: 'no-referrer' }] }];
77
+ },
78
+ }
79
+ : {}),
70
80
  experimental: {
71
81
  externalDir: true,
72
82
  },
73
83
  eslint: {
74
- dirs: ['pages', 'utils'],
84
+ ignoreDuringBuilds: true,
75
85
  },
76
86
  transpilePackages: ['handoff-app', 'react-syntax-highlighter'],
77
87
  typescript: {
@@ -97,7 +107,6 @@ const nextConfig = {
97
107
  HANDOFF_RUNTIME_MODE: '%HANDOFF_RUNTIME_MODE%',
98
108
  HANDOFF_REGISTRY_DRIVER: '%HANDOFF_REGISTRY_DRIVER%',
99
109
  HANDOFF_REGISTRY_DATABASE_URL_ENV: '%HANDOFF_REGISTRY_DATABASE_URL_ENV%',
100
- HANDOFF_REGISTRY_API_TOKEN_ENV: '%HANDOFF_REGISTRY_API_TOKEN_ENV%',
101
110
  // Asset storage selection (provider + non-secret options + env-var names). Secret values (Blob
102
111
  // token, custom credentials) are read from their named env var at request time, never baked.
103
112
  HANDOFF_ASSET_STORAGE_ADAPTER: '%HANDOFF_ASSET_STORAGE_ADAPTER%',
@@ -164,7 +173,7 @@ const nextConfig = {
164
173
  return content;
165
174
  },
166
175
  },
167
- turbopack: {
176
+ turbopack: {
168
177
  resolveAlias: {
169
178
  '@handoff': path.resolve('%HANDOFF_MODULE_PATH%/src'),
170
179
  '@': path.resolve('.'),
@@ -1,5 +1,7 @@
1
1
  import type { AppProps } from 'next/app';
2
2
 
3
+ import { RegistrySessionProvider } from '../components/Auth/RegistrySessionProvider';
4
+ import { RegistryInstallGate } from '../components/Auth/RegistryInstallGate';
3
5
  import { NavProvider } from '../components/context/NavProvider';
4
6
  import '../css/index.css';
5
7
  import '../css/theme.css';
@@ -8,10 +10,14 @@ function MyApp({ Component, pageProps }: AppProps) {
8
10
  return (
9
11
  // NavProvider lives above the page tree so the registry nav (shell + entities) is fetched once
10
12
  // and cached across soft navigations, resetting only on hard refresh.
11
- <NavProvider initialNav={pageProps.navData} currentSectionId={pageProps.currentSectionId}>
12
- {/* @ts-ignore */}
13
- <Component {...pageProps} />
14
- </NavProvider>
13
+ <RegistrySessionProvider>
14
+ <RegistryInstallGate>
15
+ <NavProvider initialNav={pageProps.navData} currentSectionId={pageProps.currentSectionId}>
16
+ {/* @ts-ignore */}
17
+ <Component {...pageProps} />
18
+ </NavProvider>
19
+ </RegistryInstallGate>
20
+ </RegistrySessionProvider>
15
21
  );
16
22
  }
17
23