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.
- package/README.md +140 -83
- package/dist/app-builder/build.js +21 -4
- package/dist/app-builder/client-config.js +9 -8
- package/dist/cli/auth/browser.d.ts +2 -0
- package/dist/cli/auth/browser.js +33 -0
- package/dist/cli/auth/device.d.ts +11 -0
- package/dist/cli/auth/device.js +155 -0
- package/dist/cli/auth/store.d.ts +29 -0
- package/dist/cli/auth/store.js +146 -0
- package/dist/cli/scaffold.js +20 -154
- package/dist/cli.js +1 -1
- package/dist/commands/index.js +4 -0
- package/dist/commands/login/index.d.ts +8 -0
- package/dist/commands/login/index.js +53 -0
- package/dist/commands/logout/index.d.ts +7 -0
- package/dist/commands/logout/index.js +54 -0
- package/dist/config/entries.d.ts +47 -0
- package/dist/config/entries.js +162 -0
- package/dist/config/index.d.ts +2 -0
- package/dist/config/index.js +5 -1
- package/dist/config/runtime.d.ts +9 -9
- package/dist/config/runtime.js +15 -15
- package/dist/registry/auth/access-tokens.d.ts +46 -0
- package/dist/registry/auth/access-tokens.js +165 -0
- package/dist/registry/auth/action-tokens.d.ts +66 -0
- package/dist/registry/auth/action-tokens.js +178 -0
- package/dist/registry/auth/crypto.d.ts +4 -0
- package/dist/registry/auth/crypto.js +16 -0
- package/dist/registry/auth/database.d.ts +7 -0
- package/dist/registry/auth/database.js +22 -0
- package/dist/registry/auth/device-authorizations.d.ts +51 -0
- package/dist/registry/auth/device-authorizations.js +170 -0
- package/dist/registry/auth/gravatar.d.ts +1 -0
- package/dist/registry/auth/gravatar.js +8 -0
- package/dist/registry/auth/index.d.ts +11 -0
- package/dist/registry/auth/index.js +72 -0
- package/dist/registry/auth/installation.d.ts +42 -0
- package/dist/registry/auth/installation.js +105 -0
- package/dist/registry/auth/password.d.ts +12 -0
- package/dist/registry/auth/password.js +58 -0
- package/dist/registry/auth/rate-limits.d.ts +22 -0
- package/dist/registry/auth/rate-limits.js +75 -0
- package/dist/registry/auth/types.d.ts +26 -0
- package/dist/registry/auth/types.js +9 -0
- package/dist/registry/auth/user-record.d.ts +13 -0
- package/dist/registry/auth/user-record.js +16 -0
- package/dist/registry/auth/users.d.ts +31 -0
- package/dist/registry/auth/users.js +135 -0
- package/dist/registry/auth/validation.d.ts +3 -0
- package/dist/registry/auth/validation.js +28 -0
- package/dist/registry/checkout/assets.js +4 -2
- package/dist/registry/checkout/index.d.ts +1 -1
- package/dist/registry/checkout/index.js +101 -23
- package/dist/registry/checkout/tokens.js +4 -2
- package/dist/registry/connection.d.ts +9 -0
- package/dist/registry/connection.js +29 -1
- package/dist/registry/db/driver.d.ts +5 -4
- package/dist/registry/db/driver.js +5 -4
- package/dist/registry/db/index.d.ts +1 -1
- package/dist/registry/db/index.js +7 -1
- package/dist/registry/db/schema.d.ts +2521 -750
- package/dist/registry/db/schema.js +126 -1
- package/dist/registry/publish/assets.js +1 -1
- package/dist/registry/publish/errors.js +3 -3
- package/dist/registry/publish/index.d.ts +1 -1
- package/dist/registry/publish/index.js +9 -9
- package/dist/registry/publish/tokens.js +1 -1
- package/dist/types/config.d.ts +4 -1
- package/docs/cli.md +4 -0
- package/drizzle/0005_add_registry_auth.sql +95 -0
- package/drizzle/meta/0005_snapshot.json +1926 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +5 -4
- package/src/app/components/Auth/AccountLayout.tsx +87 -0
- package/src/app/components/Auth/AuthControls.tsx +99 -0
- package/src/app/components/Auth/AuthShell.tsx +73 -0
- package/src/app/components/Auth/RegistryInstallGate.tsx +23 -0
- package/src/app/components/Auth/RegistrySessionProvider.tsx +15 -0
- package/src/app/components/Auth/api.ts +20 -0
- package/src/app/components/Layout/Header.tsx +2 -0
- package/src/app/components/Navigation/MobileNav.tsx +2 -0
- package/src/app/lib/auth/api.ts +113 -0
- package/src/app/lib/auth/config.ts +175 -0
- package/src/app/lib/auth/email.ts +34 -0
- package/src/app/lib/docs-api/runtime-config.ts +0 -14
- package/src/app/lib/registry-api/auth.ts +29 -46
- package/src/app/lib/registry-api/errors.ts +2 -2
- package/src/app/lib/registry-api/handler.ts +19 -14
- package/src/app/lib/registry-api/token-transfer.ts +1 -1
- package/src/app/lib/registry-api/transfer.ts +4 -4
- package/src/app/next.config.mjs +12 -3
- package/src/app/pages/_app.tsx +10 -4
- package/src/app/pages/account/index.tsx +153 -0
- package/src/app/pages/account/tokens.tsx +230 -0
- package/src/app/pages/account/users.tsx +310 -0
- package/src/app/pages/api/account/index.ts +26 -0
- package/src/app/pages/api/account/tokens/[id].ts +17 -0
- package/src/app/pages/api/account/tokens/index.ts +44 -0
- package/src/app/pages/api/admin/users/[id]/[action].ts +89 -0
- package/src/app/pages/api/admin/users/index.ts +53 -0
- package/src/app/pages/api/auth/[...nextauth].ts +4 -0
- package/src/app/pages/api/auth/request-reset.ts +53 -0
- package/src/app/pages/api/auth/reset-password.ts +29 -0
- package/src/app/pages/api/install.ts +102 -0
- package/src/app/pages/api/oauth/device/approve.ts +23 -0
- package/src/app/pages/api/oauth/device/index.ts +41 -0
- package/src/app/pages/api/oauth/revoke.ts +24 -0
- package/src/app/pages/api/oauth/token.ts +31 -0
- package/src/app/pages/api/registry/health.ts +4 -3
- package/src/app/pages/api/registry/transfer/[kind]/[id].ts +3 -3
- package/src/app/pages/api/registry/transfer/assets/[collection].ts +2 -1
- package/src/app/pages/api/registry/transfer/assets/blobs/[hash].ts +2 -1
- package/src/app/pages/api/registry/transfer/tokens/[...setId].ts +2 -2
- package/src/app/pages/cli/device.tsx +143 -0
- package/src/app/pages/install.tsx +336 -0
- package/src/app/pages/login.tsx +117 -0
- package/src/app/pages/reset-password.tsx +159 -0
- package/src/app/types/next-auth.d.ts +27 -0
- package/src/app-builder/build.ts +22 -5
- package/src/app-builder/client-config.ts +10 -9
- package/src/cli/auth/browser.ts +29 -0
- package/src/cli/auth/device.ts +181 -0
- package/src/cli/auth/store.ts +137 -0
- package/src/cli/scaffold.ts +20 -197
- package/src/cli.ts +1 -1
- package/src/commands/index.ts +4 -0
- package/src/commands/login/index.ts +48 -0
- package/src/commands/logout/index.ts +51 -0
- package/src/config/entries.ts +175 -0
- package/src/config/index.ts +4 -0
- package/src/config/runtime.ts +15 -14
- package/src/registry/auth/access-tokens.ts +198 -0
- package/src/registry/auth/action-tokens.ts +225 -0
- package/src/registry/auth/crypto.ts +13 -0
- package/src/registry/auth/database.ts +15 -0
- package/src/registry/auth/device-authorizations.ts +223 -0
- package/src/registry/auth/gravatar.ts +6 -0
- package/src/registry/auth/index.ts +89 -0
- package/src/registry/auth/installation.ts +117 -0
- package/src/registry/auth/password.ts +46 -0
- package/src/registry/auth/rate-limits.ts +84 -0
- package/src/registry/auth/types.ts +34 -0
- package/src/registry/auth/user-record.ts +25 -0
- package/src/registry/auth/users.ts +145 -0
- package/src/registry/auth/validation.ts +24 -0
- package/src/registry/checkout/assets.ts +4 -2
- package/src/registry/checkout/index.ts +114 -24
- package/src/registry/checkout/tokens.ts +4 -2
- package/src/registry/connection.ts +23 -0
- package/src/registry/db/driver.ts +5 -4
- package/src/registry/db/index.ts +12 -0
- package/src/registry/db/schema.ts +174 -1
- package/src/registry/publish/assets.ts +1 -1
- package/src/registry/publish/errors.ts +3 -3
- package/src/registry/publish/index.ts +9 -9
- package/src/registry/publish/tokens.ts +1 -1
- package/src/types/config.ts +4 -1
- package/templates/default/README.md.tpl +18 -0
- package/templates/default/gitignore.tpl +1 -0
- package/templates/default/handoff.config.js.tpl +0 -1
- package/templates/default/handoff.config.ts.tpl +0 -1
- package/templates/default/handoff.config.with-examples.js.tpl +0 -1
- 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 {
|
|
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?: '
|
|
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
|
-
/**
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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: '
|
|
38
|
+
code: 'unauthorized',
|
|
54
39
|
message:
|
|
55
|
-
`
|
|
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
|
-
|
|
60
|
-
if (!provided || !tokensMatch(provided, expected)) {
|
|
43
|
+
if (!registryPrincipalHasScope(principal, scope)) {
|
|
61
44
|
return {
|
|
62
45
|
ok: false,
|
|
63
|
-
code: '
|
|
64
|
-
message:
|
|
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 {
|
|
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`);
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
|
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 (
|
|
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.
|
|
439
|
-
*
|
|
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.
|
|
469
|
-
*
|
|
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 }) => {
|
package/src/app/next.config.mjs
CHANGED
|
@@ -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
|
-
|
|
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('.'),
|
package/src/app/pages/_app.tsx
CHANGED
|
@@ -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
|
-
<
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|