@antelopejs/dms-frontend 0.1.6 → 0.1.8
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/README.md +28 -8
- package/dist/config.js +2 -1
- package/dist/layers.js +1 -0
- package/dist/materialize.js +22 -0
- package/dist/workspace-setup.js +66 -3
- package/package.json +1 -1
- package/templates/vue/server/auth/client-ip.mjs +14 -0
- package/templates/vue/server/auth/routes.mjs +54 -13
- package/templates/vue/server/tester.mjs +2 -3
- package/templates/vue/server.mjs +2 -2
package/README.md
CHANGED
|
@@ -190,18 +190,38 @@ never sends a token and never receives one: it gets back the same
|
|
|
190
190
|
tenant-assignment outcomes are handled identically.
|
|
191
191
|
|
|
192
192
|
Because the route turns a backend endpoint into a login, it only calls the
|
|
193
|
-
endpoints
|
|
193
|
+
endpoints that were declared for it.
|
|
194
|
+
|
|
195
|
+
Backend modules declare their own. A module registering its frontend names the
|
|
196
|
+
routes that finish its flow, the DMS aggregates them into the frontend manifest
|
|
197
|
+
it serves with the bootstrap secret, and `dev`/`build` write them into the
|
|
198
|
+
workspace as it is materialized:
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
// in the backend module
|
|
202
|
+
await AddFrontendModule({
|
|
203
|
+
name: "@antelopejs/dms-saas-frontend-vue",
|
|
204
|
+
sourcePath: path.join(__dirname, "../frontend-vue"),
|
|
205
|
+
renderer: { name: "vue", version: "3" },
|
|
206
|
+
authEstablishEndpoints: ["/api/saas/register/finalize"],
|
|
207
|
+
});
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
A standard deployment therefore needs no configuration at all. The environment
|
|
211
|
+
variable stays as an override, for a backend route no module declares — or one
|
|
212
|
+
declared by a DMS too old to carry the field:
|
|
194
213
|
|
|
195
214
|
```bash
|
|
196
215
|
# .env
|
|
197
|
-
DMS_AUTH_ESTABLISH_ENDPOINTS=/api/
|
|
216
|
+
DMS_AUTH_ESTABLISH_ENDPOINTS=/api/invites/redeem
|
|
198
217
|
```
|
|
199
218
|
|
|
200
|
-
The list is
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
is `
|
|
219
|
+
The effective allow-list is the union of the two. Every entry, from either
|
|
220
|
+
source, is matched verbatim against absolute `/api/…` paths — no prefixes, no
|
|
221
|
+
query strings, no traversal — so no backend route that happens to mint a token
|
|
222
|
+
pair can be turned into a login by a request from the browser. An undeclared
|
|
223
|
+
endpoint is answered `403` and never called. The route is `POST`-only and
|
|
224
|
+
same-origin, like every other auth action.
|
|
205
225
|
|
|
206
226
|
### Workspaces
|
|
207
227
|
|
|
@@ -246,7 +266,7 @@ frontend-module registry drives server and client entries.
|
|
|
246
266
|
| | `DMS_COOKIE_SECURE` | Secure cookies (`true` by default; `ajs dms dev` defaults to `false`) |
|
|
247
267
|
| | `DMS_TRUSTED_PROXY_HOPS` | Number of trusted, rightmost reverse-proxy hops (default `0`) |
|
|
248
268
|
| | `DMS_SESSION_SECRET` | Session cookie encryption key, 32 characters or more (required for login) |
|
|
249
|
-
| | `DMS_AUTH_ESTABLISH_ENDPOINTS` |
|
|
269
|
+
| | `DMS_AUTH_ESTABLISH_ENDPOINTS` | Extra backend endpoints `/auth/establish` may open a session from, on top of those the backend's modules declare (comma-separated, empty by default) |
|
|
250
270
|
| | `DMS_CLIENT_BASE_URL` | Public frontend URL used in generated links and emails |
|
|
251
271
|
|
|
252
272
|
All of these can be set in the project's `.env` instead of the environment; see [Configuration](#configuration).
|
package/dist/config.js
CHANGED
|
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.Options = exports.TAILWIND_SOURCE_GLOB = exports.PNPM_LIFECYCLE_SCRIPTS = exports.layerCopyIgnore = exports.LAYER_COPY_BLOCKLIST = exports.FRONTEND_MODULE_ENTRY = exports.LAYERS_SUBDIR = exports.TEMPLATE_FILES = exports.WORKSPACE_DIR_MODE = exports.DEPS_HASH_FILE = exports.DMS_FRONTEND_HOME = void 0;
|
|
6
|
+
exports.Options = exports.TAILWIND_SOURCE_GLOB = exports.PNPM_LIFECYCLE_SCRIPTS = exports.layerCopyIgnore = exports.LAYER_COPY_BLOCKLIST = exports.AUTH_ESTABLISH_FILE = exports.FRONTEND_MODULE_ENTRY = exports.LAYERS_SUBDIR = exports.TEMPLATE_FILES = exports.WORKSPACE_DIR_MODE = exports.DEPS_HASH_FILE = exports.DMS_FRONTEND_HOME = void 0;
|
|
7
7
|
exports.writeSecretBearingFile = writeSecretBearingFile;
|
|
8
8
|
exports.normalizeBootstrapSecret = normalizeBootstrapSecret;
|
|
9
9
|
exports.resolveBootstrapSecret = resolveBootstrapSecret;
|
|
@@ -52,6 +52,7 @@ exports.TEMPLATE_FILES = [
|
|
|
52
52
|
];
|
|
53
53
|
exports.LAYERS_SUBDIR = "frontend-modules";
|
|
54
54
|
exports.FRONTEND_MODULE_ENTRY = "dms.frontend.ts";
|
|
55
|
+
exports.AUTH_ESTABLISH_FILE = "generated-auth-establish.json";
|
|
55
56
|
exports.LAYER_COPY_BLOCKLIST = [
|
|
56
57
|
"node_modules",
|
|
57
58
|
"dist",
|
package/dist/layers.js
CHANGED
package/dist/materialize.js
CHANGED
|
@@ -5,6 +5,7 @@ exports.copyStaticTemplates = copyStaticTemplates;
|
|
|
5
5
|
exports.writeWorkspacePackageJson = writeWorkspacePackageJson;
|
|
6
6
|
exports.materializeLayers = materializeLayers;
|
|
7
7
|
exports.createFrontendModuleRegistry = createFrontendModuleRegistry;
|
|
8
|
+
exports.collectAuthEstablishEndpoints = collectAuthEstablishEndpoints;
|
|
8
9
|
exports.writeFrontendModuleRegistry = writeFrontendModuleRegistry;
|
|
9
10
|
exports.writeDmsMainCss = writeDmsMainCss;
|
|
10
11
|
const node_fs_1 = require("node:fs");
|
|
@@ -107,9 +108,30 @@ function createFrontendModuleRegistry(workspaceDir, layers) {
|
|
|
107
108
|
validateFrontendModuleRegistry(registry, workspaceDir);
|
|
108
109
|
return registry;
|
|
109
110
|
}
|
|
111
|
+
const BACKEND_API_PATH = /^\/api\/[A-Za-z0-9][A-Za-z0-9._~-]*(?:\/[A-Za-z0-9][A-Za-z0-9._~-]*)*$/;
|
|
112
|
+
function collectAuthEstablishEndpoints(layers) {
|
|
113
|
+
const endpoints = new Set();
|
|
114
|
+
for (const layer of layers) {
|
|
115
|
+
for (const endpoint of layer.authEstablishEndpoints ?? []) {
|
|
116
|
+
if (typeof endpoint === "string" && BACKEND_API_PATH.test(endpoint)) {
|
|
117
|
+
endpoints.add(endpoint);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
console.warn(`⚠ Ignoring malformed authEstablishEndpoints entry ${JSON.stringify(endpoint)} ` +
|
|
121
|
+
`declared by ${layer.packageName ?? layer.path}: expected an absolute ` +
|
|
122
|
+
"backend API path under /api/ with no query string.");
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return [...endpoints];
|
|
126
|
+
}
|
|
127
|
+
function writeAuthEstablishEndpoints(workspaceDir, layers) {
|
|
128
|
+
const endpoints = collectAuthEstablishEndpoints(layers);
|
|
129
|
+
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(workspaceDir, config_1.AUTH_ESTABLISH_FILE), `${JSON.stringify({ endpoints }, null, 2)}\n`);
|
|
130
|
+
}
|
|
110
131
|
function writeFrontendModuleRegistry(workspaceDir, layers) {
|
|
111
132
|
const registry = createFrontendModuleRegistry(workspaceDir, layers);
|
|
112
133
|
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(workspaceDir, "generated-frontend-modules.json"), `${JSON.stringify(registry, null, 2)}\n`);
|
|
134
|
+
writeAuthEstablishEndpoints(workspaceDir, layers);
|
|
113
135
|
writeFrontendTypePaths(workspaceDir, registry);
|
|
114
136
|
writeFrontendModuleLoader(workspaceDir, registry);
|
|
115
137
|
writeLocaleMessages(workspaceDir, registry);
|
package/dist/workspace-setup.js
CHANGED
|
@@ -9,6 +9,7 @@ exports.setupWorkspace = setupWorkspace;
|
|
|
9
9
|
const node_child_process_1 = require("node:child_process");
|
|
10
10
|
const node_crypto_1 = require("node:crypto");
|
|
11
11
|
const node_fs_1 = require("node:fs");
|
|
12
|
+
const node_os_1 = require("node:os");
|
|
12
13
|
const node_path_1 = require("node:path");
|
|
13
14
|
const layers_1 = require("./layers");
|
|
14
15
|
const manifest_1 = require("./manifest");
|
|
@@ -50,15 +51,77 @@ function installDeps(workspaceDir) {
|
|
|
50
51
|
stdio: "inherit",
|
|
51
52
|
});
|
|
52
53
|
}
|
|
54
|
+
const FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
55
|
+
const CHILD_SHUTDOWN_TIMEOUT_MS = 5000;
|
|
56
|
+
function exitCodeForSignal(signal) {
|
|
57
|
+
const number = node_os_1.constants.signals[signal];
|
|
58
|
+
return 128 + (typeof number === "number" ? number : 15);
|
|
59
|
+
}
|
|
53
60
|
function runCommand(command, args, options) {
|
|
61
|
+
const isWindows = process.platform === "win32";
|
|
54
62
|
return new Promise((resolve, reject) => {
|
|
55
63
|
const child = (0, node_child_process_1.spawn)(command, args, {
|
|
56
64
|
stdio: "inherit",
|
|
57
|
-
shell:
|
|
65
|
+
shell: isWindows,
|
|
58
66
|
...options,
|
|
59
67
|
});
|
|
60
|
-
|
|
61
|
-
|
|
68
|
+
let escalation;
|
|
69
|
+
let forwarded;
|
|
70
|
+
const alive = () => child.exitCode === null && child.signalCode === null;
|
|
71
|
+
const stopChild = (signal) => {
|
|
72
|
+
if (!alive())
|
|
73
|
+
return;
|
|
74
|
+
try {
|
|
75
|
+
child.kill(isWindows ? "SIGTERM" : signal);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
}
|
|
79
|
+
if (escalation || isWindows)
|
|
80
|
+
return;
|
|
81
|
+
escalation = setTimeout(() => {
|
|
82
|
+
if (alive()) {
|
|
83
|
+
try {
|
|
84
|
+
child.kill("SIGKILL");
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}, CHILD_SHUTDOWN_TIMEOUT_MS);
|
|
90
|
+
escalation.unref();
|
|
91
|
+
};
|
|
92
|
+
const onSignal = (signal) => {
|
|
93
|
+
forwarded = signal;
|
|
94
|
+
stopChild(signal);
|
|
95
|
+
};
|
|
96
|
+
const onParentExit = () => {
|
|
97
|
+
if (alive()) {
|
|
98
|
+
try {
|
|
99
|
+
child.kill("SIGKILL");
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
for (const signal of FORWARDED_SIGNALS)
|
|
106
|
+
process.on(signal, onSignal);
|
|
107
|
+
process.on("exit", onParentExit);
|
|
108
|
+
const cleanup = () => {
|
|
109
|
+
if (escalation)
|
|
110
|
+
clearTimeout(escalation);
|
|
111
|
+
for (const signal of FORWARDED_SIGNALS)
|
|
112
|
+
process.off(signal, onSignal);
|
|
113
|
+
process.off("exit", onParentExit);
|
|
114
|
+
};
|
|
115
|
+
child.on("error", (error) => {
|
|
116
|
+
cleanup();
|
|
117
|
+
reject(error);
|
|
118
|
+
});
|
|
119
|
+
child.on("exit", (code, signal) => {
|
|
120
|
+
cleanup();
|
|
121
|
+
if (code !== null)
|
|
122
|
+
return resolve(code);
|
|
123
|
+
resolve(exitCodeForSignal(signal ?? forwarded ?? "SIGTERM"));
|
|
124
|
+
});
|
|
62
125
|
});
|
|
63
126
|
}
|
|
64
127
|
async function setupWorkspace(opts) {
|
package/package.json
CHANGED
|
@@ -50,3 +50,17 @@ export function clientIp(request) {
|
|
|
50
50
|
const chain = [...forwarded, socketIp];
|
|
51
51
|
return chain[Math.max(0, chain.length - hops - 1)] ?? socketIp;
|
|
52
52
|
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Body answered with the 403 of the CSRF origin check.
|
|
56
|
+
*
|
|
57
|
+
* The check itself is unchanged: a state-changing request must carry an
|
|
58
|
+
* `Origin` matching the frontend's own. The `reason` only makes the refusal
|
|
59
|
+
* diagnosable, since a bare `{"error":"Forbidden"}` on `POST /auth/login`
|
|
60
|
+
* looks exactly like bad credentials to an API client that simply forgot
|
|
61
|
+
* the header (curl, a server-side integration, a test harness).
|
|
62
|
+
*/
|
|
63
|
+
export const CROSS_ORIGIN_ERROR = Object.freeze({
|
|
64
|
+
error: "Forbidden",
|
|
65
|
+
reason: "missing or untrusted Origin header",
|
|
66
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
2
3
|
import { backend, body, json, UpstreamError } from "./backend.mjs";
|
|
3
|
-
import { isSameOrigin } from "./client-ip.mjs";
|
|
4
|
+
import { CROSS_ORIGIN_ERROR, isSameOrigin } from "./client-ip.mjs";
|
|
4
5
|
import {
|
|
5
6
|
oauthCallback,
|
|
6
7
|
oauthHandoff,
|
|
@@ -28,6 +29,36 @@ const PASSTHROUGH = new Map([
|
|
|
28
29
|
const BACKEND_API_PATH =
|
|
29
30
|
/^\/api\/[A-Za-z0-9][A-Za-z0-9._~-]*(?:\/[A-Za-z0-9][A-Za-z0-9._~-]*)*$/;
|
|
30
31
|
|
|
32
|
+
// Written at the workspace root by the loader, from the endpoints each backend
|
|
33
|
+
// module declared in the frontend manifest. This file sits two directories up.
|
|
34
|
+
const DECLARED_ENDPOINTS_FILE = new URL(
|
|
35
|
+
"../../generated-auth-establish.json",
|
|
36
|
+
import.meta.url,
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
let declaredEndpoints;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Backend endpoints the modules of this deployment declared as session-opening.
|
|
43
|
+
*
|
|
44
|
+
* Read from the workspace rather than from the backend: the file is generated
|
|
45
|
+
* when the workspace is materialized, so the server needs no backend round trip
|
|
46
|
+
* — and no operator — to know which module flows may end on a login. A
|
|
47
|
+
* workspace built from a DMS that predates the declaration simply has no file,
|
|
48
|
+
* and the list falls back to whatever the environment names.
|
|
49
|
+
*
|
|
50
|
+
* @param file Generated file to read
|
|
51
|
+
* @returns The declared paths, unfiltered
|
|
52
|
+
*/
|
|
53
|
+
export function readDeclaredEstablishEndpoints(file = DECLARED_ENDPOINTS_FILE) {
|
|
54
|
+
try {
|
|
55
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
56
|
+
return Array.isArray(parsed?.endpoints) ? parsed.endpoints : [];
|
|
57
|
+
} catch {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
31
62
|
export function publicSession(session) {
|
|
32
63
|
if (!session) return {};
|
|
33
64
|
return {
|
|
@@ -88,20 +119,30 @@ async function establish(request, response, endpoint) {
|
|
|
88
119
|
/**
|
|
89
120
|
* Backend endpoints this deployment lets a module open a session from.
|
|
90
121
|
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
122
|
+
* The union of what the backend's modules declared — the standard case, which
|
|
123
|
+
* needs no configuration — and what the environment adds on top, which is how
|
|
124
|
+
* an operator extends the list for a route no module owns. Everything else
|
|
125
|
+
* stays refused, so no backend endpoint that happens to mint a token pair can
|
|
126
|
+
* be turned into a login by a request from the browser.
|
|
94
127
|
*
|
|
95
|
-
* @param declaration Comma-separated absolute backend paths
|
|
96
|
-
* @
|
|
128
|
+
* @param declaration Comma-separated absolute backend paths from the environment
|
|
129
|
+
* @param declared Paths declared by the backend's frontend modules
|
|
130
|
+
* @returns The well-formed backend API paths, without duplicates
|
|
97
131
|
*/
|
|
98
132
|
export function allowedEstablishEndpoints(
|
|
99
133
|
declaration = process.env.DMS_AUTH_ESTABLISH_ENDPOINTS,
|
|
134
|
+
declared = (declaredEndpoints ??= readDeclaredEstablishEndpoints()),
|
|
100
135
|
) {
|
|
101
|
-
|
|
136
|
+
const fromEnvironment = (declaration ?? "")
|
|
102
137
|
.split(",")
|
|
103
|
-
.map((entry) => entry.trim())
|
|
104
|
-
|
|
138
|
+
.map((entry) => entry.trim());
|
|
139
|
+
return [
|
|
140
|
+
...new Set(
|
|
141
|
+
[...declared, ...fromEnvironment].filter(
|
|
142
|
+
(entry) => typeof entry === "string" && BACKEND_API_PATH.test(entry),
|
|
143
|
+
),
|
|
144
|
+
),
|
|
145
|
+
];
|
|
105
146
|
}
|
|
106
147
|
|
|
107
148
|
/**
|
|
@@ -288,8 +329,7 @@ export async function handleAuth(request, response, url) {
|
|
|
288
329
|
response.setHeader("allow", "GET, POST, DELETE");
|
|
289
330
|
return json(response, 405, { error: "Method Not Allowed" });
|
|
290
331
|
}
|
|
291
|
-
if (!isSameOrigin(request))
|
|
292
|
-
return json(response, 403, { error: "Forbidden" });
|
|
332
|
+
if (!isSameOrigin(request)) return json(response, 403, CROSS_ORIGIN_ERROR);
|
|
293
333
|
return request.method === "DELETE"
|
|
294
334
|
? logout(request, response)
|
|
295
335
|
: refresh(request, response);
|
|
@@ -301,8 +341,9 @@ export async function handleAuth(request, response, url) {
|
|
|
301
341
|
return match[2] === "start"
|
|
302
342
|
? oauthStart(request, response, match[1], url)
|
|
303
343
|
: oauthCallback(request, response, match[1], url);
|
|
304
|
-
if (request.method !== "POST"
|
|
305
|
-
return json(response, 403, { error: "Forbidden" });
|
|
344
|
+
if (request.method !== "POST")
|
|
345
|
+
return json(response, 403, { error: "Forbidden", reason: "POST required" });
|
|
346
|
+
if (!isSameOrigin(request)) return json(response, 403, CROSS_ORIGIN_ERROR);
|
|
306
347
|
if (url.pathname === "/auth/oauth/handoff")
|
|
307
348
|
return oauthHandoff(request, response);
|
|
308
349
|
const endpoint = PASSTHROUGH.get(url.pathname);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { body, json, RequestBodyError } from "./auth/backend.mjs";
|
|
2
|
-
import { isSameOrigin } from "./auth/client-ip.mjs";
|
|
2
|
+
import { CROSS_ORIGIN_ERROR, isSameOrigin } from "./auth/client-ip.mjs";
|
|
3
3
|
import { readSession } from "./auth/session.mjs";
|
|
4
4
|
|
|
5
5
|
const RESPONSE_LIMIT = 64 * 1024;
|
|
@@ -206,8 +206,7 @@ export async function handleTester(request, response) {
|
|
|
206
206
|
response.setHeader("allow", "POST");
|
|
207
207
|
return json(response, 405, { error: "Method Not Allowed" });
|
|
208
208
|
}
|
|
209
|
-
if (!isSameOrigin(request))
|
|
210
|
-
return json(response, 403, { error: "Forbidden" });
|
|
209
|
+
if (!isSameOrigin(request)) return json(response, 403, CROSS_ORIGIN_ERROR);
|
|
211
210
|
try {
|
|
212
211
|
const session = readSession(request);
|
|
213
212
|
if (!session?.accessToken)
|
package/templates/vue/server.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
} from "node:zlib";
|
|
12
12
|
import { ofetch } from "ofetch";
|
|
13
13
|
import { RequestBodyError, UpstreamError } from "./server/auth/backend.mjs";
|
|
14
|
-
import { isSameOrigin } from "./server/auth/client-ip.mjs";
|
|
14
|
+
import { CROSS_ORIGIN_ERROR, isSameOrigin } from "./server/auth/client-ip.mjs";
|
|
15
15
|
import {
|
|
16
16
|
handleAuth,
|
|
17
17
|
publicSession,
|
|
@@ -242,7 +242,7 @@ export function requestOwnership(method, pathname) {
|
|
|
242
242
|
async function proxy(request, response, fallbackOnNotFound = false) {
|
|
243
243
|
if (!SAFE_METHODS.has(request.method) && !isSameOrigin(request)) {
|
|
244
244
|
response.writeHead(403, { "content-type": JSON_TYPE });
|
|
245
|
-
response.end(JSON.stringify(
|
|
245
|
+
response.end(JSON.stringify(CROSS_ORIGIN_ERROR));
|
|
246
246
|
return true;
|
|
247
247
|
}
|
|
248
248
|
const target = new URL(request.url, process.env.DMS_API_BASE_URL);
|