Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
191 commits
Select commit Hold shift + click to select a range
c50509a
Custom Domains CRUD, Verification
Soxasora May 1, 2025
5e80c3f
Domains refactor, Domain Verification normalization
Soxasora May 2, 2025
4d24845
Domains normalization: Attempts, Records, Certificates
Soxasora May 3, 2025
b624c59
Domain Verification worker adjusted to new schema; use triggers to ch…
Soxasora May 4, 2025
89f1eb4
wip: Domain Verification worker, log all verification steps
Soxasora May 6, 2025
9d1c137
wip: clearer Domain Verification flow, surround AWS calls with try ca…
Soxasora May 6, 2025
dc119a8
Domain Verification schema updates
Soxasora May 6, 2025
67fb2c8
HOLD the domain and delete the certificate when a territory expires
Soxasora May 6, 2025
725ce81
delete the certificate from ACM when we're about to STOP a territory
Soxasora May 6, 2025
f3930f7
Domain resolver refactor, use transactions, add comments
Soxasora May 6, 2025
01e319e
Stages for Domain Verification attempts logging, fix certificate dele…
Soxasora May 6, 2025
e132ad0
separate ACM certificate requests and validation values
Soxasora May 7, 2025
cd9cb68
Domains UI/UX enhancements; core fixes to schema; general cleanup
Soxasora May 7, 2025
9e96d7c
delete any existing domain verification jobs if we're updating the do…
Soxasora May 8, 2025
82a71f5
Log AWS-related error messages; fix deleteCertificate recursion
Soxasora May 8, 2025
f95ab6a
fix missing await on async customDomainMiddleware
Soxasora May 8, 2025
4f49382
Merge branch 'master' into custom_domains_base
Soxasora May 8, 2025
2382f3b
hotfix: delete certificate from ACM also on domain removal
Soxasora May 9, 2025
c732135
Merge branch 'master' into custom_domains_base
huumn May 14, 2025
ca13d80
prepare for dnsmasq, light cleanup
Soxasora May 16, 2025
2a77fd1
fix DNS server typo
Soxasora May 16, 2025
072c1ae
don't ask ACM to delete a certificate in a db transaction
Soxasora May 16, 2025
7da660a
fix typo
Soxasora May 17, 2025
d0b9467
better handling of territory changes, ACM certificates and domains in…
Soxasora May 18, 2025
2c4ca44
address plpgsql syntax issues, move INSERT for pgboss.schedule in a f…
Soxasora May 18, 2025
52dd035
fallback to system's default DNS servers if dnsmasq is not available/…
Soxasora May 18, 2025
0a7eda2
better error handling of node:dns resolver
Soxasora May 20, 2025
e6bd73b
Merge branch 'master' into custom_domains_base
huumn May 21, 2025
76be3ae
hotfix: remove the port in dev for domain mapping
Soxasora May 21, 2025
e0e2dea
add aws container to domains profile
huumn May 21, 2025
807c2d3
move existingTXT on a more appropriate place, TODOs on prisma schema
Soxasora May 22, 2025
e8d97ba
territory redirects and rewrites for middleware, adjust navbar reacti…
Soxasora May 22, 2025
476c10b
30 seconds of interval between verification jobs, after 1 hour of dom…
Soxasora May 22, 2025
ef549a9
also get records when getting the existing domain, recreate the domai…
Soxasora May 22, 2025
1e3b1c6
hotfix: use validateSchema the correct way, change from domain to dom…
Soxasora May 22, 2025
25674c2
hide custom domains from the world but the admins
Soxasora May 22, 2025
eddd453
debounce next verification jobs with a singleton key, avoiding other …
Soxasora May 22, 2025
bbf2b0a
ELBv2 implementation to attach a certificate to a load balancer; Mock…
Soxasora May 23, 2025
f36eef4
use directly the interested ELB Listener ARN via env vars; get rid of…
Soxasora May 24, 2025
51aadf2
throw database and AWS-related errors; don't log the STAGE on critica…
Soxasora May 24, 2025
5bf8aba
remove unused certificate attachment to ELB checks
Soxasora May 24, 2025
1643c09
remove unused ELB env var, remove useless console.logs
Soxasora May 24, 2025
d579b55
eradicate TXT records from custom domains; adjust functions to expect…
Soxasora May 25, 2025
102b2f2
Merge branch 'master' into custom_domains_base
Soxasora May 25, 2025
093910b
pass CNAME record directly instead of the whole records map
Soxasora May 25, 2025
0ad0b33
don't delete the domain if resuming from HOLD
Soxasora May 25, 2025
ebf2c8b
wip: custom domains documentation
Soxasora May 28, 2025
f361958
docs: explain all the triggers, ACM and ALB implementations, fix head…
Soxasora May 28, 2025
6a0b2cc
docs: clearer explanations
Soxasora May 30, 2025
ac03f32
use locally scoped configs for ACM and ELB APIs
Soxasora Jun 13, 2025
55357e6
cleanup: remove unused domainMapping query from domain resolver
Soxasora Jun 13, 2025
9c0f9e0
Merge branch 'master' into custom_domains_base
Soxasora Mar 30, 2026
4e36bfb
ux: add reset/verify buttons to territory domain config
Soxasora Mar 31, 2026
9021358
temp: snFetch dynamic getAgent import to avoid pulling node's http li…
Soxasora Mar 31, 2026
167eac3
pin localstack to 4.12, enable only s3 and acm localstack mocks
Soxasora Apr 1, 2026
a492bc9
update: change NORMAL_POLL_INTERVAL to NORMAL_POLL_INTERVAL_MS
Soxasora Apr 1, 2026
e270827
fix: delete old domains that have been on HOLD FOR 30 days or more
Soxasora Apr 2, 2026
4a35e94
domain creation: compact resuming and creation logic, fix duplicate d…
Soxasora Apr 2, 2026
686ba80
[domain-verification] consistent error object return instead of only …
Soxasora Apr 2, 2026
66e6d22
[domain-query] don't expose domain certificates to domain query, only…
Soxasora Apr 2, 2026
de8c792
[domain-form] ux: disable domain name input field if domain is regist…
Soxasora Apr 2, 2026
ef3bd56
[domain-form] gql: allow null domain names (for removal)
Soxasora Apr 2, 2026
b5bee87
[domain-verification] check for null records during DNS verification
Soxasora Apr 2, 2026
76d266c
[domain-verification] cleanup: better verification interval naming
Soxasora Apr 2, 2026
3512f37
[domain-verification] fix: avoid record not found error when trying t…
Soxasora Apr 2, 2026
c03caa4
[domain-verification] fix: don't verify domains on HOLD, handles edge…
Soxasora Apr 2, 2026
7a459b9
[domain-form] fix: manual start and stop polling
Soxasora Apr 2, 2026
fe3b619
[domain-context]: always set the ssrDomain as the current custom domain
Soxasora Apr 2, 2026
56d8198
Merge branch 'master' into feat/custom-domains-base
Soxasora Apr 2, 2026
66aba73
[domain-verification] fix verification threshold minutes->days typo
Soxasora Apr 2, 2026
7fbd291
[domain-verification] remove startAfter customizable parameter from d…
Soxasora Apr 2, 2026
08398bb
[domain-query] protect from null territory, nullable domainName in SE…
Soxasora Apr 2, 2026
1d69243
[domain-verification] also remove ACM certificates when a domain tran…
Soxasora Apr 2, 2026
16cfd54
[domain-form] show 'active' when domain is fully verified
Soxasora Apr 2, 2026
0c267cb
[domain-form] re-verify domain on HOLD
Soxasora Apr 2, 2026
276b3db
[middleware][navigation] usePrefix and useNavKeys to support custom d…
Soxasora Apr 3, 2026
fab26ef
Merge branch 'master' into feat/custom-domains-base
Soxasora Apr 3, 2026
846914f
chore: upgrade Next.js from 14.2.25 to 15.5.14; upgrade to React 19; …
Soxasora Apr 5, 2026
ffb119b
Merge branch 'master' into chore/update-nextjs-16
Soxasora Apr 5, 2026
e8fedc8
upgrade Bootstrap to 5.3.8; upgrade react-boostrap to 2.10.10; silenc…
Soxasora Apr 5, 2026
8f7e221
upgrade Next.js to 16.2.2, rename middleware.js to proxy.js
Soxasora Apr 5, 2026
fd7cb96
polyfill URLPattern and add to js-standard lint globals
Soxasora Apr 5, 2026
29ab41b
upgrade to next-plausible 4.0.0; remove PlausibleProvider dead props
Soxasora Apr 5, 2026
0cf9f68
fix: run withPlausibleProxy through webpack instead of treating it as…
Soxasora Apr 5, 2026
da82997
apollo: useLazyQuery now throws AbortError, add errorPolicy 'all' to …
Soxasora Apr 5, 2026
3813e00
apollo migration: remove @client and @defer directives support from A…
Soxasora Apr 6, 2026
4717770
apollo migration: switch now-internal getOperationName with graphql's…
Soxasora Apr 6, 2026
7a6367c
remove Apollo's execute double catch
Soxasora Apr 6, 2026
d556244
remove temporary nodejs nextjs 15 middleware workaround
Soxasora Apr 6, 2026
cb5b2fd
cleanup: linting
Soxasora Apr 6, 2026
a447ba1
Merge branch 'master' into feat/custom-domains-base
Soxasora Apr 7, 2026
e6f2f97
export cleanDomainVerificationJobs for resolver and worker
Soxasora Apr 7, 2026
3d6ab69
merge chore/update-nextjs-16
Soxasora Apr 7, 2026
f04aa91
adapt to Next.js 16 and Apollo Client 4 changes
Soxasora Apr 7, 2026
41a0307
remove legacyBehavior and passHref, use NextLink as anchor for compon…
Soxasora Apr 8, 2026
adf7f35
Merge branch 'chore/update-nextjs-16' into feat/custom-domains-base
Soxasora Apr 8, 2026
f9e541f
remove custom domains API endpoint, check domains via cached direct q…
Soxasora Apr 8, 2026
d466483
fix: workaround to enable HMR on custom domains
Soxasora Apr 8, 2026
0d749ac
remove default `.sndev` suffix from `next.config.js`, move to `.env.d…
Soxasora Apr 8, 2026
fa17f5b
remove old comment about sndev being always included
Soxasora Apr 8, 2026
0d154c0
correct again allowedDevOrigins usage
Soxasora Apr 8, 2026
c0d6934
correct forceRefreshThreshold and cacheExpiry usage; lower to 5 minut…
Soxasora Apr 8, 2026
8c25df3
simple logger for custom domains gated by NEXT_PUBLIC_CUSTOM_DOMAINS_…
Soxasora Apr 8, 2026
c8119e8
merge custom-domains-authsync
Soxasora Apr 8, 2026
d999ba0
sync: support local custom domains with port, default / redirectUri
Soxasora Apr 8, 2026
2c35d71
remove Sorts spacings on the desktop second top bar to compensate for…
Soxasora Apr 8, 2026
f140a83
Merge branch 'feat/custom-domains-base' into feat/custom-domains-auth
Soxasora Apr 8, 2026
2ae1b0d
Merge branch 'master' into chore/update-nextjs-16
Soxasora Apr 9, 2026
8104700
Merge branch 'chore/update-nextjs-16' into feat/custom-domains-base
Soxasora Apr 9, 2026
07f67dd
Merge branch 'feat/custom-domains-base' into feat/custom-domains-auth
Soxasora Apr 9, 2026
af6db57
fix: case-insensitive domains per RFC 4343, domain saved to lower case
Soxasora Apr 9, 2026
4987a25
fix: invert cacheExpiry and forceRefreshThreshold values
Soxasora Apr 9, 2026
d8c0630
cast string `me.id` to `Number`
Soxasora Apr 9, 2026
e546381
fix: return null for empty domain records instead of an empty array (…
Soxasora Apr 9, 2026
96fd1f0
fix: allowedDevOrigins starts by default with NEXT_PUBLIC_URL, new AL…
Soxasora Apr 10, 2026
d7e6c3a
fix: put allowedDevOrigins in the right next.config.js section
Soxasora Apr 10, 2026
dbbb4ce
merge chore/update-nextjs-16
Soxasora Apr 10, 2026
f79b1ce
Merge branch 'feat/custom-domains-base' into feat/custom-domains-auth
Soxasora Apr 10, 2026
4fe5edc
fix: normalize custom domains debug env var in constants.js
Soxasora Apr 10, 2026
3686e8d
fix: don't treat domain name changes as resuming from HOLD (even if s…
Soxasora Apr 10, 2026
d72d3a3
cleanup: explicit territory-domains form steps
Soxasora Apr 10, 2026
64badaa
Merge branch 'feat/custom-domains-base' into feat/custom-domains-auth
Soxasora Apr 10, 2026
59c5e76
disable lurker signup button on custom domains (conflicts with one-cl…
Soxasora Apr 10, 2026
7728d00
cleanup: make some sense out of the various timings
Soxasora Apr 10, 2026
dde60f1
fix: update the domain status only if it has changed
Soxasora Apr 10, 2026
2b363f8
Merge branch 'feat/custom-domains-base' into feat/custom-domains-auth
Soxasora Apr 10, 2026
5324862
merge master, adapt changes to Apollo Client v4
Soxasora Apr 12, 2026
5b55a11
Merge branch 'chore/update-nextjs-16' into feat/custom-domains-base
Soxasora Apr 12, 2026
8cf4cb2
Merge branch 'feat/custom-domains-base' into feat/custom-domains-auth
Soxasora Apr 12, 2026
5ff7144
security: domain-specific JWTs, remove pre-existing custom domain hea…
Soxasora Apr 13, 2026
539b2a5
Merge branch 'master' into chore/update-nextjs-16
huumn Apr 13, 2026
85d3e80
fix: switch back to notifyOnNetworkStatusChange false to get the orig…
Soxasora Apr 14, 2026
1a2b129
remove dangling sub prop
Soxasora Apr 14, 2026
e5c99c6
filter AbortError from useLazyQuery executions
Soxasora Apr 14, 2026
977a56b
Merge branch 'chore/update-nextjs-16' into feat/custom-domains-base
Soxasora Apr 14, 2026
2b790f6
Merge branch 'feat/custom-domains-base' into feat/custom-domains-auth
Soxasora Apr 14, 2026
aebe054
Merge branch 'master' into feat/custom-domains-base
Soxasora Apr 15, 2026
7da5141
Merge branch 'feat/custom-domains-base' into feat/custom-domains-auth
Soxasora Apr 15, 2026
2ca0b78
fix: create domain verification records even when resuming from HOLD
Soxasora Apr 16, 2026
5960ac4
fix: normalize retrieved DNS CNAME records, hostname equality check
Soxasora Apr 16, 2026
108bd45
fix: update updated_at of Domain when transitioning to HOLD via db tr…
Soxasora Apr 16, 2026
2b10305
remove useless/unused Sub.domain
Soxasora Apr 16, 2026
5f57c77
don't consider x-forwarded-host
Soxasora Apr 16, 2026
c2ea2e5
protect from multiple CNAMEs at the same name
Soxasora Apr 16, 2026
f4fb87e
Merge branch 'feat/custom-domains-base' into feat/custom-domains-auth
Soxasora Apr 16, 2026
8d71482
protect from token hijacking with versioned JWTs and periodic DNS checks
Soxasora Apr 17, 2026
8c99bce
Merge branch 'master' into feat/custom-domains-base
Soxasora Apr 17, 2026
da785ae
update package-lock.json
Soxasora Apr 17, 2026
4a5a87a
Merge branch 'feat/custom-domains-base' into feat/custom-domains-auth
Soxasora Apr 17, 2026
ee74adb
cleanup: remove postponed changes to RSS (+seo), consistency with master
Soxasora Apr 17, 2026
aba427b
cleanup: explicit 3 retries after unrecoverable domain verification fail
Soxasora Apr 17, 2026
9a79de1
fix: use CUSTOM_DOMAINS_DEBUG for domains cached fetcher debug
Soxasora Apr 17, 2026
da1a650
cleanup: use DOMAIN_VERIFICATION_RETRY_LIMIT to check max retrycount …
Soxasora Apr 17, 2026
3e8389f
wrap post-failure cleanup in a try/catch to avoid masking the origina…
Soxasora Apr 17, 2026
63f44ac
safer territory path checks
Soxasora Apr 17, 2026
bc9933c
reduce AWS calls by reusing already-latest certificate description; m…
Soxasora Apr 17, 2026
3e6adba
merge feat/custom-domains-base
Soxasora Apr 17, 2026
4defce7
remove orphan Sub.domain resolver and typedef
Soxasora Apr 19, 2026
c4dd809
add IdempotencyToken to ACM certificate requests to always return the…
Soxasora Apr 19, 2026
f731808
cleanup: remove dead code in attachACMCertificateToELB
Soxasora Apr 19, 2026
a4b0e1a
cleanup: simplify DomainProvider, remove useEffect no-op previously u…
Soxasora Apr 19, 2026
6e372f1
cleanup: basic rel for external territory badges
Soxasora Apr 19, 2026
da0c969
cleanup: remove useless domainName and subName indexes on Domain, the…
Soxasora Apr 19, 2026
cc3d494
correct localstack docker hostname for media-check comment
Soxasora Apr 19, 2026
0884742
cleanup: update docs
Soxasora Apr 19, 2026
d8416a5
comprehensive subdomain regex
Soxasora Apr 19, 2026
c80b68e
fix: me can be null, protect from crash
Soxasora Apr 19, 2026
616ce1f
Merge branch 'master' into feat/custom-domains-base
huumn Apr 19, 2026
080a638
cleanup: simplify DomainProvider
Soxasora Apr 20, 2026
5b8ce0b
terminal ACM states now stops domain verification, will put domain on…
Soxasora Apr 20, 2026
db798b3
territory form: required non-empty domain input field
Soxasora Apr 20, 2026
742a53d
simplify getACMValidationValues return
Soxasora Apr 20, 2026
832b462
export NEXT_PUBLIC_CUSTOM_DOMAINS_DEBUG env var to service worker
Soxasora Apr 20, 2026
e6a966a
explicit DOMAIN_BETA_IDS for custom domains access
Soxasora Apr 22, 2026
a65d7b4
getDomainMappingFromRequest helper to get and validate custom domain …
Soxasora Apr 22, 2026
9eab96c
Merge branch 'master' into feat/custom-domains-base
Soxasora Apr 22, 2026
d61a2a4
fix: support both NextRequest and Node requests; fix: return a domain…
Soxasora Apr 22, 2026
a71337c
fix: use hostname instead of host for custom domain <-> sn main domai…
Soxasora Apr 22, 2026
8d93f92
revert a4b0e1a simplify DomainProvider
Soxasora Apr 22, 2026
7a91a61
merge feat/custom-domains-base
Soxasora Apr 23, 2026
c0c0953
exp: move auth UI to stacker.news, login with nym button
Soxasora Apr 23, 2026
76ced42
add support for callbackUrl, cleanup
Soxasora Apr 23, 2026
19008fc
fix signup, authRequired redirects and callbackUrl
Soxasora Apr 23, 2026
0435861
redirect to custom domain auth sync on login/signup via nextauth
Soxasora Apr 23, 2026
b896297
reminder: unify with nextauth custom redirect
Soxasora Apr 27, 2026
70d8ce4
merge master
Soxasora Apr 27, 2026
fc608d0
Merge branch 'master' into feat/custom-domains-auth
huumn Apr 27, 2026
f690831
better login with nym button, dropdown to select an account before sy…
Soxasora Apr 28, 2026
104272f
respect the rules of hooks
Soxasora Apr 28, 2026
dcf3192
protect redirectUri from hosting an open redirect
Soxasora Apr 28, 2026
b10f97b
Merge branch 'master' into feat/custom-domains-auth
Soxasora Apr 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion api/ssrApollo.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,9 @@ export function getGetServerSideProps (
}

if (authRequired && !me) {
let callback = process.env.NEXT_PUBLIC_URL + req.url
// if we're on a custom domain, use the domain header instead of the main domain
const origin = domain ? req.headers['x-stacker-news-domain'] : process.env.NEXT_PUBLIC_URL
let callback = origin + req.url
// On client-side routing, the callback is a NextJS URL
// so we need to remove the NextJS stuff.
// Example: /_next/data/development/territory.json
Expand Down
6 changes: 6 additions & 0 deletions components/account.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import useCookie from '@/components/use-cookie'
import Link from 'next/link'
import AddIcon from '@/svgs/add-fill.svg'
import { cookieOptions, MULTI_AUTH_ANON, MULTI_AUTH_LIST, MULTI_AUTH_POINTER } from '@/lib/auth'
import { useDomain } from '@/components/territory-domains'

const b64Decode = str => Buffer.from(str, 'base64').toString('utf-8')

Expand Down Expand Up @@ -94,6 +95,11 @@ const AccountListRow = ({ account, selected, ...props }) => {

export const useIsLurker = () => {
const accounts = useAccounts()
const { domain } = useDomain()

// TODO, signup button for lurkers conflicts with one-click-login path
if (domain) return false
Comment thread
Soxasora marked this conversation as resolved.

return accounts.length === 0
}

Expand Down
61 changes: 61 additions & 0 deletions components/login-button.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ import TwitterIcon from '@/svgs/twitter-fill.svg'
import LightningIcon from '@/svgs/bolt.svg'
import NostrIcon from '@/svgs/nostr.svg'
import Button from 'react-bootstrap/Button'
import useCookie from './use-cookie'
import { cookieOptions, MULTI_AUTH_POINTER } from '@/lib/auth'
import { useAccounts } from './account'
import SNIcon from '@/svgs/sn.svg'
import { ButtonGroup, Dropdown } from 'react-bootstrap'
import styles from '@/lib/lexical/theme/editor.module.css'
import ArrowDownIcon from '@/svgs/editor/toolbar/arrow-down.svg'
import classNames from 'classnames'

export default function LoginButton ({ text, type, className, onClick, disabled }) {
let Icon, variant
Expand Down Expand Up @@ -38,3 +46,56 @@ export default function LoginButton ({ text, type, className, onClick, disabled
</Button>
)
}

// TODO: it's maybe better to select an account and give it to the sync endpoint instead of switching accounts here
export function LoginWithNymButton ({ className, onClick, disabled }) {
const accounts = useAccounts()
const [pointerCookie, setPointerCookie] = useCookie(MULTI_AUTH_POINTER)

const account = accounts.find(account => account.id === Number(pointerCookie))
if (!account) return null

const title = `Log in with @${account?.name}`

const mainButton = (
<Button
variant='success'
onClick={onClick}
disabled={disabled}
className={className}
title={title}
style={{ minWidth: 0 }}
>
<SNIcon width={20} height={20} className='me-3 flex-shrink-0' />
<span className='text-truncate' style={{ minWidth: 0 }}>{title}</span>
</Button>
)

if (accounts.length === 1) return mainButton

return (
<Dropdown className='mb-4 w-100' as={ButtonGroup}>
{mainButton}
<Dropdown.Toggle
split
variant='success'
onPointerDown={e => { e.preventDefault(); e.stopPropagation() }}
title='select account'
style={{ maxWidth: '42px' }}
>
<ArrowDownIcon width={16} height={16} />
</Dropdown.Toggle>
<Dropdown.Menu className={styles.dropdownExtra} style={{ width: '150px' }}>
{accounts.map(account => (
<Dropdown.Item
key={account.id}
onClick={() => setPointerCookie(account.id, cookieOptions({ httpOnly: false }))}
className={classNames(styles.dropdownExtraItem, Number(account.id) === Number(pointerCookie) && styles.active)}
>
<span className={styles.dropdownExtraItemText}>{account.name}</span>
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
)
}
31 changes: 28 additions & 3 deletions components/login.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@ import Alert from 'react-bootstrap/Alert'
import { useRouter } from 'next/router'
import { LightningAuthWithExplainer } from './lightning-auth'
import { NostrAuthWithExplainer } from './nostr-auth'
import LoginButton from './login-button'
import LoginButton, { LoginWithNymButton } from './login-button'
import { emailSchema } from '@/lib/validate'
import { OverlayTrigger, Tooltip } from 'react-bootstrap'
import { datePivot } from '@/lib/time'
import * as cookie from 'cookie'
import { cookieOptions } from '@/lib/auth'
import { cookieOptions, MULTI_AUTH_ANON, MULTI_AUTH_POINTER } from '@/lib/auth'
import Link from 'next/link'
import useCookie from './use-cookie'

export function EmailLoginForm ({ text, callbackUrl, multiAuth }) {
const disabled = multiAuth
Expand Down Expand Up @@ -73,9 +74,18 @@ export function authErrorMessage (error, signin) {

const multiAuthProviders = ['Lightning', 'Nostr']

export default function Login ({ providers, callbackUrl, multiAuth, error, text, Header, Footer, signin }) {
export default function Login ({ providers, callbackUrl, multiAuth, error, text, Header, Footer, signin, syncSignup, domainData }) {
const [errorMessage, setErrorMessage] = useState(authErrorMessage(error, signin))
const router = useRouter()
const [, setPointerCookie] = useCookie(MULTI_AUTH_POINTER)

// we can't signup if we're already logged in to another account
// for signups with auth sync, we first need to switch to anon.
useEffect(() => {
if (syncSignup) {
setPointerCookie(MULTI_AUTH_ANON, cookieOptions({ httpOnly: false }))
}
}, [syncSignup, setPointerCookie])

multiAuth = typeof multiAuth === 'string' ? multiAuth === 'true' : !!multiAuth

Expand Down Expand Up @@ -116,6 +126,21 @@ export default function Login ({ providers, callbackUrl, multiAuth, error, text,
dismissible
>{errorMessage}
</Alert>}
{/** custom domain auth sync button */}
{domainData && (
<LoginWithNymButton
className={styles.providerButton}
onClick={() => {
// TODO: unify with nextauth custom redirect
const redirectUri = callbackUrl?.startsWith('http')
? new URL(callbackUrl).pathname
: callbackUrl || '/'
// port is only present in local dev; in prod domainData.port is null and we send the bare host.
const domain = domainData.port ? `${domainData.domainName}:${domainData.port}` : domainData.domainName
router.push({ pathname: '/api/auth/sync', query: { domain, redirectUri } })
}}
/>
)}
{sortedProviders.map(provider => {
switch (provider.name) {
case 'Email':
Expand Down
6 changes: 5 additions & 1 deletion components/nav/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import SwitchAccountList, { nextAccount, useAccounts, useIsLurker } from '@/comp
import { useShowModal } from '@/components/modal'
import { ObstacleButtons } from '@/components/obstacle'
import { numWithUnits } from '@/lib/format'
import { useDomain } from '@/components/territory-domains'

export function Brand ({ className }) {
return (
Expand Down Expand Up @@ -258,6 +259,7 @@ export default function LoginButton () {
function LogoutObstacle ({ onClose }) {
const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
const router = useRouter()
const { domain } = useDomain()

const handleLogout = async () => {
const next = await nextAccount()
Expand All @@ -275,7 +277,9 @@ function LogoutObstacle ({ onClose }) {
await togglePushSubscription().catch(console.error)
}

await signOut({ callbackUrl: '/' })
onClose()
await signOut({ callbackUrl: '/', redirect: !domain })
domain && router.push('/')
}

return (
Expand Down
101 changes: 101 additions & 0 deletions docs/dev/custom-domains.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,19 @@ Every midnight, the `clearLongHeldDomains` job gets executed to remove domains t

A domain removal also means the certificate removal, which triggers **Ask ACM to delete certificate**.

### Active Domain DNS Drift Check
A pgboss cron `checkActiveDomainsDNS` runs every 5 minutes (`*/5 * * * *`) and, for each `ACTIVE` domain:
- re-resolves the stored `CNAME` `DomainVerificationRecord` against live DNS via the same `verifyDNSRecord` helper used during initial verification
- on a clean drift (record present but mismatched), flips the domain to `HOLD`
- on a temporary resolver error (i.e. timeout), logs and skips

Switching to `HOLD` cascades into:
1. **Bump token version** — a db trigger on `Domain` increments `tokenVersion` whenever the domain switches from or to `ACTIVE`. [see token revocation via `tokenVersion`](#token-revocation-via-tokenversion).
2. **Delete cert + verification records**
3. **Ask ACM to delete certificate** — chained from the cert deletion

The territory owner can re-verify and the domain returns to `ACTIVE`, but with a higher `tokenVersion` than any token issued before the drift.

### Update `DomainVerificationRecord` status
The `DomainVerification` job logs every step into `DomainVerificationAttempt`, when it comes to steps that involves DNS records like the `CNAME` record or ACM validation records, a connection between `DomainVerificationAttempt` and `DomainVerificationRecord` gets established.

Expand All @@ -180,3 +193,91 @@ Whenever a domain or domain certificate gets deleted, we run a job called `delet
It detaches the ACM certificate from our ALB listener and then deletes the ACM certificate from ACM.

It's a necessary step to ensure that we don't waste AWS resources and also provide safety regarding the custom domain access to Stacker News.

# Auth Sync

Cross-domain JWT authentication is a complex issue due to browser security restrictions, mainly because cookies:
- are bound to specific domains

and

- can't be set for another domain
- -- `stacker.news` <- cookie -> `pizza.com` 🚫

Instead of fighting these restrictions, Auth Sync works with them by creating a whole new session:
- user visits `pizza.com/login`
- middleware redirects to auth sync **on the main domain** accessing that domain cookies
- -- `https://stacker.news/api/auth/sync?domain=pizza.com&redirectUri=/items/212142`
- checks if pizza.com is an **allowed domain**
- checks if there's a session
- -- if not: redirects to `stacker.news/login` with `/api/auth/sync` as callback to continue syncing
- auth sync creates a short-lived verification token and redirects back to the custom domain with the `token` parameter
- -- `https://pizza.com/?token=42424242&redirectUri=/items/212142`
- middleware exchanges this token for a session, **setting the session cookie** on pizza.com
- -- `POST: https://stacker.news/api/auth/sync; token: 42424242`


The verification token is a one-time code that dies in **5 minutes** and has **256 bits** of entropy. The JWT is then generated server-side and applied to the final middleware response.

### Token revocation via `domainId` + `tokenVersion`

JWTs are stateless, so once a session cookie has been set on `pizza.com` we cannot un-issue it: the cookie remains valid in every browser that ever signed in until it expires (30 days by default). That is a problem the moment we suspect the domain itself is no longer trustworthy.

Every custom-domain JWT carries two claims that together make it revocable without abandoning the JWT model:

- **`domainId`** — the primary key of the `Domain` row the token was minted against. Pins the JWT to a specific *row lifetime*. If the row is deleted and recreated (owner removes and re-adds the domain, takeover, etc.), the replacement row has a fresh autoincrement `id` that no pre-existing JWT can reference.
- **`domainVersion`** — this is the value of `Domain.tokenVersion` when the JWT was created. If the domain leaves and later returns to `ACTIVE`, `tokenVersion` increases. Old JWTs with a different version become invalid.

A `BEFORE UPDATE` trigger on `Domain` (`bump_domain_token_version`) increments `tokenVersion` on **any transition to/from `ACTIVE`**. The trigger alone can't help across row lifetimes, which is exactly why `domainId` exists.

##### Where `domainId` and `tokenVersion` are read

Two sides read these, with different consistency requirements:

- **Mint side** — `createEphemeralSessionToken` in [pages/api/auth/sync.js](../../pages/api/auth/sync.js) reads the row **directly from the DB** (uncached) and snapshots both `id` and `tokenVersion` into the JWT. Since the minted cookie lives for up to 30 days, any staleness here could mint a token against an outdated row identity or revoked reign.
- **Verify side** — the next-auth `jwt` callback reads through `getDomainMapping`, which goes through `domainsMappingsCache` (same cache the proxy uses). This runs on every custom-domain request, so hitting the DB here would be expensive. Bounded staleness is acceptable because the mint side already guarantees that no *new* tokens can be minted with the old identity — the stale window only delays the rejection of pre-existing tokens.

##### Enforcement

The check happens once per request, in [pages/api/auth/[...nextauth].js](../../pages/api/auth/[...nextauth].js)'s `jwt` callback, after the existing same-domain check:

```js
if (token?.domainName) {
// ... same-domain check ...

const mapping = await getDomainMapping(token.domainName)
if (!mapping) return null // domain is not ACTIVE right now
if (mapping.id !== token.domainId) return null // row was deleted and recreated
if (mapping.tokenVersion !== token.domainVersion) return null // ACTIVE reign has changed
}
```

`getDomainMapping` reads from `domainsMappingsCache` (the same cache the proxy uses). Both SSR (`getServerSession`) and `/api/graphql` go through `getAuthOptions` -> this callback.

##### Why all three checks?

They cover different failure modes:
- `!mapping` — the domain is not `ACTIVE` **right now** (on HOLD, deleted, unknown).
- `mapping.id !== token.domainId` — the row was deleted and recreated since the token was minted. A fresh row always has a strictly greater autoincrement `id`, so old tokens can never match the new row regardless of what `tokenVersion` happens to land on.
- `mapping.tokenVersion !== token.domainVersion` — the domain has crossed the `ACTIVE` boundary at least once since the token was minted, within the same row lifetime.

##### an attack scenario, prevented

Two variants worth walking through, since they exercise different parts of the defense.

**Variant A — DNS drift within a single row lifetime** (caught by `tokenVersion`):

1. `pizza.com` is `ACTIVE` with `tokenVersion=3`. Alice signs in and gets a JWT carrying `{ domainName: 'pizza.com', domainId: 42, domainVersion: 3 }`.
2. The attacker hijacks DNS for `pizza.com` and exfiltrates her cookie.
3. Within ~5 minutes, `checkActiveDomainsDNS` notices the CNAME no longer matches and switches the domain to `HOLD`. The `ACTIVE -> HOLD` trigger bumps `tokenVersion` to `4`, and the on-HOLD trigger deletes the certificate and verification records.
4. Next request from Alice's browser **or** the attacker's stolen cookie, once the verifier's cache refreshes past the bump: `!mapping` is true -> the request is rejected and the user is `anon`.
5. The territory owner notices, fixes DNS, re-verifies. The domain goes back through `PENDING` and the `PENDING -> ACTIVE` trigger bumps `tokenVersion` again, to `5`.
6. The domain is `ACTIVE` again, so `!mapping` passes and `domainId` still matches (the row was updated, not recreated). **But** the cached `tokenVersion` is `5` while the JWT snapshots `3`, so the version check rejects them: `5 !== 3` -> both have to sign in again.

**Variant B — owner removes and re-adds the domain** (caught by `domainId`):

1. `pizza.com` is `ACTIVE`, row `id=42`, `tokenVersion=1`. Alice signs in and gets a JWT carrying `{ domainName: 'pizza.com', domainId: 42, domainVersion: 1 }`. The attacker steals her cookie and keeps it warm (actively replaying so it gets re-encoded with the default 30-day session maxAge).
2. The owner calls `setDomain(subName, null)`, which hard-deletes row `id=42` (cascading into cert cleanup). Alice's and the attacker's cookies start failing the `!mapping` check.
3. Weeks later, the owner re-adds `pizza.com`. A fresh row is created, `id=43`, `tokenVersion` defaulted to `0`.
4. Verification succeeds, the `PENDING -> ACTIVE` trigger bumps `tokenVersion` to `1`.
5. The attacker tries their stolen cookie again. The domain is `ACTIVE` (so `!mapping` passes) and the new `tokenVersion=1` happens to collide with the stolen JWT's `domainVersion=1`. Without `domainId`, **this would resurrect the stolen token**. With `domainId` in place: `mapping.id` is `43`, the JWT claims `42`, `43 !== 42` -> rejected.
6 changes: 4 additions & 2 deletions lib/domains.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ export const domainsMappingsCache = cachedFetcher(async function fetchDomainsMap
try {
const domains = await prisma.domain.findMany({
select: {
id: true, // pins JWTs to a specific Domain row across delete/recreate cycles
domainName: true,
subName: true
subName: true,
tokenVersion: true // jwt revocability within a single row lifetime
},
where: {
status: 'ACTIVE'
Expand All @@ -24,7 +26,7 @@ export const domainsMappingsCache = cachedFetcher(async function fetchDomainsMap
if (!domains.length) return null

return domains.reduce((acc, domain) => {
acc[domain.domainName.toLowerCase()] = { domainName: domain.domainName, subName: domain.subName }
acc[domain.domainName.toLowerCase()] = domain
return acc
}, {})
} catch (error) {
Expand Down
15 changes: 15 additions & 0 deletions lib/url.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,21 @@ export function isHashLink (url) {
return url?.startsWith('#')
}

// validates that a redirect URI is a same-origin path and cannot escape to
// another origin via protocol-relative URLs ("//evil.com")
export function isSafeRedirectPath (uri) {
if (typeof uri !== 'string' || uri.length === 0) return false
if (uri[0] !== '/') return false
if (uri[1] === '/' || uri[1] === '\\') return false
try {
// arbitrarily resolve against the main domain. if the origin changes, it's unsafe
const base = process.env.NEXT_PUBLIC_URL
return new URL(uri, base).origin === base
} catch {
return false
}
}

export function isInternalLink (url) {
return isHashLink(url) || !isExternal(url)
}
Expand Down
Loading
Loading