@lightdash/cli 1.44.0 → 1.46.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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/config.d.ts +4 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +39 -4
- package/dist/config.test.d.ts +2 -0
- package/dist/config.test.d.ts.map +1 -0
- package/dist/config.test.js +40 -0
- package/dist/handlers/apps/appCodeFiles.d.ts +1 -0
- package/dist/handlers/apps/appCodeFiles.d.ts.map +1 -1
- package/dist/handlers/apps/appCodeFiles.js +7 -4
- package/dist/handlers/apps/appCodeFiles.test.js +8 -0
- package/dist/handlers/apps/preview.d.ts +56 -0
- package/dist/handlers/apps/preview.d.ts.map +1 -0
- package/dist/handlers/apps/preview.js +262 -0
- package/dist/handlers/apps/preview.test.d.ts +2 -0
- package/dist/handlers/apps/preview.test.d.ts.map +1 -0
- package/dist/handlers/apps/preview.test.js +189 -0
- package/dist/handlers/apps/previewProxy.d.ts +20 -0
- package/dist/handlers/apps/previewProxy.d.ts.map +1 -0
- package/dist/handlers/apps/previewProxy.js +144 -0
- package/dist/handlers/apps/previewProxy.test.d.ts +2 -0
- package/dist/handlers/apps/previewProxy.test.d.ts.map +1 -0
- package/dist/handlers/apps/previewProxy.test.js +163 -0
- package/dist/handlers/apps/scaffolding.test.js +7 -0
- package/dist/handlers/utils.d.ts +8 -0
- package/dist/handlers/utils.d.ts.map +1 -1
- package/dist/handlers/utils.js +1 -0
- package/dist/index.js +16 -4
- package/dist/vendor/authoring/AGENTS.md.tmpl +3 -3
- package/dist/vendor/authoring/README.md.tmpl +7 -4
- package/dist/vendor/authoring/developing-data-apps-locally/SKILL.md +19 -6
- package/dist/vendor/authoring/gitignore +1 -0
- package/dist/vendor/template/vite.config.js +63 -3
- package/package.json +3 -3
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const tslib_1 = require("tslib");
|
|
4
|
+
const fs_1 = require("fs");
|
|
5
|
+
const os = tslib_1.__importStar(require("os"));
|
|
6
|
+
const path = tslib_1.__importStar(require("path"));
|
|
7
|
+
const utils_1 = require("../utils");
|
|
8
|
+
const appCodeFiles_1 = require("./appCodeFiles");
|
|
9
|
+
const preview_1 = require("./preview");
|
|
10
|
+
const previewBundle = {
|
|
11
|
+
manifest: {
|
|
12
|
+
codeVersion: 1,
|
|
13
|
+
appUuid: 'app-uuid-1',
|
|
14
|
+
projectUuid: 'proj-uuid-1',
|
|
15
|
+
version: 2,
|
|
16
|
+
name: 'Preview App',
|
|
17
|
+
description: '',
|
|
18
|
+
template: null,
|
|
19
|
+
downloadedAt: '2026-07-01T00:00:00.000Z',
|
|
20
|
+
},
|
|
21
|
+
files: [],
|
|
22
|
+
};
|
|
23
|
+
describe('getAuthHeader', () => {
|
|
24
|
+
it('uses ApiKey authentication for personal access tokens', () => {
|
|
25
|
+
expect((0, utils_1.getAuthHeader)('ldpat_secret')).toBe('ApiKey ldpat_secret');
|
|
26
|
+
});
|
|
27
|
+
it('uses Bearer authentication for service-account tokens', () => {
|
|
28
|
+
expect((0, utils_1.getAuthHeader)('ldsvc_secret')).toBe('Bearer ldsvc_secret');
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
describe('buildPreviewChildEnv', () => {
|
|
32
|
+
it('passes through only required host variables and no parent secrets', () => {
|
|
33
|
+
const env = (0, preview_1.buildPreviewChildEnv)({
|
|
34
|
+
serverUrl: 'http://localhost:3000',
|
|
35
|
+
projectUuid: 'proj-uuid-1',
|
|
36
|
+
proxyPort: 45678,
|
|
37
|
+
proxyNonce: 'nonce-abc',
|
|
38
|
+
parentEnv: {
|
|
39
|
+
HOME: '/home/developer',
|
|
40
|
+
PATH: '/usr/bin',
|
|
41
|
+
LIGHTDASH_API_KEY: 'ldpat_parent_secret',
|
|
42
|
+
LIGHTDASH_PROXY_AUTHORIZATION: 'Basic parent-secret',
|
|
43
|
+
NPM_TOKEN: 'npm_parent_secret',
|
|
44
|
+
VITE_LIGHTDASH_API_KEY: 'ldpat_browser_secret',
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
expect(env).toEqual({
|
|
48
|
+
HOME: '/home/developer',
|
|
49
|
+
PATH: '/usr/bin',
|
|
50
|
+
VITE_LIGHTDASH_URL: 'http://localhost:3000',
|
|
51
|
+
VITE_LIGHTDASH_API_KEY: 'nonce-abc',
|
|
52
|
+
VITE_LIGHTDASH_PROJECT_UUID: 'proj-uuid-1',
|
|
53
|
+
LIGHTDASH_PREVIEW_PROXY_TARGET: 'http://127.0.0.1:45678',
|
|
54
|
+
LIGHTDASH_PREVIEW_PROXY_NONCE: 'nonce-abc',
|
|
55
|
+
});
|
|
56
|
+
expect(env).not.toHaveProperty('LIGHTDASH_API_KEY');
|
|
57
|
+
expect(env).not.toHaveProperty('LIGHTDASH_PROXY_AUTHORIZATION');
|
|
58
|
+
expect(env).not.toHaveProperty('NPM_TOKEN');
|
|
59
|
+
for (const value of Object.values(env)) {
|
|
60
|
+
expect(value).not.toMatch(/^ld(pat|svc)_/);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
it('strips a trailing slash from the server url', () => {
|
|
64
|
+
const env = (0, preview_1.buildPreviewChildEnv)({
|
|
65
|
+
serverUrl: 'http://localhost:3000/',
|
|
66
|
+
projectUuid: 'p',
|
|
67
|
+
proxyPort: 1,
|
|
68
|
+
proxyNonce: 'n',
|
|
69
|
+
});
|
|
70
|
+
expect(env.VITE_LIGHTDASH_URL).toBe('http://localhost:3000');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
describe('assertScaffoldingSupportsPreviewProxy', () => {
|
|
74
|
+
it('passes when vite.config.js reads the proxy target', async () => {
|
|
75
|
+
const dir = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'ld-prev-'));
|
|
76
|
+
await fs_1.promises.writeFile(path.join(dir, 'vite.config.js'), 'const t = env.LIGHTDASH_PREVIEW_PROXY_TARGET;');
|
|
77
|
+
await expect((0, preview_1.assertScaffoldingSupportsPreviewProxy)(dir)).resolves.toBeUndefined();
|
|
78
|
+
});
|
|
79
|
+
it('rejects pre-proxy scaffolding with a re-download instruction', async () => {
|
|
80
|
+
const dir = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'ld-prev-'));
|
|
81
|
+
await fs_1.promises.writeFile(path.join(dir, 'vite.config.js'), 'const k = env.LIGHTDASH_PREVIEW_API_KEY;');
|
|
82
|
+
await expect((0, preview_1.assertScaffoldingSupportsPreviewProxy)(dir)).rejects.toThrow(/Re-download the app/);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
describe('resolvePreviewTarget', () => {
|
|
86
|
+
it('defaults appDir to cwd and project to the manifest projectUuid', async () => {
|
|
87
|
+
const dir = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'ld-prev-'));
|
|
88
|
+
await (0, appCodeFiles_1.writeBundleToDir)(dir, previewBundle);
|
|
89
|
+
const target = await (0, preview_1.resolvePreviewTarget)({ cwd: dir });
|
|
90
|
+
expect(target.appDir).toBe(dir);
|
|
91
|
+
expect(target.projectUuid).toBe('proj-uuid-1');
|
|
92
|
+
});
|
|
93
|
+
it('resolves a relative path arg against cwd and honors --project override', async () => {
|
|
94
|
+
const parent = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'ld-prev-'));
|
|
95
|
+
const appDir = path.join(parent, 'apps', 'my-app');
|
|
96
|
+
await (0, appCodeFiles_1.writeBundleToDir)(appDir, previewBundle);
|
|
97
|
+
const target = await (0, preview_1.resolvePreviewTarget)({
|
|
98
|
+
pathArg: 'apps/my-app',
|
|
99
|
+
projectFlag: 'other-proj',
|
|
100
|
+
cwd: parent,
|
|
101
|
+
});
|
|
102
|
+
expect(target.appDir).toBe(appDir);
|
|
103
|
+
expect(target.projectUuid).toBe('other-proj');
|
|
104
|
+
});
|
|
105
|
+
it('errors clearly when the folder has no manifest', async () => {
|
|
106
|
+
const dir = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'ld-prev-'));
|
|
107
|
+
await expect((0, preview_1.resolvePreviewTarget)({ cwd: dir })).rejects.toThrow(/No lightdash-app\.yml found in/);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
describe('assertNodeModulesPresent', () => {
|
|
111
|
+
it('passes when node_modules exists', async () => {
|
|
112
|
+
const dir = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'ld-prev-'));
|
|
113
|
+
await fs_1.promises.mkdir(path.join(dir, 'node_modules'));
|
|
114
|
+
await expect((0, preview_1.assertNodeModulesPresent)(dir)).resolves.toBeUndefined();
|
|
115
|
+
});
|
|
116
|
+
it('errors telling the user to npm install, without installing', async () => {
|
|
117
|
+
const dir = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'ld-prev-'));
|
|
118
|
+
await expect((0, preview_1.assertNodeModulesPresent)(dir)).rejects.toThrow(/Run 'npm install' in .* \(preview does not auto-install\)/);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
describe('projectNotFoundMessage', () => {
|
|
122
|
+
it('names the project, the server, and both remedies', () => {
|
|
123
|
+
const msg = (0, preview_1.projectNotFoundMessage)({
|
|
124
|
+
projectUuid: 'proj-uuid-1',
|
|
125
|
+
serverUrl: 'https://cloud.example.com',
|
|
126
|
+
});
|
|
127
|
+
expect(msg).toContain('proj-uuid-1');
|
|
128
|
+
expect(msg).toContain('https://cloud.example.com');
|
|
129
|
+
expect(msg).toMatch(/lightdash login/);
|
|
130
|
+
expect(msg).toMatch(/--project/);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
describe('preflightPreviewRequest', () => {
|
|
134
|
+
it('reports network failures separately from rejected credentials', async () => {
|
|
135
|
+
const fetchFn = vi
|
|
136
|
+
.fn()
|
|
137
|
+
.mockRejectedValue(new Error('connection refused'));
|
|
138
|
+
await expect((0, preview_1.preflightPreviewRequest)({
|
|
139
|
+
apiPath: '/api/v1/user',
|
|
140
|
+
serverUrl: 'http://localhost:8080',
|
|
141
|
+
authorization: 'ApiKey test',
|
|
142
|
+
fetchFn,
|
|
143
|
+
})).rejects.toThrow('Could not connect to http://localhost:8080 while starting preview: connection refused');
|
|
144
|
+
});
|
|
145
|
+
it('returns false for an HTTP rejection', async () => {
|
|
146
|
+
const fetchFn = vi
|
|
147
|
+
.fn()
|
|
148
|
+
.mockResolvedValue({ ok: false });
|
|
149
|
+
await expect((0, preview_1.preflightPreviewRequest)({
|
|
150
|
+
apiPath: '/api/v1/user',
|
|
151
|
+
serverUrl: 'http://localhost:8080',
|
|
152
|
+
authorization: 'ApiKey test',
|
|
153
|
+
fetchFn,
|
|
154
|
+
})).resolves.toBe(false);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
describe('removeLegacyPreviewCredential', () => {
|
|
158
|
+
it('removes only the obsolete credential and preserves other settings', async () => {
|
|
159
|
+
const dir = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'ld-prev-'));
|
|
160
|
+
const envPath = path.join(dir, '.env.local');
|
|
161
|
+
await fs_1.promises.writeFile(envPath, [
|
|
162
|
+
'VITE_CUSTOM_SETTING=keep-me',
|
|
163
|
+
'LIGHTDASH_PREVIEW_API_KEY=ldpat_remove_me',
|
|
164
|
+
'# LIGHTDASH_PREVIEW_API_KEY=commented-out',
|
|
165
|
+
'ANOTHER_SETTING=also-keep-me',
|
|
166
|
+
'',
|
|
167
|
+
].join('\n'));
|
|
168
|
+
await expect((0, preview_1.removeLegacyPreviewCredential)(dir)).resolves.toBe(true);
|
|
169
|
+
await expect(fs_1.promises.readFile(envPath, 'utf-8')).resolves.toBe([
|
|
170
|
+
'VITE_CUSTOM_SETTING=keep-me',
|
|
171
|
+
'# LIGHTDASH_PREVIEW_API_KEY=commented-out',
|
|
172
|
+
'ANOTHER_SETTING=also-keep-me',
|
|
173
|
+
'',
|
|
174
|
+
].join('\n'));
|
|
175
|
+
});
|
|
176
|
+
it('deletes an env file that contains only the obsolete credential', async () => {
|
|
177
|
+
const dir = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'ld-prev-'));
|
|
178
|
+
const envPath = path.join(dir, '.env.local');
|
|
179
|
+
await fs_1.promises.writeFile(envPath, 'export LIGHTDASH_PREVIEW_API_KEY=ldpat_remove_me\n');
|
|
180
|
+
await expect((0, preview_1.removeLegacyPreviewCredential)(dir)).resolves.toBe(true);
|
|
181
|
+
await expect(fs_1.promises.stat(envPath)).rejects.toMatchObject({
|
|
182
|
+
code: 'ENOENT',
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
it('does nothing when there is no legacy credential', async () => {
|
|
186
|
+
const dir = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'ld-prev-'));
|
|
187
|
+
await expect((0, preview_1.removeLegacyPreviewCredential)(dir)).resolves.toBe(false);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export declare const PREVIEW_PROXY_NONCE_HEADER = "x-lightdash-preview-nonce";
|
|
2
|
+
export type PreviewProxyHandle = {
|
|
3
|
+
port: number;
|
|
4
|
+
close: () => Promise<void>;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Loopback proxy that owns the preview credential. The vite dev server
|
|
8
|
+
* forwards /api traffic here; only routes a deployed app could reach through
|
|
9
|
+
* the SDK bridge — pinned to the previewed project — are forwarded upstream,
|
|
10
|
+
* with the credential attached server-side. Preview does not write it into the
|
|
11
|
+
* app folder or pass it to the vite process/browser.
|
|
12
|
+
*/
|
|
13
|
+
export declare const startPreviewProxy: (args: {
|
|
14
|
+
upstreamUrl: string;
|
|
15
|
+
authorization: string;
|
|
16
|
+
proxyAuthorization?: string;
|
|
17
|
+
projectUuid: string;
|
|
18
|
+
nonce: string;
|
|
19
|
+
}) => Promise<PreviewProxyHandle>;
|
|
20
|
+
//# sourceMappingURL=previewProxy.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"previewProxy.d.ts","sourceRoot":"","sources":["../../../src/handlers/apps/previewProxy.ts"],"names":[],"mappings":"AAWA,eAAO,MAAM,0BAA0B,8BAA8B,CAAC;AA6CtE,MAAM,MAAM,kBAAkB,GAAG;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC9B,CAAC;AAyBF;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,SAAU;IACpC,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,MAAM,CAAC;IACtB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;CACjB,KAAG,OAAO,CAAC,kBAAkB,CA+G7B,CAAC"}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.startPreviewProxy = exports.PREVIEW_PROXY_NONCE_HEADER = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const common_1 = require("@lightdash/common");
|
|
6
|
+
const crypto_1 = require("crypto");
|
|
7
|
+
const http = tslib_1.__importStar(require("http"));
|
|
8
|
+
const https = tslib_1.__importStar(require("https"));
|
|
9
|
+
// Per-run shared secret between the browser, Vite, and the CLI proxy. Vite
|
|
10
|
+
// forwards the browser's scoped Authorization value and adds this header;
|
|
11
|
+
// both must match before the durable credential is attached upstream.
|
|
12
|
+
exports.PREVIEW_PROXY_NONCE_HEADER = 'x-lightdash-preview-nonce';
|
|
13
|
+
// Hop-by-hop headers are connection-scoped and must not be forwarded.
|
|
14
|
+
const HOP_BY_HOP_HEADERS = new Set([
|
|
15
|
+
'connection',
|
|
16
|
+
'keep-alive',
|
|
17
|
+
'proxy-authenticate',
|
|
18
|
+
'proxy-authorization',
|
|
19
|
+
'te',
|
|
20
|
+
'trailer',
|
|
21
|
+
'transfer-encoding',
|
|
22
|
+
'upgrade',
|
|
23
|
+
]);
|
|
24
|
+
// Client → upstream: host is derived from the upstream URL; the browser-side
|
|
25
|
+
// scoped preview capability and the nonce must not travel upstream, and
|
|
26
|
+
// localhost cookies are not the instance's business.
|
|
27
|
+
const DROPPED_REQUEST_HEADERS = new Set([
|
|
28
|
+
'host',
|
|
29
|
+
'authorization',
|
|
30
|
+
'cookie',
|
|
31
|
+
exports.PREVIEW_PROXY_NONCE_HEADER,
|
|
32
|
+
]);
|
|
33
|
+
// Upstream → client: the instance must not plant cookies on the dev origin.
|
|
34
|
+
const DROPPED_RESPONSE_HEADERS = new Set(['set-cookie']);
|
|
35
|
+
const filterHeaders = (source, dropped) => {
|
|
36
|
+
const out = {};
|
|
37
|
+
for (const [name, value] of Object.entries(source)) {
|
|
38
|
+
const lower = name.toLowerCase();
|
|
39
|
+
if (value !== undefined &&
|
|
40
|
+
!HOP_BY_HOP_HEADERS.has(lower) &&
|
|
41
|
+
!dropped.has(lower)) {
|
|
42
|
+
out[lower] = value;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
};
|
|
47
|
+
const sendJsonError = (res, status, message) => {
|
|
48
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
49
|
+
res.end(JSON.stringify({ status: 'error', results: null, message }));
|
|
50
|
+
};
|
|
51
|
+
const secretsMatch = (actual, expected) => {
|
|
52
|
+
if (typeof actual !== 'string')
|
|
53
|
+
return false;
|
|
54
|
+
const actualBuffer = Buffer.from(actual);
|
|
55
|
+
const expectedBuffer = Buffer.from(expected);
|
|
56
|
+
return (actualBuffer.length === expectedBuffer.length &&
|
|
57
|
+
(0, crypto_1.timingSafeEqual)(actualBuffer, expectedBuffer));
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Loopback proxy that owns the preview credential. The vite dev server
|
|
61
|
+
* forwards /api traffic here; only routes a deployed app could reach through
|
|
62
|
+
* the SDK bridge — pinned to the previewed project — are forwarded upstream,
|
|
63
|
+
* with the credential attached server-side. Preview does not write it into the
|
|
64
|
+
* app folder or pass it to the vite process/browser.
|
|
65
|
+
*/
|
|
66
|
+
const startPreviewProxy = (args) => {
|
|
67
|
+
const upstream = new URL(args.upstreamUrl);
|
|
68
|
+
const requestFn = upstream.protocol === 'https:' ? https.request : http.request;
|
|
69
|
+
const server = http.createServer((req, res) => {
|
|
70
|
+
const method = req.method ?? 'GET';
|
|
71
|
+
const nonceHeader = req.headers[exports.PREVIEW_PROXY_NONCE_HEADER];
|
|
72
|
+
const browserAuthorization = req.headers.authorization;
|
|
73
|
+
if (!secretsMatch(nonceHeader, args.nonce) ||
|
|
74
|
+
!secretsMatch(browserAuthorization, `ApiKey ${args.nonce}`)) {
|
|
75
|
+
sendJsonError(res, 401, 'Preview proxy: missing or invalid nonce');
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
let pathname;
|
|
79
|
+
try {
|
|
80
|
+
pathname = new URL(req.url ?? '/', 'http://localhost').pathname;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
sendJsonError(res, 400, 'Preview proxy: malformed request URL');
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
// Same authority as the deployed app's postMessage bridge: anything
|
|
87
|
+
// the bridge would reject, preview rejects too.
|
|
88
|
+
if (!(0, common_1.isAllowedAppSdkRoute)(method, pathname)) {
|
|
89
|
+
sendJsonError(res, 403, `Preview proxy: ${method} ${pathname} is not a data-app SDK route. Preview only allows the routes a deployed app can reach.`);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const routeProjectUuid = (0, common_1.extractAppSdkRouteProjectUuid)(pathname);
|
|
93
|
+
if (routeProjectUuid !== null &&
|
|
94
|
+
routeProjectUuid !== args.projectUuid) {
|
|
95
|
+
sendJsonError(res, 403, `Preview proxy: request targets project ${routeProjectUuid}, but this preview is pinned to ${args.projectUuid}.`);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const headers = filterHeaders(req.headers, DROPPED_REQUEST_HEADERS);
|
|
99
|
+
headers.authorization = args.authorization;
|
|
100
|
+
if (args.proxyAuthorization) {
|
|
101
|
+
headers['proxy-authorization'] = args.proxyAuthorization;
|
|
102
|
+
}
|
|
103
|
+
const upstreamReq = requestFn({
|
|
104
|
+
protocol: upstream.protocol,
|
|
105
|
+
hostname: upstream.hostname,
|
|
106
|
+
port: upstream.port || undefined,
|
|
107
|
+
path: req.url,
|
|
108
|
+
method,
|
|
109
|
+
headers,
|
|
110
|
+
}, (upstreamRes) => {
|
|
111
|
+
res.writeHead(upstreamRes.statusCode ?? 502, filterHeaders(upstreamRes.headers, DROPPED_RESPONSE_HEADERS));
|
|
112
|
+
upstreamRes.pipe(res);
|
|
113
|
+
});
|
|
114
|
+
upstreamReq.on('error', (err) => {
|
|
115
|
+
if (!res.headersSent) {
|
|
116
|
+
sendJsonError(res, 502, `Preview proxy: upstream request failed (${err.message})`);
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
res.destroy();
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
req.pipe(upstreamReq);
|
|
123
|
+
});
|
|
124
|
+
return new Promise((resolve, reject) => {
|
|
125
|
+
server.once('error', reject);
|
|
126
|
+
server.listen(0, '127.0.0.1', () => {
|
|
127
|
+
const address = server.address();
|
|
128
|
+
if (address === null || typeof address === 'string') {
|
|
129
|
+
reject(new Error('Preview proxy failed to bind a loopback port'));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
resolve({
|
|
133
|
+
port: address.port,
|
|
134
|
+
close: () => new Promise((resolveClose) => {
|
|
135
|
+
server.close(() => resolveClose());
|
|
136
|
+
// Pending keep-alive sockets would otherwise delay
|
|
137
|
+
// shutdown after vite exits.
|
|
138
|
+
server.closeAllConnections();
|
|
139
|
+
}),
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
};
|
|
144
|
+
exports.startPreviewProxy = startPreviewProxy;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"previewProxy.test.d.ts","sourceRoot":"","sources":["../../../src/handlers/apps/previewProxy.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const tslib_1 = require("tslib");
|
|
4
|
+
const http = tslib_1.__importStar(require("http"));
|
|
5
|
+
const previewProxy_1 = require("./previewProxy");
|
|
6
|
+
const PROJECT_UUID = 'proj-uuid-1';
|
|
7
|
+
const NONCE = 'test-nonce';
|
|
8
|
+
const API_KEY = 'ldpat_secret';
|
|
9
|
+
const AUTHORIZATION = `ApiKey ${API_KEY}`;
|
|
10
|
+
/** Records every request and replies 200 with a JSON echo. */
|
|
11
|
+
const startFakeUpstream = () => {
|
|
12
|
+
const seen = [];
|
|
13
|
+
const server = http.createServer((req, res) => {
|
|
14
|
+
const chunks = [];
|
|
15
|
+
req.on('data', (chunk) => chunks.push(chunk));
|
|
16
|
+
req.on('end', () => {
|
|
17
|
+
seen.push({
|
|
18
|
+
method: req.method ?? '',
|
|
19
|
+
url: req.url ?? '',
|
|
20
|
+
headers: req.headers,
|
|
21
|
+
body: Buffer.concat(chunks).toString('utf-8'),
|
|
22
|
+
});
|
|
23
|
+
res.writeHead(200, {
|
|
24
|
+
'content-type': 'application/json',
|
|
25
|
+
'set-cookie': 'upstream=1',
|
|
26
|
+
});
|
|
27
|
+
res.end(JSON.stringify({ status: 'ok' }));
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
server.listen(0, '127.0.0.1', () => {
|
|
32
|
+
const { port } = server.address();
|
|
33
|
+
resolve({
|
|
34
|
+
url: `http://127.0.0.1:${port}`,
|
|
35
|
+
seen,
|
|
36
|
+
close: () => new Promise((r) => {
|
|
37
|
+
server.close(() => r());
|
|
38
|
+
server.closeAllConnections();
|
|
39
|
+
}),
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
describe('startPreviewProxy', () => {
|
|
45
|
+
let upstream;
|
|
46
|
+
let proxy;
|
|
47
|
+
const proxyFetch = (path, init = {}) => {
|
|
48
|
+
const { nonce = NONCE, authorization = `ApiKey ${NONCE}`, ...rest } = init;
|
|
49
|
+
const headers = new Headers(rest.headers);
|
|
50
|
+
if (nonce !== null)
|
|
51
|
+
headers.set(previewProxy_1.PREVIEW_PROXY_NONCE_HEADER, nonce);
|
|
52
|
+
if (authorization !== null) {
|
|
53
|
+
headers.set('authorization', authorization);
|
|
54
|
+
}
|
|
55
|
+
return fetch(`http://127.0.0.1:${proxy.port}${path}`, {
|
|
56
|
+
...rest,
|
|
57
|
+
headers,
|
|
58
|
+
});
|
|
59
|
+
};
|
|
60
|
+
beforeEach(async () => {
|
|
61
|
+
upstream = await startFakeUpstream();
|
|
62
|
+
proxy = await (0, previewProxy_1.startPreviewProxy)({
|
|
63
|
+
upstreamUrl: upstream.url,
|
|
64
|
+
authorization: AUTHORIZATION,
|
|
65
|
+
projectUuid: PROJECT_UUID,
|
|
66
|
+
nonce: NONCE,
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
afterEach(async () => {
|
|
70
|
+
await proxy.close();
|
|
71
|
+
await upstream.close();
|
|
72
|
+
});
|
|
73
|
+
it('forwards an allowlisted route with the credential attached', async () => {
|
|
74
|
+
const res = await proxyFetch(`/api/v2/projects/${PROJECT_UUID}/query/metric-query`, {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers: {
|
|
77
|
+
'content-type': 'application/json',
|
|
78
|
+
authorization: `ApiKey ${NONCE}`,
|
|
79
|
+
},
|
|
80
|
+
body: JSON.stringify({ exploreName: 'orders' }),
|
|
81
|
+
});
|
|
82
|
+
expect(res.status).toBe(200);
|
|
83
|
+
expect(upstream.seen).toHaveLength(1);
|
|
84
|
+
const forwarded = upstream.seen[0];
|
|
85
|
+
expect(forwarded.method).toBe('POST');
|
|
86
|
+
expect(forwarded.body).toBe('{"exploreName":"orders"}');
|
|
87
|
+
// The browser-side scoped capability is replaced by the real credential…
|
|
88
|
+
expect(forwarded.headers.authorization).toBe(AUTHORIZATION);
|
|
89
|
+
// …and the run-scoped nonce never travels upstream.
|
|
90
|
+
expect(forwarded.headers[previewProxy_1.PREVIEW_PROXY_NONCE_HEADER]).toBeUndefined();
|
|
91
|
+
});
|
|
92
|
+
it('preserves the query string on result polling', async () => {
|
|
93
|
+
const res = await proxyFetch(`/api/v2/projects/${PROJECT_UUID}/query/some-query-uuid?page=2&pageSize=500`);
|
|
94
|
+
expect(res.status).toBe(200);
|
|
95
|
+
expect(upstream.seen[0].url).toBe(`/api/v2/projects/${PROJECT_UUID}/query/some-query-uuid?page=2&pageSize=500`);
|
|
96
|
+
});
|
|
97
|
+
it('rejects non-SDK routes without contacting upstream', async () => {
|
|
98
|
+
const res = await proxyFetch('/api/v1/org/projects');
|
|
99
|
+
expect(res.status).toBe(403);
|
|
100
|
+
const body = (await res.json());
|
|
101
|
+
expect(body.message).toMatch(/not a data-app SDK route/);
|
|
102
|
+
expect(upstream.seen).toHaveLength(0);
|
|
103
|
+
});
|
|
104
|
+
it('rejects allowlisted routes aimed at a different project', async () => {
|
|
105
|
+
const res = await proxyFetch('/api/v2/projects/other-project/query/metric-query', { method: 'POST' });
|
|
106
|
+
expect(res.status).toBe(403);
|
|
107
|
+
const body = (await res.json());
|
|
108
|
+
expect(body.message).toMatch(/pinned to proj-uuid-1/);
|
|
109
|
+
expect(upstream.seen).toHaveLength(0);
|
|
110
|
+
});
|
|
111
|
+
it('rejects allowlisted paths with the wrong method', async () => {
|
|
112
|
+
const res = await proxyFetch('/api/v1/user', { method: 'POST' });
|
|
113
|
+
expect(res.status).toBe(403);
|
|
114
|
+
expect(upstream.seen).toHaveLength(0);
|
|
115
|
+
});
|
|
116
|
+
it('rejects requests without the per-run nonce', async () => {
|
|
117
|
+
const missing = await proxyFetch('/api/v1/user', { nonce: null });
|
|
118
|
+
expect(missing.status).toBe(401);
|
|
119
|
+
const wrong = await proxyFetch('/api/v1/user', { nonce: 'guessed' });
|
|
120
|
+
expect(wrong.status).toBe(401);
|
|
121
|
+
expect(upstream.seen).toHaveLength(0);
|
|
122
|
+
});
|
|
123
|
+
it('rejects requests without the browser-side scoped capability', async () => {
|
|
124
|
+
const missing = await proxyFetch('/api/v1/user', {
|
|
125
|
+
authorization: null,
|
|
126
|
+
});
|
|
127
|
+
expect(missing.status).toBe(401);
|
|
128
|
+
const wrong = await proxyFetch('/api/v1/user', {
|
|
129
|
+
authorization: 'ApiKey guessed',
|
|
130
|
+
});
|
|
131
|
+
expect(wrong.status).toBe(401);
|
|
132
|
+
expect(upstream.seen).toHaveLength(0);
|
|
133
|
+
});
|
|
134
|
+
it('does not pass upstream cookies back to the page', async () => {
|
|
135
|
+
const res = await proxyFetch('/api/v1/user');
|
|
136
|
+
expect(res.status).toBe(200);
|
|
137
|
+
expect(res.headers.get('set-cookie')).toBeNull();
|
|
138
|
+
});
|
|
139
|
+
it('forwards service-account and reverse-proxy authorization', async () => {
|
|
140
|
+
const serviceProxy = await (0, previewProxy_1.startPreviewProxy)({
|
|
141
|
+
upstreamUrl: upstream.url,
|
|
142
|
+
authorization: 'Bearer ldsvc_secret',
|
|
143
|
+
proxyAuthorization: 'Basic reverse-proxy-secret',
|
|
144
|
+
projectUuid: PROJECT_UUID,
|
|
145
|
+
nonce: 'service-nonce',
|
|
146
|
+
});
|
|
147
|
+
try {
|
|
148
|
+
const res = await fetch(`http://127.0.0.1:${serviceProxy.port}/api/v1/user`, {
|
|
149
|
+
headers: {
|
|
150
|
+
[previewProxy_1.PREVIEW_PROXY_NONCE_HEADER]: 'service-nonce',
|
|
151
|
+
authorization: 'ApiKey service-nonce',
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
expect(res.status).toBe(200);
|
|
155
|
+
const forwarded = upstream.seen.at(-1);
|
|
156
|
+
expect(forwarded?.headers.authorization).toBe('Bearer ldsvc_secret');
|
|
157
|
+
expect(forwarded?.headers['proxy-authorization']).toBe('Basic reverse-proxy-secret');
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
await serviceProxy.close();
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -67,6 +67,13 @@ describe('buildStaticAuthoringFiles', () => {
|
|
|
67
67
|
expect(text('README.md')).toContain('Revenue');
|
|
68
68
|
expect(text('.gitignore')).toContain('node_modules');
|
|
69
69
|
});
|
|
70
|
+
it('uses npm for the standard local workflow', () => {
|
|
71
|
+
expect(text('README.md')).toContain('npm install && npm run build');
|
|
72
|
+
expect(text('AGENTS.md')).toContain('npm install && npm run build');
|
|
73
|
+
expect(text('.claude/skills/developing-data-apps-locally/SKILL.md')).toContain('`npm install` then `npm run build`');
|
|
74
|
+
expect(text('.npmrc')).toContain('ignore-scripts=true');
|
|
75
|
+
expect(text('.npmrc')).not.toContain('shamefully-hoist');
|
|
76
|
+
});
|
|
70
77
|
it('never writes app source (no src/ files)', () => {
|
|
71
78
|
expect(files.every((f) => !f.path.startsWith('src/'))).toBe(true);
|
|
72
79
|
});
|
package/dist/handlers/utils.d.ts
CHANGED
|
@@ -7,6 +7,14 @@ type RequestHeader = {
|
|
|
7
7
|
[LightdashRequestMethodHeader]: RequestMethod;
|
|
8
8
|
[LightdashCliVersionHeader]: string;
|
|
9
9
|
};
|
|
10
|
+
/**
|
|
11
|
+
* We initially used personal access tokens (PATs) for CLI authentication without any prefix.
|
|
12
|
+
* Service account tokens are launching with a prefix, so we'll check them first.
|
|
13
|
+
* We assume a missing prefix is a personal access token, although new PATs will be created with a prefix.
|
|
14
|
+
*
|
|
15
|
+
* See {@link AuthTokenPrefix} for the available token prefixes
|
|
16
|
+
*/
|
|
17
|
+
export declare function getAuthHeader(token: string): string;
|
|
10
18
|
export declare function buildRequestHeaders(token: string): RequestHeader;
|
|
11
19
|
export {};
|
|
12
20
|
//# sourceMappingURL=utils.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../src/handlers/utils.ts"],"names":[],"mappings":"AAAA,OAAO,EAEH,yBAAyB,EACzB,4BAA4B,EAC5B,aAAa,EAChB,MAAM,mBAAmB,CAAC;AAQ3B,KAAK,aAAa,GAAG;IACjB,cAAc,EAAE,kBAAkB,CAAC;IACnC,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,aAAa,EAAE,MAAM,CAAC;IACtB,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,CAAC,4BAA4B,CAAC,EAAE,aAAa,CAAC;IAC9C,CAAC,yBAAyB,CAAC,EAAE,MAAM,CAAC;CACvC,CAAC;
|
|
1
|
+
{"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../src/handlers/utils.ts"],"names":[],"mappings":"AAAA,OAAO,EAEH,yBAAyB,EACzB,4BAA4B,EAC5B,aAAa,EAChB,MAAM,mBAAmB,CAAC;AAQ3B,KAAK,aAAa,GAAG;IACjB,cAAc,EAAE,kBAAkB,CAAC;IACnC,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,aAAa,EAAE,MAAM,CAAC;IACtB,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,CAAC,4BAA4B,CAAC,EAAE,aAAa,CAAC;IAC9C,CAAC,yBAAyB,CAAC,EAAE,MAAM,CAAC;CACvC,CAAC;AAEF;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,UAK1C;AAED,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,iBAiBhD"}
|
package/dist/handlers/utils.js
CHANGED
package/dist/index.js
CHANGED
|
@@ -8,6 +8,7 @@ const uuid_1 = require("uuid");
|
|
|
8
8
|
const env_1 = require("./env");
|
|
9
9
|
const error_1 = require("./error");
|
|
10
10
|
const globalState_1 = tslib_1.__importDefault(require("./globalState"));
|
|
11
|
+
const preview_1 = require("./handlers/apps/preview");
|
|
11
12
|
const compile_1 = require("./handlers/compile");
|
|
12
13
|
const connectSnowflake_1 = require("./handlers/connectSnowflake");
|
|
13
14
|
const refresh_1 = require("./handlers/dbt/refresh");
|
|
@@ -24,7 +25,7 @@ const lint_1 = require("./handlers/lint");
|
|
|
24
25
|
const listProjects_1 = require("./handlers/listProjects");
|
|
25
26
|
const login_1 = require("./handlers/login");
|
|
26
27
|
const preAggregateAudit_1 = require("./handlers/preAggregateAudit");
|
|
27
|
-
const
|
|
28
|
+
const preview_2 = require("./handlers/preview");
|
|
28
29
|
const renameHandler_1 = require("./handlers/renameHandler");
|
|
29
30
|
const renameProject_1 = require("./handlers/renameProject");
|
|
30
31
|
const runChart_1 = require("./handlers/runChart");
|
|
@@ -274,7 +275,7 @@ commander_1.program
|
|
|
274
275
|
.option('--expires-in <hours>', 'Number of hours until the preview project auto-expires (default: 720, i.e. 30 days)')
|
|
275
276
|
.option('--validate-warehouse-columns', validateWarehouseColumnsDescription, false)
|
|
276
277
|
.option('--no-partial-compilation', 'Fail when a field or join cannot be compiled instead of returning a partial explore')
|
|
277
|
-
.action(
|
|
278
|
+
.action(preview_2.previewHandler);
|
|
278
279
|
commander_1.program
|
|
279
280
|
.command('start-preview')
|
|
280
281
|
.description('Creates a new preview project')
|
|
@@ -314,13 +315,13 @@ commander_1.program
|
|
|
314
315
|
.option('--expires-in <hours>', 'Number of hours until the preview project auto-expires (default: 720, i.e. 30 days)')
|
|
315
316
|
.option('--validate-warehouse-columns', validateWarehouseColumnsDescription, false)
|
|
316
317
|
.option('--no-partial-compilation', 'Fail when a field or join cannot be compiled instead of returning a partial explore')
|
|
317
|
-
.action(
|
|
318
|
+
.action(preview_2.startPreviewHandler);
|
|
318
319
|
commander_1.program
|
|
319
320
|
.command('stop-preview')
|
|
320
321
|
.description('Deletes preview project')
|
|
321
322
|
.option('--name [preview name]', '[required] Name for the preview project to be deleted')
|
|
322
323
|
.option('--verbose', undefined, false)
|
|
323
|
-
.action(
|
|
324
|
+
.action(preview_2.stopPreviewHandler);
|
|
324
325
|
const ORGANIZATION_MODE_OPTIONS = new Set([
|
|
325
326
|
'organization',
|
|
326
327
|
'path',
|
|
@@ -416,6 +417,17 @@ const uploadCommand = commander_1.program
|
|
|
416
417
|
.option('--organization', 'upload all organization-scoped resources without selecting a project', false)
|
|
417
418
|
.option('--send-invites', 'send invitations to eligible pending users during organization upload', false);
|
|
418
419
|
uploadCommand.action(withOrganizationMode(download_1.uploadHandler));
|
|
420
|
+
const appsProgram = commander_1.program
|
|
421
|
+
.command('apps')
|
|
422
|
+
.description('Work with data apps (enterprise)');
|
|
423
|
+
appsProgram
|
|
424
|
+
.command('preview [path]')
|
|
425
|
+
.description('Preview a downloaded data app locally against a real Lightdash instance, authenticated as you. Your credential stays in the CLI, behind a local proxy limited to data-app SDK routes.')
|
|
426
|
+
.option('--project <project uuid>', 'preview against a specific project (default: the projectUuid in lightdash-app.yml)')
|
|
427
|
+
.option('--url <url>', 'Lightdash server URL (default: your login config)')
|
|
428
|
+
.option('--token <token>', 'API key / personal access token (default: your login config)')
|
|
429
|
+
.option('--verbose', undefined, false)
|
|
430
|
+
.action(preview_1.appsPreviewHandler);
|
|
419
431
|
commander_1.program
|
|
420
432
|
.command('deploy')
|
|
421
433
|
.description('Compiles and deploys a Lightdash project')
|
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
Two skills in `.claude/skills/` describe how to edit this app:
|
|
4
4
|
- `lightdash-data-app` — the Lightdash SDK surface (querying data, filters, downloads).
|
|
5
|
-
- `developing-data-apps-locally` — the local workflow: edit `src/`, `
|
|
5
|
+
- `developing-data-apps-locally` — the local workflow: edit `src/`, `npm run build`, `lightdash upload --apps <appUuid>` (uuid in `lightdash-app.yml`); the SDK is the only data access; `.lightdash/context/` holds the project's semantic layer and theme.
|
|
6
6
|
|
|
7
7
|
Edit only `src/`. Root config files are read-only reference — the server rebuilds against a trusted template.
|
|
8
8
|
|
|
9
|
-
The local `
|
|
9
|
+
The local `npm install && npm run build` is an optional pre-check. If install fails, skip it and upload — never modify machine config, npmrc, or dependency files to force it; the server rebuild is authoritative.
|
|
10
10
|
|
|
11
|
-
Build with the template's preinstalled dependency set (see `package.json`). Adding new npm packages only works when the instance has custom dependencies enabled — assume it does not; ask the user before considering a new library, and never vendor library source into `src/` as a workaround. Where enabled, adding a dependency requires a matching `pnpm-lock.yaml`, so `pnpm add <pkg>` (or `pnpm install --lockfile-only`) must succeed locally. If it fails, stop and report the error — hand-editing `package.json` without the lockfile makes the upload fail.
|
|
11
|
+
Build with the template's preinstalled dependency set (see `package.json`). Adding new npm packages only works when the instance has custom dependencies enabled — assume it does not; ask the user before considering a new library, and never vendor library source into `src/` as a workaround. Where enabled, adding a dependency is the one workflow that still requires pnpm: upload requires a matching `pnpm-lock.yaml`, so `pnpm add <pkg>` (or `pnpm install --lockfile-only`) must succeed locally. If it fails, stop and report the error — hand-editing `package.json` without the lockfile makes the upload fail.
|
|
12
12
|
|
|
13
13
|
`.npmrc` sets `ignore-scripts=true` — never remove it or run installs with scripts enabled; this app may be authored by someone else and their dependencies' lifecycle scripts must not run here.
|