@antelopejs/dms-frontend 0.1.1 → 0.1.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/README.md +103 -2
- package/dist/commands/clean.js +5 -2
- package/dist/config.js +3 -1
- package/dist/env-file.js +33 -0
- package/dist/index.js +17 -1
- package/dist/workspace.js +30 -7
- package/package.json +4 -1
- package/templates/vue/server/auth/routes.mjs +67 -2
- package/templates/vue/server/auth/session.mjs +4 -1
- package/templates/vue/server.mjs +1 -1
package/README.md
CHANGED
|
@@ -126,9 +126,105 @@ The SDK also exposes `use` for Vue plugins. Entries execute by descending manife
|
|
|
126
126
|
|
|
127
127
|
## Discovery, caching, and security
|
|
128
128
|
|
|
129
|
-
In development, `ajs dms` discovers the backend from the nearest live `.antelope/dev.json`. It reads the local bootstrap credential from `.antelope/dms-dev.json` only when that discovered backend matches the destination URL. For production and CI, set `DMS_API_BASE_URL` and `DMS_BOOTSTRAP_SECRET` in the environment rather than passing credentials on the command line.
|
|
129
|
+
In development, `ajs dms` discovers the backend from the nearest live `.antelope/dev.json`. It reads the local bootstrap credential from `.antelope/dms-dev.json` only when that discovered backend matches the destination URL. For production and CI, set `DMS_API_BASE_URL` and `DMS_BOOTSTRAP_SECRET` in the environment, or in the project's `.env`, rather than passing credentials on the command line.
|
|
130
130
|
|
|
131
|
-
Each canonical backend URL gets an owner-only workspace under `~/.antelopejs/dms-frontend
|
|
131
|
+
Each canonical backend URL gets an owner-only workspace under `~/.antelopejs/dms-frontend` (see [Workspaces](#workspaces) for how the key is derived). Manifest caches, private module configuration, and extracted archives retain restrictive permissions. `--offline` reuses the last successful manifest and archive; an authorization failure never falls back to privileged cached data.
|
|
132
|
+
|
|
133
|
+
## Configuration
|
|
134
|
+
|
|
135
|
+
Every command loads `.env.local` then `.env` from the **current working
|
|
136
|
+
directory** before it parses its options, so a project can keep its
|
|
137
|
+
configuration in a file instead of exporting variables by hand:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
# .env
|
|
141
|
+
DMS_API_BASE_URL=http://localhost:5010
|
|
142
|
+
DMS_CLIENT_BASE_URL=http://localhost:3001
|
|
143
|
+
DMS_BOOTSTRAP_SECRET=replace_with_a_strong_random_value
|
|
144
|
+
DMS_SESSION_SECRET=replace_with_at_least_32_characters
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Precedence is environment, then `.env.local`, then `.env`: a variable already
|
|
148
|
+
present in the environment is never overwritten, so `DMS_API_BASE_URL=… ajs dms
|
|
149
|
+
build` and a CI job's injected secrets always win over a file left in the
|
|
150
|
+
checkout. A variable exported as an empty string counts as set. Only the
|
|
151
|
+
current directory is read — never a parent directory, and never the generated
|
|
152
|
+
workspace under `~/.antelopejs/dms-frontend`, which is this tool's own output
|
|
153
|
+
and is handed its environment explicitly by the command that spawns it. The
|
|
154
|
+
values loaded here reach the workspace build started by `build`, the dev server
|
|
155
|
+
started by `dev`, and the production server started by `start`, because those
|
|
156
|
+
child processes inherit the environment. A missing file is not an error; an
|
|
157
|
+
unreadable one is reported and skipped.
|
|
158
|
+
|
|
159
|
+
`DMS_SESSION_SECRET` is mandatory for anything that touches a session. The
|
|
160
|
+
generated server encrypts its session cookie with it, and with no value — or
|
|
161
|
+
one shorter than 32 characters — the login page at `/auth` fails the first
|
|
162
|
+
sign-in attempt rather than starting degraded. Generate one with
|
|
163
|
+
`openssl rand -hex 32`.
|
|
164
|
+
|
|
165
|
+
### Opening a session from a module flow
|
|
166
|
+
|
|
167
|
+
`/auth/login`, `/auth/signup` and `/auth/verify-2fa` are not the only ways a
|
|
168
|
+
visitor becomes authenticated: a module can own a flow that ends in an
|
|
169
|
+
authenticated user — a self-service registration completing after payment, an
|
|
170
|
+
invitation being redeemed — and needs the session cookie opened at the end of
|
|
171
|
+
it. `POST /auth/establish` is the generic form of those three routes.
|
|
172
|
+
|
|
173
|
+
The browser names a backend endpoint and the payload to send it:
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
await $fetch("/auth/establish", {
|
|
177
|
+
method: "POST",
|
|
178
|
+
body: {
|
|
179
|
+
endpoint: "/api/saas/register/finalize",
|
|
180
|
+
payload: { /* whatever that backend route expects */ },
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
The frontend server calls that endpoint itself over its own server-to-server
|
|
186
|
+
channel to `DMS_API_BASE_URL`, exactly as it calls `/api/auth/login`, and
|
|
187
|
+
writes the session from the token pair the backend answers with. The browser
|
|
188
|
+
never sends a token and never receives one: it gets back the same
|
|
189
|
+
`{ user, account }` body the login route returns, and the two-factor and
|
|
190
|
+
tenant-assignment outcomes are handled identically.
|
|
191
|
+
|
|
192
|
+
Because the route turns a backend endpoint into a login, it only calls the
|
|
193
|
+
endpoints the deployment names:
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
# .env
|
|
197
|
+
DMS_AUTH_ESTABLISH_ENDPOINTS=/api/saas/register/finalize,/api/invites/redeem
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
The list is empty by default and matched verbatim against absolute `/api/…`
|
|
201
|
+
paths — no prefixes, no query strings, no traversal — so no backend route that
|
|
202
|
+
happens to mint a token pair can be turned into a login by a request from the
|
|
203
|
+
browser. An undeclared endpoint is answered `403` and never called. The route
|
|
204
|
+
is `POST`-only and same-origin, like every other auth action.
|
|
205
|
+
|
|
206
|
+
### Workspaces
|
|
207
|
+
|
|
208
|
+
Each canonical backend URL gets its own owner-only workspace under
|
|
209
|
+
`~/.antelopejs/dms-frontend/<sha256>/`; `build`, `start`, `clean -b` and
|
|
210
|
+
`dev -b` all key on that URL, so one backend means one workspace shared by
|
|
211
|
+
every command.
|
|
212
|
+
|
|
213
|
+
`dev` without `-b` is the deliberate exception. It discovers the backend from
|
|
214
|
+
the enclosing antelope project's `.antelope/dev.json` and keys its workspace on
|
|
215
|
+
the **project directory** instead, because a development backend can land on a
|
|
216
|
+
different port between runs and re-keying on the URL would discard
|
|
217
|
+
`node_modules`, the manifest cache and the client-side appId scope every time it
|
|
218
|
+
does. The consequence is that `ajs dms dev` followed by `ajs dms build -b <url>`
|
|
219
|
+
against the same backend creates two workspaces of their own — several hundred
|
|
220
|
+
megabytes each. Pass `-b` to `dev` to share a single one. `clean --all` lists
|
|
221
|
+
both and names the project a workspace is keyed on; `clean -b <url>` only
|
|
222
|
+
reaches the URL-keyed one.
|
|
223
|
+
|
|
224
|
+
A `DMS_API_BASE_URL` line in `.env` counts as an explicit backend, so a project
|
|
225
|
+
that configures one gets the single shared workspace and gives up autodiscovery
|
|
226
|
+
— including its tolerance for the backend moving to another port. Leave the
|
|
227
|
+
variable out of `.env` to keep autodiscovery for `dev`.
|
|
132
228
|
|
|
133
229
|
## Rendering model
|
|
134
230
|
|
|
@@ -149,6 +245,11 @@ frontend-module registry drives server and client entries.
|
|
|
149
245
|
| `--bootstrap-secret` | `DMS_BOOTSTRAP_SECRET` | Backend bootstrap credential |
|
|
150
246
|
| | `DMS_COOKIE_SECURE` | Secure cookies (`true` by default; `ajs dms dev` defaults to `false`) |
|
|
151
247
|
| | `DMS_TRUSTED_PROXY_HOPS` | Number of trusted, rightmost reverse-proxy hops (default `0`) |
|
|
248
|
+
| | `DMS_SESSION_SECRET` | Session cookie encryption key, 32 characters or more (required for login) |
|
|
249
|
+
| | `DMS_AUTH_ESTABLISH_ENDPOINTS` | Backend endpoints `/auth/establish` may open a session from (comma-separated, empty by default) |
|
|
250
|
+
| | `DMS_CLIENT_BASE_URL` | Public frontend URL used in generated links and emails |
|
|
251
|
+
|
|
252
|
+
All of these can be set in the project's `.env` instead of the environment; see [Configuration](#configuration).
|
|
152
253
|
|
|
153
254
|
Use pnpm for all repository and workspace operations.
|
|
154
255
|
|
package/dist/commands/clean.js
CHANGED
|
@@ -21,14 +21,17 @@ function cmdClean() {
|
|
|
21
21
|
}
|
|
22
22
|
for (const ws of workspaces) {
|
|
23
23
|
(0, node_fs_1.rmSync)(ws.dir, { recursive: true, force: true });
|
|
24
|
-
(0, cli_ui_1.success)(`Removed ${ws.dir} (${ws
|
|
24
|
+
(0, cli_ui_1.success)(`Removed ${ws.dir} (${(0, common_1.describeWorkspace)(ws)})`);
|
|
25
25
|
}
|
|
26
26
|
console.log("");
|
|
27
27
|
(0, cli_ui_1.success)(`Cleaned ${workspaces.length} workspace(s).`);
|
|
28
28
|
return;
|
|
29
29
|
}
|
|
30
30
|
if (!options.backendUrl) {
|
|
31
|
-
(0, cli_ui_1.warning)("Specify -b <url> to clean a specific workspace, or --all to clean everything
|
|
31
|
+
(0, cli_ui_1.warning)("Specify -b <url> to clean a specific workspace, or --all to clean everything.\n" +
|
|
32
|
+
" -b only reaches the workspace 'build', 'start' and 'dev -b' share for that URL;\n" +
|
|
33
|
+
" a workspace 'dev' created without -b is keyed on the project directory and is\n" +
|
|
34
|
+
" only removable with --all.");
|
|
32
35
|
process.exit(1);
|
|
33
36
|
}
|
|
34
37
|
const workspaceDir = (0, common_1.getWorkspaceDir)(options.backendUrl);
|
package/dist/config.js
CHANGED
|
@@ -88,7 +88,9 @@ exports.Options = {
|
|
|
88
88
|
.default("3001")
|
|
89
89
|
.env("PORT"),
|
|
90
90
|
force: new commander_1.Option("-f, --force", "Force reinstall dependencies"),
|
|
91
|
-
|
|
91
|
+
get offline() {
|
|
92
|
+
return new commander_1.Option("--offline", "Skip the backend manifest fetch and reuse the last cached manifest (env: DMS_OFFLINE)").default(booleanFromEnv("DMS_OFFLINE"));
|
|
93
|
+
},
|
|
92
94
|
bootstrapSecret: new commander_1.Option("--bootstrap-secret <secret>", "Credential presented to the backend's layer endpoints (env: DMS_BOOTSTRAP_SECRET, preferred — " +
|
|
93
95
|
"a secret passed on the command line is visible to every process on the machine). In dev it is " +
|
|
94
96
|
"discovered from the antelope project's .antelope/dms-dev.json.").env("DMS_BOOTSTRAP_SECRET"),
|
package/dist/env-file.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ENV_FILE_NAMES = void 0;
|
|
4
|
+
exports.loadProjectEnv = loadProjectEnv;
|
|
5
|
+
const node_fs_1 = require("node:fs");
|
|
6
|
+
const node_path_1 = require("node:path");
|
|
7
|
+
const node_util_1 = require("node:util");
|
|
8
|
+
exports.ENV_FILE_NAMES = [".env.local", ".env"];
|
|
9
|
+
function loadProjectEnv(options = {}) {
|
|
10
|
+
const cwd = options.cwd ?? process.cwd();
|
|
11
|
+
const env = options.env ?? process.env;
|
|
12
|
+
const onWarning = options.onWarning ?? ((message) => console.warn(message));
|
|
13
|
+
const loaded = [];
|
|
14
|
+
for (const name of exports.ENV_FILE_NAMES) {
|
|
15
|
+
const file = (0, node_path_1.join)(cwd, name);
|
|
16
|
+
if (!(0, node_fs_1.existsSync)(file))
|
|
17
|
+
continue;
|
|
18
|
+
let parsed;
|
|
19
|
+
try {
|
|
20
|
+
parsed = (0, node_util_1.parseEnv)((0, node_fs_1.readFileSync)(file, "utf-8"));
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
onWarning(`⚠ Ignoring ${file}: ${err?.message ?? err}`);
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
27
|
+
if (value !== undefined && env[key] === undefined)
|
|
28
|
+
env[key] = value;
|
|
29
|
+
}
|
|
30
|
+
loaded.push(file);
|
|
31
|
+
}
|
|
32
|
+
return loaded;
|
|
33
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -12,10 +12,12 @@ const dev_1 = require("./commands/dev");
|
|
|
12
12
|
const prepare_1 = require("./commands/prepare");
|
|
13
13
|
const start_1 = require("./commands/start");
|
|
14
14
|
const verify_source_1 = require("./commands/verify-source");
|
|
15
|
+
const env_file_1 = require("./env-file");
|
|
15
16
|
const update_check_1 = require("./update-check");
|
|
16
17
|
const cli_ui_1 = require("./utils/cli-ui");
|
|
17
18
|
const { version } = require("../package.json");
|
|
18
19
|
const runCLI = async () => {
|
|
20
|
+
(0, env_file_1.loadProjectEnv)();
|
|
19
21
|
const argv = process.argv.slice(2);
|
|
20
22
|
void (0, update_check_1.checkForUpdate)({ currentVersion: version, argv });
|
|
21
23
|
if (process.argv.length <= 2) {
|
|
@@ -28,7 +30,21 @@ const runCLI = async () => {
|
|
|
28
30
|
`Materializes frontend modules from an AntelopeJS backend and starts a Vue or React Vite and Inertia application.`)
|
|
29
31
|
.version(version, "-v, --version", "Display version number")
|
|
30
32
|
.option("--no-update-check", "Skip the daily check for a newer DMS frontend release")
|
|
31
|
-
.helpCommand("help [command]", "Display help for a specific command")
|
|
33
|
+
.helpCommand("help [command]", "Display help for a specific command")
|
|
34
|
+
.addHelpText("after", `
|
|
35
|
+
Environment:
|
|
36
|
+
Every command reads ${env_file_1.ENV_FILE_NAMES.join(" then ")} from the current directory before parsing
|
|
37
|
+
its options, so DMS_API_BASE_URL, DMS_BOOTSTRAP_SECRET, DMS_SESSION_SECRET and
|
|
38
|
+
the other variables below can live in the project's .env. A variable already
|
|
39
|
+
set in the environment always wins over a file, and .env.local wins over .env.
|
|
40
|
+
The generated workspace never loads a .env of its own.
|
|
41
|
+
|
|
42
|
+
Workspaces:
|
|
43
|
+
Each canonical backend URL gets its own workspace under
|
|
44
|
+
~/.antelopejs/dms-frontend. 'dev' without -b is the exception: it keys the
|
|
45
|
+
workspace on the antelope project directory instead, so a backend that lands
|
|
46
|
+
on a different port between runs keeps its node_modules and manifest cache.
|
|
47
|
+
Pass -b to 'dev' to share one workspace with 'build' and 'start'.`);
|
|
32
48
|
program.addCommand((0, dev_1.cmdDev)());
|
|
33
49
|
program.addCommand((0, build_1.cmdBuild)());
|
|
34
50
|
program.addCommand((0, start_1.cmdStart)());
|
package/dist/workspace.js
CHANGED
|
@@ -3,8 +3,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.FRONTEND_MANIFEST_VERSION = void 0;
|
|
4
4
|
exports.getWorkspaceDirForKey = getWorkspaceDirForKey;
|
|
5
5
|
exports.projectWorkspaceKey = projectWorkspaceKey;
|
|
6
|
+
exports.projectDirFromWorkspaceKey = projectDirFromWorkspaceKey;
|
|
6
7
|
exports.getWorkspaceDir = getWorkspaceDir;
|
|
7
8
|
exports.ensureWorkspace = ensureWorkspace;
|
|
9
|
+
exports.describeWorkspace = describeWorkspace;
|
|
8
10
|
exports.listWorkspaces = listWorkspaces;
|
|
9
11
|
exports.writeWorkspaceMeta = writeWorkspaceMeta;
|
|
10
12
|
exports.bootstrapHeaders = bootstrapHeaders;
|
|
@@ -16,8 +18,14 @@ const WORKSPACE_META_FILE = ".ajs-dms-meta.json";
|
|
|
16
18
|
function getWorkspaceDirForKey(key) {
|
|
17
19
|
return (0, node_path_1.join)(config_1.DMS_FRONTEND_HOME, (0, config_1.sha256Hex)(key));
|
|
18
20
|
}
|
|
21
|
+
const PROJECT_KEY_PREFIX = "project:";
|
|
19
22
|
function projectWorkspaceKey(projectDir) {
|
|
20
|
-
return
|
|
23
|
+
return `${PROJECT_KEY_PREFIX}${(0, node_path_1.resolve)(projectDir)}`;
|
|
24
|
+
}
|
|
25
|
+
function projectDirFromWorkspaceKey(workspaceKey) {
|
|
26
|
+
return workspaceKey?.startsWith(PROJECT_KEY_PREFIX)
|
|
27
|
+
? workspaceKey.slice(PROJECT_KEY_PREFIX.length)
|
|
28
|
+
: undefined;
|
|
21
29
|
}
|
|
22
30
|
function getWorkspaceDir(backendUrl) {
|
|
23
31
|
return getWorkspaceDirForKey((0, config_1.canonicalizeBackendUrl)(backendUrl));
|
|
@@ -30,6 +38,12 @@ function ensureWorkspace(workspaceKey) {
|
|
|
30
38
|
(0, node_fs_1.chmodSync)(dir, config_1.WORKSPACE_DIR_MODE);
|
|
31
39
|
return dir;
|
|
32
40
|
}
|
|
41
|
+
function describeWorkspace(entry) {
|
|
42
|
+
const projectDir = projectDirFromWorkspaceKey(entry.workspaceKey);
|
|
43
|
+
return projectDir
|
|
44
|
+
? `${entry.backendUrl}, keyed on project ${projectDir}`
|
|
45
|
+
: entry.backendUrl;
|
|
46
|
+
}
|
|
33
47
|
function listWorkspaces() {
|
|
34
48
|
if (!(0, node_fs_1.existsSync)(config_1.DMS_FRONTEND_HOME))
|
|
35
49
|
return [];
|
|
@@ -38,16 +52,20 @@ function listWorkspaces() {
|
|
|
38
52
|
if (!entry.isDirectory())
|
|
39
53
|
continue;
|
|
40
54
|
const dir = (0, node_path_1.join)(config_1.DMS_FRONTEND_HOME, entry.name);
|
|
41
|
-
const
|
|
42
|
-
if (backendUrl === undefined) {
|
|
55
|
+
const meta = readWorkspaceMeta(dir);
|
|
56
|
+
if (meta?.backendUrl === undefined) {
|
|
43
57
|
console.warn(`⚠ Skipping ${dir}: no readable ${WORKSPACE_META_FILE}.`);
|
|
44
58
|
continue;
|
|
45
59
|
}
|
|
46
|
-
workspaces.push({
|
|
60
|
+
workspaces.push({
|
|
61
|
+
dir,
|
|
62
|
+
backendUrl: meta.backendUrl,
|
|
63
|
+
workspaceKey: meta.workspaceKey,
|
|
64
|
+
});
|
|
47
65
|
}
|
|
48
66
|
return workspaces;
|
|
49
67
|
}
|
|
50
|
-
function
|
|
68
|
+
function readWorkspaceMeta(dir) {
|
|
51
69
|
let meta;
|
|
52
70
|
try {
|
|
53
71
|
meta = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(dir, WORKSPACE_META_FILE), "utf-8"));
|
|
@@ -55,8 +73,13 @@ function readWorkspaceBackendUrl(dir) {
|
|
|
55
73
|
catch {
|
|
56
74
|
return undefined;
|
|
57
75
|
}
|
|
58
|
-
const
|
|
59
|
-
return
|
|
76
|
+
const record = meta;
|
|
77
|
+
return {
|
|
78
|
+
backendUrl: typeof record?.backendUrl === "string" ? record.backendUrl : undefined,
|
|
79
|
+
workspaceKey: typeof record?.workspaceKey === "string"
|
|
80
|
+
? record.workspaceKey
|
|
81
|
+
: undefined,
|
|
82
|
+
};
|
|
60
83
|
}
|
|
61
84
|
function writeWorkspaceMeta(workspaceDir, backendUrl, workspaceKey) {
|
|
62
85
|
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(workspaceDir, WORKSPACE_META_FILE), JSON.stringify({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@antelopejs/dms-frontend",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Frontend-agnostic loader for AntelopeJS DMS, shipping the Vue 3 renderer (Vite, Inertia, SSR)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"antelope",
|
|
@@ -96,5 +96,8 @@
|
|
|
96
96
|
"@oxc-parser/binding-linux-x64-gnu": "^0.95.0",
|
|
97
97
|
"@oxc-parser/binding-win32-x64-msvc": "^0.95.0"
|
|
98
98
|
},
|
|
99
|
+
"engines": {
|
|
100
|
+
"node": ">=20.12.0"
|
|
101
|
+
},
|
|
99
102
|
"packageManager": "pnpm@10.6.5"
|
|
100
103
|
}
|
|
@@ -23,6 +23,11 @@ const PASSTHROUGH = new Map([
|
|
|
23
23
|
["/auth/request-2fa-email", "/api/auth/request-2fa-email"],
|
|
24
24
|
]);
|
|
25
25
|
|
|
26
|
+
// Absolute backend API paths only: no scheme, no authority, no query string,
|
|
27
|
+
// and no segment that could climb out of `/api/`.
|
|
28
|
+
const BACKEND_API_PATH =
|
|
29
|
+
/^\/api\/[A-Za-z0-9][A-Za-z0-9._~-]*(?:\/[A-Za-z0-9][A-Za-z0-9._~-]*)*$/;
|
|
30
|
+
|
|
26
31
|
export function publicSession(session) {
|
|
27
32
|
if (!session) return {};
|
|
28
33
|
return {
|
|
@@ -64,10 +69,10 @@ function singleFlight(token, operation) {
|
|
|
64
69
|
return promise;
|
|
65
70
|
}
|
|
66
71
|
|
|
67
|
-
async function
|
|
72
|
+
async function establishWith(request, response, endpoint, payload) {
|
|
68
73
|
const result = await backend(endpoint, request, {
|
|
69
74
|
method: "POST",
|
|
70
|
-
body:
|
|
75
|
+
body: payload,
|
|
71
76
|
});
|
|
72
77
|
if (result.requires_2fa || result.requires_tenant_assignment)
|
|
73
78
|
return json(response, 200, result);
|
|
@@ -76,6 +81,65 @@ async function establish(request, response, endpoint) {
|
|
|
76
81
|
json(response, 200, { user: result.user, account });
|
|
77
82
|
}
|
|
78
83
|
|
|
84
|
+
async function establish(request, response, endpoint) {
|
|
85
|
+
return establishWith(request, response, endpoint, await body(request));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Backend endpoints this deployment lets a module open a session from.
|
|
90
|
+
*
|
|
91
|
+
* Empty by default: a module route only becomes a session-opening route once
|
|
92
|
+
* the operator names it, so no backend endpoint that happens to mint a token
|
|
93
|
+
* pair can be turned into a login by a request from the browser.
|
|
94
|
+
*
|
|
95
|
+
* @param declaration Comma-separated absolute backend paths
|
|
96
|
+
* @returns The declared paths that are well-formed backend API paths
|
|
97
|
+
*/
|
|
98
|
+
export function allowedEstablishEndpoints(
|
|
99
|
+
declaration = process.env.DMS_AUTH_ESTABLISH_ENDPOINTS,
|
|
100
|
+
) {
|
|
101
|
+
return (declaration ?? "")
|
|
102
|
+
.split(",")
|
|
103
|
+
.map((entry) => entry.trim())
|
|
104
|
+
.filter((entry) => BACKEND_API_PATH.test(entry));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Whether a caller-named endpoint is one of the declared ones.
|
|
109
|
+
*
|
|
110
|
+
* The grammar is checked on the caller's value too, so a declaration that was
|
|
111
|
+
* never meant to be a prefix cannot be widened by a traversal or a query
|
|
112
|
+
* string smuggled into the request.
|
|
113
|
+
*
|
|
114
|
+
* @param endpoint Endpoint the browser asked to establish a session from
|
|
115
|
+
* @param allowed Declared endpoints
|
|
116
|
+
* @returns True when the endpoint may be called
|
|
117
|
+
*/
|
|
118
|
+
export function isAllowedEstablishEndpoint(endpoint, allowed) {
|
|
119
|
+
return (
|
|
120
|
+
typeof endpoint === "string" &&
|
|
121
|
+
BACKEND_API_PATH.test(endpoint) &&
|
|
122
|
+
allowed.includes(endpoint)
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Open a session from a backend endpoint that mints a token pair.
|
|
128
|
+
*
|
|
129
|
+
* The generic half of `/auth/login`: a module whose own flow ends in an
|
|
130
|
+
* authenticated user — a self-service registration completing, an invitation
|
|
131
|
+
* being redeemed — points this at the backend route that finishes it, and the
|
|
132
|
+
* session cookie is written from the tokens the loader fetched itself. The
|
|
133
|
+
* browser never carries a token: it names an endpoint and a payload, and gets
|
|
134
|
+
* back the same `{ user, account }` the login route answers.
|
|
135
|
+
*/
|
|
136
|
+
async function establishFromEndpoint(request, response) {
|
|
137
|
+
const input = await body(request);
|
|
138
|
+
if (!isAllowedEstablishEndpoint(input.endpoint, allowedEstablishEndpoints()))
|
|
139
|
+
return json(response, 403, { error: "Forbidden" });
|
|
140
|
+
return establishWith(request, response, input.endpoint, input.payload ?? {});
|
|
141
|
+
}
|
|
142
|
+
|
|
79
143
|
export async function refreshSession(request, response) {
|
|
80
144
|
const session = readSession(request);
|
|
81
145
|
if (!session?.refreshToken) return undefined;
|
|
@@ -209,6 +273,7 @@ const actions = {
|
|
|
209
273
|
establish(request, response, "/api/auth/signup"),
|
|
210
274
|
"/auth/verify-2fa": (request, response) =>
|
|
211
275
|
establish(request, response, "/api/auth/verify-2fa"),
|
|
276
|
+
"/auth/establish": establishFromEndpoint,
|
|
212
277
|
"/auth/switch-account": switchAccount,
|
|
213
278
|
"/auth/switch-tenant": switchTenant,
|
|
214
279
|
"/auth/validate-account": validateAccount,
|
|
@@ -17,7 +17,10 @@ const UUID =
|
|
|
17
17
|
function key() {
|
|
18
18
|
const secret = process.env.DMS_SESSION_SECRET;
|
|
19
19
|
if (!secret || secret.length < 32)
|
|
20
|
-
throw new Error(
|
|
20
|
+
throw new Error(
|
|
21
|
+
"DMS_SESSION_SECRET must contain at least 32 characters; " +
|
|
22
|
+
"generate one with: openssl rand -hex 32",
|
|
23
|
+
);
|
|
21
24
|
return createHash("sha256").update(secret).digest();
|
|
22
25
|
}
|
|
23
26
|
|
package/templates/vue/server.mjs
CHANGED
|
@@ -56,7 +56,7 @@ const AUTH_SERVER_ROUTES = [
|
|
|
56
56
|
["DELETE", /^\/api\/_auth\/session\/?$/],
|
|
57
57
|
[
|
|
58
58
|
"POST",
|
|
59
|
-
/^\/auth\/(?:login|signup|verify-2fa|request-2fa-email|switch-account|switch-tenant|validate-account|remove-account)\/?$/,
|
|
59
|
+
/^\/auth\/(?:login|signup|verify-2fa|establish|request-2fa-email|switch-account|switch-tenant|validate-account|remove-account)\/?$/,
|
|
60
60
|
],
|
|
61
61
|
["POST", /^\/auth\/oauth\/handoff\/?$/],
|
|
62
62
|
["GET", /^\/auth\/oauth\/[^/]+\/(?:start|callback)\/?$/],
|