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
package/src/app-builder/build.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { buildPatterns } from '../pipeline/patterns';
|
|
|
8
8
|
import processComponents from '../transformers/preview/component/builder';
|
|
9
9
|
import { buildMainCss } from '../transformers/preview/component/css';
|
|
10
10
|
import { buildMainJS } from '../transformers/preview/component/javascript';
|
|
11
|
-
import {
|
|
11
|
+
import { resolveDatabaseUrlEnv, resolveRegistryDriver } from '../registry/db/driver';
|
|
12
12
|
import { resolveAssetStorageFromConfig } from '../registry/asset-storage/resolve';
|
|
13
13
|
import type { RuntimeMode } from '../types/config';
|
|
14
14
|
import { Logger } from '../utils/logger';
|
|
@@ -258,7 +258,6 @@ const initializeProjectApp = async (handoff: Handoff, options: InitializeProject
|
|
|
258
258
|
const escapedRuntimeMode = escapeForSingleQuotedJsString(runtimeMode);
|
|
259
259
|
const escapedRegistryDriver = escapeForSingleQuotedJsString(resolveRegistryDriver(handoff.config));
|
|
260
260
|
const escapedDatabaseUrlEnv = escapeForSingleQuotedJsString(resolveDatabaseUrlEnv(handoff.config));
|
|
261
|
-
const escapedApiTokenEnv = escapeForSingleQuotedJsString(resolveApiTokenEnv(handoff.config));
|
|
262
261
|
// Asset storage selection baked (provider + module + env-var names + non-secret options JSON).
|
|
263
262
|
const assetStorage = resolveAssetStorageFromConfig(handoff.config);
|
|
264
263
|
const escapedAssetStorageAdapter = escapeForSingleQuotedJsString(assetStorage.adapterKind);
|
|
@@ -276,7 +275,6 @@ const initializeProjectApp = async (handoff: Handoff, options: InitializeProject
|
|
|
276
275
|
'%HANDOFF_RUNTIME_MODE%': escapedRuntimeMode,
|
|
277
276
|
'%HANDOFF_REGISTRY_DRIVER%': escapedRegistryDriver,
|
|
278
277
|
'%HANDOFF_REGISTRY_DATABASE_URL_ENV%': escapedDatabaseUrlEnv,
|
|
279
|
-
'%HANDOFF_REGISTRY_API_TOKEN_ENV%': escapedApiTokenEnv,
|
|
280
278
|
'%HANDOFF_ASSET_STORAGE_ADAPTER%': escapedAssetStorageAdapter,
|
|
281
279
|
'%HANDOFF_ASSET_STORAGE_MODULE%': escapedAssetStorageModule,
|
|
282
280
|
'%HANDOFF_ASSET_STORAGE_TOKEN_ENV%': escapedAssetStorageTokenEnv,
|
|
@@ -418,7 +416,11 @@ and \`.next/static/\` already copied alongside so the server serves them.
|
|
|
418
416
|
## Required environment variables (supplied at deploy time, never baked in)
|
|
419
417
|
|
|
420
418
|
- \`${databaseUrlEnv}\` — PostgreSQL/Neon connection string, read at request time.
|
|
421
|
-
- \`
|
|
419
|
+
- \`AUTH_SECRET\` — a long, random secret used to sign browser sessions.
|
|
420
|
+
- \`AUTH_URL\` — the canonical public registry URL, including the configured base path.
|
|
421
|
+
|
|
422
|
+
Optional email delivery uses \`RESEND_API_KEY\` and \`AUTH_FROM_EMAIL\`. Without them, invitation
|
|
423
|
+
links are shown once to an administrator for manual delivery.
|
|
422
424
|
|
|
423
425
|
## Database migrations
|
|
424
426
|
|
|
@@ -433,6 +435,21 @@ ${databaseUrlEnv}="postgres://…" handoff-app db:migrate
|
|
|
433
435
|
built with, and applies the package-owned migration set. It runs independently of \`build\` and of
|
|
434
436
|
starting the server, so run it as a release/one-shot job.
|
|
435
437
|
|
|
438
|
+
## Complete the one-time installation
|
|
439
|
+
|
|
440
|
+
After migrations and deployment, open \`/install\` immediately and create the initial administrator.
|
|
441
|
+
The installer verifies the deployment but never changes the schema. An exposed, uninstalled registry
|
|
442
|
+
can be claimed by its first visitor, so do not leave this step unattended.
|
|
443
|
+
|
|
444
|
+
Once installation completes, sign in and authorize a workspace:
|
|
445
|
+
|
|
446
|
+
\`\`\`bash
|
|
447
|
+
handoff-app login --url https://registry.example.com
|
|
448
|
+
\`\`\`
|
|
449
|
+
|
|
450
|
+
The browser approval issues a revocable, user-owned credential. The former
|
|
451
|
+
\`HANDOFF_REGISTRY_API_TOKEN\` fixed server secret is no longer accepted.
|
|
452
|
+
|
|
436
453
|
## Run it (self-hosting)
|
|
437
454
|
|
|
438
455
|
Standalone is the documented artifact for self-hosting on a Node.js server, VPS, Docker, or
|
|
@@ -481,7 +498,7 @@ containers, custom Node servers, and other non-Vercel hosts.
|
|
|
481
498
|
* package is driver-specific (the Neon serverless driver also needs `ws` for its Node WebSocket transport).
|
|
482
499
|
*/
|
|
483
500
|
const getRequiredRegistryRuntimeModules = (handoff: Handoff): string[] => {
|
|
484
|
-
const base = ['next', 'react', 'react-dom', 'drizzle-orm'];
|
|
501
|
+
const base = ['next', 'next-auth', 'react', 'react-dom', 'drizzle-orm'];
|
|
485
502
|
const driver = resolveRegistryDriver(handoff.config);
|
|
486
503
|
const driverModules = driver === 'neon' ? ['@neondatabase/serverless', 'ws'] : ['pg'];
|
|
487
504
|
// Asset-storage SDKs the deployed registry must be able to load at request time. The pre-packaged
|
|
@@ -3,7 +3,8 @@ import path from 'path';
|
|
|
3
3
|
import Handoff from '..';
|
|
4
4
|
import { getClientConfig } from '../config';
|
|
5
5
|
import { resolveAssetStorageFromConfig } from '../registry/asset-storage/resolve';
|
|
6
|
-
import {
|
|
6
|
+
import { resolveAuthenticatedRegistryConnection } from '../registry/connection';
|
|
7
|
+
import { resolveDatabaseUrlEnv, resolveRegistryDriver } from '../registry/db/driver';
|
|
7
8
|
import type { Config, RuntimeMode } from '../types/config';
|
|
8
9
|
import { getAppPath } from './paths';
|
|
9
10
|
|
|
@@ -59,7 +60,6 @@ const buildServerRuntimeConfig = (config: Config, modeOverride?: RuntimeMode) =>
|
|
|
59
60
|
registry: {
|
|
60
61
|
driver: resolveRegistryDriver(config),
|
|
61
62
|
databaseUrlEnv: resolveDatabaseUrlEnv(config),
|
|
62
|
-
apiTokenEnv: resolveApiTokenEnv(config),
|
|
63
63
|
},
|
|
64
64
|
assetStorage: {
|
|
65
65
|
adapter: assetStorage.adapterKind,
|
|
@@ -93,13 +93,14 @@ export const persistClientConfig = async (handoff: Handoff, options: PersistClie
|
|
|
93
93
|
await fs.ensureDir(appPath);
|
|
94
94
|
|
|
95
95
|
const clientConfig = getClientConfig(handoff.config);
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
96
|
+
const mode = options.runtimeModeOverride ?? clientConfig.runtime.mode;
|
|
97
|
+
// A registry build target is never a connected workspace, so it must bake `connected: false`
|
|
98
|
+
// even when packaged from a connected project. Otherwise a workspace is connected whenever a
|
|
99
|
+
// registry URL resolves — from config/env or a saved device login — so `login` alone surfaces
|
|
100
|
+
// the publish/checkout UI without also requiring HANDOFF_REGISTRY_URL.
|
|
101
|
+
const connected =
|
|
102
|
+
mode === 'registry' ? false : Boolean((await resolveAuthenticatedRegistryConnection(handoff.config, handoff.workingPath)).url);
|
|
103
|
+
clientConfig.runtime = { ...clientConfig.runtime, mode, connected };
|
|
103
104
|
|
|
104
105
|
await fs.writeJson(path.resolve(appPath, 'client.config.json'), { config: clientConfig }, { spaces: 2 });
|
|
105
106
|
await fs.writeJson(
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
|
|
3
|
+
/** Fire-and-forget opening of a URL in the system browser. The printed URL remains the fallback. */
|
|
4
|
+
export const tryOpenBrowser = (url: string): void => {
|
|
5
|
+
try {
|
|
6
|
+
const { protocol } = new URL(url);
|
|
7
|
+
if (protocol !== 'http:' && protocol !== 'https:') return;
|
|
8
|
+
} catch {
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const platform = process.platform;
|
|
13
|
+
// rundll32 is a plain executable rather than a shell, so spawn hands it the URL as one
|
|
14
|
+
// untouched argument. `cmd /c start` would re-parse shell metacharacters in it.
|
|
15
|
+
const command = platform === 'darwin' ? 'open' : platform === 'win32' ? 'rundll32.exe' : 'xdg-open';
|
|
16
|
+
const args = platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url];
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const child = spawn(command, args, {
|
|
20
|
+
detached: true,
|
|
21
|
+
stdio: 'ignore',
|
|
22
|
+
windowsHide: platform === 'win32',
|
|
23
|
+
});
|
|
24
|
+
child.on('error', () => undefined);
|
|
25
|
+
child.unref();
|
|
26
|
+
} catch {
|
|
27
|
+
// Headless and SSH environments can use the URL printed by the login flow.
|
|
28
|
+
}
|
|
29
|
+
};
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { Logger } from '../../utils/logger';
|
|
2
|
+
import { tryOpenBrowser } from './browser';
|
|
3
|
+
import { assertRegistryOriginUrl, type CliAuth, normalizeRegistryUrl, writeCliAuth } from './store';
|
|
4
|
+
|
|
5
|
+
const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
|
|
6
|
+
const DEFAULT_DEVICE_EXPIRES_IN_SECONDS = 15 * 60;
|
|
7
|
+
const DEFAULT_TOKEN_EXPIRES_IN_SECONDS = 365 * 24 * 60 * 60;
|
|
8
|
+
const DEFAULT_POLL_INTERVAL_SECONDS = 5;
|
|
9
|
+
|
|
10
|
+
interface DeviceAuthorizationResponse {
|
|
11
|
+
device_code?: string;
|
|
12
|
+
user_code?: string;
|
|
13
|
+
verification_uri?: string;
|
|
14
|
+
verification_uri_complete?: string;
|
|
15
|
+
expires_in?: number;
|
|
16
|
+
interval?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface TokenResponse {
|
|
20
|
+
access_token?: string;
|
|
21
|
+
token_type?: string;
|
|
22
|
+
expires_in?: number;
|
|
23
|
+
error?: string | { code?: string; message?: string };
|
|
24
|
+
error_description?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface Envelope<T> {
|
|
28
|
+
data?: T;
|
|
29
|
+
error?: string | { code?: string; message?: string };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface LoginOptions {
|
|
33
|
+
openBrowser?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const sleep = (milliseconds: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
37
|
+
|
|
38
|
+
const responsePayload = <T>(value: T | Envelope<T>): T => {
|
|
39
|
+
const envelope = value as Envelope<T>;
|
|
40
|
+
return envelope && typeof envelope === 'object' && envelope.data ? envelope.data : (value as T);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const responseError = (value: unknown): { code?: string; message?: string } => {
|
|
44
|
+
if (!value || typeof value !== 'object') return {};
|
|
45
|
+
const body = value as TokenResponse;
|
|
46
|
+
if (typeof body.error === 'string') {
|
|
47
|
+
return { code: body.error, message: body.error_description || body.error };
|
|
48
|
+
}
|
|
49
|
+
if (body.error && typeof body.error === 'object') {
|
|
50
|
+
return { code: body.error.code, message: body.error.message };
|
|
51
|
+
}
|
|
52
|
+
return {};
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const readJson = async (response: Response): Promise<unknown> => {
|
|
56
|
+
const text = await response.text();
|
|
57
|
+
if (!text) return {};
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(text) as unknown;
|
|
60
|
+
} catch {
|
|
61
|
+
return {};
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const shouldOpenBrowser = (options: LoginOptions): boolean => {
|
|
66
|
+
if (options.openBrowser === false) return false;
|
|
67
|
+
if (process.env.CI === '1' || process.env.CI === 'true') return false;
|
|
68
|
+
return process.env.HANDOFF_LOGIN_NO_BROWSER !== '1' && process.env.HANDOFF_LOGIN_NO_BROWSER !== 'true';
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const positiveNumberOr = (value: unknown, fallback: number): number =>
|
|
72
|
+
typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback;
|
|
73
|
+
|
|
74
|
+
/** Complete the device grant and persist the one-time plaintext access token for this workspace. */
|
|
75
|
+
export const loginWithDevice = async (workingPath: string, remoteUrl: string, options: LoginOptions = {}): Promise<CliAuth> => {
|
|
76
|
+
const baseUrl = normalizeRegistryUrl(remoteUrl);
|
|
77
|
+
let deviceResponse: Response;
|
|
78
|
+
try {
|
|
79
|
+
deviceResponse = await fetch(`${baseUrl}/api/oauth/device`, {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
|
82
|
+
body: '{}',
|
|
83
|
+
});
|
|
84
|
+
} catch (error) {
|
|
85
|
+
throw new Error(`Could not reach the registry at ${baseUrl}: ${error instanceof Error ? error.message : String(error)}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const rawDevice = await readJson(deviceResponse);
|
|
89
|
+
const device = responsePayload(rawDevice as DeviceAuthorizationResponse | Envelope<DeviceAuthorizationResponse>);
|
|
90
|
+
if (!deviceResponse.ok) {
|
|
91
|
+
const failure = responseError(rawDevice);
|
|
92
|
+
throw new Error(`Device authorization failed (${deviceResponse.status}): ${failure.message || deviceResponse.statusText}`);
|
|
93
|
+
}
|
|
94
|
+
if (!device.device_code || !device.user_code || !device.verification_uri) {
|
|
95
|
+
throw new Error('The registry returned an invalid device authorization response.');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const verificationUrl = assertRegistryOriginUrl(
|
|
99
|
+
baseUrl,
|
|
100
|
+
device.verification_uri_complete ||
|
|
101
|
+
`${device.verification_uri}${device.verification_uri.includes('?') ? '&' : '?'}user_code=${encodeURIComponent(device.user_code)}`,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
Logger.log('');
|
|
105
|
+
Logger.log('Approve this device in your browser:');
|
|
106
|
+
Logger.log(verificationUrl);
|
|
107
|
+
Logger.log('');
|
|
108
|
+
Logger.log(`Device code: ${device.user_code}`);
|
|
109
|
+
Logger.log('');
|
|
110
|
+
|
|
111
|
+
if (shouldOpenBrowser(options)) {
|
|
112
|
+
tryOpenBrowser(verificationUrl);
|
|
113
|
+
Logger.info('Attempted to open your default browser.');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const deadline = Date.now() + positiveNumberOr(device.expires_in, DEFAULT_DEVICE_EXPIRES_IN_SECONDS) * 1000;
|
|
117
|
+
let intervalMs = positiveNumberOr(device.interval, DEFAULT_POLL_INTERVAL_SECONDS) * 1000;
|
|
118
|
+
|
|
119
|
+
while (Date.now() < deadline) {
|
|
120
|
+
await sleep(Math.min(intervalMs, Math.max(0, deadline - Date.now())));
|
|
121
|
+
|
|
122
|
+
let tokenResponse: Response;
|
|
123
|
+
try {
|
|
124
|
+
tokenResponse = await fetch(`${baseUrl}/api/oauth/token`, {
|
|
125
|
+
method: 'POST',
|
|
126
|
+
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
|
127
|
+
body: JSON.stringify({
|
|
128
|
+
grant_type: DEVICE_GRANT,
|
|
129
|
+
device_code: device.device_code,
|
|
130
|
+
}),
|
|
131
|
+
});
|
|
132
|
+
} catch (error) {
|
|
133
|
+
throw new Error(`Could not reach the registry at ${baseUrl}: ${error instanceof Error ? error.message : String(error)}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const rawToken = await readJson(tokenResponse);
|
|
137
|
+
const token = responsePayload(rawToken as TokenResponse | Envelope<TokenResponse>);
|
|
138
|
+
if (tokenResponse.ok && token.access_token) {
|
|
139
|
+
const expiresIn = positiveNumberOr(token.expires_in, DEFAULT_TOKEN_EXPIRES_IN_SECONDS);
|
|
140
|
+
const auth: CliAuth = {
|
|
141
|
+
remoteUrl: baseUrl,
|
|
142
|
+
accessToken: token.access_token,
|
|
143
|
+
expiresAtMs: Date.now() + expiresIn * 1000,
|
|
144
|
+
};
|
|
145
|
+
await writeCliAuth(workingPath, auth);
|
|
146
|
+
return auth;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const failure = responseError(token);
|
|
150
|
+
if (failure.code === 'authorization_pending') continue;
|
|
151
|
+
if (failure.code === 'slow_down') {
|
|
152
|
+
intervalMs = Math.min(intervalMs + 5_000, 60_000);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (failure.code === 'expired_token') break;
|
|
156
|
+
throw new Error(`Token request failed: ${failure.message || tokenResponse.statusText || 'unknown error'}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
throw new Error('Device authorization expired. Run `handoff-app login` again.');
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Best-effort remote revocation. The caller must clear the local credential regardless of failure.
|
|
164
|
+
* The bearer header identifies the current token while the body supports RFC 7009-style handlers.
|
|
165
|
+
*/
|
|
166
|
+
export const revokeAccessToken = async (auth: CliAuth): Promise<void> => {
|
|
167
|
+
const response = await fetch(`${normalizeRegistryUrl(auth.remoteUrl)}/api/oauth/revoke`, {
|
|
168
|
+
method: 'POST',
|
|
169
|
+
headers: {
|
|
170
|
+
Accept: 'application/json',
|
|
171
|
+
Authorization: `Bearer ${auth.accessToken}`,
|
|
172
|
+
'Content-Type': 'application/json',
|
|
173
|
+
},
|
|
174
|
+
body: JSON.stringify({ token: auth.accessToken }),
|
|
175
|
+
});
|
|
176
|
+
if (!response.ok) {
|
|
177
|
+
const body = await readJson(response);
|
|
178
|
+
const failure = responseError(body);
|
|
179
|
+
throw new Error(failure.message || `${response.status} ${response.statusText}`);
|
|
180
|
+
}
|
|
181
|
+
};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
/** Credentials issued to this workspace by one registry. */
|
|
5
|
+
export interface CliAuth {
|
|
6
|
+
remoteUrl: string;
|
|
7
|
+
accessToken: string;
|
|
8
|
+
/** Epoch milliseconds after which the token must not be used. */
|
|
9
|
+
expiresAtMs: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const AUTH_DIRECTORY = '.handoff';
|
|
13
|
+
const AUTH_FILE = 'cli-auth.json';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Canonicalize a registry URL for both HTTP requests and exact credential matching.
|
|
17
|
+
* Base paths are retained; query strings, fragments, credentials, and trailing slashes are not.
|
|
18
|
+
*/
|
|
19
|
+
export const normalizeRegistryUrl = (value: string): string => {
|
|
20
|
+
let parsed: URL;
|
|
21
|
+
try {
|
|
22
|
+
parsed = new URL(value.trim());
|
|
23
|
+
} catch {
|
|
24
|
+
throw new Error(`Invalid registry URL "${value}". Pass an absolute http:// or https:// URL.`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
28
|
+
throw new Error(`Invalid registry URL protocol "${parsed.protocol}". Use http:// or https://.`);
|
|
29
|
+
}
|
|
30
|
+
if (parsed.username || parsed.password) {
|
|
31
|
+
throw new Error('Registry URLs must not contain credentials.');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const pathname = parsed.pathname.replace(/\/+$/, '');
|
|
35
|
+
return `${parsed.origin}${pathname === '/' ? '' : pathname}`;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A verification URL is only trustworthy if it's a web URL on the same origin as the
|
|
40
|
+
* registry the CLI is authenticating against. This stops a tampered device-authorization
|
|
41
|
+
* response from sending the browser to (or injecting a command via) another origin.
|
|
42
|
+
*/
|
|
43
|
+
export const assertRegistryOriginUrl = (baseUrl: string, candidate: string): string => {
|
|
44
|
+
let url: URL;
|
|
45
|
+
try {
|
|
46
|
+
url = new URL(candidate);
|
|
47
|
+
} catch {
|
|
48
|
+
throw new Error('The registry returned an invalid verification URL.');
|
|
49
|
+
}
|
|
50
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
51
|
+
throw new Error(`The registry returned a verification URL with an unsupported protocol "${url.protocol}".`);
|
|
52
|
+
}
|
|
53
|
+
if (url.origin !== new URL(baseUrl).origin) {
|
|
54
|
+
throw new Error('The registry returned a verification URL for a different origin than the registry.');
|
|
55
|
+
}
|
|
56
|
+
return url.toString();
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const cliAuthFilePath = (workingPath: string): string => path.resolve(workingPath, AUTH_DIRECTORY, AUTH_FILE);
|
|
60
|
+
|
|
61
|
+
const isCliAuth = (value: unknown): value is CliAuth => {
|
|
62
|
+
if (!value || typeof value !== 'object') return false;
|
|
63
|
+
const auth = value as Partial<CliAuth>;
|
|
64
|
+
return (
|
|
65
|
+
typeof auth.remoteUrl === 'string' &&
|
|
66
|
+
typeof auth.accessToken === 'string' &&
|
|
67
|
+
auth.accessToken.length > 0 &&
|
|
68
|
+
typeof auth.expiresAtMs === 'number' &&
|
|
69
|
+
Number.isFinite(auth.expiresAtMs)
|
|
70
|
+
);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Invalid or partial credential files are treated as unauthenticated. */
|
|
74
|
+
export const readCliAuth = async (workingPath: string): Promise<CliAuth | null> => {
|
|
75
|
+
try {
|
|
76
|
+
const value = (await fs.readJson(cliAuthFilePath(workingPath))) as unknown;
|
|
77
|
+
if (!isCliAuth(value)) return null;
|
|
78
|
+
return { ...value, remoteUrl: normalizeRegistryUrl(value.remoteUrl) };
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Atomically replace the credential file. Restrictive permissions are best-effort on platforms
|
|
86
|
+
* whose filesystems do not support POSIX modes.
|
|
87
|
+
*/
|
|
88
|
+
export const writeCliAuth = async (workingPath: string, auth: CliAuth): Promise<void> => {
|
|
89
|
+
const filePath = cliAuthFilePath(workingPath);
|
|
90
|
+
const directory = path.dirname(filePath);
|
|
91
|
+
const temporaryPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
92
|
+
const value: CliAuth = { ...auth, remoteUrl: normalizeRegistryUrl(auth.remoteUrl) };
|
|
93
|
+
|
|
94
|
+
await fs.ensureDir(directory, 0o700);
|
|
95
|
+
try {
|
|
96
|
+
await fs.chmod(directory, 0o700);
|
|
97
|
+
} catch {
|
|
98
|
+
// Windows and some mounted filesystems do not implement POSIX modes.
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
await fs.writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
103
|
+
try {
|
|
104
|
+
await fs.chmod(temporaryPath, 0o600);
|
|
105
|
+
} catch {
|
|
106
|
+
// Best effort; see the directory chmod above.
|
|
107
|
+
}
|
|
108
|
+
await fs.rename(temporaryPath, filePath);
|
|
109
|
+
try {
|
|
110
|
+
await fs.chmod(filePath, 0o600);
|
|
111
|
+
} catch {
|
|
112
|
+
// Best effort.
|
|
113
|
+
}
|
|
114
|
+
} catch (error) {
|
|
115
|
+
await fs.remove(temporaryPath).catch(() => undefined);
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export const clearCliAuth = async (workingPath: string): Promise<void> => {
|
|
121
|
+
const filePath = cliAuthFilePath(workingPath);
|
|
122
|
+
if (await fs.pathExists(filePath)) {
|
|
123
|
+
await fs.remove(filePath);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export const cliAuthMatchesRegistry = (auth: CliAuth | null, remoteUrl: string): boolean => {
|
|
128
|
+
if (!auth) return false;
|
|
129
|
+
try {
|
|
130
|
+
return auth.remoteUrl === normalizeRegistryUrl(remoteUrl);
|
|
131
|
+
} catch {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export const cliAuthTokenIsValid = (auth: CliAuth | null, skewMs = 30_000): boolean =>
|
|
137
|
+
Boolean(auth?.accessToken && auth.expiresAtMs > Date.now() + skewMs);
|