@antelopejs/dms-frontend 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +190 -0
- package/README.md +131 -0
- package/dist/commands/build.js +66 -0
- package/dist/commands/clean.js +42 -0
- package/dist/commands/dev.js +116 -0
- package/dist/commands/prepare.js +60 -0
- package/dist/commands/start.js +49 -0
- package/dist/commands/verify-source.js +39 -0
- package/dist/common.js +24 -0
- package/dist/config.js +142 -0
- package/dist/discovery.js +123 -0
- package/dist/fs-sync.js +142 -0
- package/dist/index.js +44 -0
- package/dist/layer-watch.js +154 -0
- package/dist/layers.js +120 -0
- package/dist/manifest.js +109 -0
- package/dist/materialize.js +249 -0
- package/dist/ports.js +30 -0
- package/dist/update-check.js +173 -0
- package/dist/utils/cli-ui.js +178 -0
- package/dist/verify-source-runner.js +228 -0
- package/dist/workspace-setup.js +109 -0
- package/dist/workspace.js +76 -0
- package/package.json +97 -0
- package/templates/vue/DmsDynamicPage.vue +89 -0
- package/templates/vue/app-config-stub.mjs +1 -0
- package/templates/vue/app-runtime.ts +240 -0
- package/templates/vue/compress-assets.mjs +48 -0
- package/templates/vue/email-locales.ts +32 -0
- package/templates/vue/email-renderer.ts +159 -0
- package/templates/vue/email-runtime.ts +23 -0
- package/templates/vue/frontend-module.ts +1418 -0
- package/templates/vue/globals.d.ts +1 -0
- package/templates/vue/index.html +24 -0
- package/templates/vue/main.ts +33 -0
- package/templates/vue/npmrc +2 -0
- package/templates/vue/package.json +35 -0
- package/templates/vue/pnpm-workspace.yaml +4 -0
- package/templates/vue/server/auth/backend.mjs +83 -0
- package/templates/vue/server/auth/client-ip.mjs +52 -0
- package/templates/vue/server/auth/oauth.mjs +213 -0
- package/templates/vue/server/auth/routes.mjs +254 -0
- package/templates/vue/server/auth/session.mjs +180 -0
- package/templates/vue/server/client-manifest.mjs +116 -0
- package/templates/vue/server/email.mjs +36 -0
- package/templates/vue/server/inertia.mjs +79 -0
- package/templates/vue/server/render-token.mjs +81 -0
- package/templates/vue/server/tester.mjs +228 -0
- package/templates/vue/server.mjs +526 -0
- package/templates/vue/ssr-renderer.ts +146 -0
- package/templates/vue/tsconfig.json +31 -0
- package/templates/vue/typecheck-loader.mjs +13 -0
- package/templates/vue/vite.config.ts +161 -0
- package/templates/vue/vite.email.config.ts +77 -0
|
@@ -0,0 +1,81 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { body, json, RequestBodyError } from "./auth/backend.mjs";
|
|
2
|
+
import { isSameOrigin } from "./auth/client-ip.mjs";
|
|
3
|
+
import { readSession } from "./auth/session.mjs";
|
|
4
|
+
|
|
5
|
+
const RESPONSE_LIMIT = 64 * 1024;
|
|
6
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
7
|
+
const METHODS = new Set([
|
|
8
|
+
"GET",
|
|
9
|
+
"HEAD",
|
|
10
|
+
"POST",
|
|
11
|
+
"PUT",
|
|
12
|
+
"PATCH",
|
|
13
|
+
"DELETE",
|
|
14
|
+
"OPTIONS",
|
|
15
|
+
]);
|
|
16
|
+
const BLOCKED_HEADERS = new Set([
|
|
17
|
+
"host",
|
|
18
|
+
"cookie",
|
|
19
|
+
"cookie2",
|
|
20
|
+
"set-cookie",
|
|
21
|
+
"set-cookie2",
|
|
22
|
+
"connection",
|
|
23
|
+
"keep-alive",
|
|
24
|
+
"proxy-authenticate",
|
|
25
|
+
"proxy-authorization",
|
|
26
|
+
"te",
|
|
27
|
+
"trailer",
|
|
28
|
+
"transfer-encoding",
|
|
29
|
+
"upgrade",
|
|
30
|
+
"content-length",
|
|
31
|
+
"expect",
|
|
32
|
+
"forwarded",
|
|
33
|
+
"origin",
|
|
34
|
+
"referer",
|
|
35
|
+
"via",
|
|
36
|
+
"authorization",
|
|
37
|
+
]);
|
|
38
|
+
const CONTROL_HEADER =
|
|
39
|
+
/^(?:sec-|proxy-|x-forwarded-|x-dms-|x-inertia|x-http-method|x-method-override|x-original-|x-rewrite-)/i;
|
|
40
|
+
// oxlint-disable-next-line eslint/no-control-regex -- URL paths must reject literal control characters before forwarding.
|
|
41
|
+
const UNSAFE_PATH = /[\\#\u0000-\u0020\u007f]/;
|
|
42
|
+
// oxlint-disable-next-line eslint/no-control-regex -- Decoded path segments need the same boundary after every decoding pass.
|
|
43
|
+
const UNSAFE_DECODED_SEGMENT = /[\\/#\u0000-\u0020\u007f]/;
|
|
44
|
+
|
|
45
|
+
function invalidInput() {
|
|
46
|
+
throw new RequestBodyError(400, "Invalid tester request");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function validatePathSegment(segment) {
|
|
50
|
+
let decoded = segment;
|
|
51
|
+
for (;;) {
|
|
52
|
+
if ([".", ".."].includes(decoded) || UNSAFE_DECODED_SEGMENT.test(decoded))
|
|
53
|
+
return invalidInput();
|
|
54
|
+
let next;
|
|
55
|
+
try {
|
|
56
|
+
next = decodeURIComponent(decoded);
|
|
57
|
+
} catch {
|
|
58
|
+
return invalidInput();
|
|
59
|
+
}
|
|
60
|
+
if (next === decoded) break;
|
|
61
|
+
decoded = next;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function targetUrl(path) {
|
|
66
|
+
if (
|
|
67
|
+
typeof path !== "string" ||
|
|
68
|
+
!path.startsWith("/") ||
|
|
69
|
+
path.startsWith("//") ||
|
|
70
|
+
UNSAFE_PATH.test(path)
|
|
71
|
+
)
|
|
72
|
+
return invalidInput();
|
|
73
|
+
path.split("?", 1)[0].split("/").forEach(validatePathSegment);
|
|
74
|
+
const base = new URL(process.env.DMS_BACKEND_URL);
|
|
75
|
+
if (
|
|
76
|
+
!["http:", "https:"].includes(base.protocol) ||
|
|
77
|
+
base.username ||
|
|
78
|
+
base.password
|
|
79
|
+
)
|
|
80
|
+
throw new Error("Invalid backend configuration");
|
|
81
|
+
const target = new URL(path, base);
|
|
82
|
+
if (target.origin !== base.origin || target.hash) return invalidInput();
|
|
83
|
+
return target;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function filteredHeaders(input, allowAuthorization = false) {
|
|
87
|
+
const source = new Headers(input);
|
|
88
|
+
const connection = new Set(
|
|
89
|
+
(source.get("connection") ?? "")
|
|
90
|
+
.toLowerCase()
|
|
91
|
+
.split(",")
|
|
92
|
+
.map((name) => name.trim()),
|
|
93
|
+
);
|
|
94
|
+
return new Headers(
|
|
95
|
+
[...source].filter(
|
|
96
|
+
([name]) =>
|
|
97
|
+
!connection.has(name) &&
|
|
98
|
+
!CONTROL_HEADER.test(name) &&
|
|
99
|
+
(!BLOCKED_HEADERS.has(name) ||
|
|
100
|
+
(allowAuthorization && name === "authorization")),
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function validateInput(input) {
|
|
106
|
+
if (!input || typeof input !== "object" || Array.isArray(input))
|
|
107
|
+
return invalidInput();
|
|
108
|
+
if (!METHODS.has(input.method) || typeof input.useSession !== "boolean")
|
|
109
|
+
return invalidInput();
|
|
110
|
+
if (
|
|
111
|
+
!input.headers ||
|
|
112
|
+
typeof input.headers !== "object" ||
|
|
113
|
+
Array.isArray(input.headers)
|
|
114
|
+
)
|
|
115
|
+
return invalidInput();
|
|
116
|
+
if (Object.values(input.headers).some((value) => typeof value !== "string"))
|
|
117
|
+
return invalidInput();
|
|
118
|
+
if (
|
|
119
|
+
input.body !== undefined &&
|
|
120
|
+
(typeof input.body !== "string" || ["GET", "HEAD"].includes(input.method))
|
|
121
|
+
)
|
|
122
|
+
return invalidInput();
|
|
123
|
+
try {
|
|
124
|
+
new Headers(input.headers);
|
|
125
|
+
} catch {
|
|
126
|
+
return invalidInput();
|
|
127
|
+
}
|
|
128
|
+
return targetUrl(input.path);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function limitedText(response) {
|
|
132
|
+
if (!response.body) return "";
|
|
133
|
+
const chunks = [];
|
|
134
|
+
let size = 0;
|
|
135
|
+
for await (const chunk of response.body) {
|
|
136
|
+
size += chunk.length;
|
|
137
|
+
if (size > RESPONSE_LIMIT) throw new Error("Backend response too large");
|
|
138
|
+
chunks.push(chunk);
|
|
139
|
+
}
|
|
140
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function isOwner(session, signal) {
|
|
144
|
+
const response = await fetch(targetUrl("/api/auth/me"), {
|
|
145
|
+
headers: {
|
|
146
|
+
authorization: `Bearer ${session.accessToken}`,
|
|
147
|
+
"x-antelopejs-namespace": "default",
|
|
148
|
+
},
|
|
149
|
+
redirect: "manual",
|
|
150
|
+
signal,
|
|
151
|
+
});
|
|
152
|
+
const text = await limitedText(response);
|
|
153
|
+
if (!response.ok) return false;
|
|
154
|
+
return JSON.parse(text)?.owner === true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function redact(value, session) {
|
|
158
|
+
const secrets = [session.accessToken, session.refreshToken].filter(
|
|
159
|
+
(secret) => typeof secret === "string" && secret.length,
|
|
160
|
+
);
|
|
161
|
+
return secrets.reduce((result, secret) => {
|
|
162
|
+
const variants = [
|
|
163
|
+
secret,
|
|
164
|
+
encodeURIComponent(secret),
|
|
165
|
+
JSON.stringify(secret).slice(1, -1),
|
|
166
|
+
];
|
|
167
|
+
return variants.reduce(
|
|
168
|
+
(text, variant) => text.replaceAll(variant, "[REDACTED]"),
|
|
169
|
+
result,
|
|
170
|
+
);
|
|
171
|
+
}, value);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function forward(input, target, session, signal) {
|
|
175
|
+
const headers = filteredHeaders(input.headers, true);
|
|
176
|
+
if (input.useSession && !headers.has("authorization")) {
|
|
177
|
+
headers.set("authorization", `Bearer ${session.accessToken}`);
|
|
178
|
+
if (!headers.has("x-antelopejs-namespace"))
|
|
179
|
+
headers.set("x-antelopejs-namespace", "default");
|
|
180
|
+
}
|
|
181
|
+
const requestOptions = {
|
|
182
|
+
method: input.method,
|
|
183
|
+
headers,
|
|
184
|
+
redirect: "manual",
|
|
185
|
+
signal,
|
|
186
|
+
};
|
|
187
|
+
if (input.body !== undefined) requestOptions.body = input.body;
|
|
188
|
+
const upstream = await fetch(target, requestOptions);
|
|
189
|
+
const responseHeaders = Object.fromEntries(
|
|
190
|
+
[...filteredHeaders(upstream.headers)].map(([name, value]) => [
|
|
191
|
+
redact(name, session),
|
|
192
|
+
redact(value, session),
|
|
193
|
+
]),
|
|
194
|
+
);
|
|
195
|
+
return {
|
|
196
|
+
status: upstream.status,
|
|
197
|
+
headers: responseHeaders,
|
|
198
|
+
body: redact(await limitedText(upstream), session),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Forwards owner-only API tester requests without returning private session credentials. */
|
|
203
|
+
export async function handleTester(request, response) {
|
|
204
|
+
response.setHeader("cache-control", "no-store");
|
|
205
|
+
if (request.method !== "POST") {
|
|
206
|
+
response.setHeader("allow", "POST");
|
|
207
|
+
return json(response, 405, { error: "Method Not Allowed" });
|
|
208
|
+
}
|
|
209
|
+
if (!isSameOrigin(request))
|
|
210
|
+
return json(response, 403, { error: "Forbidden" });
|
|
211
|
+
try {
|
|
212
|
+
const session = readSession(request);
|
|
213
|
+
if (!session?.accessToken)
|
|
214
|
+
return json(response, 401, { error: "Not authenticated" });
|
|
215
|
+
const input = await body(request);
|
|
216
|
+
const target = validateInput(input);
|
|
217
|
+
const signal = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
|
|
218
|
+
if (!(await isOwner(session, signal)))
|
|
219
|
+
return json(response, 403, { error: "Forbidden" });
|
|
220
|
+
return json(response, 200, await forward(input, target, session, signal));
|
|
221
|
+
} catch (error) {
|
|
222
|
+
const status = error instanceof RequestBodyError ? error.status : 502;
|
|
223
|
+
return json(response, status, {
|
|
224
|
+
error:
|
|
225
|
+
status === 502 ? "DMS tester request failed" : "Invalid tester request",
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|