@boopkit/cap-users 0.4.2 → 0.4.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/capability.json CHANGED
@@ -41,7 +41,7 @@
41
41
  },
42
42
  {
43
43
  "phase": "imports",
44
- "code": "import { ensureVisitor, addAuthContext, authRoutes } from './middleware/auth';",
44
+ "code": "import { resolveVisitor, addAuthContext, authRoutes } from './middleware/auth';",
45
45
  "description": "auth middleware and routes — session resolution, trust context, signup/login/logout"
46
46
  },
47
47
  {
@@ -58,8 +58,8 @@
58
58
  {
59
59
  "phase": "preRoutes",
60
60
  "order": 15,
61
- "code": "app.use(ensureVisitor);",
62
- "description": "resolve visitor identity from auth_id cookie"
61
+ "code": "app.use(resolveVisitor);",
62
+ "description": "resolve user from auth_id/visitor_id cookie if present (no user is created for anonymous traffic — write paths must call getOrCreateUser)"
63
63
  },
64
64
  {
65
65
  "phase": "preRoutes",
@@ -1,16 +1,21 @@
1
1
  /**
2
2
  * Auth middleware factories for Express.
3
3
  *
4
- * Provides three middleware functions:
5
- * - `ensureVisitor` — resolves session from auth_id cookie, creates anonymous visitor if missing
6
- * - `requireAuth` — gates routes by trust tier, redirects web / 401s API
7
- * - `addAuthContext` — populates res.locals.auth for view templates
4
+ * Provides:
5
+ * - `resolveVisitor` — resolves req.user/sessionToken/visitorToken from auth_id/visitor_id
6
+ * cookies if present. Never creates a user or sets cookies. If no cookie resolves,
7
+ * req.user stays undefined so anonymous traffic does not mint user records.
8
+ * - `getOrCreateUser` — helper for write paths (waitlist capture, signup, onboarding).
9
+ * Mints a user + visitor identity + session token and sets cookies on first call.
10
+ * Idempotent: if resolveVisitor already populated req, returns the existing user.
11
+ * - `requireAuth` — gates routes by trust tier, redirects web / 401s API.
12
+ * - `addAuthContext` — populates res.locals.auth for view templates.
8
13
  */
9
14
  import type { Request, Response, NextFunction } from 'express';
10
15
  import type { AuthService } from './auth-service.js';
11
16
  import type { IdentityService } from './identity-service.js';
12
17
  import type { UserService } from './user-service.js';
13
- import type { BaseUserInfo, TrustTier } from './types.js';
18
+ import type { BaseUserInfo, TrustTier, User } from './types.js';
14
19
  /** Dependencies injected into createAuthMiddleware. */
15
20
  export interface AuthMiddlewareDeps {
16
21
  authService: AuthService;
@@ -39,7 +44,8 @@ export interface AuthLocals {
39
44
  }
40
45
  /** The middleware functions returned by createAuthMiddleware. */
41
46
  export interface AuthMiddleware {
42
- ensureVisitor: (req: Request, res: Response, next: NextFunction) => void;
47
+ resolveVisitor: (req: Request, res: Response, next: NextFunction) => void;
48
+ getOrCreateUser: (req: Request, res: Response) => Promise<User<BaseUserInfo>>;
43
49
  requireAuth: (requiredTrust?: TrustTier) => (req: Request, res: Response, next: NextFunction) => void;
44
50
  addAuthContext: (req: Request, res: Response, next: NextFunction) => void;
45
51
  }
@@ -1 +1 @@
1
- {"version":3,"file":"auth-middleware.d.ts","sourceRoot":"","sources":["../src/auth-middleware.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAG/D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AACrD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAC7D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AACrD,OAAO,KAAK,EAAE,YAAY,EAAE,SAAS,EAAQ,MAAM,YAAY,CAAC;AAMhE,uDAAuD;AACvD,MAAM,WAAW,kBAAkB;IACjC,WAAW,EAAE,WAAW,CAAC;IACzB,eAAe,EAAE,eAAe,CAAC;IACjC,WAAW,EAAE,WAAW,CAAC,YAAY,CAAC,CAAC;CACxC;AAED,wCAAwC;AACxC,MAAM,WAAW,qBAAqB;IACpC,wDAAwD;IACxD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,wDAAwD;IACxD,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,8DAA8D;IAC9D,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,gEAAgE;IAChE,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,kDAAkD;IAClD,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,4DAA4D;AAC5D,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,SAAS,GAAG,IAAI,CAAC;IAC5B,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5B;AAED,iEAAiE;AACjE,MAAM,WAAW,cAAc;IAC7B,aAAa,EAAE,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,YAAY,KAAK,IAAI,CAAC;IACzE,WAAW,EAAE,CAAC,aAAa,CAAC,EAAE,SAAS,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,YAAY,KAAK,IAAI,CAAC;IACtG,cAAc,EAAE,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,YAAY,KAAK,IAAI,CAAC;CAC3E;AASD;;GAEG;AACH,wBAAgB,oBAAoB,CAClC,IAAI,EAAE,kBAAkB,EACxB,OAAO,CAAC,EAAE,qBAAqB,GAC9B,cAAc,CAuJhB"}
1
+ {"version":3,"file":"auth-middleware.d.ts","sourceRoot":"","sources":["../src/auth-middleware.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAGH,OAAO,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAG/D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AACrD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAC7D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AACrD,OAAO,KAAK,EAAE,YAAY,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,YAAY,CAAC;AAMhE,uDAAuD;AACvD,MAAM,WAAW,kBAAkB;IACjC,WAAW,EAAE,WAAW,CAAC;IACzB,eAAe,EAAE,eAAe,CAAC;IACjC,WAAW,EAAE,WAAW,CAAC,YAAY,CAAC,CAAC;CACxC;AAED,wCAAwC;AACxC,MAAM,WAAW,qBAAqB;IACpC,wDAAwD;IACxD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,wDAAwD;IACxD,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,8DAA8D;IAC9D,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,gEAAgE;IAChE,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,kDAAkD;IAClD,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,4DAA4D;AAC5D,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,SAAS,GAAG,IAAI,CAAC;IAC5B,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5B;AAED,iEAAiE;AACjE,MAAM,WAAW,cAAc;IAC7B,cAAc,EAAE,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,YAAY,KAAK,IAAI,CAAC;IAC1E,eAAe,EAAE,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,KAAK,OAAO,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC,CAAC;IAC9E,WAAW,EAAE,CAAC,aAAa,CAAC,EAAE,SAAS,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,YAAY,KAAK,IAAI,CAAC;IACtG,cAAc,EAAE,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,YAAY,KAAK,IAAI,CAAC;CAC3E;AASD;;GAEG;AACH,wBAAgB,oBAAoB,CAClC,IAAI,EAAE,kBAAkB,EACxB,OAAO,CAAC,EAAE,qBAAqB,GAC9B,cAAc,CA0KhB"}
@@ -2,10 +2,15 @@
2
2
  /**
3
3
  * Auth middleware factories for Express.
4
4
  *
5
- * Provides three middleware functions:
6
- * - `ensureVisitor` — resolves session from auth_id cookie, creates anonymous visitor if missing
7
- * - `requireAuth` — gates routes by trust tier, redirects web / 401s API
8
- * - `addAuthContext` — populates res.locals.auth for view templates
5
+ * Provides:
6
+ * - `resolveVisitor` — resolves req.user/sessionToken/visitorToken from auth_id/visitor_id
7
+ * cookies if present. Never creates a user or sets cookies. If no cookie resolves,
8
+ * req.user stays undefined so anonymous traffic does not mint user records.
9
+ * - `getOrCreateUser` — helper for write paths (waitlist capture, signup, onboarding).
10
+ * Mints a user + visitor identity + session token and sets cookies on first call.
11
+ * Idempotent: if resolveVisitor already populated req, returns the existing user.
12
+ * - `requireAuth` — gates routes by trust tier, redirects web / 401s API.
13
+ * - `addAuthContext` — populates res.locals.auth for view templates.
9
14
  */
10
15
  Object.defineProperty(exports, "__esModule", { value: true });
11
16
  exports.createAuthMiddleware = createAuthMiddleware;
@@ -27,19 +32,19 @@ function createAuthMiddleware(deps, options) {
27
32
  const visitorCookieMaxAge = options?.visitorCookieMaxAge ?? TEN_YEARS_MS;
28
33
  const loginPath = options?.loginPath ?? '/login';
29
34
  /**
30
- * ensureVisitor — two-cookie pattern:
31
- * 1. Resolve/create visitor_id cookie (long-lived tracking, never rotated)
32
- * 2. Resolve/create auth_id cookie (session authentication, rotated on login)
33
- * Sets req.user, req.sessionToken, and req.visitorToken.
35
+ * resolveVisitor — read-only resolver. Populates req.user, req.sessionToken, and
36
+ * req.visitorToken from the auth_id/visitor_id cookies when present. Never creates
37
+ * a user or writes cookies. Anonymous traffic (no resolvable cookie) passes through
38
+ * with req.user undefined. Write paths that need a persisted user should call
39
+ * getOrCreateUser explicitly.
34
40
  */
35
- function ensureVisitor(req, res, next) {
36
- resolveVisitor(req, res).then(() => next()).catch(next);
41
+ function resolveVisitor(req, res, next) {
42
+ resolveFromCookies(req).then(() => next()).catch(next);
37
43
  }
38
- async function resolveVisitor(req, res) {
44
+ async function resolveFromCookies(req) {
39
45
  let user;
40
46
  let sessionToken;
41
47
  let visitorToken;
42
- // --- Phase 1: Resolve user from existing cookies ---
43
48
  // Try auth_id first (most authoritative for current session)
44
49
  const rawAuthToken = req.cookies?.[cookieName];
45
50
  if (rawAuthToken) {
@@ -60,22 +65,36 @@ function createAuthMiddleware(deps, options) {
60
65
  if (!user) {
61
66
  user = await userService.findById(identity.userId) ?? undefined;
62
67
  }
63
- visitorToken = rawVisitorToken;
68
+ if (user) {
69
+ visitorToken = rawVisitorToken;
70
+ }
71
+ // If the visitor identity resolves but to a different user than auth_id
72
+ // resolved, we keep the auth_id user; the visitor cookie is treated as stale
73
+ // and a fresh one will be minted on the next getOrCreateUser call.
64
74
  }
65
- // If identity not found, visitor cookie is stale — will be replaced below
66
75
  }
67
- // --- Phase 2: Create missing resources ---
68
- // Create user if neither cookie resolved one
76
+ req.user = user;
77
+ req.sessionToken = sessionToken;
78
+ req.visitorToken = visitorToken;
79
+ }
80
+ /**
81
+ * getOrCreateUser — call from write paths that need a persisted user (waitlist
82
+ * capture, signup, onboarding). Mints a user + visitor identity + session token
83
+ * and sets the auth_id/visitor_id cookies. Idempotent: re-uses whatever resolveVisitor
84
+ * already populated on req, and only mints the pieces that are missing.
85
+ */
86
+ async function getOrCreateUser(req, res) {
87
+ let user = req.user;
88
+ let sessionToken = req.sessionToken;
89
+ let visitorToken = req.visitorToken;
69
90
  if (!user) {
70
91
  user = await userService.create({ type: 'user' });
71
92
  }
72
- // Ensure visitor_id cookie exists
73
93
  if (!visitorToken) {
74
94
  visitorToken = (0, auth_service_js_1.generateToken)();
75
95
  await identityService.create(user.id, 'cookie', (0, auth_service_js_1.hashToken)(visitorToken));
76
96
  res.cookie(visitorCookieName, visitorToken, { maxAge: visitorCookieMaxAge, httpOnly: true });
77
97
  }
78
- // Ensure auth_id session cookie exists
79
98
  if (!sessionToken) {
80
99
  sessionToken = await authService.generateSessionToken(user.id);
81
100
  res.cookie(cookieName, sessionToken, { maxAge: cookieMaxAge, httpOnly: true });
@@ -83,6 +102,7 @@ function createAuthMiddleware(deps, options) {
83
102
  req.user = user;
84
103
  req.sessionToken = sessionToken;
85
104
  req.visitorToken = visitorToken;
105
+ return user;
86
106
  }
87
107
  /**
88
108
  * requireAuth — middleware factory. Checks trust tier for the current session.
@@ -128,11 +148,14 @@ function createAuthMiddleware(deps, options) {
128
148
  async function populateAuthContext(req, res) {
129
149
  const user = req.user;
130
150
  const sessionToken = req.sessionToken;
131
- if (!user || !sessionToken) {
151
+ if (!user) {
132
152
  res.locals.auth = { trustTier: null, userName: null, userEmail: null, userInitial: null };
133
153
  return;
134
154
  }
135
- const trustTier = await authService.getTrustTier(user.id, sessionToken);
155
+ // A resolved user is at minimum 'recognized' (we identified them by cookie). The
156
+ // session token's grant can raise that to 'authenticated' or 'verified' when present.
157
+ const grantedTier = sessionToken ? await authService.getTrustTier(user.id, sessionToken) : null;
158
+ const trustTier = grantedTier ?? 'recognized';
136
159
  const name = user.info.name ?? null;
137
160
  const email = user.info.email ?? null;
138
161
  const userInitial = (name ?? email ?? '?')[0].toUpperCase();
@@ -144,7 +167,8 @@ function createAuthMiddleware(deps, options) {
144
167
  };
145
168
  }
146
169
  return {
147
- ensureVisitor,
170
+ resolveVisitor,
171
+ getOrCreateUser,
148
172
  requireAuth,
149
173
  addAuthContext,
150
174
  };
@@ -1 +1 @@
1
- {"version":3,"file":"auth-middleware.js","sourceRoot":"","sources":["../src/auth-middleware.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"auth-middleware.js","sourceRoot":"","sources":["../src/auth-middleware.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boopkit/cap-users",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "User accounts, authentication, and session management via Prisma",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -49,7 +49,7 @@ Links a user to an auth mechanism via `(domain, key)` compound unique key:
49
49
  | `email` | email address | `alice@example.com` |
50
50
  | `cookie` | session token | `base64url-token` |
51
51
  | `api-key` | hashed key | `hashed-base64url` |
52
- | OAuth provider | external ID | `google-user-id` |
52
+ | OAuth provider, bare — `google`, `microsoft` | provider's stable subject id | `google-sub-id-12345` |
53
53
 
54
54
  Soft-deleted via `endedAt` — revoked identities remain queryable with `includeEnded: true`.
55
55
 
@@ -143,23 +143,55 @@ The plaintext key is returned exactly once at creation. Only the hash is stored.
143
143
 
144
144
  ### OAuth (as client)
145
145
 
146
- When a user logs in via Google (or other provider), the app is an OAuth *client*. The provider proves identity; boop stores the result.
146
+ When a user logs in via Google or Microsoft, the app is an OAuth *client*. The provider proves identity; the app stores the result.
147
+
148
+ These rows are what the `oauth-client` capability writes. It writes them through the services below and adds no user model of its own, so everything on this page still describes the whole truth about a user after you install it — and once installed, its own [`oauth-client.ref.md`](oauth-client.ref.md) covers the sign-in routes, the provider registry, and token refresh in depth.
147
149
 
148
150
  | Layer | What happens |
149
151
  |-------|-------------|
150
152
  | **Identity** | `domain: "google", key: "google-sub-id-12345"` — maps provider ID to user |
151
- | **Authentication** | `{ strategy: "oauth-google", accepted: true, refresh_token: "rt_...", access_token: "at_...", ... }` — delegated credentials stored |
153
+ | **Authentication** | `{ strategy: "oauth-google", accepted: true, access_token: "at_...", refresh_token: "rt_...", access_token_expires_at: 1756..., oauth_profile: {...} }` — delegated credentials stored |
152
154
  | **Credentials** | None — OAuth credentials are delegated, stored in the authentication entry |
153
155
  | **Authorization** | Created on login, tied to the session cookie — same as password login |
154
156
 
155
157
  OAuth tokens (`refresh_token`, `access_token`) are **delegated credentials** — tokens held from the provider to act on the user's behalf. They are NOT how the app recognizes the user on subsequent requests. Users are recognized via cookies, same as password login.
156
158
 
157
- **OAuth login flow:** provider callback with auth code → exchange for tokens → look up or create identity (`domain: "google"`) → store tokens in authentication entry → rotate session → create per-device grant.
159
+ **The identity key is the provider's stable subject identifier** — Google's `sub`, Microsoft's directory object id.
160
+ It is never the email address or the UPN, because both are reassignable and keying on one hands a recycled address the previous holder's account.
161
+
162
+ **The bare identity domain and the prefixed strategy are deliberate, not an oversight.**
163
+ The identity domain is `google` — bare, like `cookie`, `email`, and `api-key`.
164
+ The authentication strategy is `oauth-google` — prefixed, like `local` and `api-key`, where the prefix is what marks a delegated method rather than a locally verified one.
165
+ The two pairs that ship are `google` / `oauth-google` and `microsoft` / `oauth-microsoft`.
166
+ The domain answers *whose account this is*; the strategy answers *how the user authenticates*.
167
+ Those are different questions, and each vocabulary is consistent within itself.
168
+ Do not normalize one to the other. The bare domain is also what lets an app that already stores `(domain: provider_name, key: profile.id)` adopt the `oauth-client` capability without rewriting a single identity row.
169
+
170
+ **OAuth login flow:** `GET /auth/:provider` mints a random nonce and stores it, the PKCE verifier, and the validated return path in a short-lived httpOnly cookie → provider callback with auth code → the nonce is compared to the cookie in constant time, failing closed → exchange the code for tokens → reconcile the provider identity, the email identity, and the anonymous cookie user to a single user, creating one if none exists → store tokens in the authentication entry with an absolute expiry from the provider's `expires_in` → rotate session → create per-device grant.
171
+
172
+ Two of those steps refuse rather than resolve. A provider account and an email address owned by two different users leaves both accounts untouched for a person to sort out, and an unverified provider email may not claim an existing account.
173
+
174
+ A third withholds rather than refuses, and it is the one that shapes what is in the identity table.
175
+ When the provider reports an address it has not verified and *nobody owns it yet*, the sign-in proceeds on the provider identity alone: no `email` identity row is written, and only the profile keeps the claim, labelled unverified.
176
+ The identity row — not the profile — is what resolves a user on a later sign-in, so writing one from an unverified claim would let someone squat an address and be handed the victim when the victim's genuinely verified sign-in matched it.
177
+ The address is not stranded: when the same provider account later reports it verified, the email identity is attached then.
178
+
179
+ Provider tokens are stored **unencrypted**, like everything else in the `authentication` property. They are bearer credentials the app must replay verbatim to the provider, so they cannot be hashed the way cookies and API keys are. They are protected by database access control, and by never being logged, rendered, or returned in a response body.
158
180
 
159
181
  ### TOTP / Second Factor
160
182
 
161
183
  TOTP upgrades trust on the current device. It does NOT rotate the session — the user already proved identity at login (which rotated the session). TOTP is additional proof on the same session, not a new authentication source.
162
184
 
185
+ ### Telling the user why a sign-in did not happen
186
+
187
+ `views/login.hbs` renders one error slot, `{{#if errorMessage}}`, and two paths fill it.
188
+
189
+ A failed password POST fills it directly from `handleLogin`'s catch, with one message for both a wrong password and an unknown address — the no-enumeration rule.
190
+
191
+ A **redirect** fills it by carrying a reason code in `?error=`, which `showLogin` turns into copy through `LOGIN_ERROR_MESSAGES` in `src/controllers/auth.ts`. The query value is never rendered: a code the map does not know falls back to `DEFAULT_LOGIN_ERROR_MESSAGE`, so the page cannot reflect a caller's string and a reason the map has not learned yet still says something rather than rendering a silent page. Only an absent or empty `?error=` leaves the slot closed.
192
+
193
+ Shipped codes are `session_expired`, `auth_required`, and `signin_failed`. Add a row to the map to teach the page a new one — including the ones a capability's own failure redirect uses. `OAUTH_SIGNIN_FAILURE_PATH` defaults to `/login`, so every OAuth refusal lands on this slot.
194
+
163
195
  ---
164
196
 
165
197
  ## Logout vs. Forget
@@ -2,7 +2,7 @@ import type { Response } from 'express';
2
2
  import type { AuthService } from '@boopkit/cap-users';
3
3
  import { AuthValidationError } from '@boopkit/cap-users';
4
4
 
5
- /** Extended Express Request with auth fields set by ensureVisitor middleware. */
5
+ /** Extended Express Request with auth fields set by resolveVisitor middleware. */
6
6
  interface AuthRequest {
7
7
  body: Record<string, any>;
8
8
  query: Record<string, any>;
@@ -21,6 +21,73 @@ const FIELD_ERROR_MAP: Record<string, string> = {
21
21
  passwordConfirmation: 'password_mismatch',
22
22
  };
23
23
 
24
+ /**
25
+ * Plain-language copy for the `?error=` reason codes a redirect can carry to
26
+ * `GET /login`.
27
+ *
28
+ * A redirect can only hand the login page a short machine token — a session
29
+ * that expired, a gate that turned the visitor away, a provider sign-in that
30
+ * was refused. This map is where that token becomes a sentence a person can
31
+ * act on, and it is the only thing `showLogin` will put in the error slot.
32
+ *
33
+ * Codes are never echoed. An unrecognised value renders
34
+ * `DEFAULT_LOGIN_ERROR_MESSAGE` instead, so the page cannot be turned into a
35
+ * reflection point for whatever a caller puts in the query string, and a
36
+ * redirect whose code this map has not learned yet still says *something*
37
+ * rather than landing the visitor on a silent page.
38
+ *
39
+ * Add a row here to teach the page a new reason; the fallback keeps working
40
+ * until you do.
41
+ */
42
+ export const LOGIN_ERROR_MESSAGES: Record<string, string> = {
43
+ session_expired: 'Your session has expired. Please log in again.',
44
+ auth_required: 'Please log in to continue.',
45
+ signin_failed: 'We could not complete that sign-in. Please try again.',
46
+ };
47
+
48
+ /**
49
+ * Teach the login page a reason code it does not ship with.
50
+ *
51
+ * `LOGIN_ERROR_MESSAGES` above is deliberately extensible — a redirect that
52
+ * carries a reason is not always one cap-users itself issued. A capability that
53
+ * turns visitors away at its own door (a provider sign-in refused for a reason
54
+ * only it can name) registers its copy here at boot, rather than each such
55
+ * capability inventing a second error slot on the page.
56
+ *
57
+ * Additive and idempotent: registering the same code twice keeps the last
58
+ * writer's copy, and a blank or non-string message is ignored so a misconfigured
59
+ * override cannot blank out a message the page needs to show. Nothing here can
60
+ * *remove* a shipped code.
61
+ */
62
+ export function registerLoginErrorMessages(messages: Record<string, string>): void {
63
+ for (const [code, message] of Object.entries(messages)) {
64
+ if (typeof message === 'string' && message.trim() !== '') {
65
+ LOGIN_ERROR_MESSAGES[code] = message.trim();
66
+ }
67
+ }
68
+ }
69
+
70
+ /** Shown for a reason code this page does not recognise. */
71
+ export const DEFAULT_LOGIN_ERROR_MESSAGE =
72
+ 'We could not complete that sign-in. Please try again.';
73
+
74
+ /**
75
+ * Turn a `?error=` query value into copy, or into nothing at all.
76
+ *
77
+ * Only an absent or empty value closes the view's `{{#if errorMessage}}` slot.
78
+ * Anything else — an unknown code, or the array Express hands back for a
79
+ * repeated parameter — renders the generic message, because the presence of
80
+ * the parameter is itself the signal that something went wrong, and a page
81
+ * that stays silent about it is the defect this function exists to close.
82
+ *
83
+ * The caller's own string is never returned, whatever it contains.
84
+ */
85
+ export function loginErrorMessage(code: unknown): string | undefined {
86
+ if (code === undefined || code === null || code === '') return undefined;
87
+ if (typeof code !== 'string') return DEFAULT_LOGIN_ERROR_MESSAGE;
88
+ return LOGIN_ERROR_MESSAGES[code] ?? DEFAULT_LOGIN_ERROR_MESSAGE;
89
+ }
90
+
24
91
  export interface AuthControllerDeps {
25
92
  authService: AuthService;
26
93
  }
@@ -44,7 +111,10 @@ export function createAuthController(deps: AuthControllerDeps) {
44
111
  layout: 'main',
45
112
  action: '/login',
46
113
  next,
47
- error: req.query.error as string | undefined,
114
+ // `errorMessage` is the name login.hbs reads. Anything else renders
115
+ // nothing, which is how a redirect-carrying-a-reason ends up on a silent
116
+ // page (boop-94l).
117
+ errorMessage: loginErrorMessage(req.query.error),
48
118
  });
49
119
  }
50
120
 
@@ -6,7 +6,7 @@ import createAuthRouter from '../routes/auth';
6
6
 
7
7
  const { authService, identityService, userService } = UserService;
8
8
 
9
- const { ensureVisitor, addAuthContext, requireAuth } = createAuthMiddleware(
9
+ const { resolveVisitor, getOrCreateUser, addAuthContext, requireAuth } = createAuthMiddleware(
10
10
  { authService, identityService, userService },
11
11
  {
12
12
  cookieName: 'auth_id',
@@ -17,4 +17,4 @@ const { ensureVisitor, addAuthContext, requireAuth } = createAuthMiddleware(
17
17
  /** Pre-built auth routes (signup, login, logout). Ready to mount with app.use(). */
18
18
  const authRoutes = createAuthRouter({ authService });
19
19
 
20
- export { ensureVisitor, addAuthContext, requireAuth, authRoutes };
20
+ export { resolveVisitor, getOrCreateUser, addAuthContext, requireAuth, authRoutes };
@@ -21,8 +21,12 @@
21
21
  <button class="auth-submit" type="submit">Log In</button>
22
22
  </form>
23
23
 
24
+ {{!-- The return path goes into a query string here, so it needs cap-web's
25
+ `urlencode`: Handlebars escapes for HTML, and an unencoded `&` in the
26
+ path would end the parameter. The hidden field above is a form value,
27
+ not a URL — HTML escaping is the right and only encoding there. --}}
24
28
  <div class="auth-alt">
25
- Don't have an account? <a href="/signup{{#if next}}?next={{next}}{{/if}}">Sign up</a>
29
+ Don't have an account? <a href="/signup{{#if next}}?next={{urlencode next}}{{/if}}">Sign up</a>
26
30
  </div>
27
31
  </section>
28
32
  </div>
@@ -22,8 +22,12 @@
22
22
  <button class="auth-submit" type="submit">Sign Up</button>
23
23
  </form>
24
24
 
25
+ {{!-- The return path goes into a query string here, so it needs cap-web's
26
+ `urlencode`: Handlebars escapes for HTML, and an unencoded `&` in the
27
+ path would end the parameter. The hidden field above is a form value,
28
+ not a URL — HTML escaping is the right and only encoding there. --}}
25
29
  <div class="auth-alt">
26
- Already have an account? <a href="/login{{#if next}}?next={{next}}{{/if}}">Log in</a>
30
+ Already have an account? <a href="/login{{#if next}}?next={{urlencode next}}{{/if}}">Log in</a>
27
31
  </div>
28
32
  </section>
29
33
  </div>