@valbuild/server 0.12.0 → 0.13.3

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/jest.config.js ADDED
@@ -0,0 +1,4 @@
1
+ /** @type {import("jest").Config} */
2
+ module.exports = {
3
+ preset: "../../jest.preset",
4
+ };
package/package.json CHANGED
@@ -1,5 +1,7 @@
1
1
  {
2
2
  "name": "@valbuild/server",
3
+ "private": false,
4
+ "description": "Val - integrated server",
3
5
  "main": "dist/valbuild-server.cjs.js",
4
6
  "module": "dist/valbuild-server.esm.js",
5
7
  "exports": {
@@ -10,7 +12,7 @@
10
12
  "./package.json": "./package.json"
11
13
  },
12
14
  "types": "dist/valbuild-server.cjs.d.ts",
13
- "version": "0.12.0",
15
+ "version": "0.13.3",
14
16
  "scripts": {
15
17
  "typecheck": "tsc --noEmit",
16
18
  "test": "jest",
@@ -23,8 +25,8 @@
23
25
  "concurrently": "^7.6.0"
24
26
  },
25
27
  "dependencies": {
26
- "@valbuild/core": "~0.12.0",
27
- "@valbuild/ui": "~0.12.0",
28
+ "@valbuild/core": "~0.13.3",
29
+ "@valbuild/ui": "~0.13.3",
28
30
  "express": "^4.18.2",
29
31
  "quickjs-emscripten": "^0.21.1",
30
32
  "ts-morph": "^17.0.1",
@@ -0,0 +1,94 @@
1
+ import express from "express";
2
+ import { Service } from "./Service";
3
+ import { PatchJSON } from "./patch/validation";
4
+ import { result } from "@valbuild/core/fp";
5
+ import { parsePatch, PatchError } from "@valbuild/core/patch";
6
+ import { getPathFromParams } from "./expressHelpers";
7
+ import { ValServer } from "./ValServer";
8
+ import { Internal } from "@valbuild/core";
9
+
10
+ export type LocalValServerOptions = {
11
+ service: Service;
12
+ };
13
+
14
+ export class LocalValServer implements ValServer {
15
+ constructor(readonly options: LocalValServerOptions) {}
16
+
17
+ async session(_req: express.Request, res: express.Response): Promise<void> {
18
+ res.json({
19
+ mode: "local",
20
+ });
21
+ }
22
+
23
+ async getIds(
24
+ req: express.Request<{ 0: string }>,
25
+ res: express.Response
26
+ ): Promise<void> {
27
+ try {
28
+ console.log(req.params);
29
+ const path = getPathFromParams(req.params);
30
+ const [moduleId, modulePath] = Internal.splitModuleIdAndModulePath(path);
31
+ const valModule = await this.options.service.get(moduleId, modulePath);
32
+
33
+ res.json(valModule);
34
+ } catch (err) {
35
+ console.error(err);
36
+ res.sendStatus(500);
37
+ }
38
+ }
39
+
40
+ async patchIds(
41
+ req: express.Request<{ 0: string }>,
42
+ res: express.Response
43
+ ): Promise<void> {
44
+ // First validate that the body has the right structure
45
+ const patchJSON = PatchJSON.safeParse(req.body);
46
+ if (!patchJSON.success) {
47
+ res.status(401).json(patchJSON.error.issues);
48
+ return;
49
+ }
50
+ // Then parse/validate
51
+ const patch = parsePatch(patchJSON.data);
52
+ if (result.isErr(patch)) {
53
+ res.status(401).json(patch.error);
54
+ return;
55
+ }
56
+ const id = getPathFromParams(req.params);
57
+ try {
58
+ const valModule = await this.options.service.patch(id, patch.value);
59
+ res.json(valModule);
60
+ } catch (err) {
61
+ if (err instanceof PatchError) {
62
+ res.status(401).send(err.message);
63
+ } else {
64
+ console.error(err);
65
+ res
66
+ .status(500)
67
+ .send(err instanceof Error ? err.message : "Unknown error");
68
+ }
69
+ }
70
+ }
71
+ private async badRequest(
72
+ req: express.Request,
73
+ res: express.Response
74
+ ): Promise<void> {
75
+ console.debug("Local server does handle this request", req.url);
76
+ res.sendStatus(400);
77
+ }
78
+
79
+ commit(req: express.Request, res: express.Response): Promise<void> {
80
+ return this.badRequest(req, res);
81
+ }
82
+
83
+ authorize(req: express.Request, res: express.Response): Promise<void> {
84
+ return this.badRequest(req, res);
85
+ }
86
+
87
+ callback(req: express.Request, res: express.Response): Promise<void> {
88
+ return this.badRequest(req, res);
89
+ }
90
+
91
+ logout(req: express.Request, res: express.Response): Promise<void> {
92
+ return this.badRequest(req, res);
93
+ }
94
+ }
@@ -0,0 +1,403 @@
1
+ import express from "express";
2
+ import crypto from "crypto";
3
+ import { decodeJwt, encodeJwt, getExpire } from "./jwt";
4
+ import { PatchJSON } from "./patch/validation";
5
+ import { result } from "@valbuild/core/fp";
6
+ import { getPathFromParams } from "./expressHelpers";
7
+ import { ValServer } from "./ValServer";
8
+ import { z } from "zod";
9
+ import { parsePatch } from "@valbuild/core/patch";
10
+
11
+ const VAL_SESSION_COOKIE = "val_session";
12
+ const VAL_STATE_COOKIE = "val_state";
13
+
14
+ export type ProxyValServerOptions = {
15
+ apiKey: string;
16
+ route: string;
17
+ valSecret: string;
18
+ valBuildUrl: string;
19
+ gitCommit: string;
20
+ gitBranch: string;
21
+ };
22
+
23
+ export class ProxyValServer implements ValServer {
24
+ constructor(readonly options: ProxyValServerOptions) {}
25
+
26
+ async authorize(req: express.Request, res: express.Response): Promise<void> {
27
+ const { redirect_to } = req.query;
28
+ if (typeof redirect_to !== "string") {
29
+ res.redirect(
30
+ this.getAppErrorUrl("Login failed: missing redirect_to param")
31
+ );
32
+ return;
33
+ }
34
+ const token = crypto.randomUUID();
35
+ const redirectUrl = new URL(redirect_to);
36
+ const appAuthorizeUrl = this.getAuthorizeUrl(
37
+ `${redirectUrl.origin}/${this.options.route}`,
38
+ token
39
+ );
40
+ res
41
+ .cookie(VAL_STATE_COOKIE, createStateCookie({ redirect_to, token }), {
42
+ httpOnly: true,
43
+ sameSite: "lax",
44
+ expires: new Date(Date.now() + 1000 * 60 * 60), // 1 hour
45
+ })
46
+ .redirect(appAuthorizeUrl);
47
+ }
48
+
49
+ async callback(req: express.Request, res: express.Response): Promise<void> {
50
+ const { success: callbackReqSuccess, error: callbackReqError } =
51
+ verifyCallbackReq(req.cookies[VAL_STATE_COOKIE], req.query);
52
+ res.clearCookie(VAL_STATE_COOKIE); // we don't need this anymore
53
+
54
+ if (callbackReqError !== null) {
55
+ res.redirect(
56
+ this.getAppErrorUrl(
57
+ `Authorization callback failed. Details: ${callbackReqError}`
58
+ )
59
+ );
60
+ return;
61
+ }
62
+
63
+ const data = await this.consumeCode(callbackReqSuccess.code);
64
+ if (data === null) {
65
+ res.redirect(this.getAppErrorUrl("Failed to exchange code for user"));
66
+ return;
67
+ }
68
+ const exp = getExpire();
69
+ const cookie = encodeJwt(
70
+ {
71
+ ...data,
72
+ exp, // this is the client side exp
73
+ },
74
+ this.options.valSecret
75
+ );
76
+
77
+ res
78
+ .cookie(VAL_SESSION_COOKIE, cookie, {
79
+ httpOnly: true,
80
+ sameSite: "strict",
81
+ secure: true,
82
+ expires: new Date(exp * 1000), // NOTE: this is not used for authorization, only for authentication
83
+ })
84
+ .redirect(callbackReqSuccess.redirect_uri || "/");
85
+ }
86
+
87
+ async logout(_req: express.Request, res: express.Response): Promise<void> {
88
+ res
89
+ .clearCookie(VAL_SESSION_COOKIE)
90
+ .clearCookie(VAL_STATE_COOKIE)
91
+ .sendStatus(200);
92
+ }
93
+
94
+ async withAuth<T>(
95
+ req: express.Request,
96
+ res: express.Response,
97
+ handler: (data: IntegratedServerJwtPayload) => Promise<T>
98
+ ): Promise<T | undefined> {
99
+ const cookie = req.cookies[VAL_SESSION_COOKIE];
100
+ if (typeof cookie === "string") {
101
+ const verification = IntegratedServerJwtPayload.safeParse(
102
+ decodeJwt(cookie, this.options.valSecret)
103
+ );
104
+ if (!verification.success) {
105
+ res.sendStatus(401);
106
+ return;
107
+ }
108
+ return handler(verification.data);
109
+ } else {
110
+ res.sendStatus(401);
111
+ }
112
+ }
113
+
114
+ async session(req: express.Request, res: express.Response): Promise<void> {
115
+ return this.withAuth(req, res, async (data) => {
116
+ const url = new URL(
117
+ "/api/val/auth/user/session",
118
+ this.options.valBuildUrl
119
+ );
120
+ const fetchRes = await fetch(url, {
121
+ headers: this.getAuthHeaders(data.token, "application/json"),
122
+ });
123
+ if (fetchRes.ok) {
124
+ res
125
+ .status(fetchRes.status)
126
+ .json({ mode: "proxy", ...(await fetchRes.json()) });
127
+ } else {
128
+ res.sendStatus(fetchRes.status);
129
+ }
130
+ });
131
+ }
132
+
133
+ async getIds(
134
+ req: express.Request<{ 0: string }>,
135
+ res: express.Response
136
+ ): Promise<void> {
137
+ return this.withAuth(req, res, async ({ token }) => {
138
+ const id = getPathFromParams(req.params);
139
+ const url = new URL(
140
+ `/api/val/modules/${encodeURIComponent(this.options.gitCommit)}${id}`,
141
+ this.options.valBuildUrl
142
+ );
143
+ const fetchRes = await fetch(url, {
144
+ headers: this.getAuthHeaders(token),
145
+ });
146
+ if (fetchRes.ok) {
147
+ res.status(fetchRes.status).json(await fetchRes.json());
148
+ } else {
149
+ res.sendStatus(fetchRes.status);
150
+ }
151
+ }).catch((e) => {
152
+ res.status(500).send({ error: { message: e?.message, status: 500 } });
153
+ });
154
+ }
155
+
156
+ async patchIds(
157
+ req: express.Request<{ 0: string }>,
158
+ res: express.Response
159
+ ): Promise<void> {
160
+ this.withAuth(req, res, async ({ token }) => {
161
+ // First validate that the body has the right structure
162
+ const patchJSON = PatchJSON.safeParse(req.body);
163
+ if (!patchJSON.success) {
164
+ res.status(401).json(patchJSON.error.issues);
165
+ return;
166
+ }
167
+ // Then parse/validate
168
+ const patch = parsePatch(patchJSON.data);
169
+ if (result.isErr(patch)) {
170
+ res.status(401).json(patch.error);
171
+ return;
172
+ }
173
+ const id = getPathFromParams(req.params);
174
+ const url = new URL(
175
+ `/api/val/modules/${encodeURIComponent(this.options.gitCommit)}${id}`,
176
+ this.options.valBuildUrl
177
+ );
178
+ // Proxy patch to val.build
179
+ const fetchRes = await fetch(url, {
180
+ method: "PATCH",
181
+ headers: this.getAuthHeaders(token, "application/json-patch+json"),
182
+ body: JSON.stringify(patch),
183
+ });
184
+ if (fetchRes.ok) {
185
+ res.status(fetchRes.status).json(await fetchRes.json());
186
+ } else {
187
+ res.sendStatus(fetchRes.status);
188
+ }
189
+ }).catch((e) => {
190
+ res.status(500).send({ error: { message: e?.message, status: 500 } });
191
+ });
192
+ }
193
+
194
+ async commit(req: express.Request, res: express.Response): Promise<void> {
195
+ this.withAuth(req, res, async ({ token }) => {
196
+ const url = new URL(
197
+ `/api/val/commit/${encodeURIComponent(this.options.gitBranch)}`,
198
+ this.options.valBuildUrl
199
+ );
200
+ const fetchRes = await fetch(url, {
201
+ method: "POST",
202
+ headers: this.getAuthHeaders(token),
203
+ });
204
+ if (fetchRes.ok) {
205
+ res.status(fetchRes.status).json(await fetchRes.json());
206
+ } else {
207
+ res.sendStatus(fetchRes.status);
208
+ }
209
+ });
210
+ }
211
+
212
+ private getAuthHeaders(
213
+ token: string,
214
+ type?: "application/json" | "application/json-patch+json"
215
+ ):
216
+ | { Authorization: string }
217
+ | { "Content-Type": string; Authorization: string } {
218
+ if (!type) {
219
+ return {
220
+ Authorization: `Bearer ${token}`,
221
+ };
222
+ }
223
+ return {
224
+ "Content-Type": type,
225
+ Authorization: `Bearer ${token}`,
226
+ };
227
+ }
228
+
229
+ private async consumeCode(code: string): Promise<{
230
+ sub: string;
231
+ exp: number;
232
+ org: string;
233
+ project: string;
234
+ token: string;
235
+ } | null> {
236
+ const url = new URL("/api/val/auth/user/token", this.options.valBuildUrl);
237
+ url.searchParams.set("code", encodeURIComponent(code));
238
+ return fetch(url, {
239
+ method: "POST",
240
+ headers: this.getAuthHeaders(this.options.apiKey, "application/json"), // NOTE: we use apiKey as auth on this endpoint (we do not have a token yet)
241
+ })
242
+ .then(async (res) => {
243
+ if (res.status === 200) {
244
+ const token = await res.text();
245
+ const verification = ValAppJwtPayload.safeParse(decodeJwt(token));
246
+ if (!verification.success) {
247
+ return null;
248
+ }
249
+ return {
250
+ ...verification.data,
251
+ token,
252
+ };
253
+ } else {
254
+ console.debug("Failed to get data from code: ", res.status);
255
+ return null;
256
+ }
257
+ })
258
+ .catch((err) => {
259
+ console.debug("Failed to get user from code: ", err);
260
+ return null;
261
+ });
262
+ }
263
+
264
+ private getAuthorizeUrl(publicValApiRoute: string, token: string): string {
265
+ const url = new URL("/authorize", this.options.valBuildUrl);
266
+ url.searchParams.set(
267
+ "redirect_uri",
268
+ encodeURIComponent(`${publicValApiRoute}/callback`)
269
+ );
270
+ url.searchParams.set("state", token);
271
+ return url.toString();
272
+ }
273
+
274
+ private getAppErrorUrl(error: string): string {
275
+ const url = new URL("/authorize", this.options.valBuildUrl);
276
+ url.searchParams.set("error", encodeURIComponent(error));
277
+ return url.toString();
278
+ }
279
+ }
280
+
281
+ function verifyCallbackReq(
282
+ stateCookie: string,
283
+ queryParams: Record<string, unknown>
284
+ ):
285
+ | {
286
+ success: { code: string; redirect_uri?: string };
287
+ error: null;
288
+ }
289
+ | { success: false; error: string } {
290
+ if (typeof stateCookie !== "string") {
291
+ return { success: false, error: "No state cookie" };
292
+ }
293
+
294
+ const { code, state: tokenFromQuery } = queryParams;
295
+
296
+ if (typeof code !== "string") {
297
+ return { success: false, error: "No code query param" };
298
+ }
299
+ if (typeof tokenFromQuery !== "string") {
300
+ return { success: false, error: "No state query param" };
301
+ }
302
+
303
+ const { success: cookieStateSuccess, error: cookieStateError } =
304
+ getStateFromCookie(stateCookie);
305
+
306
+ if (cookieStateError !== null) {
307
+ return { success: false, error: cookieStateError };
308
+ }
309
+
310
+ if (cookieStateSuccess.token !== tokenFromQuery) {
311
+ return { success: false, error: "Invalid state token" };
312
+ }
313
+
314
+ return {
315
+ success: { code, redirect_uri: cookieStateSuccess.redirect_to },
316
+ error: null,
317
+ };
318
+ }
319
+
320
+ type StateCookie = {
321
+ redirect_to: string;
322
+ token: string;
323
+ };
324
+
325
+ function getStateFromCookie(stateCookie: string):
326
+ | {
327
+ success: StateCookie;
328
+ error: null;
329
+ }
330
+ | { success: false; error: string } {
331
+ try {
332
+ const decoded = Buffer.from(stateCookie, "base64").toString("utf8");
333
+ const parsed = JSON.parse(decoded) as unknown;
334
+
335
+ if (!parsed) {
336
+ return {
337
+ success: false,
338
+ error: "Invalid state cookie: could not parse",
339
+ };
340
+ }
341
+ if (typeof parsed !== "object") {
342
+ return {
343
+ success: false,
344
+ error: "Invalid state cookie: parsed object is not an object",
345
+ };
346
+ }
347
+ if ("token" in parsed && "redirect_to" in parsed) {
348
+ const { token, redirect_to } = parsed;
349
+ if (typeof token !== "string") {
350
+ return {
351
+ success: false,
352
+ error: "Invalid state cookie: no token in parsed object",
353
+ };
354
+ }
355
+ if (typeof redirect_to !== "string") {
356
+ return {
357
+ success: false,
358
+ error: "Invalid state cookie: no redirect_to in parsed object",
359
+ };
360
+ }
361
+ return {
362
+ success: {
363
+ token,
364
+ redirect_to,
365
+ },
366
+ error: null,
367
+ };
368
+ } else {
369
+ return {
370
+ success: false,
371
+ error: "Invalid state cookie: no token or redirect_to in parsed object",
372
+ };
373
+ }
374
+ } catch (err) {
375
+ return {
376
+ success: false,
377
+ error: "Invalid state cookie: could not parse",
378
+ };
379
+ }
380
+ }
381
+
382
+ function createStateCookie(state: StateCookie): string {
383
+ return Buffer.from(JSON.stringify(state), "utf8").toString("base64");
384
+ }
385
+
386
+ const ValAppJwtPayload = z.object({
387
+ sub: z.string(),
388
+ exp: z.number(),
389
+ project: z.string(),
390
+ org: z.string(),
391
+ });
392
+ type ValAppJwtPayload = z.infer<typeof ValAppJwtPayload>;
393
+
394
+ const IntegratedServerJwtPayload = z.object({
395
+ sub: z.string(),
396
+ exp: z.number(),
397
+ token: z.string(),
398
+ org: z.string(),
399
+ project: z.string(),
400
+ });
401
+ export type IntegratedServerJwtPayload = z.infer<
402
+ typeof IntegratedServerJwtPayload
403
+ >;
@@ -0,0 +1,8 @@
1
+ import { type Source, type SerializedSchema } from "@valbuild/core";
2
+ import { type SourcePath } from "@valbuild/core/src/val";
3
+
4
+ export type SerializedModuleContent = {
5
+ source: Source;
6
+ schema: SerializedSchema;
7
+ path: SourcePath;
8
+ };
package/src/Service.ts ADDED
@@ -0,0 +1,108 @@
1
+ import { newQuickJSWASMModule, QuickJSRuntime } from "quickjs-emscripten";
2
+ import { patchValFile } from "./patchValFile";
3
+ import { readValFile } from "./readValFile";
4
+ import { Patch } from "@valbuild/core/patch";
5
+ import { ValModuleLoader } from "./ValModuleLoader";
6
+ import { newValQuickJSRuntime } from "./ValQuickJSRuntime";
7
+ import { ValSourceFileHandler } from "./ValSourceFileHandler";
8
+ import ts from "typescript";
9
+ import { getCompilerOptions } from "./getCompilerOptions";
10
+ import { IValFSHost } from "./ValFSHost";
11
+ import fs from "fs";
12
+ import { SerializedModuleContent } from "./SerializedModuleContent";
13
+ import {
14
+ ModuleId,
15
+ ModulePath,
16
+ Internal,
17
+ SourcePath,
18
+ Schema,
19
+ SelectorSource,
20
+ } from "@valbuild/core";
21
+
22
+ export type ServiceOptions = {
23
+ /**
24
+ * Relative path to the val.config.js file from the root directory.
25
+ *
26
+ * @example src/val.config
27
+ */
28
+ valConfigPath: string;
29
+ };
30
+
31
+ export async function createService(
32
+ projectRoot: string,
33
+ opts: ServiceOptions,
34
+ host: IValFSHost = {
35
+ ...ts.sys,
36
+ writeFile: fs.writeFileSync,
37
+ }
38
+ ): Promise<Service> {
39
+ const compilerOptions = getCompilerOptions(projectRoot, host);
40
+ const sourceFileHandler = new ValSourceFileHandler(
41
+ projectRoot,
42
+ compilerOptions,
43
+ host
44
+ );
45
+ const loader = new ValModuleLoader(
46
+ projectRoot,
47
+ compilerOptions,
48
+ sourceFileHandler,
49
+ host
50
+ );
51
+ const module = await newQuickJSWASMModule();
52
+ const runtime = await newValQuickJSRuntime(module, loader);
53
+ return new Service(opts, sourceFileHandler, runtime);
54
+ }
55
+
56
+ export class Service {
57
+ readonly valConfigPath: string;
58
+
59
+ constructor(
60
+ { valConfigPath }: ServiceOptions,
61
+ private readonly sourceFileHandler: ValSourceFileHandler,
62
+ private readonly runtime: QuickJSRuntime
63
+ ) {
64
+ this.valConfigPath = valConfigPath;
65
+ }
66
+
67
+ async get(
68
+ moduleId: ModuleId,
69
+ modulePath: ModulePath
70
+ ): Promise<SerializedModuleContent> {
71
+ const valModule = await readValFile(
72
+ moduleId,
73
+ this.valConfigPath,
74
+ this.runtime
75
+ );
76
+
77
+ const resolved = Internal.resolvePath(
78
+ modulePath,
79
+ valModule.source,
80
+ valModule.schema
81
+ );
82
+ return {
83
+ path: [moduleId, resolved.path].join(".") as SourcePath,
84
+ schema:
85
+ resolved.schema instanceof Schema<SelectorSource>
86
+ ? resolved.schema.serialize()
87
+ : resolved.schema,
88
+ source: resolved.source,
89
+ };
90
+ }
91
+
92
+ async patch(
93
+ moduleId: string,
94
+ patch: Patch
95
+ ): Promise<SerializedModuleContent> {
96
+ return patchValFile(
97
+ moduleId,
98
+ this.valConfigPath,
99
+ patch,
100
+ this.sourceFileHandler,
101
+ this.runtime
102
+ );
103
+ }
104
+
105
+ dispose() {
106
+ this.runtime.dispose();
107
+ }
108
+ }
package/src/ValFS.ts ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * A filesystem that can update and read files.
3
+ * It does not support creating new files or directories.
4
+ *
5
+ */
6
+ export interface ValFS {
7
+ readDirectory(
8
+ rootDir: string,
9
+ extensions: readonly string[],
10
+ excludes: readonly string[] | undefined,
11
+ includes: readonly string[],
12
+ depth?: number | undefined
13
+ ): readonly string[];
14
+
15
+ writeFile(filePath: string, data: string, encoding: "binary" | "utf8"): void;
16
+
17
+ fileExists(filePath: string): boolean;
18
+
19
+ readFile(filePath: string): string | undefined;
20
+
21
+ realpath(path: string): string;
22
+ }