@tokenoftrust/storefront-runner 4.8.0 → 4.8.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/apps/storefront/astro.config.mjs +2 -1
- package/apps/storefront/dev-plugins/dev-auth-outbox.mjs +181 -0
- package/apps/storefront/src/lib/access/auth/devAuth.ts +34 -2
- package/apps/storefront/src/lib/access/auth/route.ts +36 -2
- package/apps/storefront/src/lib/access/auth/totAccessClient.ts +6 -18
- package/apps/storefront/src/lib/access/dashboard/staffStoreEntry.ts +2 -1
- package/apps/storefront/src/lib/access/dashboard/staffVendorAccess.ts +6 -10
- package/apps/storefront/src/lib/access/shared/accessExplain.ts +106 -28
- package/apps/storefront/src/lib/access/shared/developerInvites.ts +40 -38
- package/apps/storefront/src/lib/access/shared/invitePreflight.ts +1 -1
- package/apps/storefront/src/lib/access/shared/principalGrants.ts +16 -17
- package/apps/storefront/src/lib/engagement/messaging/messagesClient.ts +13 -7
- package/apps/storefront/src/lib/observability/diag/failureReason.ts +37 -0
- package/apps/storefront/src/lib/observability/diag/redact.ts +11 -14
- package/apps/storefront/src/pages/api/auth/magic-exchange.ts +4 -7
- package/apps/storefront/src/pages/api/auth/send.ts +12 -12
- package/apps/storefront/src/pages/api/auth/step-up-verify.ts +2 -1
- package/apps/storefront/src/pages/api/auth/verify.ts +7 -3
- package/apps/storefront/src/pages/auth/login.astro +24 -6
- package/apps/storefront/src/pages/auth/magic.astro +4 -7
- package/package.json +1 -1
|
@@ -59,6 +59,7 @@ const gitSha = (() => {
|
|
|
59
59
|
import preact from "@astrojs/preact";
|
|
60
60
|
import tailwindcss from "@tailwindcss/vite";
|
|
61
61
|
import { tenantHotReload } from "./dev-plugins/tenant-hot-reload.mjs";
|
|
62
|
+
import { devAuthOutbox } from "./dev-plugins/dev-auth-outbox.mjs";
|
|
62
63
|
import { materializeGuard } from "./integrations/materialize-guard.mjs";
|
|
63
64
|
import { lifecycleHostAsset } from "./integrations/lifecycle-host-asset.mjs";
|
|
64
65
|
import { googleMeasurementSandboxAsset } from "./integrations/google-measurement-sandbox-asset.mjs";
|
|
@@ -133,7 +134,7 @@ export default defineConfig({
|
|
|
133
134
|
__CLI_INSTALL_SPEC__: JSON.stringify(cliInstallSpec),
|
|
134
135
|
__GIT_SHA__: JSON.stringify(gitSha),
|
|
135
136
|
},
|
|
136
|
-
plugins: [tailwindcss(), tenantHotReload()],
|
|
137
|
+
plugins: [tailwindcss(), tenantHotReload(), devAuthOutbox()],
|
|
137
138
|
optimizeDeps: {
|
|
138
139
|
// Vite's SSR optimizer can try to prebundle the Cloudflare adapter's
|
|
139
140
|
// worker entrypoints, then the worker runner looks for a generated
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { appendFileSync, chmodSync, mkdirSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
// DEV_AUTH=1 local sign-in code delivery. Dev-only (`apply: "serve"`), never reaches
|
|
8
|
+
// the production Worker.
|
|
9
|
+
//
|
|
10
|
+
// The one-time code is minted inside workerd (POST /api/auth/send), which has no host
|
|
11
|
+
// filesystem, so it hands the code to this Node-side endpoint. The endpoint appends it to
|
|
12
|
+
// apps/storefront/.dev-auth/outbox.txt (directory 0700, file 0600, gitignored) — readable
|
|
13
|
+
// only by the developer who owns the checkout. The code and the address are never written
|
|
14
|
+
// to the console or to any response.
|
|
15
|
+
//
|
|
16
|
+
// Wiring: on POST /api/auth/send this plugin stamps the request with the outbox URL and a
|
|
17
|
+
// per-process token (`x-tot-dev-auth-outbox`, `x-tot-dev-auth-outbox-token`). Both headers
|
|
18
|
+
// are overwritten on that route and stripped from every other request, so a browser can
|
|
19
|
+
// neither forge them nor learn the token.
|
|
20
|
+
|
|
21
|
+
export const OUTBOX_PATH = "/__tot/dev-auth-outbox";
|
|
22
|
+
export const OUTBOX_URL_HEADER = "x-tot-dev-auth-outbox";
|
|
23
|
+
export const OUTBOX_TOKEN_HEADER = "x-tot-dev-auth-outbox-token";
|
|
24
|
+
const SEND_ROUTE = "/api/auth/send";
|
|
25
|
+
|
|
26
|
+
const EMAIL_SHAPE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
27
|
+
const CODE_SHAPE = /^\d{6}$/;
|
|
28
|
+
|
|
29
|
+
/** Default outbox file: apps/storefront/.dev-auth/outbox.txt */
|
|
30
|
+
export const DEFAULT_OUTBOX_FILE = join(
|
|
31
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
32
|
+
"..",
|
|
33
|
+
".dev-auth",
|
|
34
|
+
"outbox.txt",
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
/** @param {any} req @returns {Promise<string>} */
|
|
38
|
+
function readBody(req) {
|
|
39
|
+
return new Promise((resolve) => {
|
|
40
|
+
let data = "";
|
|
41
|
+
req.on("data", (/** @type {any} */ c) => {
|
|
42
|
+
data += c;
|
|
43
|
+
if (data.length > 16 * 1024) req.destroy();
|
|
44
|
+
});
|
|
45
|
+
req.on("end", () => resolve(data));
|
|
46
|
+
req.on("error", () => resolve(data));
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** @param {string | undefined} address */
|
|
51
|
+
export function isLoopback(address) {
|
|
52
|
+
if (!address) return false;
|
|
53
|
+
return address === "::1" || address === "::ffff:127.0.0.1" || address.startsWith("127.");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** @param {string} a @param {string} b */
|
|
57
|
+
function tokenMatches(a, b) {
|
|
58
|
+
const x = Buffer.from(a);
|
|
59
|
+
const y = Buffer.from(b);
|
|
60
|
+
return x.length === y.length && timingSafeEqual(x, y);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The outbox endpoint as a plain function, so it is testable without a dev server.
|
|
65
|
+
* Returns the HTTP status it answered with.
|
|
66
|
+
*
|
|
67
|
+
* @param {{ outboxFile: string, token: string, log: (line: string) => void, now?: () => Date }} opts
|
|
68
|
+
*/
|
|
69
|
+
export function createOutboxHandler({ outboxFile, token, log, now = () => new Date() }) {
|
|
70
|
+
/**
|
|
71
|
+
* @param {{ remoteAddress?: string, headers: Record<string, string | string[] | undefined>, body: string }} input
|
|
72
|
+
* @returns {number}
|
|
73
|
+
*/
|
|
74
|
+
return function handle({ remoteAddress, headers, body }) {
|
|
75
|
+
if (!isLoopback(remoteAddress)) return 403;
|
|
76
|
+
const presented = headers[OUTBOX_TOKEN_HEADER];
|
|
77
|
+
if (typeof presented !== "string" || !tokenMatches(presented, token)) return 403;
|
|
78
|
+
/** @type {any} */
|
|
79
|
+
let parsed;
|
|
80
|
+
try {
|
|
81
|
+
parsed = JSON.parse(body || "{}");
|
|
82
|
+
} catch {
|
|
83
|
+
return 400;
|
|
84
|
+
}
|
|
85
|
+
const email = typeof parsed?.email === "string" ? parsed.email : "";
|
|
86
|
+
const code = typeof parsed?.code === "string" ? parsed.code : "";
|
|
87
|
+
const ttl = Number.isInteger(parsed?.ttlSeconds) && parsed.ttlSeconds > 0 ? parsed.ttlSeconds : 0;
|
|
88
|
+
if (!EMAIL_SHAPE.test(email) || !CODE_SHAPE.test(code) || /[\r\n]/.test(email)) return 400;
|
|
89
|
+
|
|
90
|
+
const at = now();
|
|
91
|
+
const expires = ttl ? new Date(at.getTime() + ttl * 1000).toISOString() : "unknown";
|
|
92
|
+
mkdirSync(dirname(outboxFile), { recursive: true, mode: 0o700 });
|
|
93
|
+
chmodSync(dirname(outboxFile), 0o700);
|
|
94
|
+
appendFileSync(
|
|
95
|
+
outboxFile,
|
|
96
|
+
`${at.toISOString()} ${email} code ${code} valid until ${expires} (single use)\n`,
|
|
97
|
+
{ mode: 0o600 },
|
|
98
|
+
);
|
|
99
|
+
chmodSync(outboxFile, 0o600);
|
|
100
|
+
log(`[DEV AUTH] sign-in code delivered to ${outboxFile} (owner-only)`);
|
|
101
|
+
return 204;
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Remove any client-supplied outbox headers, then set `set`. Both `headers` and
|
|
107
|
+
* `rawHeaders` are rewritten, because request adapters differ in which one they read.
|
|
108
|
+
*
|
|
109
|
+
* @param {any} req @param {Record<string, string>} set
|
|
110
|
+
*/
|
|
111
|
+
export function stampHeaders(req, set) {
|
|
112
|
+
const names = [OUTBOX_URL_HEADER, OUTBOX_TOKEN_HEADER];
|
|
113
|
+
for (const n of names) delete req.headers[n];
|
|
114
|
+
if (Array.isArray(req.rawHeaders)) {
|
|
115
|
+
/** @type {string[]} */
|
|
116
|
+
const kept = [];
|
|
117
|
+
for (let i = 0; i + 1 < req.rawHeaders.length; i += 2) {
|
|
118
|
+
if (!names.includes(String(req.rawHeaders[i]).toLowerCase())) {
|
|
119
|
+
kept.push(req.rawHeaders[i], req.rawHeaders[i + 1]);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
for (const [k, v] of Object.entries(set)) kept.push(k, v);
|
|
123
|
+
req.rawHeaders.length = 0;
|
|
124
|
+
req.rawHeaders.push(...kept);
|
|
125
|
+
}
|
|
126
|
+
for (const [k, v] of Object.entries(set)) req.headers[k] = v;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** @param {any} server @returns {string} */
|
|
130
|
+
function outboxUrl(server) {
|
|
131
|
+
const addr = server.httpServer?.address?.();
|
|
132
|
+
if (!addr || typeof addr === "string") return "";
|
|
133
|
+
const host = addr.family === "IPv6" || addr.family === 6 ? `[${addr.address === "::" ? "::1" : addr.address}]` : addr.address === "0.0.0.0" ? "127.0.0.1" : addr.address;
|
|
134
|
+
return `http://${host}:${addr.port}${OUTBOX_PATH}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** @param {{ outboxFile?: string }} [opts] */
|
|
138
|
+
export function devAuthOutbox(opts = {}) {
|
|
139
|
+
const outboxFile = opts.outboxFile ?? DEFAULT_OUTBOX_FILE;
|
|
140
|
+
const token = randomBytes(32).toString("hex");
|
|
141
|
+
const handle = createOutboxHandler({
|
|
142
|
+
outboxFile,
|
|
143
|
+
token,
|
|
144
|
+
// eslint-disable-next-line no-console -- pointer only; never the code or address
|
|
145
|
+
log: (line) => console.log(line),
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
/** @type {any} */
|
|
149
|
+
const plugin = {
|
|
150
|
+
name: "tot:dev-auth-outbox",
|
|
151
|
+
apply: "serve",
|
|
152
|
+
/** @param {any} server */
|
|
153
|
+
configureServer(server) {
|
|
154
|
+
server.middlewares.use(
|
|
155
|
+
async (/** @type {any} */ req, /** @type {any} */ res, /** @type {any} */ next) => {
|
|
156
|
+
const path = (req.url ?? "").split("?")[0];
|
|
157
|
+
if (path === OUTBOX_PATH) {
|
|
158
|
+
if (req.method !== "POST") {
|
|
159
|
+
res.statusCode = 405;
|
|
160
|
+
res.end();
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const status = handle({
|
|
164
|
+
remoteAddress: req.socket?.remoteAddress,
|
|
165
|
+
headers: req.headers,
|
|
166
|
+
body: await readBody(req),
|
|
167
|
+
});
|
|
168
|
+
res.statusCode = status;
|
|
169
|
+
res.setHeader("cache-control", "no-store");
|
|
170
|
+
res.end();
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const url = req.method === "POST" && path === SEND_ROUTE ? outboxUrl(server) : "";
|
|
174
|
+
stampHeaders(req, url ? { [OUTBOX_URL_HEADER]: url, [OUTBOX_TOKEN_HEADER]: token } : {});
|
|
175
|
+
next();
|
|
176
|
+
},
|
|
177
|
+
);
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
return plugin;
|
|
181
|
+
}
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* On localhost tot20 isn't reachable, so real sign-in/invite emails can't be sent
|
|
5
5
|
* or verified. This module lets a developer complete sign-in locally: `/api/auth/send`
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* stores a one-time code here and delivers it to the owner-private local outbox
|
|
7
|
+
* (`apps/storefront/.dev-auth/outbox.txt`, written by `dev-plugins/dev-auth-outbox.mjs`);
|
|
8
|
+
* `/api/auth/verify` consumes it and mints a session WITHOUT tot20/JWKS. The code is
|
|
9
|
+
* never printed to the console or returned in a response.
|
|
8
10
|
*
|
|
9
11
|
* SECURITY — this must never exist in a deployed build:
|
|
10
12
|
* 1. EVERY call site MUST wrap use in `if (import.meta.env.DEV)`. That is a STATIC
|
|
@@ -54,6 +56,36 @@ export async function storeDevCode(kv: CodeKv, email: string, code: string): Pro
|
|
|
54
56
|
});
|
|
55
57
|
}
|
|
56
58
|
|
|
59
|
+
/** Headers the dev-auth-outbox Vite plugin stamps onto POST /api/auth/send. */
|
|
60
|
+
export const DEV_AUTH_OUTBOX_URL_HEADER = "x-tot-dev-auth-outbox";
|
|
61
|
+
export const DEV_AUTH_OUTBOX_TOKEN_HEADER = "x-tot-dev-auth-outbox-token";
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Hand `code` to the dev server's owner-private outbox. Returns false when the outbox
|
|
65
|
+
* plugin isn't wired (no stamped headers) or the write fails; never throws, and never
|
|
66
|
+
* logs the code or the address.
|
|
67
|
+
*/
|
|
68
|
+
export async function deliverDevCode(
|
|
69
|
+
request: Request,
|
|
70
|
+
email: string,
|
|
71
|
+
code: string,
|
|
72
|
+
fetchImpl: typeof fetch = fetch,
|
|
73
|
+
): Promise<boolean> {
|
|
74
|
+
const url = request.headers.get(DEV_AUTH_OUTBOX_URL_HEADER);
|
|
75
|
+
const token = request.headers.get(DEV_AUTH_OUTBOX_TOKEN_HEADER);
|
|
76
|
+
if (!url || !token) return false;
|
|
77
|
+
try {
|
|
78
|
+
const res = await fetchImpl(url, {
|
|
79
|
+
method: "POST",
|
|
80
|
+
headers: { "content-type": "application/json", [DEV_AUTH_OUTBOX_TOKEN_HEADER]: token },
|
|
81
|
+
body: JSON.stringify({ email, code, ttlSeconds: DEV_AUTH_CODE_TTL_SECONDS }),
|
|
82
|
+
});
|
|
83
|
+
return res.status === 204;
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
57
89
|
/**
|
|
58
90
|
* One-time consume: returns true iff `code` matches the stored code for `email`,
|
|
59
91
|
* deleting it on success so a code can't be replayed.
|
|
@@ -171,7 +171,41 @@ export function safeNext(next: string | null | undefined): string {
|
|
|
171
171
|
return next;
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
-
/**
|
|
174
|
+
/**
|
|
175
|
+
* The address being signed in, carried between the login steps. It rides an HttpOnly
|
|
176
|
+
* cookie rather than the redirect URL, so it never lands in access logs, browser
|
|
177
|
+
* history, Referer headers or analytics. Its lifetime matches a sign-in code's.
|
|
178
|
+
*/
|
|
179
|
+
export const LOGIN_EMAIL_COOKIE = "tot_login_email";
|
|
180
|
+
export const LOGIN_EMAIL_MAX_AGE_SECONDS = 600;
|
|
181
|
+
|
|
182
|
+
type CookieContext = { cookies: APIContext["cookies"] };
|
|
183
|
+
|
|
184
|
+
export function setLoginEmailCookie(context: CookieContext, email: string): void {
|
|
185
|
+
context.cookies.set(LOGIN_EMAIL_COOKIE, email, {
|
|
186
|
+
httpOnly: true,
|
|
187
|
+
secure: true,
|
|
188
|
+
sameSite: "lax",
|
|
189
|
+
path: "/",
|
|
190
|
+
maxAge: LOGIN_EMAIL_MAX_AGE_SECONDS,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The in-progress sign-in address, or "" when absent or not email-shaped. */
|
|
195
|
+
export function readLoginEmail(context: CookieContext): string {
|
|
196
|
+
const value = context.cookies.get(LOGIN_EMAIL_COOKIE)?.value ?? "";
|
|
197
|
+
return EMAIL_RE.test(value) ? value : "";
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function clearLoginEmailCookie(context: CookieContext): void {
|
|
201
|
+
context.cookies.delete(LOGIN_EMAIL_COOKIE, { path: "/" });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Redirect back to the login page carrying step-2 state, no-JS-safe. The query holds
|
|
206
|
+
* only finite state (`sent`, `error`, `reason`, `requiredAcr`, `next`); a supplied
|
|
207
|
+
* `email` goes to the HttpOnly login-email cookie, never the URL.
|
|
208
|
+
*/
|
|
175
209
|
export function loginRedirect(
|
|
176
210
|
context: APIContext,
|
|
177
211
|
params: {
|
|
@@ -189,9 +223,9 @@ export function loginRedirect(
|
|
|
189
223
|
requiredAcr?: string;
|
|
190
224
|
},
|
|
191
225
|
): Response {
|
|
226
|
+
if (params.email) setLoginEmailCookie(context, params.email);
|
|
192
227
|
const q = new URLSearchParams();
|
|
193
228
|
if (params.sent) q.set("sent", "1");
|
|
194
|
-
if (params.email) q.set("email", params.email);
|
|
195
229
|
if (params.error) q.set("error", params.error);
|
|
196
230
|
if (params.reason) q.set("reason", params.reason);
|
|
197
231
|
if (params.requiredAcr) q.set("requiredAcr", params.requiredAcr);
|
|
@@ -27,7 +27,6 @@
|
|
|
27
27
|
* bodies, response shapes) is contract-fixed.
|
|
28
28
|
*/
|
|
29
29
|
import { readEnv } from "@/lib/publication/shared/env";
|
|
30
|
-
import { diagEmail } from "@/lib/observability/diag/redact";
|
|
31
30
|
import { FetchHttpTransport, type HttpResponseLike, type HttpTransport } from "@/lib/developer-experience/http/fetchTransport";
|
|
32
31
|
import { classifyStepUp, type StepUpChallenge, type StepUpObligation } from "./stepUpChallenge.js";
|
|
33
32
|
|
|
@@ -249,26 +248,15 @@ export class HttpTotAccessClient implements TotAccessClient {
|
|
|
249
248
|
{},
|
|
250
249
|
{ email: email.toLowerCase(), ...this.credentialFields() },
|
|
251
250
|
);
|
|
252
|
-
|
|
251
|
+
// Finite fields only: no address, domain, or upstream body content.
|
|
253
252
|
try {
|
|
254
|
-
|
|
255
|
-
} catch {
|
|
256
|
-
body = "(non-JSON)";
|
|
257
|
-
}
|
|
258
|
-
try {
|
|
259
|
-
console.info(
|
|
260
|
-
"[diag:federated-send]",
|
|
261
|
-
JSON.stringify({ email: diagEmail(email.toLowerCase()), status: res.status, body }),
|
|
262
|
-
);
|
|
253
|
+
console.info("[diag:federated-send]", JSON.stringify({ status: res.status }));
|
|
263
254
|
} catch {
|
|
264
255
|
/* never break */
|
|
265
256
|
}
|
|
266
257
|
} catch (e) {
|
|
267
258
|
try {
|
|
268
|
-
console.warn(
|
|
269
|
-
"[diag:federated-send] transport error",
|
|
270
|
-
e instanceof Error ? e.message : String(e),
|
|
271
|
-
);
|
|
259
|
+
console.warn("[diag:federated-send] transport error");
|
|
272
260
|
} catch {
|
|
273
261
|
/* never break */
|
|
274
262
|
}
|
|
@@ -333,9 +321,9 @@ export class HttpTotAccessClient implements TotAccessClient {
|
|
|
333
321
|
...this.credentialFields(),
|
|
334
322
|
},
|
|
335
323
|
);
|
|
336
|
-
} catch
|
|
324
|
+
} catch {
|
|
337
325
|
try {
|
|
338
|
-
console.warn("[diag:mint] transport error"
|
|
326
|
+
console.warn("[diag:mint] transport error");
|
|
339
327
|
} catch {
|
|
340
328
|
/* never break */
|
|
341
329
|
}
|
|
@@ -353,7 +341,7 @@ export class HttpTotAccessClient implements TotAccessClient {
|
|
|
353
341
|
// (only present on success) is never in this branch, so nothing sensitive leaks.
|
|
354
342
|
if (!result.ok) {
|
|
355
343
|
try {
|
|
356
|
-
console.warn("[diag:mint]", JSON.stringify({ status: res.status
|
|
344
|
+
console.warn("[diag:mint]", JSON.stringify({ status: res.status }));
|
|
357
345
|
} catch {
|
|
358
346
|
/* never break */
|
|
359
347
|
}
|
|
@@ -21,6 +21,7 @@ import { updateSession } from "@/lib/access/auth/session";
|
|
|
21
21
|
import { isStaffSession, buildStaffSelection } from "./staffAdmission";
|
|
22
22
|
import {
|
|
23
23
|
createStaffVendorAccessClient,
|
|
24
|
+
staffAccessFailureMessage,
|
|
24
25
|
staffAccessHttpStatus,
|
|
25
26
|
StaffVendorAccessClientError,
|
|
26
27
|
} from "./staffVendorAccess";
|
|
@@ -89,7 +90,7 @@ export async function enterStoreAsStaff(input: StaffStoreEntryInput): Promise<St
|
|
|
89
90
|
return {
|
|
90
91
|
ok: false,
|
|
91
92
|
status: staffAccessHttpStatus(result.reason, result.status),
|
|
92
|
-
body: { error: result.
|
|
93
|
+
body: { error: staffAccessFailureMessage(result.reason), reason: result.reason, code: result.code },
|
|
93
94
|
};
|
|
94
95
|
}
|
|
95
96
|
|
|
@@ -188,9 +188,7 @@ export class HttpStaffVendorAccessClient implements StaffVendorAccessClient {
|
|
|
188
188
|
ok: false,
|
|
189
189
|
status: 0,
|
|
190
190
|
reason: "error",
|
|
191
|
-
message:
|
|
192
|
-
"staffVendorAccess: transport failure searching vendors " +
|
|
193
|
-
(cause instanceof Error ? cause.message : String(cause)),
|
|
191
|
+
message: "staffVendorAccess: transport failure searching vendors",
|
|
194
192
|
};
|
|
195
193
|
}
|
|
196
194
|
let parsed: unknown;
|
|
@@ -228,9 +226,7 @@ export class HttpStaffVendorAccessClient implements StaffVendorAccessClient {
|
|
|
228
226
|
ok: false,
|
|
229
227
|
status: 0,
|
|
230
228
|
reason: "error",
|
|
231
|
-
message:
|
|
232
|
-
"staffVendorAccess: transport failure granting access " +
|
|
233
|
-
(cause instanceof Error ? cause.message : String(cause)),
|
|
229
|
+
message: "staffVendorAccess: transport failure granting access",
|
|
234
230
|
};
|
|
235
231
|
}
|
|
236
232
|
let parsed: unknown;
|
|
@@ -295,9 +291,8 @@ function failure(
|
|
|
295
291
|
c: Record<string, unknown>,
|
|
296
292
|
): { ok: false; status: number; reason: StaffAccessFailureReason; message: string; code?: string } {
|
|
297
293
|
const code = typeof c.code === "string" ? c.code : undefined;
|
|
298
|
-
const message = typeof c.message === "string" ? c.message : undefined;
|
|
299
294
|
const reason = reasonFor(code, status);
|
|
300
|
-
return { ok: false, status, reason, message:
|
|
295
|
+
return { ok: false, status, reason, message: staffAccessFailureMessage(reason), ...(code ? { code } : {}) };
|
|
301
296
|
}
|
|
302
297
|
|
|
303
298
|
function reasonFor(code: string | undefined, status: number): StaffAccessFailureReason {
|
|
@@ -340,7 +335,8 @@ export function staffAccessHttpStatus(reason: StaffAccessFailureReason, upstream
|
|
|
340
335
|
}
|
|
341
336
|
}
|
|
342
337
|
|
|
343
|
-
|
|
338
|
+
/** Finite staff-facing copy for a staff-access refusal — the only text a route returns. */
|
|
339
|
+
export function staffAccessFailureMessage(reason: StaffAccessFailureReason): string {
|
|
344
340
|
switch (reason) {
|
|
345
341
|
case "notStaff":
|
|
346
342
|
return "this account doesn't hold ToT-staff access to that vendor";
|
|
@@ -351,7 +347,7 @@ function defaultMessage(reason: StaffAccessFailureReason, status: number): strin
|
|
|
351
347
|
case "invalid":
|
|
352
348
|
return "a valid vendor is required";
|
|
353
349
|
default:
|
|
354
|
-
return
|
|
350
|
+
return "Token of Trust couldn't complete the staff-access request; try again shortly";
|
|
355
351
|
}
|
|
356
352
|
}
|
|
357
353
|
|
|
@@ -41,19 +41,109 @@ export interface AccessExplanation {
|
|
|
41
41
|
steps: ExplainStep[];
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
/** The
|
|
44
|
+
/** The steps tot-mcp's explain can fail on (tot-mcp access-explain-advice.ts `AccessStep`). */
|
|
45
|
+
export const ACCESS_STEPS = [
|
|
46
|
+
"identity",
|
|
47
|
+
"broker",
|
|
48
|
+
"tenant-context",
|
|
49
|
+
"capability-row",
|
|
50
|
+
"row-resolution",
|
|
51
|
+
"repo-provisioned",
|
|
52
|
+
"session-scope",
|
|
53
|
+
"checkout",
|
|
54
|
+
] as const;
|
|
55
|
+
|
|
56
|
+
export type AccessStep = (typeof ACCESS_STEPS)[number];
|
|
57
|
+
export type VerdictStep = AccessStep | "unknown";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The reason and fix shown for each failed step. The storefront owns this copy: tot-mcp's
|
|
61
|
+
* own `reason`/`fix` are fixed text today, but the granter's page and its logs must not
|
|
62
|
+
* depend on a remote service never putting identity detail there.
|
|
63
|
+
*/
|
|
64
|
+
export const STEP_ADVICE = {
|
|
65
|
+
// Each value is ONE string literal: `"a" + "b"` widens to `string` even under
|
|
66
|
+
// `as const`, which would make StepReason/StepFix free text again.
|
|
67
|
+
identity: {
|
|
68
|
+
reason:
|
|
69
|
+
"This person has no Token of Trust identity enrolled with tot-mcp yet, so no session of theirs can resolve any store.",
|
|
70
|
+
fix: "Have them accept the invite and sign in once (`tot login`, or connect the tot MCP and complete sign-in). If they already have, run identity_link_begin from their session; enrollment did not happen at sign-in.",
|
|
71
|
+
},
|
|
72
|
+
broker: {
|
|
73
|
+
reason: "Token of Trust did not answer the store enumeration for this identity.",
|
|
74
|
+
fix: "Retry in a minute. If it persists, the broker is failing for this identity — file it with this result attached.",
|
|
75
|
+
},
|
|
76
|
+
"tenant-context": {
|
|
77
|
+
reason:
|
|
78
|
+
"Token of Trust does not list this store for this identity: the invite is still pending, or it was accepted under a different email address.",
|
|
79
|
+
fix: "Confirm the invitee accepted from the exact address invited. If they accepted from a different address, resend the invite to the address they sign in with.",
|
|
80
|
+
},
|
|
81
|
+
"capability-row": {
|
|
82
|
+
reason: "No invite record exists for this person on this store.",
|
|
83
|
+
fix: "Resend the invite.",
|
|
84
|
+
},
|
|
85
|
+
"row-resolution": {
|
|
86
|
+
reason:
|
|
87
|
+
"The invite record and the sign-in read disagree about this person — the two paths resolve different identities.",
|
|
88
|
+
fix: "This is a tot-mcp defect, not a setup problem. File it with this result attached.",
|
|
89
|
+
},
|
|
90
|
+
"repo-provisioned": {
|
|
91
|
+
reason: "The store has no provisioned code repository, so there is nothing to check out.",
|
|
92
|
+
fix: "Provision the store repository first (Team page \"Provision now\", or repo_provision).",
|
|
93
|
+
},
|
|
94
|
+
"session-scope": {
|
|
95
|
+
reason:
|
|
96
|
+
"Token of Trust lists the store for this identity, but a fresh session built from that answer does not hold it.",
|
|
97
|
+
fix: "This is a tot-mcp defect, not a setup problem. File it with this result attached.",
|
|
98
|
+
},
|
|
99
|
+
checkout: {
|
|
100
|
+
reason: "A session holds the store, but checkout cannot resolve its repository.",
|
|
101
|
+
fix: "Provision the store repository, or resend the invite so it records the app binding.",
|
|
102
|
+
},
|
|
103
|
+
unknown: {
|
|
104
|
+
reason: "Access does not resolve.",
|
|
105
|
+
fix: "Run access_explain for this developer and store.",
|
|
106
|
+
},
|
|
107
|
+
} as const satisfies Record<VerdictStep, { reason: string; fix: string }>;
|
|
108
|
+
|
|
109
|
+
export type StepReason = (typeof STEP_ADVICE)[VerdictStep]["reason"];
|
|
110
|
+
export type StepFix = (typeof STEP_ADVICE)[VerdictStep]["fix"];
|
|
111
|
+
|
|
112
|
+
/** Why a verification could not be completed; never upstream text. */
|
|
113
|
+
export const UNAVAILABLE_REASONS = [
|
|
114
|
+
"verification is not configured here",
|
|
115
|
+
"the tot-mcp address is malformed",
|
|
116
|
+
"tot-mcp did not answer",
|
|
117
|
+
"tot-mcp refused the verification request",
|
|
118
|
+
"tot-mcp sent an unreadable answer",
|
|
119
|
+
"not verified",
|
|
120
|
+
] as const;
|
|
121
|
+
|
|
122
|
+
export type UnavailableReason = (typeof UNAVAILABLE_REASONS)[number];
|
|
123
|
+
|
|
124
|
+
const STEPS: ReadonlySet<string> = new Set(ACCESS_STEPS);
|
|
125
|
+
|
|
126
|
+
/** The finite step for a value tot-mcp sent ("unknown" when it is not one we know). */
|
|
127
|
+
export function verdictStepOf(value: unknown): VerdictStep {
|
|
128
|
+
return typeof value === "string" && STEPS.has(value) ? (value as AccessStep) : "unknown";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The capability a verified invitee holds (the invite always writes this one). */
|
|
132
|
+
const VERIFIED_CAPABILITY = "preview-access";
|
|
133
|
+
|
|
134
|
+
/** The granter-facing verdict. Every string in it is storefront-owned, finite copy. */
|
|
45
135
|
export type AccessVerification =
|
|
46
|
-
| { state: "verified"; capability:
|
|
136
|
+
| { state: "verified"; capability: typeof VERIFIED_CAPABILITY; expiry: "none"; advice: "tot-mcp" }
|
|
47
137
|
| {
|
|
48
138
|
state: "awaiting-acceptance";
|
|
49
139
|
inviteStatus: "pending" | "accepted";
|
|
50
|
-
failedStep:
|
|
51
|
-
reason:
|
|
52
|
-
fix:
|
|
140
|
+
failedStep: VerdictStep;
|
|
141
|
+
reason: StepReason;
|
|
142
|
+
fix: StepFix;
|
|
53
143
|
advice: "tot-mcp";
|
|
54
144
|
}
|
|
55
|
-
| { state: "broken"; failedStep:
|
|
56
|
-
| { state: "unavailable"; reason:
|
|
145
|
+
| { state: "broken"; failedStep: VerdictStep; reason: StepReason; fix: StepFix; advice: "tot-mcp" }
|
|
146
|
+
| { state: "unavailable"; reason: UnavailableReason; advice: "tot-mcp" };
|
|
57
147
|
|
|
58
148
|
/** POST transport seam (injectable for tests). */
|
|
59
149
|
export interface ExplainTransport {
|
|
@@ -88,18 +178,14 @@ export function classifyExplanation(
|
|
|
88
178
|
inviteStatus: "pending" | "accepted",
|
|
89
179
|
): AccessVerification {
|
|
90
180
|
if (explanation.verified) {
|
|
91
|
-
return {
|
|
92
|
-
state: "verified",
|
|
93
|
-
capability: explanation.capability ?? "preview-access",
|
|
94
|
-
expiry: "none",
|
|
95
|
-
advice: "tot-mcp",
|
|
96
|
-
};
|
|
181
|
+
return { state: "verified", capability: VERIFIED_CAPABILITY, expiry: "none", advice: "tot-mcp" };
|
|
97
182
|
}
|
|
98
183
|
const failed = explanation.steps.find((s) => s.step === explanation.failedStep);
|
|
184
|
+
const failedStep = verdictStepOf(explanation.failedStep);
|
|
99
185
|
const verdict = {
|
|
100
|
-
failedStep
|
|
101
|
-
reason:
|
|
102
|
-
fix:
|
|
186
|
+
failedStep,
|
|
187
|
+
reason: STEP_ADVICE[failedStep].reason,
|
|
188
|
+
fix: STEP_ADVICE[failedStep].fix,
|
|
103
189
|
advice: "tot-mcp" as const,
|
|
104
190
|
};
|
|
105
191
|
// Accepting on the storefront does not enroll the tot identity — the developer's first
|
|
@@ -133,14 +219,10 @@ export async function explainDeveloperAccess(
|
|
|
133
219
|
return { state: "unavailable", reason: "the tot-mcp address is malformed", advice: "tot-mcp" };
|
|
134
220
|
}
|
|
135
221
|
if (outcome.kind === "transport-error") {
|
|
136
|
-
return {
|
|
137
|
-
state: "unavailable",
|
|
138
|
-
reason: "tot-mcp did not answer: " + outcome.message,
|
|
139
|
-
advice: "tot-mcp",
|
|
140
|
-
};
|
|
222
|
+
return { state: "unavailable", reason: "tot-mcp did not answer", advice: "tot-mcp" };
|
|
141
223
|
}
|
|
142
224
|
if (outcome.kind === "misconfigured" || outcome.kind === "http-error") {
|
|
143
|
-
return { state: "unavailable", reason:
|
|
225
|
+
return { state: "unavailable", reason: "tot-mcp refused the verification request", advice: "tot-mcp" };
|
|
144
226
|
}
|
|
145
227
|
const res = outcome;
|
|
146
228
|
try {
|
|
@@ -149,11 +231,7 @@ export async function explainDeveloperAccess(
|
|
|
149
231
|
return { state: "unavailable", reason: "tot-mcp sent an unreadable answer", advice: "tot-mcp" };
|
|
150
232
|
}
|
|
151
233
|
return classifyExplanation(explanation, inviteStatus);
|
|
152
|
-
} catch
|
|
153
|
-
return {
|
|
154
|
-
state: "unavailable",
|
|
155
|
-
reason: "tot-mcp did not answer: " + (cause instanceof Error ? cause.message : String(cause)),
|
|
156
|
-
advice: "tot-mcp",
|
|
157
|
-
};
|
|
234
|
+
} catch {
|
|
235
|
+
return { state: "unavailable", reason: "tot-mcp sent an unreadable answer", advice: "tot-mcp" };
|
|
158
236
|
}
|
|
159
237
|
}
|
|
@@ -54,9 +54,9 @@
|
|
|
54
54
|
*/
|
|
55
55
|
import { readEnv } from "@/lib/publication/shared/env";
|
|
56
56
|
import { callMcpService } from "@/lib/developer-experience/http/mcpServiceCall";
|
|
57
|
-
import { diagEmail } from "@/lib/observability/diag/redact";
|
|
58
57
|
import { checkStorePreviewReady, type PreflightDecision } from "@/lib/access/shared/invitePreflight";
|
|
59
58
|
import { explainDeveloperAccess, type AccessVerification } from "@/lib/access/shared/accessExplain";
|
|
59
|
+
import { failureReasonForStatus, type FailureReason } from "@/lib/observability/diag/failureReason";
|
|
60
60
|
|
|
61
61
|
/**
|
|
62
62
|
* The header carrying the storefront-signed staff BROKER ASSERTION to tot20
|
|
@@ -444,8 +444,6 @@ export class HttpDeveloperInviteClient implements DeveloperInviteClient {
|
|
|
444
444
|
// the email-send trace). `signInUrlHost` is the top allowlist suspect.
|
|
445
445
|
logInviteDiag("request", {
|
|
446
446
|
endpoint: `${this.baseUrl}/api/clients/developerInvites`,
|
|
447
|
-
tenant: input.tenant,
|
|
448
|
-
email: diagEmail(input.email.toLowerCase()),
|
|
449
447
|
sendEmail: input.sendEmail ?? "(unset → tot20 default)",
|
|
450
448
|
hasSignInUrl: !!input.signInUrl,
|
|
451
449
|
signInUrlHost: safeHost(input.signInUrl),
|
|
@@ -460,14 +458,12 @@ export class HttpDeveloperInviteClient implements DeveloperInviteClient {
|
|
|
460
458
|
headers,
|
|
461
459
|
body,
|
|
462
460
|
);
|
|
463
|
-
} catch
|
|
461
|
+
} catch {
|
|
464
462
|
return {
|
|
465
463
|
ok: false,
|
|
466
464
|
status: 0,
|
|
467
465
|
reason: "error",
|
|
468
|
-
message:
|
|
469
|
-
"developerInvites: transport failure calling tot20 " +
|
|
470
|
-
(cause instanceof Error ? cause.message : String(cause)),
|
|
466
|
+
message: "developerInvites: transport failure calling tot20",
|
|
471
467
|
};
|
|
472
468
|
}
|
|
473
469
|
|
|
@@ -486,19 +482,17 @@ export class HttpDeveloperInviteClient implements DeveloperInviteClient {
|
|
|
486
482
|
// DIAGNOSTIC: tot20's decision — the crux is `emailed`. `changed:false, emailed:false`
|
|
487
483
|
// means tot20 authorized + already had the role but did NOT send (the gate we're hunting).
|
|
488
484
|
logInviteDiag("response", {
|
|
489
|
-
tenant: input.tenant,
|
|
490
|
-
email: diagEmail(input.email.toLowerCase()),
|
|
491
485
|
httpStatus: res.status,
|
|
492
486
|
ok: result.ok,
|
|
493
487
|
...(result.ok
|
|
494
488
|
? {
|
|
495
489
|
changed: result.changed,
|
|
496
490
|
emailed: result.emailed,
|
|
497
|
-
|
|
498
|
-
|
|
491
|
+
hasEmailReason: !!result.emailReason,
|
|
492
|
+
hasEmailDetail: !!result.emailDetail,
|
|
499
493
|
signInEligible: result.signInEligible,
|
|
500
494
|
}
|
|
501
|
-
: { reason: result.reason,
|
|
495
|
+
: { reason: result.reason, hasCode: !!result.code }),
|
|
502
496
|
});
|
|
503
497
|
return result;
|
|
504
498
|
}
|
|
@@ -529,14 +523,12 @@ export class HttpDeveloperInviteClient implements DeveloperInviteClient {
|
|
|
529
523
|
`${this.baseUrl}/api/clients/developerInvites?${q.toString()}`,
|
|
530
524
|
headers,
|
|
531
525
|
);
|
|
532
|
-
} catch
|
|
526
|
+
} catch {
|
|
533
527
|
return {
|
|
534
528
|
ok: false,
|
|
535
529
|
status: 0,
|
|
536
530
|
reason: "error",
|
|
537
|
-
message:
|
|
538
|
-
"developerInvites: transport failure listing invites " +
|
|
539
|
-
(cause instanceof Error ? cause.message : String(cause)),
|
|
531
|
+
message: "developerInvites: transport failure listing invites",
|
|
540
532
|
};
|
|
541
533
|
}
|
|
542
534
|
let parsed: unknown;
|
|
@@ -576,14 +568,12 @@ export class HttpDeveloperInviteClient implements DeveloperInviteClient {
|
|
|
576
568
|
`${this.baseUrl}/api/clients/developerInvites/${encodeURIComponent(inviteId)}`,
|
|
577
569
|
headers,
|
|
578
570
|
);
|
|
579
|
-
} catch
|
|
571
|
+
} catch {
|
|
580
572
|
return {
|
|
581
573
|
ok: false,
|
|
582
574
|
status: 0,
|
|
583
575
|
reason: "error",
|
|
584
|
-
message:
|
|
585
|
-
"developerInvites: transport failure revoking invite " +
|
|
586
|
-
(cause instanceof Error ? cause.message : String(cause)),
|
|
576
|
+
message: "developerInvites: transport failure revoking invite",
|
|
587
577
|
};
|
|
588
578
|
}
|
|
589
579
|
let parsed: unknown;
|
|
@@ -801,6 +791,29 @@ export function developerInviteHttpStatus(
|
|
|
801
791
|
}
|
|
802
792
|
}
|
|
803
793
|
|
|
794
|
+
/**
|
|
795
|
+
* Client-facing copy for a developer-invite failure. Routes answer with this, never with
|
|
796
|
+
* the result's `message` (which can carry tot20's own text).
|
|
797
|
+
*/
|
|
798
|
+
export function developerInviteFailureMessage(reason: DeveloperInviteFailureReason): string {
|
|
799
|
+
switch (reason) {
|
|
800
|
+
case "notGranted":
|
|
801
|
+
return "storefront isn't authorized to manage developers on this vendor";
|
|
802
|
+
case "notAuthenticated":
|
|
803
|
+
return "storefront's operator credential was rejected";
|
|
804
|
+
case "tenantRequired":
|
|
805
|
+
return "a target vendor is required";
|
|
806
|
+
case "invalidEmail":
|
|
807
|
+
return "a valid developer email is required";
|
|
808
|
+
case "tenantNotFound":
|
|
809
|
+
return "this vendor isn't a resolvable Token of Trust app";
|
|
810
|
+
case "storeNotProvisioned":
|
|
811
|
+
return "this vendor's store repo isn't provisioned on this environment yet";
|
|
812
|
+
default:
|
|
813
|
+
return "Token of Trust couldn't complete the request; try again shortly";
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
804
817
|
function defaultMessage(
|
|
805
818
|
reason: DeveloperInviteFailureReason,
|
|
806
819
|
status: number,
|
|
@@ -1108,9 +1121,9 @@ export async function recordDeveloperAppBinding(
|
|
|
1108
1121
|
export type AcceptInviteResult =
|
|
1109
1122
|
| { accepted: true; changed: boolean }
|
|
1110
1123
|
// Not attempted (seam unconfigured) or no invite to accept — not a hard failure.
|
|
1111
|
-
| { accepted: false; skipped: true; reason:
|
|
1124
|
+
| { accepted: false; skipped: true; reason: "no-tenant-or-email" | "not-configured" | "no-invite-for-email" }
|
|
1112
1125
|
// Attempted but failed for a real reason (auth / store fault / transport).
|
|
1113
|
-
| { accepted: false; skipped: false; reason:
|
|
1126
|
+
| { accepted: false; skipped: false; reason: FailureReason };
|
|
1114
1127
|
|
|
1115
1128
|
/**
|
|
1116
1129
|
* Mark a developer invite ACCEPTED at the magic-link confirm (#8425 accept-before-
|
|
@@ -1167,19 +1180,9 @@ export async function acceptDeveloperInvite(
|
|
|
1167
1180
|
if (res.status === 404) {
|
|
1168
1181
|
return { accepted: false, skipped: true, reason: "no-invite-for-email" };
|
|
1169
1182
|
}
|
|
1170
|
-
return {
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
reason: `tot20 accept returned status ${res.status}`,
|
|
1174
|
-
};
|
|
1175
|
-
} catch (cause) {
|
|
1176
|
-
return {
|
|
1177
|
-
accepted: false,
|
|
1178
|
-
skipped: false,
|
|
1179
|
-
reason:
|
|
1180
|
-
"developerInvites accept transport failure: " +
|
|
1181
|
-
(cause instanceof Error ? cause.message : String(cause)),
|
|
1182
|
-
};
|
|
1183
|
+
return { accepted: false, skipped: false, reason: failureReasonForStatus(res.status) };
|
|
1184
|
+
} catch {
|
|
1185
|
+
return { accepted: false, skipped: false, reason: "transport" };
|
|
1183
1186
|
}
|
|
1184
1187
|
}
|
|
1185
1188
|
|
|
@@ -1258,9 +1261,8 @@ export async function inviteDeveloperToTenant(
|
|
|
1258
1261
|
const preflight = deps.preflight ?? checkStorePreviewReady;
|
|
1259
1262
|
const ready = await preflight(input.tenant);
|
|
1260
1263
|
if (ready.block) {
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
);
|
|
1264
|
+
// Finite fields only: no tenant, no free-form preflight reason.
|
|
1265
|
+
console.warn("[developerInvites] invite blocked by preflight");
|
|
1264
1266
|
return {
|
|
1265
1267
|
invite: {
|
|
1266
1268
|
ok: false,
|
|
@@ -82,7 +82,7 @@ export async function checkStorePreviewReady(
|
|
|
82
82
|
if (outcome.kind === "not-configured") return { block: false, reason: "not-configured" };
|
|
83
83
|
if (outcome.kind === "bad-url") return { block: false, reason: "bad-mcp-url" };
|
|
84
84
|
if (outcome.kind === "transport-error") {
|
|
85
|
-
return { block: false, reason: "preflight request failed
|
|
85
|
+
return { block: false, reason: "preflight request failed" };
|
|
86
86
|
}
|
|
87
87
|
if (outcome.kind === "misconfigured") {
|
|
88
88
|
return { block: false, reason: `preflight misconfigured: status ${outcome.status}` };
|
|
@@ -76,7 +76,6 @@
|
|
|
76
76
|
* swaps in the Http client with no call-site change.
|
|
77
77
|
*/
|
|
78
78
|
import { readEnv } from "@/lib/publication/shared/env";
|
|
79
|
-
import { diagEmail } from "@/lib/observability/diag/redact";
|
|
80
79
|
import { BROKER_ASSERTION_HEADER, BROKER_PRIVATE_KEY_ENV } from "@/lib/access/auth/brokerAssertion";
|
|
81
80
|
|
|
82
81
|
/** A human grantee — the only principal type this HTTP surface accepts. */
|
|
@@ -285,8 +284,6 @@ export class HttpPrincipalGrantClient implements PrincipalGrantClient {
|
|
|
285
284
|
|
|
286
285
|
logGrantDiag("request", {
|
|
287
286
|
endpoint: `${this.baseUrl}/api/clients/principalGrants`,
|
|
288
|
-
tenant: input.tenant,
|
|
289
|
-
email: diagEmail(input.principal.email.toLowerCase()),
|
|
290
287
|
capability: input.capability,
|
|
291
288
|
expiresAt: input.expiresAt,
|
|
292
289
|
});
|
|
@@ -298,17 +295,18 @@ export class HttpPrincipalGrantClient implements PrincipalGrantClient {
|
|
|
298
295
|
this.brokeredHeaders(assertion, false),
|
|
299
296
|
body,
|
|
300
297
|
);
|
|
301
|
-
} catch
|
|
302
|
-
return transportFailure("creating principal grant"
|
|
298
|
+
} catch {
|
|
299
|
+
return transportFailure("creating principal grant");
|
|
303
300
|
}
|
|
304
301
|
const parsed = await safeJson(res, "creating principal grant");
|
|
305
302
|
if ("failed" in parsed) return parsed.failed;
|
|
306
303
|
const result = normalizeCreateResponse(res.status, parsed.body);
|
|
307
304
|
logGrantDiag("response", {
|
|
308
|
-
tenant: input.tenant,
|
|
309
305
|
httpStatus: res.status,
|
|
310
306
|
ok: result.ok,
|
|
311
|
-
...(result.ok
|
|
307
|
+
...(result.ok
|
|
308
|
+
? { hasRef: !!result.ref }
|
|
309
|
+
: { reason: result.reason, hasCode: !!result.code }),
|
|
312
310
|
});
|
|
313
311
|
return result;
|
|
314
312
|
}
|
|
@@ -324,8 +322,8 @@ export class HttpPrincipalGrantClient implements PrincipalGrantClient {
|
|
|
324
322
|
`${this.baseUrl}/api/clients/principalGrants?tenant=${encodeURIComponent(tenant)}`,
|
|
325
323
|
this.brokeredHeaders(assertion, true),
|
|
326
324
|
);
|
|
327
|
-
} catch
|
|
328
|
-
return transportFailure("listing principal grants"
|
|
325
|
+
} catch {
|
|
326
|
+
return transportFailure("listing principal grants");
|
|
329
327
|
}
|
|
330
328
|
const parsed = await safeJson(res, "listing principal grants");
|
|
331
329
|
if ("failed" in parsed) return parsed.failed;
|
|
@@ -345,8 +343,8 @@ export class HttpPrincipalGrantClient implements PrincipalGrantClient {
|
|
|
345
343
|
`?tenant=${encodeURIComponent(tenant)}`,
|
|
346
344
|
this.brokeredHeaders(assertion, true),
|
|
347
345
|
);
|
|
348
|
-
} catch
|
|
349
|
-
return transportFailure("revoking principal grant"
|
|
346
|
+
} catch {
|
|
347
|
+
return transportFailure("revoking principal grant");
|
|
350
348
|
}
|
|
351
349
|
const parsed = await safeJson(res, "revoking principal grant");
|
|
352
350
|
if ("failed" in parsed) return parsed.failed;
|
|
@@ -365,12 +363,12 @@ function brokerNotConfigured(): PrincipalGrantFailed {
|
|
|
365
363
|
};
|
|
366
364
|
}
|
|
367
365
|
|
|
368
|
-
async function transportFailure(action: string
|
|
366
|
+
async function transportFailure(action: string): Promise<PrincipalGrantFailed> {
|
|
369
367
|
return {
|
|
370
368
|
ok: false,
|
|
371
369
|
status: 0,
|
|
372
370
|
reason: "error",
|
|
373
|
-
message: `principalGrants: transport failure ${action}
|
|
371
|
+
message: `principalGrants: transport failure ${action}`,
|
|
374
372
|
};
|
|
375
373
|
}
|
|
376
374
|
|
|
@@ -458,11 +456,12 @@ function failureFrom(
|
|
|
458
456
|
const code = typeof c.code === "string" ? c.code : undefined;
|
|
459
457
|
const reason = reasonFor(code, status);
|
|
460
458
|
const upstream = typeof c.message === "string" ? c.message : undefined;
|
|
461
|
-
const message = reason === "error" && upstream ? upstream :
|
|
459
|
+
const message = reason === "error" && upstream ? upstream : principalGrantFailureMessage(reason);
|
|
462
460
|
return { status, reason, message, ...(code ? { code } : {}) };
|
|
463
461
|
}
|
|
464
462
|
|
|
465
|
-
|
|
463
|
+
/** Finite operator-facing copy for a principal-grant refusal — the only text a route returns. */
|
|
464
|
+
export function principalGrantFailureMessage(reason: PrincipalGrantFailureReason): string {
|
|
466
465
|
switch (reason) {
|
|
467
466
|
case "notAuthenticated":
|
|
468
467
|
return "storefront isn't authorized to grant on this tenant";
|
|
@@ -486,7 +485,7 @@ function defaultMessage(reason: PrincipalGrantFailureReason, status: number): st
|
|
|
486
485
|
case "brokerNotConfigured":
|
|
487
486
|
return "delegation isn't available on this deployment";
|
|
488
487
|
default:
|
|
489
|
-
return
|
|
488
|
+
return "Token of Trust couldn't complete the delegation; try again shortly";
|
|
490
489
|
}
|
|
491
490
|
}
|
|
492
491
|
|
|
@@ -578,7 +577,7 @@ export class MockPrincipalGrantClient implements PrincipalGrantClient {
|
|
|
578
577
|
ok: false,
|
|
579
578
|
status: 403,
|
|
580
579
|
reason: "subjectNotTenantOwner",
|
|
581
|
-
message:
|
|
580
|
+
message: principalGrantFailureMessage("subjectNotTenantOwner"),
|
|
582
581
|
code: "operatorGrants:subjectNotTenantOwner",
|
|
583
582
|
};
|
|
584
583
|
}
|
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
* Built like `@/lib/grants/developerInvites` (interface + Http impl + Mock + env factory).
|
|
17
17
|
*/
|
|
18
18
|
import { readEnv } from "@/lib/publication/shared/env";
|
|
19
|
+
import {
|
|
20
|
+
failureReasonForStatus,
|
|
21
|
+
type FailureReason,
|
|
22
|
+
} from "@/lib/observability/diag/failureReason";
|
|
19
23
|
|
|
20
24
|
export interface InlineEmail {
|
|
21
25
|
/** Recipient address. */
|
|
@@ -26,9 +30,13 @@ export interface InlineEmail {
|
|
|
26
30
|
html: string;
|
|
27
31
|
}
|
|
28
32
|
|
|
33
|
+
/**
|
|
34
|
+
* `reason` is a finite category. Upstream failure text (exception messages, tot20's
|
|
35
|
+
* `message`/`reason` fields) can echo the recipient address, so it never leaves this client.
|
|
36
|
+
*/
|
|
29
37
|
export type MessageSendResult =
|
|
30
38
|
| { sent: true; id?: string }
|
|
31
|
-
| { sent: false; status: number; reason:
|
|
39
|
+
| { sent: false; status: number; reason: FailureReason };
|
|
32
40
|
|
|
33
41
|
export interface MessagesClient {
|
|
34
42
|
sendInlineEmail(msg: InlineEmail): Promise<MessageSendResult>;
|
|
@@ -97,14 +105,14 @@ export class HttpMessagesClient implements MessagesClient {
|
|
|
97
105
|
...this.credentialFields(),
|
|
98
106
|
},
|
|
99
107
|
);
|
|
100
|
-
} catch
|
|
101
|
-
return { sent: false, status: 0, reason:
|
|
108
|
+
} catch {
|
|
109
|
+
return { sent: false, status: 0, reason: "transport" };
|
|
102
110
|
}
|
|
103
111
|
let body: unknown;
|
|
104
112
|
try {
|
|
105
113
|
body = await res.json();
|
|
106
114
|
} catch {
|
|
107
|
-
return { sent: false, status: res.status, reason:
|
|
115
|
+
return { sent: false, status: res.status, reason: "non-json" };
|
|
108
116
|
}
|
|
109
117
|
return normalizeSend(res.status, body);
|
|
110
118
|
}
|
|
@@ -117,9 +125,7 @@ export function normalizeSend(status: number, body: unknown): MessageSendResult
|
|
|
117
125
|
if (c.sent === true || (status >= 200 && status < 300 && c.sent !== false)) {
|
|
118
126
|
return { sent: true, ...(typeof c.id === "string" ? { id: c.id } : {}) };
|
|
119
127
|
}
|
|
120
|
-
|
|
121
|
-
typeof c.message === "string" ? c.message : typeof c.reason === "string" ? c.reason : `send failed (status ${status})`;
|
|
122
|
-
return { sent: false, status, reason };
|
|
128
|
+
return { sent: false, status, reason: failureReasonForStatus(status) };
|
|
123
129
|
}
|
|
124
130
|
|
|
125
131
|
// --- Mock (tests + local dev) -----------------------------------------------
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The finite set of failure categories a diagnostic line or client-facing response may
|
|
3
|
+
* carry. Upstream failure text (exception messages, response bodies, remote `message` /
|
|
4
|
+
* `reason` fields) can hold addresses, tokens or tenant detail, so it is mapped to one of
|
|
5
|
+
* these categories at the producer and never forwarded verbatim.
|
|
6
|
+
*/
|
|
7
|
+
export const FAILURE_REASONS = [
|
|
8
|
+
"transport",
|
|
9
|
+
"non-json",
|
|
10
|
+
"unauthorized",
|
|
11
|
+
"not-found",
|
|
12
|
+
"rate-limited",
|
|
13
|
+
"rejected",
|
|
14
|
+
"upstream-error",
|
|
15
|
+
"not-configured",
|
|
16
|
+
"unknown",
|
|
17
|
+
] as const;
|
|
18
|
+
|
|
19
|
+
export type FailureReason = (typeof FAILURE_REASONS)[number];
|
|
20
|
+
|
|
21
|
+
const KNOWN: ReadonlySet<string> = new Set(FAILURE_REASONS);
|
|
22
|
+
|
|
23
|
+
export function isFailureReason(value: unknown): value is FailureReason {
|
|
24
|
+
return typeof value === "string" && KNOWN.has(value);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Category for an HTTP status (0 = no response reached us). */
|
|
28
|
+
export function failureReasonForStatus(status: number): FailureReason {
|
|
29
|
+
if (status === 0) return "transport";
|
|
30
|
+
if (status === 401 || status === 403) return "unauthorized";
|
|
31
|
+
if (status === 404) return "not-found";
|
|
32
|
+
if (status === 429) return "rate-limited";
|
|
33
|
+
if (status >= 400 && status < 500) return "rejected";
|
|
34
|
+
if (status >= 500 && status < 600) return "upstream-error";
|
|
35
|
+
if (status >= 200 && status < 300) return "rejected";
|
|
36
|
+
return "unknown";
|
|
37
|
+
}
|
|
@@ -1,20 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Redaction helpers for diagnostic logs
|
|
2
|
+
* Redaction helpers for diagnostic logs. An email address never appears in a diagnostic
|
|
3
|
+
* line in any build: not the local part, not its first character, not the domain.
|
|
4
|
+
* Correlate with an opaque reference (for example `hashActorId`) instead.
|
|
3
5
|
*/
|
|
4
6
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
return
|
|
7
|
+
export const REDACTED_EMAIL = "[redacted-email]";
|
|
8
|
+
|
|
9
|
+
/** A log-safe stand-in for an email address. Reveals nothing about the input. */
|
|
10
|
+
export function maskEmail(_email: string): string {
|
|
11
|
+
return REDACTED_EMAIL;
|
|
10
12
|
}
|
|
11
13
|
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
* are local + gitignored and the raw address is needed for debugging; MASKED everywhere the
|
|
16
|
-
* build is deployed, so prod/worker logs never persist raw recipient PII.
|
|
17
|
-
*/
|
|
18
|
-
export function diagEmail(email: string): string {
|
|
19
|
-
return import.meta.env.DEV ? email : maskEmail(email);
|
|
14
|
+
/** Email for a diagnostic line. Fully redacted in local dev and deployed builds alike. */
|
|
15
|
+
export function diagEmail(_email: string): string {
|
|
16
|
+
return REDACTED_EMAIL;
|
|
20
17
|
}
|
|
@@ -116,8 +116,9 @@ export async function POST(context: APIContext): Promise<Response> {
|
|
|
116
116
|
const claims = await verifier.verify(idToken); // enforces aud === STOREFRONT_APP_DOMAIN
|
|
117
117
|
derived = sessionFromFederatedClaims(claims);
|
|
118
118
|
} catch (err) {
|
|
119
|
+
// The error class only: a verifier message can quote token claims (the address).
|
|
119
120
|
console.error("auth/magic-exchange: id_token verification failed; failing closed", {
|
|
120
|
-
|
|
121
|
+
errorClass: err instanceof Error ? err.name : typeof err,
|
|
121
122
|
});
|
|
122
123
|
return magicRedirect(context, { error: "verify_failed", tenant, emailHint, next });
|
|
123
124
|
}
|
|
@@ -148,14 +149,11 @@ export async function POST(context: APIContext): Promise<Response> {
|
|
|
148
149
|
try {
|
|
149
150
|
const accepted = await acceptDeveloperInvite(tenant, derived.email);
|
|
150
151
|
if (!accepted.accepted && !accepted.skipped) {
|
|
151
|
-
console.warn("auth/magic-exchange: invite accept did not record", {
|
|
152
|
-
tenant,
|
|
153
|
-
reason: accepted.reason,
|
|
154
|
-
});
|
|
152
|
+
console.warn("auth/magic-exchange: invite accept did not record", { reason: accepted.reason });
|
|
155
153
|
}
|
|
156
154
|
} catch (err) {
|
|
157
155
|
console.warn("auth/magic-exchange: invite accept threw (swallowed)", {
|
|
158
|
-
|
|
156
|
+
errorClass: err instanceof Error ? err.name : typeof err,
|
|
159
157
|
});
|
|
160
158
|
}
|
|
161
159
|
|
|
@@ -167,7 +165,6 @@ export async function POST(context: APIContext): Promise<Response> {
|
|
|
167
165
|
const verification = await explainDeveloperAccess(tenant, derived.email, "accepted");
|
|
168
166
|
if (verification.state === "broken") {
|
|
169
167
|
console.warn("auth/magic-exchange: accepted invite does not resolve", {
|
|
170
|
-
tenant,
|
|
171
168
|
failedStep: verification.failedStep,
|
|
172
169
|
});
|
|
173
170
|
}
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
devAuthEnabled,
|
|
21
21
|
devAuthCode,
|
|
22
22
|
storeDevCode,
|
|
23
|
-
|
|
23
|
+
deliverDevCode,
|
|
24
24
|
} from "@/lib/access/auth/devAuth";
|
|
25
25
|
|
|
26
26
|
export const prerender = false;
|
|
@@ -46,22 +46,22 @@ export async function POST(context: APIContext): Promise<Response> {
|
|
|
46
46
|
// dev guards in middleware/index.ts). It NEVER weakens the hosted path.
|
|
47
47
|
//
|
|
48
48
|
// On localhost tot20 isn't reachable, so no email can be sent. When a developer
|
|
49
|
-
// opts in with `DEV_AUTH=1` in `.dev.vars`, we instead mint a one-time code,
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
// `2026-07-09-storefront-local-dev-auth-mode.md`; until it lands, the code is
|
|
54
|
-
// stored + printed but sign-in completes only once that verify path is wired.)
|
|
49
|
+
// opts in with `DEV_AUTH=1` in `.dev.vars`, we instead mint a one-time code, store
|
|
50
|
+
// it in the SESSION KV, and deliver it to the owner-private local outbox
|
|
51
|
+
// (`apps/storefront/.dev-auth/outbox.txt`). The code and address never reach the
|
|
52
|
+
// console or the response. /api/auth/verify consumes it (single use, 10 minutes).
|
|
55
53
|
if (import.meta.env.DEV && (await devAuthEnabled())) {
|
|
56
54
|
const kv = await readKv("SESSION");
|
|
57
55
|
if (kv) {
|
|
58
56
|
const code = devAuthCode();
|
|
59
57
|
await storeDevCode(kv, email, code);
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
58
|
+
if (!(await deliverDevCode(context.request, email, code))) {
|
|
59
|
+
// eslint-disable-next-line no-console -- dev-only diagnostic; no code, no address
|
|
60
|
+
console.warn(
|
|
61
|
+
"[DEV AUTH] could not deliver the sign-in code to the local outbox " +
|
|
62
|
+
"(is the dev-auth-outbox plugin registered in astro.config.mjs?).",
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
65
|
} else {
|
|
66
66
|
// eslint-disable-next-line no-console -- dev-only diagnostic
|
|
67
67
|
console.warn(
|
|
@@ -94,8 +94,9 @@ export async function POST(context: APIContext): Promise<Response> {
|
|
|
94
94
|
const claims = await verifier.verify(result.idToken); // enforces aud === STOREFRONT_APP_DOMAIN
|
|
95
95
|
derived = sessionFromFederatedClaims(claims);
|
|
96
96
|
} catch (err) {
|
|
97
|
+
// The error class only: a verifier message can quote token claims (the address).
|
|
97
98
|
console.error("auth/step-up-verify: id_token verification failed; failing closed", {
|
|
98
|
-
|
|
99
|
+
errorClass: err instanceof Error ? err.name : typeof err,
|
|
99
100
|
});
|
|
100
101
|
return loginRedirect(context, {
|
|
101
102
|
sent: true,
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
getAccessClient,
|
|
20
20
|
getSessionStore,
|
|
21
21
|
setSessionCookie,
|
|
22
|
+
clearLoginEmailCookie,
|
|
22
23
|
safeNext,
|
|
23
24
|
loginRedirect,
|
|
24
25
|
} from "@/lib/access/auth/route";
|
|
@@ -51,8 +52,8 @@ export async function POST(context: APIContext): Promise<Response> {
|
|
|
51
52
|
// LOCAL DEV AUTH (verify side). `import.meta.env.DEV` is a STATIC boolean — this
|
|
52
53
|
// whole branch (and the devAuth imports) are tree-shaken out of the production
|
|
53
54
|
// build, so the hosted path below (offline JWKS verification, fail-closed) is
|
|
54
|
-
// never weakened. Second gate: the DEV_AUTH opt-in. Consume the
|
|
55
|
-
//
|
|
55
|
+
// never weakened. Second gate: the DEV_AUTH opt-in. Consume the code stored by
|
|
56
|
+
// /api/auth/send (delivered to the private local outbox) and mint a local session WITHOUT tot20 — the
|
|
56
57
|
// localhost completion of the invite→sign-in loop. See @/lib/auth/devAuth.
|
|
57
58
|
if (import.meta.env.DEV && (await devAuthEnabled())) {
|
|
58
59
|
const store = await getSessionStore();
|
|
@@ -72,6 +73,7 @@ export async function POST(context: APIContext): Promise<Response> {
|
|
|
72
73
|
nowSeconds,
|
|
73
74
|
);
|
|
74
75
|
setSessionCookie(context, id, DEFAULT_SESSION_TTL_SECONDS);
|
|
76
|
+
clearLoginEmailCookie(context);
|
|
75
77
|
return context.redirect(next, 303);
|
|
76
78
|
}
|
|
77
79
|
|
|
@@ -127,8 +129,9 @@ export async function POST(context: APIContext): Promise<Response> {
|
|
|
127
129
|
// Bind EVERY membership — no single-tenant scoping (multi-vendor picker input).
|
|
128
130
|
derived = sessionFromFederatedClaims(claims);
|
|
129
131
|
} catch (err) {
|
|
132
|
+
// The error class only: a verifier message can quote token claims (the address).
|
|
130
133
|
console.error("auth/verify: federated id_token verification failed; failing closed", {
|
|
131
|
-
|
|
134
|
+
errorClass: err instanceof Error ? err.name : typeof err,
|
|
132
135
|
});
|
|
133
136
|
return loginRedirect(context, { sent: true, email, error: "verify_failed", next });
|
|
134
137
|
}
|
|
@@ -155,5 +158,6 @@ export async function POST(context: APIContext): Promise<Response> {
|
|
|
155
158
|
nowSeconds,
|
|
156
159
|
);
|
|
157
160
|
setSessionCookie(context, id, DEFAULT_SESSION_TTL_SECONDS);
|
|
161
|
+
clearLoginEmailCookie(context);
|
|
158
162
|
return context.redirect(next, 303);
|
|
159
163
|
}
|
|
@@ -11,7 +11,12 @@
|
|
|
11
11
|
* reach the browser. Inline style carries the per-request CSP nonce.
|
|
12
12
|
*/
|
|
13
13
|
import { normalizeEmailHint } from "@/lib/access/privacy/emailHint";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
EMAIL_RE,
|
|
16
|
+
safeNext,
|
|
17
|
+
readLoginEmail,
|
|
18
|
+
setLoginEmailCookie,
|
|
19
|
+
} from "@/lib/access/auth/route";
|
|
15
20
|
// Self-hosted web fonts (audit finding 6) — same-origin @fontsource, replacing
|
|
16
21
|
// the render-blocking fonts.googleapis.com stylesheet. Covers Plus Jakarta Sans,
|
|
17
22
|
// Inter, and JetBrains Mono used by this page's inline styles.
|
|
@@ -21,8 +26,19 @@ export const prerender = false;
|
|
|
21
26
|
|
|
22
27
|
const cspNonce = Astro.locals.cspNonce;
|
|
23
28
|
const url = new URL(Astro.request.url);
|
|
29
|
+
// The in-progress address rides the HttpOnly login-email cookie, never the URL. A
|
|
30
|
+
// legacy `?email=` link is canonicalized: the address moves to the cookie and the
|
|
31
|
+
// page reloads without it, so it doesn't linger in history or Referer headers.
|
|
32
|
+
if (url.searchParams.has("email")) {
|
|
33
|
+
const legacyEmail = (url.searchParams.get("email") ?? "").trim().toLowerCase();
|
|
34
|
+
if (EMAIL_RE.test(legacyEmail)) setLoginEmailCookie(Astro, legacyEmail);
|
|
35
|
+
const canonical = new URLSearchParams(url.searchParams);
|
|
36
|
+
canonical.delete("email");
|
|
37
|
+
const qs = canonical.toString();
|
|
38
|
+
return Astro.redirect(`/auth/login${qs ? `?${qs}` : ""}`, 302);
|
|
39
|
+
}
|
|
24
40
|
const sent = url.searchParams.get("sent") === "1";
|
|
25
|
-
const email = (
|
|
41
|
+
const email = readLoginEmail(Astro);
|
|
26
42
|
const emailHint = normalizeEmailHint(url.searchParams.get("email_hint")) ?? "";
|
|
27
43
|
const errorCode = url.searchParams.get("error");
|
|
28
44
|
// Same open-redirect chokepoint the verify endpoint + login gate use — never a
|
|
@@ -56,7 +72,8 @@ const errorMessage = errorCode ? (ERROR_MESSAGES[errorCode] ?? "Something went w
|
|
|
56
72
|
// A stale/failed code should drop back to the email step so a new code can be requested.
|
|
57
73
|
const showCodeStep = sent && errorCode !== "expired" && errorCode !== "too_many_attempts";
|
|
58
74
|
// Local-dev banner (statically dropped from prod). The message depends on the mode:
|
|
59
|
-
// DEV_AUTH=1 →
|
|
75
|
+
// DEV_AUTH=1 → local codes are active (no email; the code goes to the owner-private
|
|
76
|
+
// outbox file named in the dev-server log);
|
|
60
77
|
// DEV_AUTH=0 → the REAL path is active (a code IS emailed by the configured tot20/qa, but
|
|
61
78
|
// only to an address that already holds a role). Read the toggle so the banner never lies.
|
|
62
79
|
const devMode = import.meta.env.DEV;
|
|
@@ -235,12 +252,13 @@ if (devMode) {
|
|
|
235
252
|
</span>
|
|
236
253
|
<p class="eyebrow">Token of Trust© · Regulated Storefront</p>
|
|
237
254
|
{devMode && (devAuthOn ? (
|
|
238
|
-
<p class="dev"><strong>Local dev —
|
|
239
|
-
your sign-in code is
|
|
255
|
+
<p class="dev"><strong>Local dev — private outbox</strong> (<code>DEV_AUTH=1</code>). No email is sent;
|
|
256
|
+
your sign-in code is written to <code>apps/storefront/.dev-auth/outbox.txt</code> (readable only by you).
|
|
257
|
+
Set <code>DEV_AUTH=0</code> to use real emails.</p>
|
|
240
258
|
) : (
|
|
241
259
|
<p class="dev"><strong>Local dev — live email</strong> (<code>DEV_AUTH=0</code>). A code is emailed by the
|
|
242
260
|
configured tot20 (<code>TOT_CORE_URL</code>), but only to an address that already holds a role. Set
|
|
243
|
-
<code>DEV_AUTH=1</code> for offline
|
|
261
|
+
<code>DEV_AUTH=1</code> for offline local codes.</p>
|
|
244
262
|
))}
|
|
245
263
|
{errorMessage && <p class="err">{errorMessage}</p>}
|
|
246
264
|
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* the token before the user ever clicked. So: NEVER exchange on GET. The token rides
|
|
10
10
|
* in a hidden form field and leaves only via the POST.
|
|
11
11
|
*/
|
|
12
|
-
import { normalizeEmailHint
|
|
12
|
+
import { normalizeEmailHint } from "@/lib/access/privacy/emailHint";
|
|
13
13
|
// Self-hosted web fonts (audit finding 6) — same-origin @fontsource, replacing
|
|
14
14
|
// the render-blocking fonts.googleapis.com stylesheet. Covers Plus Jakarta Sans,
|
|
15
15
|
// Inter, and JetBrains Mono used by this page's inline styles.
|
|
@@ -20,15 +20,12 @@ export const prerender = false;
|
|
|
20
20
|
const cspNonce = Astro.locals.cspNonce;
|
|
21
21
|
const url = new URL(Astro.request.url);
|
|
22
22
|
const token = url.searchParams.get("token") ?? "";
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
redactEmailForHint(legacyEmail) ??
|
|
27
|
-
"";
|
|
23
|
+
// An `email` query parameter is never read, echoed or used to prefill: the address is
|
|
24
|
+
// stripped from the URL and sign-in proceeds on the token alone.
|
|
25
|
+
const emailHint = normalizeEmailHint(url.searchParams.get("email_hint")) ?? "";
|
|
28
26
|
if (url.searchParams.has("email")) {
|
|
29
27
|
const canonical = new URLSearchParams(url.searchParams);
|
|
30
28
|
canonical.delete("email");
|
|
31
|
-
if (emailHint) canonical.set("email_hint", emailHint);
|
|
32
29
|
return Astro.redirect(
|
|
33
30
|
`/auth/magic${canonical.toString() ? `?${canonical.toString()}` : ""}`,
|
|
34
31
|
302,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "4.8.
|
|
3
|
+
"version": "4.8.1",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
|
|
6
6
|
"packageManager": "pnpm@11.9.0",
|