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,102 @@
|
|
|
1
|
+
import type { NextApiRequest, NextApiResponse } from 'next';
|
|
2
|
+
import { getRegistryInstallationState, installRegistry } from '@handoff/registry/auth';
|
|
3
|
+
import { allowApiMethods, prepareRegistryApi } from '../../lib/auth/api';
|
|
4
|
+
import { canonicalRegistryUrl, registryAuthSecret } from '../../lib/auth/config';
|
|
5
|
+
import { registryEmailIsConfigured } from '../../lib/auth/email';
|
|
6
|
+
|
|
7
|
+
const check = (id: string, label: string, ok: boolean, message: string, optional = false) => ({
|
|
8
|
+
id,
|
|
9
|
+
label,
|
|
10
|
+
ok,
|
|
11
|
+
message,
|
|
12
|
+
optional,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export default async function installHandler(req: NextApiRequest, res: NextApiResponse) {
|
|
16
|
+
res.setHeader('Cache-Control', 'no-store, max-age=0');
|
|
17
|
+
res.setHeader('Pragma', 'no-cache');
|
|
18
|
+
|
|
19
|
+
const method = allowApiMethods(req, res, ['GET', 'POST']);
|
|
20
|
+
if (!method) return;
|
|
21
|
+
const context = await prepareRegistryApi(req, res, { installed: false, mutation: method === 'POST' });
|
|
22
|
+
if (!context) return;
|
|
23
|
+
|
|
24
|
+
let state: Awaited<ReturnType<typeof getRegistryInstallationState>> | null = null;
|
|
25
|
+
try {
|
|
26
|
+
state = await getRegistryInstallationState(context.db);
|
|
27
|
+
} catch {
|
|
28
|
+
// A failed state query means the registry database is not ready to serve the installer; the
|
|
29
|
+
// database check below reports it.
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const installed = state?.status === 'installed';
|
|
33
|
+
if (method === 'GET') {
|
|
34
|
+
const secretReady = registryAuthSecret().length >= 32;
|
|
35
|
+
const canonicalReady = canonicalRegistryUrl() !== null;
|
|
36
|
+
const databaseReady = Boolean(state);
|
|
37
|
+
const emptyUsers = state?.status === 'ready';
|
|
38
|
+
const checks = [
|
|
39
|
+
check('runtime', 'Registry runtime', true, 'This deployment is running in registry mode.'),
|
|
40
|
+
check(
|
|
41
|
+
'database',
|
|
42
|
+
'Database connection',
|
|
43
|
+
databaseReady,
|
|
44
|
+
databaseReady ? 'The registry database is reachable.' : 'The registry database is not ready. Check the deployment and try again.'
|
|
45
|
+
),
|
|
46
|
+
check(
|
|
47
|
+
'authSecret',
|
|
48
|
+
'Authentication secret',
|
|
49
|
+
secretReady,
|
|
50
|
+
secretReady ? 'AUTH_SECRET is configured.' : 'Set AUTH_SECRET to a random value of at least 32 characters.'
|
|
51
|
+
),
|
|
52
|
+
check(
|
|
53
|
+
'appUrl',
|
|
54
|
+
'Canonical application URL',
|
|
55
|
+
canonicalReady,
|
|
56
|
+
canonicalReady ? 'AUTH_URL is a valid absolute HTTP(S) URL.' : 'Set AUTH_URL to the public registry URL, including its base path.'
|
|
57
|
+
),
|
|
58
|
+
check(
|
|
59
|
+
'email',
|
|
60
|
+
'Email delivery',
|
|
61
|
+
registryEmailIsConfigured(),
|
|
62
|
+
registryEmailIsConfigured()
|
|
63
|
+
? 'Resend delivery is configured.'
|
|
64
|
+
: 'Optional. Set RESEND_API_KEY and AUTH_FROM_EMAIL, or deliver invitation links manually.',
|
|
65
|
+
true
|
|
66
|
+
),
|
|
67
|
+
];
|
|
68
|
+
const ready = !installed && Boolean(emptyUsers && state) && secretReady && canonicalReady;
|
|
69
|
+
res.status(200).json({ installed, ready, checks, emailConfigured: registryEmailIsConfigured() });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (installed) {
|
|
74
|
+
res.status(409).json({ error: 'This registry has already been installed.', installed: true });
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (!state || state.status !== 'ready' || registryAuthSecret().length < 32 || !canonicalRegistryUrl()) {
|
|
78
|
+
res.status(409).json({ error: 'The registry did not pass installation preflight. Correct the deployment configuration and retry.' });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const body = req.body && typeof req.body === 'object' ? req.body : {};
|
|
83
|
+
const password = typeof body.password === 'string' ? body.password : '';
|
|
84
|
+
if (password !== body.passwordConfirmation) {
|
|
85
|
+
res.status(400).json({ error: 'Passwords do not match.' });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const result = await installRegistry(context.db, {
|
|
89
|
+
email: typeof body.email === 'string' ? body.email : '',
|
|
90
|
+
name: typeof body.name === 'string' ? body.name : '',
|
|
91
|
+
password,
|
|
92
|
+
});
|
|
93
|
+
if ('reason' in result) {
|
|
94
|
+
if (result.reason === 'already_installed' || result.reason === 'users_exist') {
|
|
95
|
+
res.status(409).json({ error: 'This registry has already been claimed or contains users.', installed: true });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
res.status(400).json({ error: 'error' in result && result.error ? result.error : 'Enter a valid email, display name, and password.' });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
res.status(201).json({ ok: true, user: result.user });
|
|
102
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { NextApiRequest, NextApiResponse } from 'next';
|
|
2
|
+
import { approveRegistryDeviceAuthorization, denyRegistryDeviceAuthorization } from '@handoff/registry/auth';
|
|
3
|
+
import { allowApiMethods, prepareRegistryApi } from '../../../../lib/auth/api';
|
|
4
|
+
|
|
5
|
+
export default async function approveDeviceHandler(req: NextApiRequest, res: NextApiResponse) {
|
|
6
|
+
const method = allowApiMethods(req, res, ['POST']);
|
|
7
|
+
if (!method) return;
|
|
8
|
+
const context = await prepareRegistryApi(req, res, { auth: 'user', mutation: true });
|
|
9
|
+
if (!context?.user) return;
|
|
10
|
+
const body = req.body && typeof req.body === 'object' ? req.body : {};
|
|
11
|
+
const userCode = typeof body.user_code === 'string' ? body.user_code : typeof body.userCode === 'string' ? body.userCode : '';
|
|
12
|
+
if (body.action === 'deny') {
|
|
13
|
+
const denied = await denyRegistryDeviceAuthorization(context.db, userCode);
|
|
14
|
+
res.status(denied ? 200 : 400).json(denied ? { ok: true } : { error: 'This device code is invalid or expired.' });
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const result = await approveRegistryDeviceAuthorization(context.db, { userCode, userId: context.user.id });
|
|
18
|
+
if (!result.ok) {
|
|
19
|
+
res.status(400).json({ error: 'This device code is invalid or expired.' });
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
res.status(200).json({ ok: true, scopes: result.scopes });
|
|
23
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { NextApiRequest, NextApiResponse } from 'next';
|
|
2
|
+
import { consumeAuthRateLimit, createRegistryDeviceAuthorization } from '@handoff/registry/auth';
|
|
3
|
+
import { allowApiMethods, prepareRegistryApi, registryPageUrl } from '../../../../lib/auth/api';
|
|
4
|
+
|
|
5
|
+
const requestAddress = (req: NextApiRequest): string => {
|
|
6
|
+
const forwarded = req.headers['x-forwarded-for'];
|
|
7
|
+
return (Array.isArray(forwarded) ? forwarded[0] : forwarded)?.split(',')[0]?.trim() || req.socket.remoteAddress || 'unknown';
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export default async function deviceAuthorizationHandler(req: NextApiRequest, res: NextApiResponse) {
|
|
11
|
+
const method = allowApiMethods(req, res, ['POST']);
|
|
12
|
+
if (!method) return;
|
|
13
|
+
const context = await prepareRegistryApi(req, res);
|
|
14
|
+
if (!context) return;
|
|
15
|
+
const throttle = await consumeAuthRateLimit(context.db, {
|
|
16
|
+
bucket: 'device',
|
|
17
|
+
identifier: requestAddress(req),
|
|
18
|
+
limit: 20,
|
|
19
|
+
windowMs: 15 * 60 * 1000,
|
|
20
|
+
});
|
|
21
|
+
if (!throttle.allowed) {
|
|
22
|
+
res.setHeader('Retry-After', String(throttle.retryAfterSeconds));
|
|
23
|
+
res.status(429).json({ error: 'slow_down', error_description: 'Too many device authorization requests.' });
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const authorization = await createRegistryDeviceAuthorization(context.db);
|
|
27
|
+
const verificationUri = registryPageUrl('/cli/device');
|
|
28
|
+
const verificationUriComplete = registryPageUrl('/cli/device', { user_code: authorization.userCode });
|
|
29
|
+
if (!verificationUri || !verificationUriComplete) {
|
|
30
|
+
res.status(500).json({ error: 'server_error', error_description: 'AUTH_URL is not configured.' });
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
res.status(200).json({
|
|
34
|
+
device_code: authorization.deviceCode,
|
|
35
|
+
user_code: authorization.userCode,
|
|
36
|
+
verification_uri: verificationUri,
|
|
37
|
+
verification_uri_complete: verificationUriComplete,
|
|
38
|
+
expires_in: authorization.expiresIn,
|
|
39
|
+
interval: authorization.interval,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { NextApiRequest, NextApiResponse } from 'next';
|
|
2
|
+
import { authenticateRegistryAccessToken, revokeRegistryAccessToken } from '@handoff/registry/auth';
|
|
3
|
+
import { allowApiMethods, prepareRegistryApi } from '../../../lib/auth/api';
|
|
4
|
+
|
|
5
|
+
const bearerToken = (req: NextApiRequest): string => {
|
|
6
|
+
const header = req.headers.authorization;
|
|
7
|
+
if (typeof header !== 'string') return '';
|
|
8
|
+
const match = /^Bearer\s+(.+)$/i.exec(header.trim());
|
|
9
|
+
return match?.[1]?.trim() || '';
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export default async function revokeTokenHandler(req: NextApiRequest, res: NextApiResponse) {
|
|
13
|
+
const method = allowApiMethods(req, res, ['POST']);
|
|
14
|
+
if (!method) return;
|
|
15
|
+
const context = await prepareRegistryApi(req, res);
|
|
16
|
+
if (!context) return;
|
|
17
|
+
const principal = await authenticateRegistryAccessToken(context.db, bearerToken(req));
|
|
18
|
+
if (!principal) {
|
|
19
|
+
res.status(401).json({ error: 'A valid active access token is required.' });
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
await revokeRegistryAccessToken(context.db, { tokenId: principal.tokenId, userId: principal.userId });
|
|
23
|
+
res.status(200).json({ ok: true });
|
|
24
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { NextApiRequest, NextApiResponse } from 'next';
|
|
2
|
+
import { exchangeRegistryDeviceAuthorization } from '@handoff/registry/auth';
|
|
3
|
+
import { allowApiMethods, prepareRegistryApi } from '../../../lib/auth/api';
|
|
4
|
+
|
|
5
|
+
const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
|
|
6
|
+
|
|
7
|
+
export default async function deviceTokenHandler(req: NextApiRequest, res: NextApiResponse) {
|
|
8
|
+
const method = allowApiMethods(req, res, ['POST']);
|
|
9
|
+
if (!method) return;
|
|
10
|
+
const context = await prepareRegistryApi(req, res);
|
|
11
|
+
if (!context) return;
|
|
12
|
+
const body = req.body && typeof req.body === 'object' ? req.body : {};
|
|
13
|
+
if (body.grant_type !== DEVICE_GRANT || typeof body.device_code !== 'string') {
|
|
14
|
+
res.status(400).json({ error: 'unsupported_grant_type', error_description: 'Use the OAuth device authorization grant.' });
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const result = await exchangeRegistryDeviceAuthorization(context.db, {
|
|
18
|
+
deviceCode: body.device_code,
|
|
19
|
+
tokenName: typeof body.token_name === 'string' ? body.token_name : 'Handoff CLI',
|
|
20
|
+
});
|
|
21
|
+
if ('error' in result) {
|
|
22
|
+
res.status(400).json({ error: result.error, error_description: result.errorDescription });
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
res.status(200).json({
|
|
26
|
+
access_token: result.accessToken,
|
|
27
|
+
token_type: result.tokenType,
|
|
28
|
+
expires_in: result.expiresIn,
|
|
29
|
+
scope: result.scopes.join(' '),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
@@ -3,9 +3,10 @@ import { ensureRegistryMode, sendRegistryError } from '@/lib/registry-api';
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* `GET /api/registry/health` — registry liveness. Registry-runtime only
|
|
6
|
-
* (`409 runtime_mode_conflict` in workspace mode)
|
|
7
|
-
* reports the runtime/source without touching the database so
|
|
8
|
-
* returns the documented bare `{ ok, runtime, source }` body
|
|
6
|
+
* (`409 runtime_mode_conflict` in workspace mode). Unlike the data endpoints (which require a scoped
|
|
7
|
+
* token), health is unauthenticated: it reports the runtime/source without touching the database so
|
|
8
|
+
* it can be used as a cheap probe, and returns the documented bare `{ ok, runtime, source }` body
|
|
9
|
+
* rather than the data envelope.
|
|
9
10
|
*/
|
|
10
11
|
export default function handler(req: NextApiRequest, res: NextApiResponse): void {
|
|
11
12
|
if (!ensureRegistryMode(res)) {
|
|
@@ -7,10 +7,10 @@ import { handleCheckoutRoute, handleTransferRoute } from '@/lib/registry-api/tra
|
|
|
7
7
|
* `/api/registry/transfer/{component|pattern|page}/:id` — the transfer endpoint.
|
|
8
8
|
*
|
|
9
9
|
* - `GET` is checkout: returns the normalized record + registry-safe source files so a
|
|
10
|
-
* connected workspace can reconstruct the entity locally.
|
|
10
|
+
* connected workspace can reconstruct the entity locally. Requires a `registry:read` token.
|
|
11
11
|
* - `PUT` is publish ingestion: the only path allowed to set an entity's
|
|
12
|
-
* render/build-defining fields, source files, rendered artifacts, and build metadata. Requires
|
|
13
|
-
*
|
|
12
|
+
* render/build-defining fields, source files, rendered artifacts, and build metadata. Requires a
|
|
13
|
+
* `registry:write` token.
|
|
14
14
|
*
|
|
15
15
|
* Both are registry-runtime only (enforced by the guard stack).
|
|
16
16
|
*/
|
|
@@ -5,8 +5,9 @@ import { handleAssetCollectionRoute } from '@/lib/registry-api/asset-transfer';
|
|
|
5
5
|
* `/api/registry/transfer/assets/:collection`: the asset collection manifest endpoint.
|
|
6
6
|
*
|
|
7
7
|
* - `GET` is checkout: the collection's manifest metadata (bodies travel separately as blobs).
|
|
8
|
+
* Requires a `registry:read` token.
|
|
8
9
|
* - `PUT` is publish ingestion: validates the manifest, asserts referenced blobs exist, and
|
|
9
|
-
* atomically replaces the collection's manifest. Requires
|
|
10
|
+
* atomically replaces the collection's manifest. Requires a `registry:write` token.
|
|
10
11
|
*
|
|
11
12
|
* Both are registry-runtime only (enforced by the guard stack).
|
|
12
13
|
*/
|
|
@@ -5,8 +5,9 @@ import { handleAssetBlobRoute } from '@/lib/registry-api/asset-transfer';
|
|
|
5
5
|
* `/api/registry/transfer/assets/blobs/:hash`: one content-addressed blob.
|
|
6
6
|
*
|
|
7
7
|
* - `PUT` stores the raw binary body through the active storage provider (inline `bytea` by default,
|
|
8
|
-
* or an object-storage adapter). Idempotent by hash; requires
|
|
8
|
+
* or an object-storage adapter). Idempotent by hash; requires a `registry:write` token.
|
|
9
9
|
* - `GET` returns the blob's bytes, or redirects to a provider URL for object-backed content.
|
|
10
|
+
* Requires a `registry:read` token.
|
|
10
11
|
*
|
|
11
12
|
* Registry-runtime only (guard stack).
|
|
12
13
|
*/
|
|
@@ -5,9 +5,9 @@ import { handleTokenCheckoutRoute, handleTokenTransferRoute } from '@/lib/regist
|
|
|
5
5
|
* `/api/registry/transfer/tokens/:setId` — the token-set transfer endpoint. The catch-all param
|
|
6
6
|
* carries the multi-segment logical set id (`foundation/colors`, `component/button`).
|
|
7
7
|
*
|
|
8
|
-
* - `GET` is checkout: the set record + its generated artifacts.
|
|
8
|
+
* - `GET` is checkout: the set record + its generated artifacts. Requires a `registry:read` token.
|
|
9
9
|
* - `PUT` is publish ingestion: validates the package and atomically upserts the set + its artifacts.
|
|
10
|
-
* Requires
|
|
10
|
+
* Requires a `registry:write` token.
|
|
11
11
|
*
|
|
12
12
|
* Both are registry-runtime only (enforced by the guard stack).
|
|
13
13
|
*/
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { AuthShell } from '../../components/Auth/AuthShell';
|
|
2
|
+
import { authApiUrl, readApiError } from '../../components/Auth/api';
|
|
3
|
+
import { Alert, AlertDescription, AlertTitle } from '../../components/ui/alert';
|
|
4
|
+
import { Button } from '../../components/ui/button';
|
|
5
|
+
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '../../components/ui/card';
|
|
6
|
+
import { Input } from '../../components/ui/input';
|
|
7
|
+
import { Label } from '../../components/ui/label';
|
|
8
|
+
import { CheckCircle2, Laptop, ShieldCheck } from 'lucide-react';
|
|
9
|
+
import Link from 'next/link';
|
|
10
|
+
import { useSession } from 'next-auth/react';
|
|
11
|
+
import { useRouter } from 'next/router';
|
|
12
|
+
import { FormEvent, useEffect, useState } from 'react';
|
|
13
|
+
|
|
14
|
+
const normalizeCode = (value: string) => value.replace(/\s/g, '').toUpperCase();
|
|
15
|
+
|
|
16
|
+
export default function DeviceApprovalPage() {
|
|
17
|
+
if (process.env.HANDOFF_RUNTIME_MODE !== 'registry') {
|
|
18
|
+
return <AuthShell title="Authorize CLI">{null}</AuthShell>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return <RegistryDeviceApprovalPage />;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function RegistryDeviceApprovalPage() {
|
|
25
|
+
const router = useRouter();
|
|
26
|
+
const { data: session, status } = useSession();
|
|
27
|
+
const [userCode, setUserCode] = useState('');
|
|
28
|
+
const [pending, setPending] = useState(false);
|
|
29
|
+
const [approved, setApproved] = useState(false);
|
|
30
|
+
const [error, setError] = useState<string | null>(null);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (router.isReady && typeof router.query.user_code === 'string') setUserCode(normalizeCode(router.query.user_code));
|
|
34
|
+
}, [router.isReady, router.query.user_code]);
|
|
35
|
+
|
|
36
|
+
const approve = async (event: FormEvent<HTMLFormElement>) => {
|
|
37
|
+
event.preventDefault();
|
|
38
|
+
setPending(true);
|
|
39
|
+
setError(null);
|
|
40
|
+
const code = normalizeCode(userCode);
|
|
41
|
+
try {
|
|
42
|
+
const response = await fetch(authApiUrl('/api/oauth/device/approve'), {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
credentials: 'include',
|
|
45
|
+
headers: { 'Content-Type': 'application/json' },
|
|
46
|
+
body: JSON.stringify({ user_code: code, userCode: code, action: 'approve' }),
|
|
47
|
+
});
|
|
48
|
+
if (!response.ok) {
|
|
49
|
+
setError(await readApiError(response, 'This device code is invalid or has expired.'));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
setApproved(true);
|
|
53
|
+
} catch {
|
|
54
|
+
setError('Could not connect to the registry.');
|
|
55
|
+
} finally {
|
|
56
|
+
setPending(false);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const callbackUrl = `/cli/device${router.asPath.includes('?') ? router.asPath.slice(router.asPath.indexOf('?')) : ''}`;
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<AuthShell title="Authorize CLI">
|
|
64
|
+
<Card>
|
|
65
|
+
<CardHeader>
|
|
66
|
+
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
|
67
|
+
<Laptop />
|
|
68
|
+
</div>
|
|
69
|
+
<CardTitle>Authorize Handoff CLI</CardTitle>
|
|
70
|
+
<CardDescription>
|
|
71
|
+
Connect the terminal that started <code>handoff-app login</code> to your registry account.
|
|
72
|
+
</CardDescription>
|
|
73
|
+
</CardHeader>
|
|
74
|
+
{status === 'loading' ? (
|
|
75
|
+
<CardContent>
|
|
76
|
+
<p className="text-sm text-muted-foreground">Checking your account…</p>
|
|
77
|
+
</CardContent>
|
|
78
|
+
) : !session?.user ? (
|
|
79
|
+
<CardContent className="space-y-4">
|
|
80
|
+
<Alert variant="info">
|
|
81
|
+
<ShieldCheck />
|
|
82
|
+
<AlertDescription>Sign in before approving a device.</AlertDescription>
|
|
83
|
+
</Alert>
|
|
84
|
+
<Button asChild className="w-full">
|
|
85
|
+
<Link href={`/login?callbackUrl=${encodeURIComponent(callbackUrl)}`}>Sign in to continue</Link>
|
|
86
|
+
</Button>
|
|
87
|
+
</CardContent>
|
|
88
|
+
) : approved ? (
|
|
89
|
+
<>
|
|
90
|
+
<CardContent>
|
|
91
|
+
<Alert variant="green">
|
|
92
|
+
<CheckCircle2 />
|
|
93
|
+
<AlertTitle>CLI authorized</AlertTitle>
|
|
94
|
+
<AlertDescription>You can close this page and return to your terminal. Login should complete shortly.</AlertDescription>
|
|
95
|
+
</Alert>
|
|
96
|
+
</CardContent>
|
|
97
|
+
<CardFooter>
|
|
98
|
+
<Button asChild variant="outline" className="w-full">
|
|
99
|
+
<Link href="/">Return to documentation</Link>
|
|
100
|
+
</Button>
|
|
101
|
+
</CardFooter>
|
|
102
|
+
</>
|
|
103
|
+
) : (
|
|
104
|
+
<form onSubmit={approve}>
|
|
105
|
+
<CardContent className="space-y-4">
|
|
106
|
+
<p className="text-sm text-muted-foreground">
|
|
107
|
+
Signed in as <strong className="text-foreground">{session.user.email}</strong>. Only approve a code you requested from your
|
|
108
|
+
own terminal.
|
|
109
|
+
</p>
|
|
110
|
+
{error ? (
|
|
111
|
+
<Alert variant="destructive">
|
|
112
|
+
<AlertDescription>{error}</AlertDescription>
|
|
113
|
+
</Alert>
|
|
114
|
+
) : null}
|
|
115
|
+
<div className="space-y-2">
|
|
116
|
+
<Label htmlFor="user-code">Device code</Label>
|
|
117
|
+
<Input
|
|
118
|
+
id="user-code"
|
|
119
|
+
value={userCode}
|
|
120
|
+
onChange={(event) => setUserCode(event.target.value.toUpperCase())}
|
|
121
|
+
required
|
|
122
|
+
autoFocus
|
|
123
|
+
autoComplete="one-time-code"
|
|
124
|
+
autoCapitalize="characters"
|
|
125
|
+
autoCorrect="off"
|
|
126
|
+
spellCheck={false}
|
|
127
|
+
placeholder="ABCD-EFGH"
|
|
128
|
+
className="text-center font-mono text-lg tracking-widest"
|
|
129
|
+
/>
|
|
130
|
+
</div>
|
|
131
|
+
</CardContent>
|
|
132
|
+
<CardFooter>
|
|
133
|
+
<Button className="w-full" type="submit" disabled={pending || !normalizeCode(userCode)}>
|
|
134
|
+
<ShieldCheck />
|
|
135
|
+
{pending ? 'Authorizing…' : 'Authorize CLI'}
|
|
136
|
+
</Button>
|
|
137
|
+
</CardFooter>
|
|
138
|
+
</form>
|
|
139
|
+
)}
|
|
140
|
+
</Card>
|
|
141
|
+
</AuthShell>
|
|
142
|
+
);
|
|
143
|
+
}
|