@mondaydotcomorg/z2h-cli 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/dist/commands/create-workspace.js +3 -3
  2. package/dist/commands/create.d.ts.map +1 -1
  3. package/dist/commands/create.js +10 -3
  4. package/dist/commands/deploy.d.ts.map +1 -1
  5. package/dist/commands/deploy.js +19 -24
  6. package/dist/commands/generate-z2h-token.d.ts +16 -0
  7. package/dist/commands/generate-z2h-token.d.ts.map +1 -0
  8. package/dist/commands/generate-z2h-token.js +107 -0
  9. package/dist/constants.d.ts +8 -0
  10. package/dist/constants.d.ts.map +1 -1
  11. package/dist/constants.js +28 -0
  12. package/dist/esm/commands/create-workspace.mjs +3 -3
  13. package/dist/esm/commands/create.d.ts.map +1 -1
  14. package/dist/esm/commands/create.mjs +10 -3
  15. package/dist/esm/commands/deploy.d.ts.map +1 -1
  16. package/dist/esm/commands/deploy.mjs +16 -21
  17. package/dist/esm/commands/generate-z2h-token.d.ts +16 -0
  18. package/dist/esm/commands/generate-z2h-token.d.ts.map +1 -0
  19. package/dist/esm/commands/generate-z2h-token.mjs +99 -0
  20. package/dist/esm/constants.d.ts +8 -0
  21. package/dist/esm/constants.d.ts.map +1 -1
  22. package/dist/esm/constants.mjs +21 -1
  23. package/dist/esm/index.mjs +18 -1
  24. package/dist/esm/util/auth/__tests__/auth-store.test.d.ts +2 -0
  25. package/dist/esm/util/auth/__tests__/auth-store.test.d.ts.map +1 -0
  26. package/dist/esm/util/auth/__tests__/broker-credential-provider.test.d.ts +2 -0
  27. package/dist/esm/util/auth/__tests__/broker-credential-provider.test.d.ts.map +1 -0
  28. package/dist/esm/util/auth/__tests__/loopback-server.test.d.ts +2 -0
  29. package/dist/esm/util/auth/__tests__/loopback-server.test.d.ts.map +1 -0
  30. package/dist/esm/util/auth/__tests__/write-auth.test.d.ts +2 -0
  31. package/dist/esm/util/auth/__tests__/write-auth.test.d.ts.map +1 -0
  32. package/dist/esm/util/auth/auth-store.d.ts +11 -0
  33. package/dist/esm/util/auth/auth-store.d.ts.map +1 -0
  34. package/dist/esm/util/auth/auth-store.mjs +29 -0
  35. package/dist/esm/util/auth/broker-credential-provider.d.ts +12 -0
  36. package/dist/esm/util/auth/broker-credential-provider.d.ts.map +1 -0
  37. package/dist/esm/util/auth/broker-credential-provider.mjs +76 -0
  38. package/dist/esm/util/auth/loopback-server.d.ts +12 -0
  39. package/dist/esm/util/auth/loopback-server.d.ts.map +1 -0
  40. package/dist/esm/util/auth/loopback-server.mjs +87 -0
  41. package/dist/esm/util/auth/write-auth.d.ts +11 -0
  42. package/dist/esm/util/auth/write-auth.d.ts.map +1 -0
  43. package/dist/esm/util/auth/write-auth.mjs +22 -0
  44. package/dist/esm/util/manifest.d.ts.map +1 -1
  45. package/dist/esm/util/manifest.mjs +2 -1
  46. package/dist/esm/util/s3/client.d.ts +7 -8
  47. package/dist/esm/util/s3/client.d.ts.map +1 -1
  48. package/dist/esm/util/s3/client.mjs +15 -30
  49. package/dist/esm/util/s3/upload.d.ts.map +1 -1
  50. package/dist/esm/util/s3/upload.mjs +2 -1
  51. package/dist/index.js +19 -1
  52. package/dist/util/auth/__tests__/auth-store.test.d.ts +2 -0
  53. package/dist/util/auth/__tests__/auth-store.test.d.ts.map +1 -0
  54. package/dist/util/auth/__tests__/broker-credential-provider.test.d.ts +2 -0
  55. package/dist/util/auth/__tests__/broker-credential-provider.test.d.ts.map +1 -0
  56. package/dist/util/auth/__tests__/loopback-server.test.d.ts +2 -0
  57. package/dist/util/auth/__tests__/loopback-server.test.d.ts.map +1 -0
  58. package/dist/util/auth/__tests__/write-auth.test.d.ts +2 -0
  59. package/dist/util/auth/__tests__/write-auth.test.d.ts.map +1 -0
  60. package/dist/util/auth/auth-store.d.ts +11 -0
  61. package/dist/util/auth/auth-store.d.ts.map +1 -0
  62. package/dist/util/auth/auth-store.js +39 -0
  63. package/dist/util/auth/broker-credential-provider.d.ts +12 -0
  64. package/dist/util/auth/broker-credential-provider.d.ts.map +1 -0
  65. package/dist/util/auth/broker-credential-provider.js +80 -0
  66. package/dist/util/auth/loopback-server.d.ts +12 -0
  67. package/dist/util/auth/loopback-server.d.ts.map +1 -0
  68. package/dist/util/auth/loopback-server.js +94 -0
  69. package/dist/util/auth/write-auth.d.ts +11 -0
  70. package/dist/util/auth/write-auth.d.ts.map +1 -0
  71. package/dist/util/auth/write-auth.js +26 -0
  72. package/dist/util/manifest.d.ts.map +1 -1
  73. package/dist/util/manifest.js +5 -4
  74. package/dist/util/s3/client.d.ts +7 -8
  75. package/dist/util/s3/client.d.ts.map +1 -1
  76. package/dist/util/s3/client.js +14 -36
  77. package/dist/util/s3/upload.d.ts.map +1 -1
  78. package/dist/util/s3/upload.js +2 -1
  79. package/package.json +10 -1
  80. package/src/commands/__tests__/create-workspace.test.ts +1 -1
  81. package/src/commands/__tests__/create.test.ts +1 -5
  82. package/src/commands/create-workspace.ts +3 -3
  83. package/src/commands/create.ts +12 -3
  84. package/src/commands/deploy.ts +18 -31
  85. package/src/commands/generate-z2h-token.ts +124 -0
  86. package/src/constants.ts +25 -0
  87. package/src/index.ts +21 -1
  88. package/src/util/auth/__tests__/auth-store.test.ts +50 -0
  89. package/src/util/auth/__tests__/broker-credential-provider.test.ts +115 -0
  90. package/src/util/auth/__tests__/loopback-server.test.ts +85 -0
  91. package/src/util/auth/__tests__/write-auth.test.ts +79 -0
  92. package/src/util/auth/auth-store.ts +31 -0
  93. package/src/util/auth/broker-credential-provider.ts +93 -0
  94. package/src/util/auth/loopback-server.ts +103 -0
  95. package/src/util/auth/write-auth.ts +24 -0
  96. package/src/util/manifest.ts +2 -1
  97. package/src/util/s3/client.ts +15 -32
  98. package/src/util/s3/upload.ts +2 -1
@@ -1,8 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import os from 'node:os';
3
3
  import { pathExists, readJson } from 'fs-extra';
4
- import { execa } from 'execa';
5
- import { info, warn, output, setOutputMode } from '../util/logger';
4
+ import { info, output, setOutputMode } from '../util/logger';
6
5
  import { resolveConsumerPaths } from '../shadow/paths';
7
6
  import {
8
7
  nextVersion,
@@ -13,36 +12,22 @@ import {
13
12
  type ManifestEntry,
14
13
  } from '../util/manifest';
15
14
  import { uploadDir } from '../util/s3/upload';
16
- import { assertBucketExists, BUCKET_WRITE_NAME, BUCKET_READ_DNS_URL } from '../util/s3/client';
15
+ import { BUCKET_WRITE_NAME, BUCKET_READ_DNS_URL } from '../constants';
16
+ import { setupS3Client } from '../util/s3/client';
17
+ import { readZthAuth } from '../util/auth/auth-store';
17
18
  import { cleanAppName, mfAppName } from '../util/app-name';
18
19
  import { buildCommand } from './build';
19
20
 
20
- const APONO_BUNDLE = '<APONO_BUNDLE_NAME>'; // TODO: replace with actual bundle name once infra setup is complete
21
-
22
21
  export interface DeployOptions {
23
22
  dryRun?: boolean;
24
23
  json?: boolean;
25
24
  }
26
25
 
27
- async function requestAponoAccess(): Promise<void> {
28
- if (APONO_BUNDLE === '<APONO_BUNDLE_NAME>') {
29
- warn(
30
- `skipping Apono request — APONO_BUNDLE is still a placeholder ("${APONO_BUNDLE}"). Set it in deploy.ts before relying on auto-grant.`
31
- );
32
- return;
33
- }
34
- info(`requesting Apono access for bundle "${APONO_BUNDLE}"`);
35
- await execa('apono', ['bundles', 'grant', APONO_BUNDLE], { stdio: 'inherit' });
36
- }
37
-
38
26
  export async function deployCommand(opts: DeployOptions): Promise<void> {
39
27
  if (opts.json) {
40
28
  setOutputMode('json');
41
29
  }
42
30
 
43
- info('checking S3 bucket');
44
- await assertBucketExists();
45
-
46
31
  const paths = resolveConsumerPaths(process.cwd());
47
32
  const consumerPkg = (await readJson(paths.consumerPkg)) as { name?: string };
48
33
  if (!consumerPkg.name) {
@@ -50,6 +35,11 @@ export async function deployCommand(opts: DeployOptions): Promise<void> {
50
35
  }
51
36
  const appName = mfAppName(consumerPkg.name);
52
37
 
38
+ // Validate the user's deploy token is on disk before doing any expensive
39
+ // work. Throws Z2hAuthMissingError with a remediation hint if not.
40
+ await readZthAuth();
41
+ setupS3Client({ appName });
42
+
53
43
  info('reading current Z2H manifest');
54
44
  const manifest = await readManifest();
55
45
  const version = nextVersion(manifest, appName);
@@ -70,6 +60,14 @@ export async function deployCommand(opts: DeployOptions): Promise<void> {
70
60
  }
71
61
  const assetManifest = (await readJson(assetManifestPath)) as AssetManifestFile;
72
62
 
63
+ const deployedBy = os.userInfo().username;
64
+ const entry: ManifestEntry = {
65
+ ...assetManifest,
66
+ version,
67
+ url,
68
+ deployedBy,
69
+ };
70
+
73
71
  if (opts.dryRun) {
74
72
  output(`[z2h-cli] dry-run: would upload to s3://${BUCKET_WRITE_NAME}/${keyPrefix}/ as v${version}`, {
75
73
  ok: true,
@@ -79,22 +77,11 @@ export async function deployCommand(opts: DeployOptions): Promise<void> {
79
77
  bucket: BUCKET_WRITE_NAME,
80
78
  keyPrefix,
81
79
  url,
82
- entry: { ...assetManifest, version, url },
80
+ entry,
83
81
  });
84
82
  return;
85
83
  }
86
84
 
87
- await requestAponoAccess();
88
-
89
- const deployedBy = os.userInfo().username;
90
-
91
- const entry: ManifestEntry = {
92
- ...assetManifest,
93
- version,
94
- url,
95
- deployedBy,
96
- };
97
-
98
85
  info(`uploading build → s3://${BUCKET_WRITE_NAME}/${keyPrefix}/`);
99
86
  await uploadDir(paths.shadowBuild, keyPrefix);
100
87
 
@@ -0,0 +1,124 @@
1
+ import readline from 'node:readline/promises';
2
+ import { stdin, stdout } from 'node:process';
3
+ import { execa } from 'execa';
4
+ import { info, warn, output, setOutputMode } from '../util/logger';
5
+ import { writeZthAuth, validateJwtPattern } from '../util/auth/write-auth';
6
+ import { startLoopbackServer } from '../util/auth/loopback-server';
7
+
8
+ export const DEFAULT_TOKEN_PAGE_URL = 'https://bigbrain.me/bigbrain-vibe/generate-z2h-token';
9
+ export const LOOPBACK_TIMEOUT_MS = 120_000;
10
+
11
+ export interface GenerateZ2HTokenOptions {
12
+ /**
13
+ * Skip browser auto-open AND skip the loopback callback. Prints the URL
14
+ * for the user to open manually, then waits for them to paste the token
15
+ * into the prompt. Use for SSH sessions, CI, or when the loopback can't
16
+ * reach the CLI for any reason.
17
+ */
18
+ noBrowser?: boolean;
19
+ /** Override the page URL — useful when pointing at staging. */
20
+ url?: string;
21
+ json?: boolean;
22
+ }
23
+
24
+ function browserOpenCommand(): string {
25
+ switch (process.platform) {
26
+ case 'darwin':
27
+ return 'open';
28
+ case 'win32':
29
+ return 'start';
30
+ default:
31
+ return 'xdg-open';
32
+ }
33
+ }
34
+
35
+ async function openInBrowser(url: string): Promise<void> {
36
+ await execa(browserOpenCommand(), [url], { stdio: 'ignore' });
37
+ }
38
+
39
+ async function promptForToken(): Promise<string> {
40
+ const rl = readline.createInterface({ input: stdin, output: stdout });
41
+ try {
42
+ const raw = await rl.question('Paste your token here, then press Enter:\n> ');
43
+ return raw.trim();
44
+ } finally {
45
+ rl.close();
46
+ }
47
+ }
48
+
49
+ function withQueryParam(url: string, key: string, value: string): string {
50
+ const sep = url.includes('?') ? '&' : '?';
51
+ return `${url}${sep}${key}=${encodeURIComponent(value)}`;
52
+ }
53
+
54
+ async function runManualFlow(pageUrl: string): Promise<string> {
55
+ info(
56
+ `open this URL in your browser to mint a Z2H deploy token:\n` +
57
+ ` ${pageUrl}\n\n` +
58
+ `1. Click "Generate token" on the page.\n` +
59
+ `2. Copy the token shown.\n` +
60
+ `3. Paste it below and press Enter.\n`
61
+ );
62
+ const token = await promptForToken();
63
+ if (!token) {
64
+ throw new Error('No token entered.');
65
+ }
66
+ return token;
67
+ }
68
+
69
+ async function runLoopbackFlow(pageUrl: string): Promise<string | null> {
70
+ const loopback = await startLoopbackServer();
71
+ const fullUrl = withQueryParam(pageUrl, 'cli_callback', loopback.callbackUrl);
72
+
73
+ info('opening your browser to mint a Z2H deploy token');
74
+ info(` ${fullUrl}`);
75
+
76
+ try {
77
+ await openInBrowser(fullUrl);
78
+ } catch {
79
+ warn('could not auto-open the browser. please visit the URL above manually.');
80
+ }
81
+
82
+ info('waiting for the page to send the token (Ctrl+C to fall back to manual paste)…');
83
+
84
+ try {
85
+ return await loopback.waitForToken(LOOPBACK_TIMEOUT_MS);
86
+ } catch {
87
+ return null;
88
+ } finally {
89
+ loopback.close();
90
+ }
91
+ }
92
+
93
+ export async function generateZ2HTokenCommand(opts: GenerateZ2HTokenOptions = {}): Promise<void> {
94
+ if (opts.json) {
95
+ setOutputMode('json');
96
+ }
97
+
98
+ const pageUrl = opts.url ?? process.env.Z2H_TOKEN_PAGE_URL ?? DEFAULT_TOKEN_PAGE_URL;
99
+
100
+ // --no-browser bypasses both auto-open and the loopback round-trip — the
101
+ // user opens the URL themselves and pastes the token. Useful when the
102
+ // loopback can't be reached (SSH, CI, locked-down browsers).
103
+ let token: string | null = null;
104
+ if (!opts.noBrowser) {
105
+ token = await runLoopbackFlow(pageUrl);
106
+ if (!token) {
107
+ warn('loopback flow timed out or was cancelled — falling back to manual paste.');
108
+ }
109
+ }
110
+ if (!token) {
111
+ token = await runManualFlow(pageUrl);
112
+ }
113
+
114
+ if (!validateJwtPattern(token)) {
115
+ throw new Error('That does not look like a JWT (expected three dot-separated segments).');
116
+ }
117
+
118
+ const { filePath } = await writeZthAuth({ token });
119
+
120
+ output(`[z2h-cli] token saved ✅ ${filePath}`, {
121
+ ok: true,
122
+ filePath,
123
+ });
124
+ }
package/src/constants.ts CHANGED
@@ -38,3 +38,28 @@ export const YARNRC_LINES = [
38
38
  "npmAuthToken: '${NPM_TOKEN}'",
39
39
  "npmRegistryServer: 'https://registry.npmjs.org/'",
40
40
  ];
41
+
42
+ // ─────────── Z2H deploy bucket / contract ─────────
43
+ // Source of truth for the bucket layout. The CLI uses these when configuring
44
+ // the S3 client + uploading; bigbrain-zth's STS broker imports them via
45
+ // `@mondaydotcomorg/z2h-cli/constants` to scope the per-deploy session
46
+ // policy to the same bucket + manifest key.
47
+
48
+ export const BUCKET_WRITE_NAME = 'prod-use1-bigbrain-zth-mf-assets';
49
+ export const REGION = 'us-east-1';
50
+ export const BUCKET_WRITE_URL = `https://${BUCKET_WRITE_NAME}.s3.${REGION}.amazonaws.com`;
51
+
52
+ export const BUCKET_READ_DNS_NAME = 'bigbrain-zth-mf-assets';
53
+ export const BUCKET_READ_DNS_URL = `https://${BUCKET_READ_DNS_NAME}.bigbrain.me`;
54
+
55
+ // No `.json` extension — TridentSkeleton's `mfUrl` API appends `/mf-assets`
56
+ // to the bucket base when fetching, so the manifest must live at this exact
57
+ // key. See microfrontends/mf-bigbrain-zth/.../ZeroToHeroHost.tsx.
58
+ export const MANIFEST_KEY = 'mf-assets';
59
+ export const MANIFEST_READ_URL = `${BUCKET_READ_DNS_URL}/${MANIFEST_KEY}`;
60
+
61
+ // ─────────── App-name slug — backend & CLI both honor this ─────
62
+ // Lowercase kebab-case, must start with a letter, 2-50 chars total. The
63
+ // backend STS broker rejects anything else with 400; the CLI can validate
64
+ // upfront for friendlier errors.
65
+ export const APP_NAME_PATTERN = /^[a-z][a-z0-9-]{1,49}$/;
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { createRequire } from 'node:module';
1
2
  import { Command } from 'commander';
2
3
  import { devCommand } from './commands/dev';
3
4
  import { buildCommand } from './commands/build';
@@ -5,8 +6,11 @@ import { cleanCommand } from './commands/clean';
5
6
  import { deployCommand } from './commands/deploy';
6
7
  import { createWorkspaceCommand } from './commands/create-workspace';
7
8
  import { createCommand } from './commands/create';
9
+ import { generateZ2HTokenCommand } from './commands/generate-z2h-token';
8
10
  import { errorLog, fail } from './util/logger';
9
11
 
12
+ const { version } = createRequire(import.meta.url)('../package.json') as { version: string };
13
+
10
14
  async function main(): Promise<void> {
11
15
  const program = new Command();
12
16
  program
@@ -25,7 +29,7 @@ async function main(): Promise<void> {
25
29
  ' 2 system error (yarn install or trident build failed)',
26
30
  ].join('\n')
27
31
  )
28
- .version('0.0.0');
32
+ .version(version);
29
33
 
30
34
  program
31
35
  .command('build')
@@ -91,6 +95,22 @@ async function main(): Promise<void> {
91
95
  }
92
96
  });
93
97
 
98
+ program
99
+ .command('generate-z2h-token')
100
+ .description(
101
+ 'Open the bigbrain.me token page, capture the token via a loopback callback, and persist it to ~/.zth/auth.json'
102
+ )
103
+ .option('--no-browser', "don't auto-open the browser; print the URL and prompt for manual paste")
104
+ .option('--url <url>', 'Override the token page URL (e.g. for staging)')
105
+ .option('--json', 'emit structured JSON on stdout')
106
+ .action(async (opts: { noBrowser?: boolean; url?: string; json?: boolean }) => {
107
+ try {
108
+ await generateZ2HTokenCommand(opts);
109
+ } catch (err) {
110
+ fail(`generate-z2h-token failed: ${(err as Error).message}`, { command: 'generate-z2h-token' }, 1);
111
+ }
112
+ });
113
+
94
114
  program
95
115
  .command('create <app-name>')
96
116
  .description('Scaffold a new Z2H consumer app inside ~/zth-projects/')
@@ -0,0 +1,50 @@
1
+ import path from 'node:path';
2
+ import os from 'node:os';
3
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
4
+ import { ensureDir, remove, writeJson } from 'fs-extra';
5
+
6
+ let scratchHome: string;
7
+ let originalHome: string | undefined;
8
+
9
+ async function reload() {
10
+ // Re-import the module so HOME-derived constants pick up our scratch dir.
11
+ return import(`../auth-store?bust=${Date.now()}`);
12
+ }
13
+
14
+ beforeEach(async () => {
15
+ originalHome = process.env.HOME;
16
+ scratchHome = path.join(os.tmpdir(), `z2h-auth-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
17
+ await ensureDir(scratchHome);
18
+ process.env.HOME = scratchHome;
19
+ });
20
+
21
+ afterEach(async () => {
22
+ if (originalHome === undefined) {
23
+ delete process.env.HOME;
24
+ } else {
25
+ process.env.HOME = originalHome;
26
+ }
27
+ await remove(scratchHome);
28
+ });
29
+
30
+ describe('readZthAuth', () => {
31
+ it('throws Z2hAuthMissingError with a remediation hint when the file is absent', async () => {
32
+ const { readZthAuth, Z2hAuthMissingError } = await reload();
33
+ await expect(readZthAuth()).rejects.toBeInstanceOf(Z2hAuthMissingError);
34
+ });
35
+
36
+ it('throws when the file exists but lacks a token', async () => {
37
+ const { readZthAuth, Z2H_AUTH_FILE } = await reload();
38
+ await ensureDir(path.dirname(Z2H_AUTH_FILE));
39
+ await writeJson(Z2H_AUTH_FILE, { somethingElse: true });
40
+ await expect(readZthAuth()).rejects.toThrow(/missing a "token"/);
41
+ });
42
+
43
+ it('returns the parsed token', async () => {
44
+ const { readZthAuth, Z2H_AUTH_FILE } = await reload();
45
+ await ensureDir(path.dirname(Z2H_AUTH_FILE));
46
+ await writeJson(Z2H_AUTH_FILE, { token: 'abc.def.ghi' });
47
+ const result = await readZthAuth();
48
+ expect(result).toEqual({ token: 'abc.def.ghi' });
49
+ });
50
+ });
@@ -0,0 +1,115 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { brokerCredentialProvider, DEFAULT_BROKER_BASE_URL, STS_BROKER_PATH } from '../broker-credential-provider';
3
+ import * as authStore from '../auth-store';
4
+
5
+ const STUB_CREDS = {
6
+ accessKeyId: 'AKIA-stub',
7
+ secretAccessKey: 'secret-stub',
8
+ sessionToken: 'session-stub',
9
+ };
10
+
11
+ const FAR_FUTURE_EXPIRY = '2099-01-01T00:00:00.000Z';
12
+ const NEAR_FUTURE_EXPIRY = new Date(Date.now() + 60_000).toISOString(); // 1 minute, well inside refresh buffer
13
+
14
+ beforeEach(() => {
15
+ vi.spyOn(authStore, 'readZthAuth').mockResolvedValue({ token: 'jwt-stub' });
16
+ });
17
+
18
+ afterEach(() => {
19
+ vi.restoreAllMocks();
20
+ vi.unstubAllEnvs();
21
+ });
22
+
23
+ function mockFetchOnce(body: unknown, init: { status?: number; text?: string } = {}) {
24
+ const fetchMock = vi.fn().mockResolvedValue({
25
+ ok: (init.status ?? 200) < 400,
26
+ status: init.status ?? 200,
27
+ json: async () => body,
28
+ text: async () => init.text ?? JSON.stringify(body),
29
+ });
30
+ vi.stubGlobal('fetch', fetchMock);
31
+ return fetchMock;
32
+ }
33
+
34
+ describe('brokerCredentialProvider', () => {
35
+ it('POSTs the appName to the broker with a Bearer token + parses the response', async () => {
36
+ const fetchMock = mockFetchOnce({ ...STUB_CREDS, expiration: FAR_FUTURE_EXPIRY });
37
+ const provider = brokerCredentialProvider({ appName: 'my-app' });
38
+ const creds = await provider();
39
+
40
+ expect(creds.accessKeyId).toBe('AKIA-stub');
41
+ expect(creds.secretAccessKey).toBe('secret-stub');
42
+ expect(creds.sessionToken).toBe('session-stub');
43
+ expect(creds.expiration?.toISOString()).toBe(FAR_FUTURE_EXPIRY);
44
+
45
+ expect(fetchMock).toHaveBeenCalledWith(
46
+ `${DEFAULT_BROKER_BASE_URL}${STS_BROKER_PATH}`,
47
+ expect.objectContaining({
48
+ method: 'POST',
49
+ body: JSON.stringify({ appName: 'my-app' }),
50
+ })
51
+ );
52
+ const sent = (fetchMock.mock.calls[0][1] as RequestInit).headers as Record<string, string>;
53
+ expect(sent.Authorization).toBe('Bearer jwt-stub');
54
+ });
55
+
56
+ it('caches and coalesces calls when expiry is far away', async () => {
57
+ const fetchMock = mockFetchOnce({ ...STUB_CREDS, expiration: FAR_FUTURE_EXPIRY });
58
+ const provider = brokerCredentialProvider({ appName: 'my-app' });
59
+
60
+ const first = await provider();
61
+ await provider();
62
+ await provider();
63
+ expect(fetchMock).toHaveBeenCalledTimes(1);
64
+
65
+ const [a, b, c] = await Promise.all([provider(), provider(), provider()]);
66
+ expect(fetchMock).toHaveBeenCalledTimes(1);
67
+ expect(a).toBe(first);
68
+ expect(b).toBe(first);
69
+ expect(c).toBe(first);
70
+ });
71
+
72
+ it('refetches when expiry is inside the 5-minute refresh buffer', async () => {
73
+ const fetchMock = vi
74
+ .fn()
75
+ .mockResolvedValueOnce({
76
+ ok: true,
77
+ status: 200,
78
+ json: async () => ({ ...STUB_CREDS, expiration: NEAR_FUTURE_EXPIRY }),
79
+ text: async () => '',
80
+ })
81
+ .mockResolvedValueOnce({
82
+ ok: true,
83
+ status: 200,
84
+ json: async () => ({ ...STUB_CREDS, expiration: FAR_FUTURE_EXPIRY }),
85
+ text: async () => '',
86
+ });
87
+ vi.stubGlobal('fetch', fetchMock);
88
+
89
+ const provider = brokerCredentialProvider({ appName: 'my-app' });
90
+ await provider();
91
+ await provider();
92
+ expect(fetchMock).toHaveBeenCalledTimes(2);
93
+ });
94
+
95
+ it('surfaces a remediation hint on 401', async () => {
96
+ mockFetchOnce({}, { status: 401, text: 'Unauthorized' });
97
+ const provider = brokerCredentialProvider({ appName: 'my-app' });
98
+ await expect(provider()).rejects.toThrow(/token rejected by the broker/);
99
+ await expect(provider()).rejects.toThrow(/generate-z2h-token/);
100
+ });
101
+
102
+ it('honors Z2H_BROKER_BASE_URL override', async () => {
103
+ vi.stubEnv('Z2H_BROKER_BASE_URL', 'https://staging.example.com');
104
+ const fetchMock = mockFetchOnce({ ...STUB_CREDS, expiration: FAR_FUTURE_EXPIRY });
105
+ const provider = brokerCredentialProvider({ appName: 'my-app' });
106
+ await provider();
107
+ expect(fetchMock).toHaveBeenCalledWith(`https://staging.example.com${STS_BROKER_PATH}`, expect.anything());
108
+ });
109
+
110
+ it('rejects responses missing required credential fields', async () => {
111
+ mockFetchOnce({ accessKeyId: 'AKIA-stub' });
112
+ const provider = brokerCredentialProvider({ appName: 'my-app' });
113
+ await expect(provider()).rejects.toThrow(/missing required credential fields/);
114
+ });
115
+ });
@@ -0,0 +1,85 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { startLoopbackServer } from '../loopback-server';
3
+
4
+ describe('startLoopbackServer', () => {
5
+ it('binds to 127.0.0.1 on a random port and returns a /token URL', async () => {
6
+ const handle = await startLoopbackServer();
7
+ try {
8
+ expect(handle.callbackUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d{2,5}\/token$/);
9
+ } finally {
10
+ handle.close();
11
+ }
12
+ });
13
+
14
+ it('resolves waitForToken when the page POSTs a token', async () => {
15
+ const handle = await startLoopbackServer();
16
+ try {
17
+ const tokenPromise = handle.waitForToken(5000);
18
+ const res = await fetch(handle.callbackUrl, {
19
+ method: 'POST',
20
+ headers: { 'Content-Type': 'application/json' },
21
+ body: JSON.stringify({ token: 'jwt-stub.payload.signature' }),
22
+ });
23
+ expect(res.status).toBe(200);
24
+ expect(await tokenPromise).toBe('jwt-stub.payload.signature');
25
+ } finally {
26
+ handle.close();
27
+ }
28
+ });
29
+
30
+ it('answers OPTIONS preflight with CORS headers', async () => {
31
+ const handle = await startLoopbackServer();
32
+ try {
33
+ const res = await fetch(handle.callbackUrl, {
34
+ method: 'OPTIONS',
35
+ headers: { Origin: 'https://bigbrain.me', 'Access-Control-Request-Method': 'POST' },
36
+ });
37
+ expect(res.status).toBe(204);
38
+ expect(res.headers.get('access-control-allow-origin')).toBe('https://bigbrain.me');
39
+ expect(res.headers.get('access-control-allow-methods')).toContain('POST');
40
+ } finally {
41
+ handle.close();
42
+ }
43
+ });
44
+
45
+ it('returns 400 for missing token', async () => {
46
+ const handle = await startLoopbackServer();
47
+ try {
48
+ const res = await fetch(handle.callbackUrl, {
49
+ method: 'POST',
50
+ headers: { 'Content-Type': 'application/json' },
51
+ body: JSON.stringify({}),
52
+ });
53
+ expect(res.status).toBe(400);
54
+ } finally {
55
+ handle.close();
56
+ }
57
+ });
58
+
59
+ it('returns 404 for unknown paths', async () => {
60
+ const handle = await startLoopbackServer();
61
+ try {
62
+ const url = handle.callbackUrl.replace('/token', '/nope');
63
+ const res = await fetch(url, { method: 'POST', body: '{}' });
64
+ expect(res.status).toBe(404);
65
+ } finally {
66
+ handle.close();
67
+ }
68
+ });
69
+
70
+ it('rejects waitForToken on close', async () => {
71
+ const handle = await startLoopbackServer();
72
+ const wait = handle.waitForToken(5000);
73
+ handle.close();
74
+ await expect(wait).rejects.toThrow(/closed before token/);
75
+ });
76
+
77
+ it('rejects waitForToken on timeout', async () => {
78
+ const handle = await startLoopbackServer();
79
+ try {
80
+ await expect(handle.waitForToken(50)).rejects.toThrow(/timed out/);
81
+ } finally {
82
+ handle.close();
83
+ }
84
+ });
85
+ });
@@ -0,0 +1,79 @@
1
+ import path from 'node:path';
2
+ import os from 'node:os';
3
+ import { stat } from 'node:fs/promises';
4
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
5
+ import { ensureDir, readJson, remove } from 'fs-extra';
6
+
7
+ let scratchHome: string;
8
+ let originalHome: string | undefined;
9
+
10
+ async function reload() {
11
+ return import(`../write-auth?bust=${Date.now()}`);
12
+ }
13
+
14
+ beforeEach(async () => {
15
+ originalHome = process.env.HOME;
16
+ scratchHome = path.join(os.tmpdir(), `z2h-write-auth-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
17
+ await ensureDir(scratchHome);
18
+ process.env.HOME = scratchHome;
19
+ });
20
+
21
+ afterEach(async () => {
22
+ if (originalHome === undefined) {
23
+ delete process.env.HOME;
24
+ } else {
25
+ process.env.HOME = originalHome;
26
+ }
27
+ await remove(scratchHome);
28
+ });
29
+
30
+ describe('writeZthAuth', () => {
31
+ it('writes the token JSON to ~/.zth/auth.json with 0600 perms', async () => {
32
+ const { writeZthAuth, Z2H_AUTH_FILE } = await reload();
33
+ const { filePath } = await writeZthAuth({ token: 'jwt-stub' });
34
+
35
+ expect(filePath).toBe(Z2H_AUTH_FILE);
36
+ const stored = await readJson(filePath);
37
+ expect(stored).toEqual({ token: 'jwt-stub' });
38
+
39
+ const stats = await stat(filePath);
40
+ // Mode masked to permission bits — owner read/write only.
41
+ expect(stats.mode & 0o777).toBe(0o600);
42
+ });
43
+
44
+ it('creates the parent directory if missing', async () => {
45
+ const { writeZthAuth } = await reload();
46
+ // scratchHome is fresh — no ~/.zth dir yet.
47
+ const { filePath } = await writeZthAuth({ token: 'jwt-stub' });
48
+ const stats = await stat(filePath);
49
+ expect(stats.isFile()).toBe(true);
50
+ });
51
+
52
+ it('overwrites an existing file', async () => {
53
+ const { writeZthAuth } = await reload();
54
+ const { filePath } = await writeZthAuth({ token: 'first' });
55
+ await writeZthAuth({ token: 'second' });
56
+ const stored = await readJson(filePath);
57
+ expect(stored).toEqual({ token: 'second' });
58
+ });
59
+ });
60
+
61
+ describe('validateJwtPattern', () => {
62
+ it('accepts three dot-separated base64url segments', async () => {
63
+ const { validateJwtPattern } = await reload();
64
+ expect(validateJwtPattern('eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0MiJ9.signature-stub')).toBe(true);
65
+ });
66
+
67
+ it('rejects strings without three segments', async () => {
68
+ const { validateJwtPattern } = await reload();
69
+ expect(validateJwtPattern('only-one-part')).toBe(false);
70
+ expect(validateJwtPattern('two.parts')).toBe(false);
71
+ expect(validateJwtPattern('four.parts.here.extra')).toBe(false);
72
+ });
73
+
74
+ it('rejects strings with non-base64url chars', async () => {
75
+ const { validateJwtPattern } = await reload();
76
+ expect(validateJwtPattern('a b.c.d')).toBe(false); // space
77
+ expect(validateJwtPattern('a.b.c=')).toBe(false); // equals (we use base64url, no padding)
78
+ });
79
+ });
@@ -0,0 +1,31 @@
1
+ import path from 'node:path';
2
+ import os from 'node:os';
3
+ import { pathExists, readJson } from 'fs-extra';
4
+
5
+ // npm-style convention: per-user config under ~/.zth (mirrors ~/.npmrc, ~/.aws/).
6
+ // Kept out of the workspace dir so accidental `git init` in ~/zth-projects can't
7
+ // stage it.
8
+ export const Z2H_AUTH_DIR = path.join(os.homedir(), '.zth');
9
+ export const Z2H_AUTH_FILE = path.join(Z2H_AUTH_DIR, 'auth.json');
10
+
11
+ export interface ZthAuth {
12
+ token: string;
13
+ }
14
+
15
+ export class Z2hAuthMissingError extends Error {
16
+ constructor(public readonly authFilePath: string) {
17
+ super(`No Z2H deploy token found at ${authFilePath}. Please run 'z2h-cli generate-z2h-token' command.`);
18
+ this.name = 'Z2hAuthMissingError';
19
+ }
20
+ }
21
+
22
+ export async function readZthAuth(): Promise<ZthAuth> {
23
+ if (!(await pathExists(Z2H_AUTH_FILE))) {
24
+ throw new Z2hAuthMissingError(Z2H_AUTH_FILE);
25
+ }
26
+ const parsed = (await readJson(Z2H_AUTH_FILE)) as Partial<ZthAuth>;
27
+ if (!parsed.token) {
28
+ throw new Error(`${Z2H_AUTH_FILE} is missing a "token" string field.`);
29
+ }
30
+ return { token: parsed.token };
31
+ }