@antelopejs/dms-frontend 0.1.2 → 0.1.4

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 CHANGED
@@ -162,6 +162,47 @@ one shorter than 32 characters — the login page at `/auth` fails the first
162
162
  sign-in attempt rather than starting degraded. Generate one with
163
163
  `openssl rand -hex 32`.
164
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
+
165
206
  ### Workspaces
166
207
 
167
208
  Each canonical backend URL gets its own owner-only workspace under
@@ -205,6 +246,7 @@ frontend-module registry drives server and client entries.
205
246
  | | `DMS_COOKIE_SECURE` | Secure cookies (`true` by default; `ajs dms dev` defaults to `false`) |
206
247
  | | `DMS_TRUSTED_PROXY_HOPS` | Number of trusted, rightmost reverse-proxy hops (default `0`) |
207
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) |
208
250
  | | `DMS_CLIENT_BASE_URL` | Public frontend URL used in generated links and emails |
209
251
 
210
252
  All of these can be set in the project's `.env` instead of the environment; see [Configuration](#configuration).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antelopejs/dms-frontend",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Frontend-agnostic loader for AntelopeJS DMS, shipping the Vue 3 renderer (Vite, Inertia, SSR)",
5
5
  "keywords": [
6
6
  "antelope",
@@ -50,36 +50,36 @@
50
50
  "chalk": "^4.1.2",
51
51
  "chokidar": "^4.0.3",
52
52
  "commander": "^13.1.0",
53
- "figlet": "^1.8.0",
53
+ "figlet": "^1.9.4",
54
54
  "ignore": "^7.0.5",
55
- "semver": "^7.7.0",
55
+ "semver": "^7.7.4",
56
56
  "unzipper": "^0.12.3"
57
57
  },
58
58
  "devDependencies": {
59
- "@antelopejs/tooling-configs": "^0.0.4",
60
- "@inertiajs/vue3": "^3.7.0",
59
+ "@antelopejs/tooling-configs": ">=0.0.6 <1.0.0",
60
+ "@inertiajs/vue3": "^3.7.1",
61
61
  "@types/figlet": "^1.7.0",
62
- "@types/node": "^24.0.0",
63
- "@types/semver": "^7.7.0",
62
+ "@types/node": "^24.8.1",
63
+ "@types/semver": "^7.7.1",
64
64
  "@types/unzipper": "^0.10.11",
65
- "@unhead/vue": "^2.0.14",
66
- "@vitejs/plugin-vue": "^6.0.0",
67
- "@vue/server-renderer": "^3.5.0",
65
+ "@unhead/vue": "^2.1.4",
66
+ "@vitejs/plugin-vue": "^6.0.1",
67
+ "@vue/server-renderer": "^3.5.25",
68
68
  "defu": "^6.1.4",
69
69
  "knip": "^6.34.0",
70
- "ofetch": "^1.4.1",
70
+ "ofetch": "^1.5.1",
71
71
  "oxfmt": "^0.66.0",
72
72
  "oxlint": "1.81.0",
73
73
  "oxlint-tsgolint": "^7.0.2001",
74
74
  "release-it": "^19.2.4",
75
75
  "release-it-changelogen": "^0.1.0",
76
- "rimraf": "^6.0.1",
76
+ "rimraf": "^6.1.2",
77
77
  "tsx": "^4.20.6",
78
- "typescript": "^5.8.2",
78
+ "typescript": "^5.9.3",
79
79
  "unplugin-auto-import": "^20.2.0",
80
- "vite": "^7.0.0",
81
- "vue": "^3.5.0",
82
- "vue-i18n": "^11.1.12"
80
+ "vite": "^7.2.4",
81
+ "vue": "^3.5.25",
82
+ "vue-i18n": "^11.2.7"
83
83
  },
84
84
  "peerDependencies": {
85
85
  "@antelopejs/core": ">=1.6.0 <2"
@@ -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 establish(request, response, endpoint) {
72
+ async function establishWith(request, response, endpoint, payload) {
68
73
  const result = await backend(endpoint, request, {
69
74
  method: "POST",
70
- body: await body(request),
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,
@@ -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)\/?$/],