@mondaydotcomorg/z2h-cli 0.2.0 → 0.3.0

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 (93) hide show
  1. package/dist/commands/create.d.ts.map +1 -1
  2. package/dist/commands/create.js +9 -2
  3. package/dist/commands/deploy.d.ts.map +1 -1
  4. package/dist/commands/deploy.js +19 -24
  5. package/dist/commands/generate-z2h-token.d.ts +16 -0
  6. package/dist/commands/generate-z2h-token.d.ts.map +1 -0
  7. package/dist/commands/generate-z2h-token.js +107 -0
  8. package/dist/constants.d.ts +8 -0
  9. package/dist/constants.d.ts.map +1 -1
  10. package/dist/constants.js +28 -0
  11. package/dist/esm/commands/create.d.ts.map +1 -1
  12. package/dist/esm/commands/create.mjs +9 -2
  13. package/dist/esm/commands/deploy.d.ts.map +1 -1
  14. package/dist/esm/commands/deploy.mjs +16 -21
  15. package/dist/esm/commands/generate-z2h-token.d.ts +16 -0
  16. package/dist/esm/commands/generate-z2h-token.d.ts.map +1 -0
  17. package/dist/esm/commands/generate-z2h-token.mjs +99 -0
  18. package/dist/esm/constants.d.ts +8 -0
  19. package/dist/esm/constants.d.ts.map +1 -1
  20. package/dist/esm/constants.mjs +21 -1
  21. package/dist/esm/index.mjs +15 -0
  22. package/dist/esm/util/auth/__tests__/auth-store.test.d.ts +2 -0
  23. package/dist/esm/util/auth/__tests__/auth-store.test.d.ts.map +1 -0
  24. package/dist/esm/util/auth/__tests__/broker-credential-provider.test.d.ts +2 -0
  25. package/dist/esm/util/auth/__tests__/broker-credential-provider.test.d.ts.map +1 -0
  26. package/dist/esm/util/auth/__tests__/loopback-server.test.d.ts +2 -0
  27. package/dist/esm/util/auth/__tests__/loopback-server.test.d.ts.map +1 -0
  28. package/dist/esm/util/auth/__tests__/write-auth.test.d.ts +2 -0
  29. package/dist/esm/util/auth/__tests__/write-auth.test.d.ts.map +1 -0
  30. package/dist/esm/util/auth/auth-store.d.ts +11 -0
  31. package/dist/esm/util/auth/auth-store.d.ts.map +1 -0
  32. package/dist/esm/util/auth/auth-store.mjs +29 -0
  33. package/dist/esm/util/auth/broker-credential-provider.d.ts +12 -0
  34. package/dist/esm/util/auth/broker-credential-provider.d.ts.map +1 -0
  35. package/dist/esm/util/auth/broker-credential-provider.mjs +76 -0
  36. package/dist/esm/util/auth/loopback-server.d.ts +12 -0
  37. package/dist/esm/util/auth/loopback-server.d.ts.map +1 -0
  38. package/dist/esm/util/auth/loopback-server.mjs +87 -0
  39. package/dist/esm/util/auth/write-auth.d.ts +11 -0
  40. package/dist/esm/util/auth/write-auth.d.ts.map +1 -0
  41. package/dist/esm/util/auth/write-auth.mjs +22 -0
  42. package/dist/esm/util/manifest.d.ts.map +1 -1
  43. package/dist/esm/util/manifest.mjs +2 -1
  44. package/dist/esm/util/s3/client.d.ts +7 -8
  45. package/dist/esm/util/s3/client.d.ts.map +1 -1
  46. package/dist/esm/util/s3/client.mjs +15 -30
  47. package/dist/esm/util/s3/upload.d.ts.map +1 -1
  48. package/dist/esm/util/s3/upload.mjs +2 -1
  49. package/dist/index.js +15 -0
  50. package/dist/util/auth/__tests__/auth-store.test.d.ts +2 -0
  51. package/dist/util/auth/__tests__/auth-store.test.d.ts.map +1 -0
  52. package/dist/util/auth/__tests__/broker-credential-provider.test.d.ts +2 -0
  53. package/dist/util/auth/__tests__/broker-credential-provider.test.d.ts.map +1 -0
  54. package/dist/util/auth/__tests__/loopback-server.test.d.ts +2 -0
  55. package/dist/util/auth/__tests__/loopback-server.test.d.ts.map +1 -0
  56. package/dist/util/auth/__tests__/write-auth.test.d.ts +2 -0
  57. package/dist/util/auth/__tests__/write-auth.test.d.ts.map +1 -0
  58. package/dist/util/auth/auth-store.d.ts +11 -0
  59. package/dist/util/auth/auth-store.d.ts.map +1 -0
  60. package/dist/util/auth/auth-store.js +39 -0
  61. package/dist/util/auth/broker-credential-provider.d.ts +12 -0
  62. package/dist/util/auth/broker-credential-provider.d.ts.map +1 -0
  63. package/dist/util/auth/broker-credential-provider.js +80 -0
  64. package/dist/util/auth/loopback-server.d.ts +12 -0
  65. package/dist/util/auth/loopback-server.d.ts.map +1 -0
  66. package/dist/util/auth/loopback-server.js +94 -0
  67. package/dist/util/auth/write-auth.d.ts +11 -0
  68. package/dist/util/auth/write-auth.d.ts.map +1 -0
  69. package/dist/util/auth/write-auth.js +26 -0
  70. package/dist/util/manifest.d.ts.map +1 -1
  71. package/dist/util/manifest.js +5 -4
  72. package/dist/util/s3/client.d.ts +7 -8
  73. package/dist/util/s3/client.d.ts.map +1 -1
  74. package/dist/util/s3/client.js +14 -36
  75. package/dist/util/s3/upload.d.ts.map +1 -1
  76. package/dist/util/s3/upload.js +2 -1
  77. package/package.json +10 -1
  78. package/src/commands/create.ts +11 -2
  79. package/src/commands/deploy.ts +18 -31
  80. package/src/commands/generate-z2h-token.ts +124 -0
  81. package/src/constants.ts +25 -0
  82. package/src/index.ts +17 -0
  83. package/src/util/auth/__tests__/auth-store.test.ts +50 -0
  84. package/src/util/auth/__tests__/broker-credential-provider.test.ts +115 -0
  85. package/src/util/auth/__tests__/loopback-server.test.ts +85 -0
  86. package/src/util/auth/__tests__/write-auth.test.ts +79 -0
  87. package/src/util/auth/auth-store.ts +31 -0
  88. package/src/util/auth/broker-credential-provider.ts +93 -0
  89. package/src/util/auth/loopback-server.ts +103 -0
  90. package/src/util/auth/write-auth.ts +24 -0
  91. package/src/util/manifest.ts +2 -1
  92. package/src/util/s3/client.ts +15 -32
  93. package/src/util/s3/upload.ts +2 -1
@@ -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
+ }
@@ -0,0 +1,93 @@
1
+ import { readZthAuth } from './auth-store';
2
+ import type { AwsCredentialIdentity, AwsCredentialIdentityProvider } from '@aws-sdk/types';
3
+
4
+ export const DEFAULT_BROKER_BASE_URL = 'https://bigbrain-zth.bigbrain.me';
5
+ export const STS_BROKER_PATH = '/z2h-cli/sts/credentials';
6
+
7
+ // Refresh the cached credentials this many ms before they expire so the next
8
+ // AWS API call doesn't race the broker round-trip against a hard expiry.
9
+ const REFRESH_BUFFER_MS = 5 * 60 * 1000;
10
+
11
+ interface BrokerResponse {
12
+ accessKeyId?: string;
13
+ secretAccessKey?: string;
14
+ sessionToken?: string;
15
+ expiration?: string;
16
+ }
17
+
18
+ function brokerBaseUrl(): string {
19
+ return process.env.Z2H_BROKER_BASE_URL || DEFAULT_BROKER_BASE_URL;
20
+ }
21
+
22
+ async function fetchCredentialsOnce(appName: string): Promise<AwsCredentialIdentity> {
23
+ const { token } = await readZthAuth();
24
+ const url = `${brokerBaseUrl()}${STS_BROKER_PATH}`;
25
+
26
+ let res: Response;
27
+ try {
28
+ res = await fetch(url, {
29
+ method: 'POST',
30
+ headers: {
31
+ Authorization: `Bearer ${token}`,
32
+ 'Content-Type': 'application/json',
33
+ Accept: 'application/json',
34
+ },
35
+ body: JSON.stringify({ appName }),
36
+ });
37
+ } catch (err) {
38
+ const message = err instanceof Error ? err.message : String(err);
39
+ throw new Error(`Z2H deploy broker request failed: ${message}`);
40
+ }
41
+
42
+ if (res.status === 401 || res.status === 403) {
43
+ throw new Error(
44
+ `Z2H deploy token rejected by the broker (${res.status}). Refresh user token by running 'z2h-cli generate-z2h-token'.`
45
+ );
46
+ }
47
+ if (!res.ok) {
48
+ const text = await res.text();
49
+ throw new Error(`Z2H deploy broker returned ${res.status}: ${text.slice(0, 1024)}`);
50
+ }
51
+
52
+ const body = (await res.json()) as BrokerResponse;
53
+ if (!body.accessKeyId || !body.secretAccessKey || !body.sessionToken || !body.expiration) {
54
+ throw new Error('Z2H deploy broker response missing required credential fields');
55
+ }
56
+
57
+ return {
58
+ accessKeyId: body.accessKeyId,
59
+ secretAccessKey: body.secretAccessKey,
60
+ sessionToken: body.sessionToken,
61
+ expiration: new Date(body.expiration),
62
+ };
63
+ }
64
+
65
+ /**
66
+ * AWS SDK credential provider that fetches short-lived deploy credentials
67
+ * from the bigbrain-zth STS broker. Credentials are cached in memory until
68
+ * 5 minutes before expiry; concurrent calls share a single in-flight fetch.
69
+ */
70
+ export function brokerCredentialProvider({ appName }: { appName: string }): AwsCredentialIdentityProvider {
71
+ let cached: AwsCredentialIdentity | undefined;
72
+ let inflight: Promise<AwsCredentialIdentity> | undefined;
73
+
74
+ return async () => {
75
+ const now = Date.now();
76
+ if (cached && cached.expiration && cached.expiration.getTime() - now > REFRESH_BUFFER_MS) {
77
+ return cached;
78
+ }
79
+ if (inflight) {
80
+ return inflight;
81
+ }
82
+
83
+ inflight = fetchCredentialsOnce(appName)
84
+ .then(creds => {
85
+ cached = creds;
86
+ return creds;
87
+ })
88
+ .finally(() => {
89
+ inflight = undefined;
90
+ });
91
+ return inflight;
92
+ };
93
+ }
@@ -0,0 +1,103 @@
1
+ import cors from 'cors';
2
+ import express, { json } from 'express';
3
+ import type { AddressInfo } from 'node:net';
4
+
5
+ const ALLOWED_ORIGINS = new Set(['https://bigbrain.me', 'https://bigbrainstaging.me']);
6
+ const CALLBACK_PATH = '/token';
7
+ const REQUEST_BODY_LIMIT = '16kb';
8
+ const PREFLIGHT_MAX_AGE_SECONDS = 600;
9
+
10
+ export interface LoopbackHandle {
11
+ /** Fully-qualified URL the browser should POST to (e.g. http://127.0.0.1:53811/token). */
12
+ callbackUrl: string;
13
+ /**
14
+ * Resolves with the token once the page POSTs it back. Rejects on timeout
15
+ * or if the server is closed before a token arrives.
16
+ */
17
+ waitForToken(timeoutMs: number): Promise<string>;
18
+ close(): void;
19
+ }
20
+
21
+ export function startLoopbackServer(): Promise<LoopbackHandle> {
22
+ return new Promise((resolve, reject) => {
23
+ let resolveToken: ((token: string) => void) | null = null;
24
+ let rejectToken: ((err: Error) => void) | null = null;
25
+
26
+ const app = express();
27
+
28
+ // Reflect the origin if it's one of ours; otherwise wildcard. The
29
+ // endpoint is loopback-bound and single-shot, so the CORS surface is
30
+ // moot — we keep allowlist behavior so the test contract stays exact.
31
+ app.use(
32
+ cors({
33
+ origin: (origin, cb) => {
34
+ if (origin && ALLOWED_ORIGINS.has(origin)) {
35
+ cb(null, origin);
36
+ } else {
37
+ cb(null, '*');
38
+ }
39
+ },
40
+ methods: ['POST', 'OPTIONS'],
41
+ allowedHeaders: ['Content-Type'],
42
+ maxAge: PREFLIGHT_MAX_AGE_SECONDS,
43
+ })
44
+ );
45
+ app.use(json({ limit: REQUEST_BODY_LIMIT }));
46
+
47
+ app.post(CALLBACK_PATH, (req, res) => {
48
+ const body = req.body as { token?: unknown } | undefined;
49
+ const token = typeof body?.token === 'string' ? body.token.trim() : '';
50
+ if (!token) {
51
+ res.status(400).json({ error: 'missing token' });
52
+ return;
53
+ }
54
+ // Ack the page so it can transition to "Token sent ✓" and auto-close.
55
+ // The bigbrain.me page never displays this body — it just inspects res.ok.
56
+ res.status(200).json({ ok: true });
57
+ if (resolveToken) {
58
+ const fn = resolveToken;
59
+ resolveToken = null;
60
+ rejectToken = null;
61
+ fn(token);
62
+ }
63
+ });
64
+
65
+ const server = app.listen(0, '127.0.0.1', () => {
66
+ const address = server.address() as AddressInfo | null;
67
+ if (!address || typeof address === 'string') {
68
+ server.close();
69
+ reject(new Error('Failed to determine loopback port'));
70
+ return;
71
+ }
72
+ resolve({
73
+ callbackUrl: `http://127.0.0.1:${address.port}${CALLBACK_PATH}`,
74
+ waitForToken(timeoutMs) {
75
+ return new Promise<string>((res, rej) => {
76
+ resolveToken = res;
77
+ rejectToken = rej;
78
+ const timer = setTimeout(() => {
79
+ if (rejectToken) {
80
+ const reject = rejectToken;
81
+ resolveToken = null;
82
+ rejectToken = null;
83
+ reject(new Error('Loopback callback timed out'));
84
+ }
85
+ }, timeoutMs);
86
+ // Don't keep the event loop alive on the timer alone.
87
+ timer.unref?.();
88
+ });
89
+ },
90
+ close() {
91
+ if (rejectToken) {
92
+ const reject = rejectToken;
93
+ resolveToken = null;
94
+ rejectToken = null;
95
+ reject(new Error('Loopback server closed before token arrived'));
96
+ }
97
+ server.close();
98
+ },
99
+ });
100
+ });
101
+ server.on('error', reject);
102
+ });
103
+ }
@@ -0,0 +1,24 @@
1
+ import { chmod } from 'node:fs/promises';
2
+ import { ensureDir, writeFile } from 'fs-extra';
3
+ import { Z2H_AUTH_DIR, Z2H_AUTH_FILE, type ZthAuth } from './auth-store';
4
+
5
+ // 0o600 — owner read/write only. The file holds a long-lived bearer token,
6
+ // so other local users on the machine must not be able to read it.
7
+ const OWNER_READ_WRITE_FILE_MODE = 0o600;
8
+
9
+ export async function writeZthAuth(auth: ZthAuth): Promise<{ filePath: string }> {
10
+ await ensureDir(Z2H_AUTH_DIR);
11
+ await writeFile(Z2H_AUTH_FILE, `${JSON.stringify(auth, null, 2)}\n`);
12
+ await chmod(Z2H_AUTH_FILE, OWNER_READ_WRITE_FILE_MODE);
13
+ return { filePath: Z2H_AUTH_FILE };
14
+ }
15
+
16
+ /**
17
+ * Three base64url segments separated by dots — a structural sanity check,
18
+ * not a real verification. Server-side validation happens at the STS broker.
19
+ */
20
+ export function validateJwtPattern(token: string): boolean {
21
+ return /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(token);
22
+ }
23
+
24
+ export { Z2H_AUTH_FILE } from './auth-store';
@@ -1,5 +1,6 @@
1
1
  import { PutObjectCommand } from '@aws-sdk/client-s3';
2
- import { BUCKET_WRITE_NAME, MANIFEST_KEY, MANIFEST_READ_URL, getS3Client } from './s3/client';
2
+ import { BUCKET_WRITE_NAME, MANIFEST_KEY, MANIFEST_READ_URL } from '../constants';
3
+ import { getS3Client } from './s3/client';
3
4
 
4
5
  export interface SharedDep {
5
6
  name?: string;
@@ -1,40 +1,23 @@
1
- import { HeadBucketCommand, S3Client, S3ServiceException } from '@aws-sdk/client-s3';
2
-
3
- export const BUCKET_WRITE_NAME = 'prod-use1-bigbrain-zth-mf-assets';
4
- export const REGION = 'us-east-1';
5
- export const BUCKET_WRITE_URL = `https://${BUCKET_WRITE_NAME}.s3.${REGION}.amazonaws.com`;
6
-
7
- export const BUCKET_READ_DNS_NAME = 'bigbrain-zth-mf-assets';
8
- export const BUCKET_READ_DNS_URL = `https://${BUCKET_READ_DNS_NAME}.bigbrain.me`;
9
-
10
- // No `.json` extension — TridentSkeleton's `mfUrl` API appends `/mf-assets`
11
- // to the bucket base when fetching, so the manifest must live at this exact
12
- // key. See microfrontends/mf-bigbrain-zth/.../ZeroToHeroHost.tsx.
13
- export const MANIFEST_KEY = 'mf-assets';
14
- export const MANIFEST_READ_URL = `${BUCKET_READ_DNS_URL}/${MANIFEST_KEY}`;
1
+ import { S3Client } from '@aws-sdk/client-s3';
2
+ import { brokerCredentialProvider } from '../auth/broker-credential-provider';
3
+ import { REGION } from '../../constants';
15
4
 
16
5
  let client: S3Client | null = null;
17
6
 
7
+ /**
8
+ * Initialize the per-process S3 client with broker-issued credentials scoped
9
+ * to the given app's S3 prefix. Must be called once before any `getS3Client`.
10
+ */
11
+ export function setupS3Client({ appName }: { appName: string }): void {
12
+ client = new S3Client({
13
+ region: REGION,
14
+ credentials: brokerCredentialProvider({ appName }),
15
+ });
16
+ }
17
+
18
18
  export function getS3Client(): S3Client {
19
19
  if (!client) {
20
- client = new S3Client({ region: REGION });
20
+ throw new Error('S3 client not initialized — call setupS3Client({ appName }) first.');
21
21
  }
22
22
  return client;
23
23
  }
24
-
25
- export async function assertBucketExists(): Promise<void> {
26
- try {
27
- await getS3Client().send(new HeadBucketCommand({ Bucket: BUCKET_WRITE_NAME }));
28
- } catch (err) {
29
- if (err instanceof S3ServiceException) {
30
- const status = err.$metadata.httpStatusCode;
31
- if (status === 404 || err.name === 'NotFound' || err.name === 'NoSuchBucket') {
32
- throw new Error(`S3 bucket "${BUCKET_WRITE_NAME}" does not exist (region: ${REGION}).`);
33
- }
34
- if (status === 403) {
35
- throw new Error(`No access to S3 bucket "${BUCKET_WRITE_NAME}" (region: ${REGION}). Check AWS credentials.`);
36
- }
37
- }
38
- throw err;
39
- }
40
- }
@@ -4,7 +4,8 @@ import { readFile } from 'fs-extra';
4
4
  import { lookup as lookupMime } from 'mime-types';
5
5
  import pMap from 'p-map';
6
6
  import { PutObjectCommand } from '@aws-sdk/client-s3';
7
- import { BUCKET_WRITE_NAME, getS3Client } from './client';
7
+ import { BUCKET_WRITE_NAME } from '../../constants';
8
+ import { getS3Client } from './client';
8
9
 
9
10
  // Each deploy uploads to a version-prefixed path (<app>/<version>/...), so
10
11
  // the URL itself is the cache key. Assets never need to be revalidated.