ReviewOS
@if (!pullRequest)

No such pull request

loghqorg/loghq has no pull request numbered 7.

@else

also looking at this

loghqorg/loghq

Deploy: uniform auth session model (main → production)

#7
Merged glennmichael123 wants to merge main into production
282 files +3028 -64856

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

app/Actions/Auth/LoginAction.tsadded+95-0
Changes to app/Actions/Auth/LoginAction.ts
@@ -0,0 +1,95 @@
1import type { RequestInstance } from '@stacksjs/types'
2import { Action } from '@stacksjs/actions'
3import { Auth, createTwoFactorChallenge, getTwoFactorState } from '@stacksjs/auth'
4import { User } from '@stacksjs/orm'
5import { response } from '@stacksjs/router'
6import { schema } from '@stacksjs/validation'
7import { buildAuthCookie, sessionExpiryMinutes } from './authCookie'
8
9/**
10 * Email/password sign-in, registered at POST /api/auth/login (routes/auth.ts).
11 *
12 * Replaces the old login.stx page action. loghq is server-rendered stx with no
13 * client hydration, so on success the issued bearer is mirrored into the
14 * HttpOnly `auth-token` cookie (see authCookie.ts) — that cookie IS the session
15 * every server-rendered page and every same-origin dashboard fetch
16 * authenticates from. There is no localStorage token and no Authorization
17 * header anywhere in the client any more.
18 *
19 * `Auth.attempt` carries the rate limiting (RateLimiter, keyed by email) and
20 * verifies credentials WITHOUT minting a token, so a 2FA-enabled account never
21 * gets a token pack before its TOTP code is also checked.
22 */
23export default new Action({
24 name: 'LoginAction',
25 description: 'Login to the application',
26 method: 'POST',
27
28 validations: {
29 email: {
30 rule: schema.string().email(),
31 message: 'Email must be a valid email address.',
32 },
33 password: {
34 rule: schema.string().min(6).max(255),
35 message: 'Password must be between 6 and 255 characters.',
36 },
37 },
38
39 async handle(request: RequestInstance) {
40 const email = String(request.get('email') ?? '').trim()
41 const password = String(request.get('password') ?? '')
42
43 const isValid = await Auth.attempt({ email, password })
44 if (!isValid)
45 return response.unauthorized('Invalid email or password.')
46
47 const authedUser = await User.where('email', '=', email).first()
48 if (!authedUser)
49 return response.unauthorized('Invalid email or password.')
50
51 // 2FA is dormant in loghq (no enrolment UI), but wired so a 2FA-enabled
52 // account can never lock itself out. getTwoFactorState reads two_factor_*
53 // columns that may not exist on this database yet — treat any failure as
54 // "not enabled" so a normal login is never broken by their absence.
55 let twoFactorEnabled = false
56 try {
57 twoFactorEnabled = (await getTwoFactorState(authedUser.id as number)).enabled
58 }
59 catch {
60 twoFactorEnabled = false
61 }
62
63 if (twoFactorEnabled) {
64 const challengeToken = await createTwoFactorChallenge(authedUser.id as number)
65 return response.json({
66 requires_two_factor: true,
67 challenge_token: challengeToken,
68 })
69 }
70
71 // Session length is set once, here, from the "remember me" checkbox: a week
72 // by default, 30 days when checked. See sessionExpiryMinutes.
73 const expiresInMinutes = sessionExpiryMinutes(request.get('remember'))
74 const result = await Auth.loginUsingId(authedUser.id as number, { expiresInMinutes })
75 if (!result)
76 return response.unauthorized('Invalid email or password.')
77
78 const user = result.user
79
80 return response.json(
81 {
82 access_token: result.token,
83 token_type: 'Bearer',
84 expires_in: result.expiresIn,
85 token: result.token,
86 user: {
87 id: user?.id,
88 email: user?.email,
89 name: user?.name,
90 },
91 },
92 { status: 200, headers: { 'Set-Cookie': buildAuthCookie(result.token, result.expiresIn) } },
93 )
94 },
95})
app/Actions/Auth/LogoutAction.tsadded+39-0
Changes to app/Actions/Auth/LogoutAction.ts
@@ -0,0 +1,39 @@
1import type { RequestInstance } from '@stacksjs/types'
2import { Action } from '@stacksjs/actions'
3import { Auth } from '@stacksjs/auth'
4import { response } from '@stacksjs/router'
5import { clearAuthCookie } from './authCookie'
6
7/**
8 * Project override of the framework's default LogoutAction — same token
9 * revocation, plus clearing the HttpOnly `auth-token` cookie the auth actions
10 * set (see authCookie.ts). The cookie is HttpOnly, so only the server can clear
11 * it: the client signOut() just calls this and reloads.
12 */
13export default new Action({
14 name: 'LogoutAction',
15 description: 'Logout from the application',
16 method: 'POST',
17 async handle(request: RequestInstance) {
18 await Auth.logout()
19
20 const clearCookie = clearAuthCookie()
21
22 // A plain full-page <form method="POST"> logout is a navigation, not an
23 // XHR, so returning JSON would render the raw payload in the browser.
24 // Redirect browser navigations to /login; XHR/API callers (Accept:
25 // application/json) still get JSON.
26 const accept = String(request.headers?.get?.('accept') ?? '')
27 if (accept.includes('text/html')) {
28 return new Response(null, {
29 status: 302,
30 headers: { 'Location': '/login', 'Set-Cookie': clearCookie },
31 })
32 }
33
34 return response.json(
35 { message: 'Successfully logged out' },
36 { status: 200, headers: { 'Set-Cookie': clearCookie } },
37 )
38 },
39})
app/Actions/Auth/RegisterAction.tsadded+78-0
Changes to app/Actions/Auth/RegisterAction.ts
@@ -0,0 +1,78 @@
1import type { RequestInstance } from '@stacksjs/types'
2import { Action } from '@stacksjs/actions'
3import { Auth, register } from '@stacksjs/auth'
4import { response } from '@stacksjs/router'
5import { schema } from '@stacksjs/validation'
6import { buildAuthCookie } from './authCookie'
7
8/**
9 * Account creation, registered at POST /api/auth/register (routes/auth.ts).
10 *
11 * Replaces the old register.stx page action. Like LoginAction, on success the
12 * issued bearer is mirrored into the HttpOnly `auth-token` cookie so the very
13 * first server-rendered page after signup is already authenticated. loghq has
14 * no teams model, so there is no personal-team creation here — just the account
15 * and its session. Where the user lands afterwards (an invited user goes to the
16 * dashboard, everyone else to the create-your-first-app page) is decided by the
17 * client, which knows the invite context from the URL.
18 */
19export default new Action({
20 name: 'RegisterAction',
21 description: 'Register a new user',
22 method: 'POST',
23
24 validations: {
25 name: {
26 rule: schema.string().min(2).max(255),
27 message: 'Name must be between 2 and 255 characters.',
28 },
29 email: {
30 rule: schema.string().email(),
31 message: 'Email must be a valid email address.',
32 },
33 password: {
34 rule: schema.string().min(8).max(255),
35 message: 'Password must be at least 8 characters.',
36 },
37 },
38
39 async handle(request: RequestInstance) {
40 const name = String(request.get('name') ?? '').trim()
41 const email = String(request.get('email') ?? '').trim()
42 const password = String(request.get('password') ?? '')
43
44 // `register` throws an HttpError for an invalid address, a short password or
45 // a duplicate. Its duplicate message is deliberately vague — the framework
46 // treats "this email exists" as an enumeration oracle — so surface it as-is.
47 let result
48 try {
49 result = await register({ name, email, password })
50 }
51 catch (err) {
52 return response.json(
53 { message: (err as Error)?.message || 'Could not create the account.' },
54 { status: 422 },
55 )
56 }
57
58 if (!result?.token)
59 return response.json({ message: 'Could not create the account.' }, { status: 422 })
60
61 // register() returns the token pack but not the user record; resolve it for
62 // the response body. The cookie's Max-Age matches the token's own expiry
63 // (config baseline — a week), so signup lands on the baseline tier.
64 const user = await Auth.getUserFromToken(result.token)
65
66 return response.json(
67 {
68 token: result.token,
69 user: {
70 id: user?.id,
71 email: user?.email,
72 name: user?.name,
73 },
74 },
75 { status: 200, headers: { 'Set-Cookie': buildAuthCookie(result.token, result.expiresIn) } },
76 )
77 },
78})
app/Actions/Auth/SocialCallbackAction.tsmodified+16-14
Changes to app/Actions/Auth/SocialCallbackAction.ts
@@ -6,6 +6,7 @@ import { config } from '@stacksjs/config'
66import { db } from '@stacksjs/database'
77import { response } from '@stacksjs/router'
88import { GitHubProvider, GoogleProvider } from '@stacksjs/socials'
9import { buildAuthCookie, sessionExpiryMinutes } from './authCookie'
910
1011function makeDriver(provider: string): GitHubProvider | GoogleProvider | null {
1112 const svc = config.services as any
@@ -97,22 +98,23 @@ export default new Action({
9798 .execute()
9899 }
99100
100 const result = await Auth.loginUsingId(userId)
101 // SSO has no "remember me" checkbox, so it lands on the baseline session
102 // (a week). loginUsingId stamps expires_at from expiresInMinutes and
103 // returns the matching expiresIn, which buildAuthCookie uses for Max-Age —
104 // one number drives both.
105 const result = await Auth.loginUsingId(userId, { expiresInMinutes: sessionExpiryMinutes(false) })
101106 if (!result?.token)
102107 return fail('We could not sign you in.')
103108
104 // Hand the token to the client, mirroring the email/password flow exactly:
105 // localStorage for bearer API calls + the loghq_token cookie so the very
106 // first server-rendered page is already authenticated. Token is embedded in
107 // the response body, never in the URL. Then land in the app.
108 const token = JSON.stringify(result.token)
109 const user = JSON.stringify({ id: userId, email, name: social.name })
110 return response.html(
111 `<!doctype html><meta charset="utf-8"><title>Signing you in</title>`
112 + `<script>try{localStorage.setItem('token', ${token});localStorage.setItem('user', ${user});`
113 + `document.cookie='loghq_token='+${token}+'; path=/; max-age=2592000; samesite=lax'+(location.protocol==='https:'?'; secure':'')}catch(e){}`
114 + `location.replace('/dashboard')</script>Signing you in...`,
115 200,
116 )
109 // Establish the session server-side, exactly like the email/password flow:
110 // a 302 to /dashboard carrying the single HttpOnly `auth-token` cookie. No
111 // localStorage, no client-readable cookie, no token in the URL.
112 return new Response(null, {
113 status: 302,
114 headers: {
115 'Location': '/dashboard',
116 'Set-Cookie': buildAuthCookie(result.token, result.expiresIn),
117 },
118 })
117119 },
118120})
app/Actions/Auth/VerifyTwoFactorLoginAction.tsadded+72-0
Changes to app/Actions/Auth/VerifyTwoFactorLoginAction.ts
@@ -0,0 +1,72 @@
1import type { RequestInstance } from '@stacksjs/types'
2import { Action } from '@stacksjs/actions'
3import { Auth, consumeTwoFactorChallenge, verifyTwoFactorLoginCode } from '@stacksjs/auth'
4import { response } from '@stacksjs/router'
5import { schema } from '@stacksjs/validation'
6import { buildAuthCookie, sessionExpiryMinutes } from './authCookie'
7
8/**
9 * Exchange a LoginAction 2FA challenge + TOTP code for a real session,
10 * registered at POST /api/auth/verify-two-factor-login (routes/auth.ts).
11 *
12 * Dormant in loghq (there is no enrolment UI), but wired end to end so a
13 * 2FA-enabled account completing step two is signed in exactly like the
14 * single-step path: the same HttpOnly `auth-token` cookie, the same
15 * remember-me tier. See LoginAction / authCookie.ts.
16 */
17export default new Action({
18 name: 'VerifyTwoFactorLoginAction',
19 description: 'Exchange a LoginAction 2FA challenge + TOTP code for a real token pack',
20 method: 'POST',
21
22 validations: {
23 challenge_token: {
24 rule: schema.string().min(1),
25 message: 'A challenge token is required.',
26 },
27 code: {
28 rule: schema.string().min(6).max(6),
29 message: 'Code must be a 6-digit TOTP code.',
30 },
31 },
32
33 async handle(request: RequestInstance) {
34 const challengeToken = request.get('challenge_token')
35 const code = request.get('code')
36
37 // Single-use: a second attempt with the same challenge token (right code or
38 // wrong) must start over from LoginAction, not retry.
39 const userId = await consumeTwoFactorChallenge(challengeToken)
40 if (!userId)
41 return response.unauthorized('This login attempt has expired — please sign in again.')
42
43 const valid = await verifyTwoFactorLoginCode(userId, code)
44 if (!valid)
45 return response.unauthorized('Invalid code — please sign in again.')
46
47 // Carry the "remember me" tier chosen on step one through to the session
48 // issued here, so a 2FA account is not silently downgraded to the baseline
49 // week. The login page re-sends the checkbox with the TOTP code.
50 const expiresInMinutes = sessionExpiryMinutes(request.get('remember'))
51 const result = await Auth.loginUsingId(userId, { expiresInMinutes })
52 if (!result)
53 return response.unauthorized('Invalid code — please sign in again.')
54
55 const user = result.user
56
57 return response.json(
58 {
59 access_token: result.token,
60 token_type: 'Bearer',
61 expires_in: result.expiresIn,
62 token: result.token,
63 user: {
64 id: user?.id,
65 email: user?.email,
66 name: user?.name,
67 },
68 },
69 { status: 200, headers: { 'Set-Cookie': buildAuthCookie(result.token, result.expiresIn) } },
70 )
71 },
72})
app/Actions/Auth/authCookie.tsadded+57-0
Changes to app/Actions/Auth/authCookie.ts
@@ -0,0 +1,57 @@
1import process from 'node:process'
2import { config } from '@stacksjs/config'
3
4/**
5 * Build the `Set-Cookie` header value that mirrors an issued bearer token
6 * into an HttpOnly cookie. The SPA's `useAuth` composable only stores the
7 * bearer in localStorage, which is invisible during server-side rendering —
8 * the dashboard's `.stx` pages (rendered directly from SQLite, no client
9 * hydration) resolve "who's logged in" from this cookie instead.
10 *
11 * Shared by every auth action that mints a session (LoginAction,
12 * RegisterAction, VerifyTwoFactorLoginAction) so the cookie contract stays
13 * identical across all three entry points into an authenticated session.
14 */
15export function buildAuthCookie(token: string, expiresInSeconds?: number): string {
16 const name = config.auth?.defaultTokenName || 'auth-token'
17 const maxAge = Math.max(1, Math.floor(expiresInSeconds ?? (config.auth?.tokenExpiry ?? 60 * 60 * 1000) / 1000))
18 const env = (process.env.APP_ENV ?? process.env.NODE_ENV ?? '').toLowerCase()
19 const isLocal = env === '' || env === 'local' || env === 'development' || env === 'dev' || env === 'test' || env === 'testing'
20
21 const parts = [`${name}=${token}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', `Max-Age=${maxAge}`]
22 if (!isLocal)
23 parts.push('Secure')
24
25 return parts.join('; ')
26}
27
28/**
29 * Clear the auth cookie {@link buildAuthCookie} sets — used by LogoutAction.
30 */
31export function clearAuthCookie(): string {
32 const name = config.auth?.defaultTokenName || 'auth-token'
33 return `${name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`
34}
35
36/** Baseline session: a week. */
37const WEEK_MINUTES = 7 * 24 * 60
38/** "Keep me signed in": a month. */
39const MONTH_MINUTES = 30 * 24 * 60
40
41/**
42 * Session length for a fresh login, in minutes, from the login form's
43 * "remember me" checkbox. Passed to `Auth.loginUsingId(id, { expiresInMinutes })`,
44 * which stamps BOTH the `oauth_access_tokens.expires_at` row and (via the
45 * returned `expiresIn`) the cookie's Max-Age from the same number — so the
46 * whole session, not just the cookie, honours the tier. Nothing extends either
47 * value afterwards (getUserFromToken leaves expires_at alone), so this is the
48 * real session cap.
49 *
50 * Unchecked is the baseline week; checked is 30 days. Accepts whatever the JSON
51 * body carries for `remember` — boolean true, or '1'/'true'/'on'/'yes'.
52 */
53export function sessionExpiryMinutes(remember: unknown): number {
54 const on = remember === true
55 || ['1', 'true', 'on', 'yes'].includes(String(remember ?? '').toLowerCase())
56 return on ? MONTH_MINUTES : WEEK_MINUTES
57}
app/Actions/MeAction.tsmodified+5-4
Changes to app/Actions/MeAction.ts
@@ -1,9 +1,9 @@
11import type { RequestInstance } from '@stacksjs/types'
22import { Action } from '@stacksjs/actions'
3import { Auth } from '@stacksjs/auth'
43import { db } from '@stacksjs/database'
54import { Payment } from '@stacksjs/payments'
65import { response } from '@stacksjs/router'
6import { userFromRequest } from '../Support/request-auth'
77
88/**
99 * Return the authenticated user plus their Pro status. The dashboard calls this
@@ -16,9 +16,10 @@ export default new Action({
1616 description: 'Return the current user and their Pro status',
1717 method: 'GET',
1818 async handle(request: RequestInstance) {
19 const authHeader = ((request as any).headers?.get?.('authorization') ?? '')
20 const bearer = (request as any).bearerToken?.() ?? authHeader.replace(/^Bearer\s+/i, '')
21 const user = bearer ? await Auth.getUserFromToken(bearer) : await request.user()
19 // Resolve from either carrier: an external bearer header, or the HttpOnly
20 // `auth-token` cookie the dashboard sends on same-origin fetches. /api/me
21 // has no `.middleware('auth')`, so the resolution happens here.
22 const user = await userFromRequest(request)
2223 if (!user)
2324 return response.unauthorized('Authentication required')
2425
app/Middleware/Auth.tsmodified+20-1
Changes to app/Middleware/Auth.ts
@@ -17,6 +17,18 @@ export default new Middleware({
1717 if (!isValid)
1818 throw new HttpError(401, 'Unauthorized. Invalid token.')
1919
20 // Stamp the resolved user like the cookie branch below and the
21 // framework-default middleware do — `request.user()` returns
22 // `_authenticatedUser` with no fallback, so without this a route behind
23 // `.middleware('auth')` that reads `await request.user()` (e.g.
24 // CreateCheckoutAction) saw undefined even after a valid bearer passed
25 // here. Do NOT overwrite `request.user` itself — it is the accessor.
26 const user = await Auth.getUserFromToken(bearerToken)
27 if (user) {
28 Auth.setUser(user)
29 ;(request as { _authenticatedUser?: unknown })._authenticatedUser = user
30 }
31
2032 log.debug(`[middleware:auth] Bearer token valid`)
2133 return
2234 }
@@ -51,11 +63,18 @@ export default new Middleware({
5163
5264 if (sessionId) {
5365 log.debug(`[middleware:auth] Validating session`)
54 const { sessionCheck } = await import('@stacksjs/auth')
66 const { sessionCheck, sessionUser } = await import('@stacksjs/auth')
5567 const isValid = await sessionCheck(sessionId)
5668 if (!isValid)
5769 throw new HttpError(401, 'Unauthorized. Session expired.')
5870
71 // Same stamping for the session branch.
72 const user = await sessionUser(sessionId)
73 if (user) {
74 Auth.setUser(user)
75 ;(request as { _authenticatedUser?: unknown })._authenticatedUser = user
76 }
77
5978 log.debug(`[middleware:auth] Session valid`)
6079 return
6180 }
app/Support/request-auth.tsmodified+6-3
Changes to app/Support/request-auth.ts
@@ -8,10 +8,12 @@
88 * This exists instead of the `auth` middleware alias because that alias does not
99 * reliably populate `request.user()` on these route handlers (see the note in
1010 * routes/projects.ts), so the token is read and resolved directly. Two carriers
11 * are accepted: a bearer header, which is what the API clients send, and the
12 * `loghq_token` cookie, which is what the dashboard has after signing in.
11 * are accepted: a bearer header, which is what the external API clients send,
12 * and the HttpOnly `auth-token` cookie (config.auth.defaultTokenName), which is
13 * what the dashboard has after signing in.
1314 */
1415import { Auth } from '@stacksjs/auth'
16import { config } from '@stacksjs/config'
1517
1618export async function userFromRequest(request: any): Promise<any | null> {
1719 const authHeader = request.headers?.get?.('authorization') ?? ''
@@ -19,7 +21,8 @@ export async function userFromRequest(request: any): Promise<any | null> {
1921
2022 if (!token) {
2123 const cookie = request.headers?.get?.('cookie') ?? ''
22 const m = cookie.match(/(?:^|;)\s*loghq_token=([^;]+)/)
24 const name = (config.auth?.defaultTokenName || 'auth-token').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
25 const m = cookie.match(new RegExp(`(?:^|;)\\s*${name}=([^;]+)`))
2326 if (m)
2427 token = decodeURIComponent(m[1])
2528 }
config/auth.tsmodified+27-10
Changes to config/auth.ts
@@ -47,20 +47,37 @@ export default {
4747 password: env.AUTH_PASSWORD_FIELD || 'password',
4848
4949 /**
50 * Access-token expiry in milliseconds (default: 1 hour).
50 * Access-token expiry in milliseconds (default: 7 days).
5151 *
52 * Access tokens are deliberately short-lived: a leaked bearer (logs,
53 * proxy, browser storage) is then usable for an hour, not a month. The
54 * paired refresh token (`refreshTokenExpiry`) carries the long-lived
55 * session and is rotated on use, so UX is unaffected.
52 * This value IS the browser session length, not just an API-bearer TTL.
53 * The custom auth actions (LoginAction, RegisterAction,
54 * VerifyTwoFactorLoginAction, SocialCallbackAction) mirror the issued
55 * access token into the HttpOnly `auth-token` cookie (see
56 * Actions/Auth/authCookie.ts) because the dashboard is server-rendered stx
57 * with no client hydration and has no other way to know who is asking. Both
58 * the cookie's Max-Age and the `oauth_access_tokens.expires_at` row are
59 * stamped from here, and nothing extends either one — `getUserFromToken`
60 * bumps `updated_at` on every request but leaves `expires_at` alone, then
61 * deletes the row once it passes. So a signed-in operator is logged out
62 * exactly this long after login regardless of activity.
63 *
64 * This is the BASELINE only. LoginAction and VerifyTwoFactorLoginAction
65 * pass a per-login `expiresInMinutes` from the sign-in form's "remember me"
66 * checkbox (see sessionExpiryMinutes in Actions/Auth/authCookie.ts): a week
67 * unchecked, 30 days checked. This default covers the entry points that have
68 * no such checkbox — register, SSO — so they land on the baseline week.
5669 */
57 // 30 days: the dashboard is a long-lived session (token kept in localStorage +
58 // a cookie). A 1h expiry logged users out mid-session.
59 tokenExpiry: env.AUTH_TOKEN_EXPIRY || 30 * 24 * 60 * 60 * 1000,
70 tokenExpiry: env.AUTH_TOKEN_EXPIRY || 7 * 24 * 60 * 60 * 1000,
6071
6172 /**
62 * Refresh-token expiry in milliseconds (default: 30 days). This is the
63 * long-lived credential exchanged for fresh access tokens.
73 * Refresh-token expiry in milliseconds (default: 30 days).
74 *
75 * NOT WIRED UP. A refresh token is minted and returned in the login
76 * response body by the auth actions and consumed by nothing: there is no
77 * refresh route and no cookie stores it, so the value below only bounds a
78 * row in `oauth_refresh_tokens` that never gets read. Session length is
79 * `tokenExpiry` above, alone — the long-lived `auth-token` cookie IS the
80 * session.
6481 */
6582 refreshTokenExpiry: env.AUTH_REFRESH_TOKEN_EXPIRY || 30 * 24 * 60 * 60 * 1000,
6683
config/server.tsmodified+11-5
Changes to config/server.ts
@@ -29,16 +29,22 @@ import type { ServerConfig } from '@stacksjs/types'
2929 *
3030 * routes/api.ts auto-prefixed /api -> covered by the prefix
3131 * routes/projects.ts all under /api/ -> covered by the prefix
32 * routes/auth.ts NOT prefixed -> named individually
32 * routes/auth.ts sign-in/up/2FA under /api/auth/ -> covered by the prefix;
33 * /logout + the /password/* + /payments/* + /webhooks/*
34 * paths are NOT prefixed -> named individually below
3335 * routes/logs.ts POST /logs (SDK ingest) -> named
3436 * routes/buddy.ts POST /jobs/{id}/cancel|retry, parameterised -> prefix
3537 *
3638 * `paths` is matched exactly and IGNORES THE METHOD, so a path listed here
3739 * takes its GET with it. That is why `/login` and `/register` are absent: they
38 * are the only two paths in the app that have both a POST route and a GET page,
39 * and listing them would proxy the sign-in page itself away. They are page
40 * actions now — the page handles its own POST — which is what made emptying
41 * `methods` possible without breaking them.
40 * each still render a GET page (the sign-in / sign-up forms), and listing them
41 * would proxy that page away. Their POST no longer lives at those paths — the
42 * forms are client-fetch to `/api/auth/login` and `/api/auth/register` (under
43 * the proxied `/api/` prefix) — so nothing here needs to name them. The four
44 * remaining page-action forms (fix, forgot-password, reset-password,
45 * projects/new) DO handle their own POST at their own path, which is what makes
46 * emptying `methods` load-bearing: naming any of them here would proxy the page
47 * away.
4248 *
4349 * Do not add a path here without checking `resources/views/` for a page of the
4450 * same name first. The failure is silent: the page stops rendering and the
resources/partials/AuthGuard.stxdeleted+0-37
Changes to resources/partials/AuthGuard.stx
@@ -1,37 +0,0 @@
1{{-- Pre-paint auth bounce for the app pages.
2
3 This is the one place in the app that is allowed to touch document.cookie
4 directly, and it stays a plain script rather than a client block on purpose:
5 it must run before the runtime exists and before anything paints, so no
6 composable is available to it and hydrating would be far too late — a
7 signed-out visitor would see the app shell first.
8
9 Keeping it in ONE partial rather than copied into five pages is what makes
10 the strict-mode report legible: the DOM guard names this file, and any
11 second file appearing in that report is a real regression rather than one of
12 five known copies.
13
14 It used to read the token from localStorage and, if the cookie was missing,
15 write the cookie and reload the whole page so the server could see it. All
16 of that is gone. The cookie IS the session now — the session store writes it
17 at sign-in and every server-rendered page authenticates from it — so there
18 is nothing to mirror and nothing to reload for. That removes a full document
19 load from first sign-in.
20
21 `#auth-pending` is rendered by the server when the cookie did not
22 authenticate, which means the token is stale rather than absent. Reading it
23 here, from the DOM, is deliberate: the server cannot distinguish "no cookie
24 yet" from "bad cookie" at render time, and moving this into the server's own
25 signed-out branch would bounce a first-visit user in a loop. --}}
26<script>
27(function () {
28 if (!/(^|;)\s*loghq_token=/.test(document.cookie)) {
29 window.location.replace('/login')
30 return
31 }
32 if (document.getElementById('auth-pending')) {
33 document.cookie = 'loghq_token=; path=/; max-age=0'
34 window.location.replace('/login')
35 }
36})()
37</script>
resources/partials/SiteFooter.stxmodified+1-1
Changes to resources/partials/SiteFooter.stx
@@ -7,7 +7,7 @@
77// Each partial owns its own copy: a partial's scope does not reach across
88// includes, and this is one boolean rather than a signal plus an onMount plus an
99// effect.
10const footerAuthed = typeof cookies !== 'undefined' && !!cookies.loghq_token
10const footerAuthed = typeof cookies !== 'undefined' && !!cookies['auth-token']
1111</script>
1212
1313<footer class="footer shell">
resources/partials/SiteNav.stxmodified+1-1
Changes to resources/partials/SiteNav.stx
@@ -24,7 +24,7 @@
2424// marketing page changes auth state in place: signing in navigates, and
2525// signOut() does a declared full load, so the next render is server-truth
2626// anyway.
27const navAuthed = typeof cookies !== 'undefined' && !!cookies.loghq_token
27const navAuthed = typeof cookies !== 'undefined' && !!cookies['auth-token']
2828</script>
2929
3030{{-- Do NOT write a template comment's opening marker as prose anywhere in this
resources/stores/session.tsmodified+35-100
Changes to resources/stores/session.ts
@@ -1,113 +1,60 @@
11/**
2 * Session store — the auth token and the cached viewer.
2 * Session store — the cached viewer and the current-project browsing preference.
33 *
44 * Same file contract as theme.ts: no value imports (they are deleted before
55 * transpile), no reserved filename, and every top-level name here must be unique
66 * across resources/stores/*.ts, because the loader concatenates them into one
77 * shared IIFE.
88 *
9 * WHY THE TOKEN IS A COOKIE AND THE USER IS LOCALSTORAGE
9 * THERE IS NO CLIENT TOKEN
1010 *
11 * useLocalStorage JSON.parses on read and JSON.stringifies on write
12 * (signals.js:3526-3539), unguarded. loghq stores the token as a RAW string, so
13 * useLocalStorage('token') would throw SyntaxError on every existing session —
14 * verified, not assumed — and would then write it back quoted, changing the
15 * format for the server too. `user` is already JSON, so it is safe.
11 * The session is a single HttpOnly `auth-token` cookie the server sets at
12 * sign-in (see app/Actions/Auth/authCookie.ts). It is invisible to JavaScript
13 * by design, so the client cannot read it, cannot attach a bearer and cannot
14 * clear it — the browser sends it automatically on every same-origin request,
15 * and only the server LogoutAction can remove it. Every authenticated data call
16 * therefore rides the cookie: `credentials: 'same-origin'`, no Authorization
17 * header anywhere. This replaced the old readable `loghq_token` cookie + bearer
18 * plumbing wholesale.
1619 *
17 * useCookie has no JSON layer at all (signals.js:3568+): it encodes with
18 * encodeURIComponent and decodes symmetrically. It is the right home for a
19 * bearer token, and it is where the token already had to live — every
20 * server-rendered page authenticates from the loghq_token cookie. Making the
21 * cookie the single source of truth removes the localStorage copy, the
22 * mirror-into-a-cookie step, and the full page reload that followed it.
23 *
24 * The pre-paint auth guards still read document.cookie directly. That is
25 * deliberate and is the one sanctioned exception: they run before the runtime
26 * exists, so no composable is available to them, and their whole job is to leave
27 * the page before it is worth rendering.
20 * The only cookie this store still owns is `loghq_project`, a browsing
21 * preference (which project the dashboard is looking at), not a credential.
2822 */
2923
3024function registerSessionStore() {
3125 defineStore('session', () => {
32 // Called bare, as any other composable would be. It could not be until
33 // stx#1838: useCookie is one of ~34 composables that live only on
34 // window.stx rather than as a bare window global, and store bundles used to
35 // be injected with no runtime-globals preamble — so the straightforward
36 // call was a ReferenceError, and this file reached through window.stx to
37 // work around it. store-loader now emits the same preamble a <script client>
38 // block gets, so the workaround is gone.
39 //
40 // The failure it caused was worth the detour: the throw happens inside the
41 // shared store IIFE, so defineStore never completes and what a developer
42 // actually sees is "Store not found" from useStore('session') — raised in a
43 // different file, which is fine.
44 //
45 // 30 days, matching what the hand-serialised writes used. useCookie adds
46 // path=/, SameSite=Lax and — only on https — Secure, which is the exact
47 // string that was being retyped at every call site.
48 const token = useCookie('loghq_token', { maxAge: 2592000, sameSite: 'Lax' })
49
5026 // Which project the dashboard and settings pages are looking at. A browsing
51 // preference, not a credential, so it gets a year.
27 // preference, not a credential, so it gets a year and stays readable.
5228 const project = useCookie('loghq_project', { maxAge: 31536000, sameSite: 'Lax' })
5329
54 // Safe under useLocalStorage: this value is already written as JSON. The
55 // default is widened so the signal is MeUser | null rather than null.
56 const user: StxSignal<MeUser | null> = useLocalStorage('user', null as MeUser | null)
57
58
5930 // ---- the viewer -------------------------------------------------------
6031 // GET /api/me was fetched independently by account.stx and dashboard.stx,
6132 // each with its own `pro` signal, so navigating between the two refetched
6233 // every time and the two copies could disagree. It lives here now: one
6334 // request per page load, shared across SPA navigations because the store
6435 // registry survives a fragment swap.
65 //
66 // Still a plain fetch, but no longer for the reason this comment used to
67 // give. The old note said useQuery throws `HTTP <status>: <statusText>` and
68 // never reads the body, so a 401 could only be recovered by matching an
69 // error string. That was fixed upstream (stx#1848) and is measured false
70 // now: the thrown error carries `.status`, `.statusText`, `.data` and
71 // `.response`, so `err.status === 401` and `err.data.message` both work.
72 //
73 // What keeps it a plain fetch is the in-flight de-duplication below. This is
74 // the one caller that must collapse concurrent callers onto a single
75 // promise, and it owns that promise directly. The caching useQuery offers is
76 // what this store already is.
7736 const viewer: StxSignal<MeUser | null> = state(null as MeUser | null)
7837 const pro = state(false)
7938 const viewerLoaded = state(false)
8039
81 // One place the bearer token is attached, and one place a stale one is
82 // noticed. Applies to every stx data primitive — useFetch, useQuery,
40 // One place authentication is carried, and one place a signed-out response
41 // is noticed. Applies to every stx data primitive — useFetch, useQuery,
8342 // useMutation all route through the same __stxFetch — so a call site says
8443 // what it wants and never how to authenticate.
8544 //
86 // Hooks REPLACE rather than stack, by design upstream: a module re-evaluated
87 // by hot reload or re-run after an SPA swap must not end up sending two
88 // Authorization headers or counting one 401 twice.
89 //
90 // The token check in onRequest is not defensive noise. Sign-in and sign-up
91 // post to /login and /register with no session, and sending `Bearer ` with
92 // an empty value is not the same as sending nothing.
93 //
94 // The same check in onResponseError is what keeps a *credential* 401 apart
95 // from a *session* 401. Bad password at sign-in answers 401 too, and
96 // clearing the session there would be harmless but bouncing to /login from
97 // /login is not. A 401 while we are holding a token is the stale-session
98 // case, and only that one signs out.
45 // onRequest sends the HttpOnly cookie (credentials: 'same-origin') and NO
46 // Authorization header — there is no client token to attach. onResponseError
47 // treats a 401 as a stale/absent session: the cookie the browser sent did
48 // not resolve a user, so clear the cached viewer and leave for /login. The
49 // cookie itself cannot be cleared here (HttpOnly); the server owns that.
9950 configureFetch({
10051 onRequest(ctx: StxFetchRequestContext) {
101 const bearer = token()
102 if (bearer)
103 ctx.options.headers.Authorization = `Bearer ${bearer}`
52 ctx.options.credentials = 'same-origin'
10453 },
10554 onResponseError(ctx: StxFetchErrorContext) {
106 if (ctx.response?.status !== 401 || !token())
55 if (ctx.response?.status !== 401)
10756 return
10857 batch(() => {
109 token.set('')
110 user.set(null)
11158 viewer.set(null)
11259 pro.set(false)
11360 viewerLoaded.set(false)
@@ -123,22 +70,20 @@ function registerSessionStore() {
12370 let viewerRequest: Promise<void> | null = null
12471
12572 async function loadViewer(): Promise<void> {
126 const bearer = token()
127 if (!bearer || viewerLoaded())
73 if (viewerLoaded())
12874 return
12975 if (viewerRequest)
13076 return viewerRequest
131 viewerRequest = fetchViewer(bearer).finally(() => { viewerRequest = null })
77 viewerRequest = fetchViewer().finally(() => { viewerRequest = null })
13278 return viewerRequest
13379 }
13480
135 async function fetchViewer(bearer: string): Promise<void> {
81 async function fetchViewer(): Promise<void> {
13682 try {
137 const res = await fetch('/api/me', { headers: { Authorization: `Bearer ${bearer}` } })
83 const res = await fetch('/api/me', { credentials: 'same-origin', headers: { Accept: 'application/json' } })
13884 if (res.status === 401) {
139 // Stale token. Clearing it through the signal also clears the cookie
140 // the server authenticates from.
141 token.set('')
85 // Signed out. The cookie is HttpOnly, so there is nothing to clear
86 // client-side — just leave.
14287 navigate('/login', { reload: true })
14388 return
14489 }
@@ -157,38 +102,28 @@ function registerSessionStore() {
157102 }
158103
159104 return {
160 token,
161105 project,
162 user,
163106 viewer,
164107 pro,
165108 viewerLoaded,
166109 loadViewer,
167 isAuthed: derived(() => !!token()),
168110
169111 /**
170 * Clear the session locally and leave. The revoke request is
171 * fire-and-forget on purpose: the credentials are being discarded either
172 * way, so a failed or slow revoke must not strand someone on a page they
173 * have already logged out of.
112 * Clear the cached viewer and leave. The revoke request is
113 * fire-and-forget on purpose: the session is being discarded either way,
114 * so a failed or slow revoke must not strand someone on a page they have
115 * already logged out of. The server LogoutAction clears the HttpOnly
116 * cookie; the client cannot.
174117 */
175118 signOut() {
176 const bearer = token()
177 if (bearer) {
178 fetch('/logout', { method: 'POST', headers: { Authorization: `Bearer ${bearer}` } })
179 .catch(() => {})
180 }
119 fetch('/logout', { method: 'POST', credentials: 'same-origin' }).catch(() => {})
181120 batch(() => {
182 token.set('')
183 user.set(null)
184121 viewer.set(null)
185122 pro.set(false)
186123 viewerLoaded.set(false)
187124 })
188125 // reload, not a fragment swap: signing out should drop every signal and
189 // cached query in memory, not carry them to the next page. `replace`
190 // would also be defensible here — it would keep the authed page out of
191 // history — but it is a fragment swap, so the in-memory state survives.
126 // cached query in memory, not carry them to the next page.
192127 navigate('/login', { reload: true })
193128 },
194129 }
resources/views/account.stxmodified+0-5
Changes to resources/views/account.stx
@@ -189,9 +189,4 @@ onMount(() => {
189189 </div>
190190</div>
191191
192{{-- Auth guard. Navigation only, renders nothing, and must run before the page is
193 usable — a directive would hydrate too late and flash the account shell to a
194 signed-out visitor. --}}
195
196@include('AuthGuard')
197192@endsection
resources/views/analytics.stxmodified+8-2
Changes to resources/views/analytics.stx
@@ -51,7 +51,7 @@ const rangeLabel = isCustom
5151const requestedProject = query.get('project')
5252const cookieProject = typeof cookies !== 'undefined' ? (cookies.loghq_project ?? '') : ''
5353
54const __token = typeof cookies !== 'undefined' ? (cookies.loghq_token ?? '') : ''
54const __token = typeof cookies !== 'undefined' ? (cookies['auth-token'] ?? '') : ''
5555let user: AuthUser | null = null
5656if (__token) {
5757 try {
@@ -378,8 +378,14 @@ summary:focus-visible {
378378
379379 <div class="mx-auto max-w-5xl">
380380
381 {{-- Signed-out gate. An stx GET server block cannot 302, so instead of a
382 client pre-paint bounce (deleted with AuthGuard) the server renders a
383 sign-in panel when the auth-token cookie resolved no user. --}}
381384 @if (!user)
382 <div id="auth-pending" class="py-24 text-center text-sm text-subtle">Loading…</div>
385 <div class="py-24 text-center">
386 <p class="mb-4 text-sm text-subtle">You're signed out.</p>
387 <StxLink to="/login" class="inline-block px-4 py-2.5 font-semibold text-accent-ink text-sm bg-accent hover:bg-[color-mix(in_srgb,var(--accent)_88%,#000)] rounded-[10px]">Sign in</StxLink>
388 </div>
383389 @endif
384390
385391 @if (user && projectsUnavailable)
resources/views/dashboard.stxmodified+15-17
Changes to resources/views/dashboard.stx
@@ -132,10 +132,11 @@ const requestedPage = Number.isFinite(pageParam) && pageParam > 0 ? pageParam :
132132// skips `__`-prefixed bindings, and everything else in this block is a bridge
133133// candidate that gets published into the HTML as soon as its NAME appears
134134// anywhere in the client script — comments included. Named `token`, this line
135// put the raw session credential in the page source of every /dashboard render,
136// because `useStore('session').token()` appears in the client block below. See
137// the fuller note in settings.stx.
138const __token = typeof cookies !== 'undefined' ? (cookies.loghq_token ?? '') : ''
135// would put the raw session credential in the page source of every /dashboard
136// render. The auth-token cookie is HttpOnly now, but this SSR read still holds
137// its raw value, so it stays `__`-prefixed and out of the bridge. See the
138// fuller note in settings.stx.
139const __token = typeof cookies !== 'undefined' ? (cookies['auth-token'] ?? '') : ''
139140let user: AuthUser | null = null
140141if (__token) {
141142 try {
@@ -554,8 +555,9 @@ function dismissInvite(token: string): void {
554555}
555556
556557// One URL, one method — the shape useMutation fits. No Authorization header: the
557// session store's configureFetch attaches the bearer, and its onResponseError
558// owns the 401, which this hand-written version had no branch for at all.
558// session store's configureFetch sends the HttpOnly auth-token cookie
559// (credentials: 'same-origin') and its onResponseError owns the 401, which this
560// hand-written version had no branch for at all.
559561//
560562// `joining` stays a page signal rather than using acceptInvite.loading(). It
561563// holds WHICH invite is in flight, not merely whether one is, because the banner
@@ -651,8 +653,14 @@ onMount(() => {
651653
652654 <div class="mx-auto max-w-5xl">
653655
656 {{-- Signed-out gate. An stx GET server block cannot 302, so instead of a
657 client pre-paint bounce (deleted with AuthGuard) the server renders a
658 sign-in panel when the auth-token cookie resolved no user. --}}
654659 @if (!user)
655 <div id="auth-pending" class="py-24 text-center text-sm text-subtle">Loading…</div>
660 <div class="py-24 text-center">
661 <p class="mb-4 text-sm text-subtle">You're signed out.</p>
662 <StxLink to="/login" class="inline-block px-4 py-2.5 font-semibold text-accent-ink text-sm bg-accent hover:bg-[color-mix(in_srgb,var(--accent)_88%,#000)] rounded-[10px]">Sign in</StxLink>
663 </div>
656664 @endif
657665
658666 @if (user && needsOnboarding)
@@ -906,14 +914,4 @@ onMount(() => {
906914
907915 </div>
908916</div>
909
910{{-- Client guard: enforce the bearer token and mirror it into the cookie the
911 server authenticates from. Navigation only — it renders nothing, and it has
912 to run before the page is usable, so it stays a plain script rather than
913 hydrating with the rest.
914 The chrome and invite banner it used to build now live in the client block
915 at the top of this file. --}}
916
917
918@include('AuthGuard')
919917@endsection
resources/views/fix.stxmodified+2-2
Changes to resources/views/fix.stx
@@ -26,7 +26,7 @@ useSeoMeta({
2626const query = new URLSearchParams(typeof __stxServeSearch !== 'undefined' ? __stxServeSearch : '')
2727const logId = (query.get('log') || '').slice(0, 255)
2828
29const __token = typeof cookies !== 'undefined' ? (cookies.loghq_token ?? '') : ''
29const __token = typeof cookies !== 'undefined' ? (cookies['auth-token'] ?? '') : ''
3030let __user: any = null
3131if (__token) {
3232 try {
@@ -132,7 +132,7 @@ const analysisVerification = Array.isArray(analysis?.verification) ? analysis.ve
132132// it completes inside this request rather than needing a queue and a poller -
133133// see the note in app/Fix/analyze.ts.
134134const action = async ({ form, cookies: reqCookies }: { form: Record<string, string | string[]>, cookies: Record<string, string> }) => {
135 const token = reqCookies.loghq_token || ''
135 const token = reqCookies['auth-token'] || ''
136136 // Re-authenticated from the request the action was handed. A POST is its own
137137 // request and the session can expire between drawing the button and pressing
138138 // it.
resources/views/index.stxmodified+1-1
Changes to resources/views/index.stx
@@ -48,7 +48,7 @@ useHead({ link: [{ rel: 'canonical', href: 'https://loghq.org/' }] })
4848// This also settles ch.9.1. Both CTAs were plain `<a href>` carrying a `:href`
4949// binding, which is a same-origin navigation done as a full document load. They
5050// are `<StxLink>` now — one branch, one destination, no binding to override it.
51const pageAuthed = typeof cookies !== 'undefined' && !!cookies.loghq_token
51const pageAuthed = typeof cookies !== 'undefined' && !!cookies['auth-token']
5252</script>
5353
5454<script client>
resources/views/login.stxmodified+123-107
Changes to resources/views/login.stx
@@ -1,17 +1,4 @@
11<script server>
2// STATIC import, and that is load-bearing — do not turn it into an
3// `await import('@stacksjs/auth')` inside the action below.
4//
5// A static import here is resolved against this project. A dynamic import
6// evaluated inside an action's body is resolved in the context of the module
7// running it — stx's variable-extractor — and the stx fork's
8// node_modules/@stacksjs/auth is a symlink into a SIBLING PROJECT (bughq). So
9// the dynamic form loaded another app's auth package, whose `@stacksjs/orm`
10// has no User model generated for this database, and `Auth.login` died on
11// `User.where is not a function`. Measured, not theorised: the stack trace
12// named bughq's path.
13import { Auth } from '@stacksjs/auth'
14
152// Show a provider's sign-in button only when it's actually configured, so the
163// buttons stay hidden until credentials are set (then reappear automatically —
174// no code change). See config/services.ts.
@@ -32,77 +19,105 @@ useHead({ link: [{ rel: 'canonical', href: 'https://loghq.org/login' }] })
3219// server-known at render time, so reading it on the client meant the message
3320// could not exist until hydration.
3421const oauthError = new URLSearchParams(typeof __stxServeSearch !== 'undefined' ? __stxServeSearch : '').get('error') || ''
22</script>
3523
36// What the action merges back on a rejected submit, declared with its
37// resting values.
38//
39// Not decoration. On a GET neither name exists at all, so the template's
40// `