@antelopejs/dms-frontend 0.5.5 → 0.6.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 (75) hide show
  1. package/README.md +377 -19
  2. package/dist/app-configs.d.ts +9 -0
  3. package/dist/app-configs.js +52 -0
  4. package/dist/asset-roots.d.ts +7 -0
  5. package/dist/asset-roots.js +34 -0
  6. package/dist/commands/build-action.js +9 -0
  7. package/dist/commands/dev-action.js +88 -19
  8. package/dist/commands/start-action.js +2 -0
  9. package/dist/common.d.ts +1 -0
  10. package/dist/common.js +1 -0
  11. package/dist/config.d.ts +2 -0
  12. package/dist/config.js +9 -3
  13. package/dist/derived-outputs.d.ts +2 -0
  14. package/dist/derived-outputs.js +38 -31
  15. package/dist/help.js +10 -3
  16. package/dist/layer-watch.js +123 -131
  17. package/dist/layers.js +2 -0
  18. package/dist/manifest-secrets.js +5 -5
  19. package/dist/manifest.js +7 -1
  20. package/dist/materialize.js +21 -1
  21. package/dist/module-set.d.ts +16 -0
  22. package/dist/module-set.js +80 -0
  23. package/dist/server-process.d.ts +1 -0
  24. package/dist/server-process.js +28 -2
  25. package/dist/verify-source-build.d.ts +11 -0
  26. package/dist/verify-source-build.js +113 -0
  27. package/dist/verify-source-runner.js +14 -57
  28. package/dist/workspace.d.ts +9 -0
  29. package/package.json +2 -1
  30. package/templates/vue/app-runtime.ts +58 -16
  31. package/templates/vue/client-server-guard.mjs +73 -0
  32. package/templates/vue/email-renderer.ts +17 -4
  33. package/templates/vue/email-runtime.ts +1 -1
  34. package/templates/vue/frontend-module.ts +1817 -259
  35. package/templates/vue/globals.d.ts +1 -1
  36. package/templates/vue/icon-hydration.ts +106 -1
  37. package/templates/vue/index.html +0 -1
  38. package/templates/vue/layer-app-config.ts +92 -0
  39. package/templates/vue/main.ts +10 -2
  40. package/templates/vue/package.json +5 -1
  41. package/templates/vue/prepare-types.mjs +34 -0
  42. package/templates/vue/server/auth/backend.mjs +13 -19
  43. package/templates/vue/server/auth/client-ip.mjs +27 -0
  44. package/templates/vue/server/auth/establish-endpoints.mjs +70 -0
  45. package/templates/vue/server/auth/session.mjs +6 -97
  46. package/templates/vue/server/auth-hooks.mjs +84 -0
  47. package/templates/vue/server/backend-proxies.mjs +110 -0
  48. package/templates/vue/server/dev-private-files.mjs +99 -0
  49. package/templates/vue/server/email.mjs +11 -33
  50. package/templates/vue/server/favicon.mjs +20 -0
  51. package/templates/vue/server/inertia.mjs +48 -3
  52. package/templates/vue/server/listen-host.mjs +19 -0
  53. package/templates/vue/server/module-event.mjs +265 -0
  54. package/templates/vue/server/module-routes.mjs +513 -0
  55. package/templates/vue/server/module-server.mjs +225 -0
  56. package/templates/vue/server/module-testing.mjs +65 -0
  57. package/templates/vue/server/page-props.mjs +151 -0
  58. package/templates/vue/server/path-patterns.mjs +38 -0
  59. package/templates/vue/server/proxy-headers.mjs +25 -0
  60. package/templates/vue/server/public-endpoints.mjs +69 -11
  61. package/templates/vue/server/request-routing.mjs +47 -0
  62. package/templates/vue/server/route-rules.mjs +129 -0
  63. package/templates/vue/server-module.ts +294 -0
  64. package/templates/vue/server.mjs +178 -169
  65. package/templates/vue/ssr-renderer.ts +60 -19
  66. package/templates/vue/tsconfig.json +2 -1
  67. package/templates/vue/ui-app-config.ts +18 -6
  68. package/templates/vue/ui-inertia-page.mjs +37 -0
  69. package/templates/vue/vite.config.ts +31 -10
  70. package/templates/vue/vite.email.config.ts +1 -1
  71. package/templates/vue/vite.server.config.ts +57 -0
  72. package/templates/vue/server/auth/oauth.mjs +0 -233
  73. package/templates/vue/server/auth/routes.mjs +0 -444
  74. package/templates/vue/server/render-token.mjs +0 -81
  75. package/templates/vue/server/tester.mjs +0 -227
@@ -1,444 +0,0 @@
1
- import { createHash } from "node:crypto";
2
- import { readFileSync } from "node:fs";
3
- import { backend, body, json, UpstreamError } from "./backend.mjs";
4
- import { CROSS_ORIGIN_ERROR, isSameOrigin } from "./client-ip.mjs";
5
- import {
6
- oauthCallback,
7
- oauthHandoff,
8
- oauthStart,
9
- sessionFrom,
10
- } from "./oauth.mjs";
11
- import {
12
- clearSealedCookie,
13
- clearSession,
14
- isPersistent,
15
- persistAccountSession,
16
- readAccount,
17
- readSealedCookie,
18
- readSession,
19
- removeAccount,
20
- setRequestSession,
21
- storeAccount,
22
- writeSealedCookie,
23
- } from "./session.mjs";
24
-
25
- const REFRESH_TTL_MS = 15_000;
26
- const refreshes = new Map();
27
-
28
- // Carries the "keep me signed in" choice of a login across its 2FA step, whose
29
- // request names only the challenge. Lives as long as the challenge token.
30
- const PENDING_LOGIN = "dms_pending_login";
31
- const PENDING_LOGIN_PATH = "/auth/verify-2fa";
32
- const PENDING_LOGIN_LIFETIME_S = 300;
33
-
34
- const PASSTHROUGH = new Map([
35
- ["/auth/request-2fa-email", "/api/auth/request-2fa-email"],
36
- ]);
37
-
38
- // Absolute backend API paths only: no scheme, no authority, no query string,
39
- // and no segment that could climb out of `/api/`.
40
- const BACKEND_API_PATH =
41
- /^\/api\/[A-Za-z0-9][A-Za-z0-9._~-]*(?:\/[A-Za-z0-9][A-Za-z0-9._~-]*)*$/;
42
-
43
- // Written at the workspace root by the loader, from the endpoints each backend
44
- // module declared in the frontend manifest. This file sits two directories up.
45
- const DECLARED_ENDPOINTS_FILE = new URL(
46
- "../../generated-auth-establish.json",
47
- import.meta.url,
48
- );
49
-
50
- let declaredEndpoints;
51
-
52
- /**
53
- * Backend endpoints the modules of this deployment declared as session-opening.
54
- *
55
- * Read from the workspace rather than from the backend: the file is generated
56
- * when the workspace is materialized, so the server needs no backend round trip
57
- * — and no operator — to know which module flows may end on a login. A
58
- * workspace built from a DMS that predates the declaration simply has no file,
59
- * and the list falls back to whatever the environment names.
60
- *
61
- * @param file Generated file to read
62
- * @returns The declared paths, unfiltered
63
- */
64
- export function readDeclaredEstablishEndpoints(file = DECLARED_ENDPOINTS_FILE) {
65
- try {
66
- const parsed = JSON.parse(readFileSync(file, "utf8"));
67
- return Array.isArray(parsed?.endpoints) ? parsed.endpoints : [];
68
- } catch {
69
- return [];
70
- }
71
- }
72
-
73
- export function publicSession(session) {
74
- if (!session) return {};
75
- return {
76
- user: session.user,
77
- session: {
78
- accountId: session.accountId,
79
- activeTenantId: session.activeTenantId,
80
- updatedAt: session.updatedAt,
81
- },
82
- };
83
- }
84
-
85
- function refreshDigest(token) {
86
- return createHash("sha256").update(token).digest("base64url");
87
- }
88
-
89
- function singleFlight(token, operation) {
90
- const now = Date.now();
91
- for (const [key, entry] of refreshes)
92
- if (
93
- entry.expiresAt &&
94
- entry.expiresAt <= now &&
95
- refreshes.get(key) === entry
96
- )
97
- refreshes.delete(key);
98
- const key = refreshDigest(token);
99
- const current = refreshes.get(key);
100
- if (current) return current.promise;
101
- const entry = { promise: undefined, expiresAt: undefined };
102
- const promise = Promise.resolve().then(operation);
103
- entry.promise = promise;
104
- refreshes.set(key, entry);
105
- const retain = () => {
106
- entry.expiresAt = Date.now() + REFRESH_TTL_MS;
107
- setTimeout(() => {
108
- if (refreshes.get(key) === entry) refreshes.delete(key);
109
- }, REFRESH_TTL_MS).unref();
110
- };
111
- promise.then(retain, retain);
112
- return promise;
113
- }
114
-
115
- async function establishWith(
116
- request,
117
- response,
118
- endpoint,
119
- payload,
120
- persistent = true,
121
- ) {
122
- const result = await backend(endpoint, request, {
123
- method: "POST",
124
- body: payload,
125
- });
126
- if (result.requires_2fa && typeof result.two_factor_token === "string")
127
- writeSealedCookie(
128
- response,
129
- PENDING_LOGIN,
130
- { token: result.two_factor_token, persistent },
131
- PENDING_LOGIN_LIFETIME_S,
132
- PENDING_LOGIN_PATH,
133
- );
134
- if (result.requires_2fa || result.requires_tenant_assignment)
135
- return json(response, 200, result);
136
- if (readSealedCookie(request, PENDING_LOGIN))
137
- clearSealedCookie(response, PENDING_LOGIN, PENDING_LOGIN_PATH);
138
- const session = sessionFrom(result, persistent);
139
- const account = persistAccountSession(request, response, session);
140
- json(response, 200, { user: result.user, account });
141
- }
142
-
143
- async function establish(request, response, endpoint) {
144
- return establishWith(request, response, endpoint, await body(request));
145
- }
146
-
147
- /**
148
- * Sign in with a password. Only a login that asked to be kept signed in
149
- * outlives the browser; any other ends when the browser closes.
150
- */
151
- async function login(request, response) {
152
- const input = await body(request);
153
- return establishWith(
154
- request,
155
- response,
156
- "/api/auth/login",
157
- input,
158
- input.keep_login === true,
159
- );
160
- }
161
-
162
- /**
163
- * Finish a login on its 2FA step, with the lifetime the login chose. A
164
- * challenge that no login of this browser left a choice for, such as the one
165
- * an OAuth sign-in hands over, keeps the default.
166
- */
167
- async function verifyTwoFactor(request, response) {
168
- const input = await body(request);
169
- const pending = readSealedCookie(request, PENDING_LOGIN);
170
- const persistent =
171
- typeof input.token === "string" && pending?.token === input.token
172
- ? isPersistent(pending)
173
- : true;
174
- return establishWith(
175
- request,
176
- response,
177
- "/api/auth/verify-2fa",
178
- input,
179
- persistent,
180
- );
181
- }
182
-
183
- /**
184
- * Backend endpoints this deployment lets a module open a session from.
185
- *
186
- * The union of what the backend's modules declared — the standard case, which
187
- * needs no configuration — and what the environment adds on top, which is how
188
- * an operator extends the list for a route no module owns. Everything else
189
- * stays refused, so no backend endpoint that happens to mint a token pair can
190
- * be turned into a login by a request from the browser.
191
- *
192
- * @param declaration Comma-separated absolute backend paths from the environment
193
- * @param declared Paths declared by the backend's frontend modules
194
- * @returns The well-formed backend API paths, without duplicates
195
- */
196
- export function allowedEstablishEndpoints(
197
- declaration = process.env.DMS_AUTH_ESTABLISH_ENDPOINTS,
198
- declared = (declaredEndpoints ??= readDeclaredEstablishEndpoints()),
199
- ) {
200
- const fromEnvironment = (declaration ?? "")
201
- .split(",")
202
- .map((entry) => entry.trim());
203
- return [
204
- ...new Set(
205
- [...declared, ...fromEnvironment].filter(
206
- (entry) => typeof entry === "string" && BACKEND_API_PATH.test(entry),
207
- ),
208
- ),
209
- ];
210
- }
211
-
212
- /**
213
- * Whether a caller-named endpoint is one of the declared ones.
214
- *
215
- * The grammar is checked on the caller's value too, so a declaration that was
216
- * never meant to be a prefix cannot be widened by a traversal or a query
217
- * string smuggled into the request.
218
- *
219
- * @param endpoint Endpoint the browser asked to establish a session from
220
- * @param allowed Declared endpoints
221
- * @returns True when the endpoint may be called
222
- */
223
- export function isAllowedEstablishEndpoint(endpoint, allowed) {
224
- return (
225
- typeof endpoint === "string" &&
226
- BACKEND_API_PATH.test(endpoint) &&
227
- allowed.includes(endpoint)
228
- );
229
- }
230
-
231
- /**
232
- * Open a session from a backend endpoint that mints a token pair.
233
- *
234
- * The generic half of `/auth/login`: a module whose own flow ends in an
235
- * authenticated user — a self-service registration completing, an invitation
236
- * being redeemed — points this at the backend route that finishes it, and the
237
- * session cookie is written from the tokens the loader fetched itself. The
238
- * browser never carries a token: it names an endpoint and a payload, and gets
239
- * back the same `{ user, account }` the login route answers.
240
- */
241
- async function establishFromEndpoint(request, response) {
242
- const input = await body(request);
243
- if (!isAllowedEstablishEndpoint(input.endpoint, allowedEstablishEndpoints()))
244
- return json(response, 403, { error: "Forbidden" });
245
- return establishWith(request, response, input.endpoint, input.payload ?? {});
246
- }
247
-
248
- export async function refreshSession(request, response) {
249
- const session = readSession(request);
250
- if (!session?.refreshToken) return undefined;
251
- try {
252
- const tokens = await singleFlight(session.refreshToken, () =>
253
- backend("/api/auth/refresh", request, {
254
- method: "POST",
255
- token: session.accessToken,
256
- body: { token: session.refreshToken },
257
- }),
258
- );
259
- const user = await backend("/api/auth/me", request, {
260
- token: tokens.access_token,
261
- });
262
- const refreshed = sessionFrom({ ...tokens, user }, isPersistent(session));
263
- persistAccountSession(request, response, refreshed, session.accountId);
264
- setRequestSession(request, refreshed);
265
- return refreshed;
266
- } catch (error) {
267
- const rejected =
268
- error instanceof UpstreamError && [400, 401, 403].includes(error.status);
269
- if (rejected) clearSession(response);
270
- setRequestSession(request, undefined);
271
- return undefined;
272
- }
273
- }
274
-
275
- async function refresh(request, response) {
276
- json(response, 200, publicSession(await refreshSession(request, response)));
277
- }
278
-
279
- async function logout(request, response) {
280
- const session = readSession(request);
281
- if (session?.refreshToken && session?.accessToken)
282
- await backend("/api/auth/logout", request, {
283
- method: "POST",
284
- token: session.accessToken,
285
- body: { token: session.refreshToken },
286
- }).catch(() => {});
287
- clearSession(response);
288
- if (session?.accountId) removeAccount(request, response, session.accountId);
289
- json(response, 200, {});
290
- }
291
-
292
- async function removeStoredAccount(request, response) {
293
- const input = await body(request);
294
- removeAccount(request, response, input.accountId);
295
- json(response, 200, { success: true });
296
- }
297
-
298
- async function switchTenant(request, response) {
299
- const session = readSession(request);
300
- const input = await body(request);
301
- if (!session) return json(response, 401, { message: "Not authenticated" });
302
- if (!input.tenantId)
303
- return json(response, 400, { message: "tenantId required" });
304
- const tokens = await backend("/api/auth/switch-tenant", request, {
305
- method: "POST",
306
- token: session.accessToken,
307
- body: { refreshToken: session.refreshToken, tenantId: input.tenantId },
308
- });
309
- const user = await backend("/api/auth/me", request, {
310
- token: tokens.access_token,
311
- });
312
- const switched = sessionFrom({ ...tokens, user }, isPersistent(session));
313
- persistAccountSession(request, response, switched, session.accountId);
314
- json(response, 200, { success: true, user });
315
- }
316
-
317
- async function switchAccount(request, response) {
318
- const input = await body(request);
319
- const account = readAccount(request, input.accountId);
320
- if (!account) return json(response, 401, { message: "Account expired" });
321
- try {
322
- const tokens = await backend("/api/auth/refresh", request, {
323
- method: "POST",
324
- body: { token: account.refreshToken },
325
- });
326
- const user = await backend("/api/auth/me", request, {
327
- token: tokens.access_token,
328
- });
329
- const session = sessionFrom({ ...tokens, user }, isPersistent(account));
330
- const descriptor = persistAccountSession(
331
- request,
332
- response,
333
- session,
334
- input.accountId,
335
- );
336
- json(response, 200, { success: true, user, account: descriptor });
337
- } catch (error) {
338
- if (
339
- error instanceof UpstreamError &&
340
- [400, 401, 403].includes(error.status)
341
- )
342
- removeAccount(request, response, input.accountId);
343
- throw error;
344
- }
345
- }
346
-
347
- /**
348
- * Keep the tokens a validation refreshed, without changing which account is
349
- * signed in. Another account only has its own cookie updated. The current one
350
- * also has the session updated, since a refresh may have rotated the token the
351
- * session holds; it keeps its user as the backend gave it.
352
- */
353
- function storeValidatedAccount(request, response, account, accountId, tokens) {
354
- const current = readSession(request);
355
- if (current?.accountId === accountId) {
356
- persistAccountSession(
357
- request,
358
- response,
359
- sessionFrom({ ...tokens, user: current.user }, isPersistent(current)),
360
- accountId,
361
- );
362
- return;
363
- }
364
- const user = {
365
- _id: account.userId,
366
- email: account.email,
367
- name: account.name,
368
- };
369
- storeAccount(
370
- request,
371
- response,
372
- sessionFrom({ ...tokens, user }, isPersistent(account)),
373
- accountId,
374
- );
375
- }
376
-
377
- async function validateAccount(request, response) {
378
- const input = await body(request);
379
- const account = readAccount(request, input.accountId);
380
- if (!account) return json(response, 200, { valid: false });
381
- try {
382
- const tokens = await backend("/api/auth/refresh", request, {
383
- method: "POST",
384
- body: { token: account.refreshToken },
385
- });
386
- storeValidatedAccount(request, response, account, input.accountId, tokens);
387
- json(response, 200, { valid: true });
388
- } catch (error) {
389
- const rejected =
390
- error instanceof UpstreamError && [400, 401, 403].includes(error.status);
391
- if (rejected) removeAccount(request, response, input.accountId);
392
- json(response, 200, { valid: rejected ? false : null });
393
- }
394
- }
395
-
396
- const actions = {
397
- "/auth/login": login,
398
- "/auth/signup": (request, response) =>
399
- establish(request, response, "/api/auth/signup"),
400
- "/auth/verify-2fa": verifyTwoFactor,
401
- "/auth/establish": establishFromEndpoint,
402
- "/auth/switch-account": switchAccount,
403
- "/auth/switch-tenant": switchTenant,
404
- "/auth/validate-account": validateAccount,
405
- "/auth/remove-account": removeStoredAccount,
406
- };
407
-
408
- export async function handleAuth(request, response, url) {
409
- if (url.pathname === "/api/_auth/session") {
410
- if (request.method === "GET")
411
- return json(response, 200, publicSession(readSession(request)));
412
- if (!["POST", "DELETE"].includes(request.method)) {
413
- response.setHeader("allow", "GET, POST, DELETE");
414
- return json(response, 405, { error: "Method Not Allowed" });
415
- }
416
- if (!isSameOrigin(request)) return json(response, 403, CROSS_ORIGIN_ERROR);
417
- return request.method === "DELETE"
418
- ? logout(request, response)
419
- : refresh(request, response);
420
- }
421
- const match = url.pathname.match(
422
- /^\/auth\/oauth\/([^/]+)\/(start|callback)$/,
423
- );
424
- if (match)
425
- return match[2] === "start"
426
- ? oauthStart(request, response, match[1], url)
427
- : oauthCallback(request, response, match[1], url);
428
- if (request.method !== "POST")
429
- return json(response, 403, { error: "Forbidden", reason: "POST required" });
430
- if (!isSameOrigin(request)) return json(response, 403, CROSS_ORIGIN_ERROR);
431
- if (url.pathname === "/auth/oauth/handoff")
432
- return oauthHandoff(request, response);
433
- const endpoint = PASSTHROUGH.get(url.pathname);
434
- if (endpoint)
435
- return json(
436
- response,
437
- 200,
438
- await backend(endpoint, request, {
439
- method: "POST",
440
- body: await body(request),
441
- }),
442
- );
443
- return actions[url.pathname]?.(request, response);
444
- }
@@ -1,81 +0,0 @@
1
- // Verification for the service token that guards the HTML render route.
2
- //
3
- // Lives apart from server.mjs so the file stays under the size the linter
4
- // allows, and so the three concerns the check actually has -- signature,
5
- // claims, replay -- are named rather than run together in one function.
6
-
7
- import { createHmac, timingSafeEqual } from "node:crypto";
8
-
9
- const RENDER_TOKEN_LIFETIME_SECONDS = 5 * 60;
10
- const RENDER_REPLAY_LIMIT = 10_000;
11
-
12
- /** jti -> exp, for tokens already spent. Pruned on every accepted token. */
13
- const usedRenderTokens = new Map();
14
-
15
- /**
16
- * Constant-time check of the token's HMAC. The length comparison guards
17
- * timingSafeEqual, which throws on mismatched lengths -- it is not itself a
18
- * timing leak, since the length of a base64url HMAC is fixed.
19
- */
20
- function signatureValid(parts, secret) {
21
- const expected = createHmac("sha256", secret)
22
- .update(`${parts[0]}.${parts[1]}`)
23
- .digest("base64url");
24
- if (expected.length !== parts[2].length) return false;
25
- return timingSafeEqual(Buffer.from(expected), Buffer.from(parts[2]));
26
- }
27
-
28
- /**
29
- * The claims this service will accept. `exp - iat` is bounded so a token
30
- * minted with a far-future expiry cannot outlive the intended window.
31
- */
32
- function claimsValid(payload, now) {
33
- return (
34
- payload.namespace === "html-render-service" &&
35
- payload.purpose === "html-render" &&
36
- payload.aud === "dms-frontend" &&
37
- typeof payload.jti === "string" &&
38
- payload.jti.length >= 16 &&
39
- Number.isSafeInteger(payload.iat) &&
40
- Number.isSafeInteger(payload.exp) &&
41
- payload.exp > now &&
42
- payload.exp - payload.iat <= RENDER_TOKEN_LIFETIME_SECONDS &&
43
- payload.iat <= now
44
- );
45
- }
46
-
47
- /**
48
- * Records a token as spent, dropping expired entries first and evicting the
49
- * oldest if the map is at its cap, so a flood of tokens cannot grow it without
50
- * bound.
51
- */
52
- function rememberToken(payload, now) {
53
- for (const [jti, expiresAt] of usedRenderTokens)
54
- if (expiresAt <= now) usedRenderTokens.delete(jti);
55
- while (usedRenderTokens.size >= RENDER_REPLAY_LIMIT)
56
- usedRenderTokens.delete(usedRenderTokens.keys().next().value);
57
- usedRenderTokens.set(payload.jti, payload.exp);
58
- }
59
-
60
- /**
61
- * @param {string | undefined} token the x-dms-service-token header
62
- * @returns {boolean} whether the token is well-formed, signed, unexpired and
63
- * not already spent. A token accepted here is immediately marked as spent.
64
- */
65
- export function validRenderToken(token) {
66
- const secret = process.env.DMS_HTML_RENDER_SECRET;
67
- if (!token || !secret) return false;
68
- const parts = token.split(".");
69
- if (parts.length !== 3) return false;
70
- if (!signatureValid(parts, secret)) return false;
71
- try {
72
- const payload = JSON.parse(Buffer.from(parts[1], "base64url"));
73
- const now = Math.floor(Date.now() / 1000);
74
- if (!claimsValid(payload, now)) return false;
75
- if (usedRenderTokens.has(payload.jti)) return false;
76
- rememberToken(payload, now);
77
- return true;
78
- } catch {
79
- return false;
80
- }
81
- }