-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathproxy.ts
More file actions
330 lines (313 loc) · 14.8 KB
/
Copy pathproxy.ts
File metadata and controls
330 lines (313 loc) · 14.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
import { NextResponse, type NextRequest } from 'next/server';
import { updateSession } from '@/lib/supabase/middleware';
import {
ATTRIBUTION_COOKIE,
ATTRIBUTION_MAX_AGE,
buildAttribution,
serializeAttribution,
} from '@/lib/links/attribution';
import {
ATTR_COOKIE,
ATTR_MAX_AGE,
buildTouch,
parseAttribution,
mergeAttribution,
touchChanged,
} from '@/lib/attribution';
import { regionFromHost, isKnownRegionHost, geoRegionFromCountry } from '@/lib/region/config';
import { geoRedirectTarget, isBotUserAgent } from '@/lib/region/geo-route';
import { indiaMarketplaceLive, multiRegionLive, regionRoutingLive, regionRedirectsLive } from '@/lib/flags';
import { isGatewayGatedPath } from '@/lib/region/gateway';
import { rowRefFromPath, fetchRowRegion, owningMarketTarget } from '@/lib/region/owning-market';
import { shouldServeWaitlist } from '@/lib/region/waitlist';
import { GEO_FROM_PARAM } from '@/lib/region/geo-redirect-marker';
// We keep TWO complementary attribution cookies:
// • tk_attr (client-readable) — first-touch UTM for client GA events + stamping
// the source on the member record at signup (lib/links/attribution).
// • taskly_attr (httpOnly) — first + last touch with organic-referrer inference,
// read server-side to stamp conversion columns on tasks/bookings + funnel
// events (lib/attribution). They serve different layers, so both are set here.
// tk_attr: first touch wins — set once, never overwritten by a later campaign.
function captureAttribution(request: NextRequest, res: NextResponse): void {
const { pathname, searchParams } = request.nextUrl;
if (pathname.startsWith('/api/')) return;
if (request.cookies.get(ATTRIBUTION_COOKIE)) return;
const data = buildAttribution(searchParams, pathname, Date.now());
if (!data) return;
res.cookies.set(ATTRIBUTION_COOKIE, serializeAttribution(data), {
httpOnly: false, // client analytics needs to read it
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: ATTRIBUTION_MAX_AGE,
path: '/',
});
}
// taskly_attr: first immutable, last advances on every tagged/organic visit.
// buildTouch returns null for pure internal/direct navigation, so this never
// churns the cookie on ordinary page views.
function captureTouch(request: NextRequest, res: NextResponse): void {
const { pathname, searchParams } = request.nextUrl;
if (pathname.startsWith('/api/')) return;
const incoming = buildTouch(
searchParams,
pathname,
request.headers.get('referer'),
request.nextUrl.host,
Date.now(),
);
if (!incoming) return;
const existing = parseAttribution(request.cookies.get(ATTR_COOKIE)?.value);
if (!touchChanged(existing, incoming)) return;
res.cookies.set(ATTR_COOKIE, JSON.stringify(mergeAttribution(existing, incoming)), {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: ATTR_MAX_AGE,
path: '/',
});
}
// taskly_region: which country domain this request is on, derived from the Host
// (tasklyanything.ai → global, .ca → Canada, .in → India). The Host is the
// source of truth (server reads it via lib/region/server); this cookie just
// mirrors it for the client RegionProvider + the geo-suggest banner (a later
// part). Client-readable, refreshed only when it changes.
const REGION_COOKIE = 'taskly_region';
function captureRegion(request: NextRequest, res: NextResponse): void {
// Try every host the edge sees and use the FIRST that's a known region domain —
// x-forwarded-host can be an internal proxy host while the real domain is on
// `host` / nextUrl.host (or vice-versa). Keeps the cookie on the right market.
const candidates = [
request.headers.get('x-forwarded-host'),
request.headers.get('host'),
request.nextUrl.host,
];
const known = candidates.find(isKnownRegionHost);
const region = regionFromHost(known ?? candidates[0]);
if (request.cookies.get(REGION_COOKIE)?.value === region) return;
res.cookies.set(REGION_COOKIE, region, {
httpOnly: false, // the client RegionProvider / country switcher reads it
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 365,
path: '/',
});
}
// taskly_geo: the region IMPLIED by the visitor's location (Cloudflare's
// CF-IPCountry header — present only when the domain is orange-clouded/proxied).
// Purely a hint for the "suggest, don't force" banner (RegionSuggestBanner); it
// never redirects. Absent header (local dev / grey-cloud) → no cookie → no banner.
const GEO_COOKIE = 'taskly_geo';
// Set by RegionSuggestBanner when the visitor dismisses the "switch market?"
// prompt — i.e. they've chosen where to be. Geo routing must respect it, or a
// visitor who deliberately stays gets bounced away on the very next navigation.
const GEO_DISMISS_COOKIE = 'taskly_geo_dismissed';
function captureGeo(request: NextRequest, res: NextResponse): void {
const cc = request.headers.get('cf-ipcountry');
if (!cc) return;
const geo = geoRegionFromCountry(cc);
if (!geo) return; // unknown/reserved country code → no suggestion
if (request.cookies.get(GEO_COOKIE)?.value === geo) return;
res.cookies.set(GEO_COOKIE, geo, {
httpOnly: false, // the client banner reads it
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24, // 1 day; refreshed each request so travel self-corrects
path: '/',
});
}
// Next 16 renamed the `middleware` file convention to `proxy`. Same behaviour:
// refresh the Supabase session, and forward Supabase OAuth `?code=` redirects to
// /auth/callback. The auth exchange routes are left untouched so they own the
// PKCE cookies.
export async function proxy(request: NextRequest) {
// Skip Supabase session refresh if env vars aren't configured
if (
!process.env.NEXT_PUBLIC_SUPABASE_URL ||
!process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||
process.env.NEXT_PUBLIC_SUPABASE_URL === 'your-project-url'
) {
return NextResponse.next();
}
// API routes own their own auth: each route handler builds a server Supabase
// client (lib/supabase/server) that refreshes the token itself on getUser().
// Running updateSession() here too just adds a Supabase round-trip (~50–150ms)
// to every API call — including webhooks/cron — for no benefit. Skip it.
// (Attribution capture already early-returns on /api, and the ?code forward
// below also excludes /api, so this changes nothing else for API routes.)
if (request.nextUrl.pathname.startsWith('/api/')) {
return NextResponse.next();
}
// If a ?code= param arrives on a PAGE (Supabase OAuth redirect), forward it to
// /auth/callback to exchange the code for a session. API routes are excluded:
// they own their own OAuth flows (e.g. /api/provider/linkedin/callback also
// receives a ?code=, and hijacking it into Supabase's exchange would fail and
// sign the user out).
const { searchParams, pathname } = request.nextUrl;
if (
searchParams.get('code') &&
pathname !== '/auth/callback' &&
!pathname.startsWith('/api/')
) {
const callbackUrl = request.nextUrl.clone();
callbackUrl.pathname = '/auth/callback';
return NextResponse.redirect(callbackUrl);
}
// The OAuth/OTP exchange routes must own the session cookies entirely. Running
// updateSession()'s getUser() here first can rotate/clear cookies (incl. the
// PKCE code-verifier) BEFORE the route handler exchanges the code, causing
// "authentication failed". Let these route handlers run untouched.
if (pathname === '/auth/callback' || pathname === '/auth/confirm') {
return NextResponse.next();
}
// The region of the domain this request arrived on — drives the anon geo
// routing (multiRegionLive) and the region-behaviour redirects below, which
// are gated on regionRoutingLive() (the SAME flag that makes hosts resolve
// to their real regions): the moment routing flips, .in becomes the India
// waitlist, .ai becomes the choose-a-country gateway, .ca the full product.
// DORMANT until then — all three domains keep serving the current app.
const hostRegion = regionFromHost(
request.headers.get('x-forwarded-host') ?? request.headers.get('host'),
);
// Per-row home-domain redirect (plan §5, Part 11f). A WhatsApp template's
// button URL is frozen at approval time, so EVERY recipient's "See your task"
// points at .ca — an Indian poster tapping it hits a domain their session
// doesn't exist on and gets the login wall.
//
// Runs FIRST, ahead of geo routing: which market owns the row is a harder
// fact than where the visitor appears to be, and ahead of the /app auth gate
// in app/(customer)/app/layout.tsx, which would otherwise bounce this exact
// visitor to /login before any page-level guard could look at the row.
//
// Costs one REST round-trip, and only on the two row-scoped paths.
if (regionRedirectsLive()) {
const ref = rowRefFromPath(pathname);
if (ref) {
const to = owningMarketTarget({
owner: await fetchRowRegion(ref, AbortSignal.timeout(1500)),
hostRegion,
knownHost: isKnownRegionHost(
request.headers.get('x-forwarded-host') ?? request.headers.get('host'),
),
pathname,
search: request.nextUrl.search,
});
// 302, never 301: ownership is a data fact that must stay correctable, and
// a cached permanent redirect would outlive any fix.
if (to) return NextResponse.redirect(to, 302);
}
}
// Geo routing: send a visitor to THEIR market's domain (CA→.ca, IN→.in,
// rest-of-world→.ai). Runs BEFORE the waitlist redirect below, so a Canadian
// who lands on .in isn't dumped on the India waitlist — they're sent to the
// working Canadian product instead.
//
// Deliberately narrow (see lib/region/geo-route.ts): anonymous, non-bot, no
// stated preference, non-exempt path only — and a 302, never a 301, so no
// permanent canonical is implied. Signed-in visitors are NEVER moved: cookies
// don't cross .ca/.in/.ai, so a redirect would log them out and drop them into
// another market's data.
//
// BOTH flags, deliberately. Geo routing is only COHERENT once the Host
// actually drives the region (regionRoutingLive): with routing off,
// resolveRegion() pins every domain to DEFAULT_REGION ('ca'), so bouncing an
// Indian visitor from .ca to .in would land them on the CANADIAN product in
// CAD — and any signup there stamps profiles.region='ca'. Requiring
// regionRoutingLive() too makes the flags order-independent; the documented
// launch sequence (Stage 3 routing → Stage 4 UI/SEO) is unaffected because
// routing is already on by the time MULTI_REGION_LIVE flips.
// Shared by the geo redirect below AND the gateway's market pick further down,
// so both answer "which market is this visitor?" the same way.
const geoInput = {
hostRegion,
geo: geoRegionFromCountry(request.headers.get('cf-ipcountry')),
// Presence of a Supabase auth cookie is enough — we only need to know that
// a session EXISTS, never to validate it, to decide not to move them.
isLoggedIn: request.cookies
.getAll()
.some((c) => c.name.startsWith('sb-') && c.name.includes('auth-token')),
isBot: isBotUserAgent(request.headers.get('user-agent')),
hasChoice: !!request.cookies.get(GEO_DISMISS_COOKIE),
pathname,
};
if (regionRoutingLive() && multiRegionLive() && regionRedirectsLive()) {
const geoTarget = geoRedirectTarget(geoInput);
if (geoTarget) {
// Rebuild from the target host so the internal Railway origin never leaks,
// and carry path + query through (UTM attribution must survive the hop).
const to = new URL(`https://${geoTarget}${pathname}${request.nextUrl.search}`);
// Mark WHERE THEY CAME FROM so the landing page can record the hop once.
// Counting it in the proxy would mean a DB write on every request; a marker
// the destination consumes (and strips) costs nothing. This is what answers
// "how many visitors did .ai hand off to .ca today?".
to.searchParams.set(GEO_FROM_PARAM, hostRegion);
return NextResponse.redirect(to, 302);
}
}
// India Phase 0: tasklyanything.in is a WAITLIST-only presence until the
// India marketplace is live — route every page to /waitlist (except that
// page, auth, legal, and the metadata routes).
//
// Honours regionRedirectsLive() like every other gate: with redirects off,
// the team can use .in from anywhere. It used to ignore that flag, which made
// the documented "every domain is freely reachable" mode a half-truth —
// /login on .in bounced regardless.
if (
shouldServeWaitlist({
hostRegion,
pathname,
routingLive: regionRoutingLive(),
marketplaceLive: indiaMarketplaceLive(),
redirectsLive: regionRedirectsLive(),
})
) {
const url = request.nextUrl.clone();
url.pathname = '/waitlist';
url.search = '';
return NextResponse.redirect(url);
}
// GLOBAL gateway (tasklyanything.ai): auto-route a visitor to THEIR own market
// when we can identify it, and otherwise leave them on .ai — no forced chooser.
if (
hostRegion === 'global' &&
regionRoutingLive() &&
regionRedirectsLive() &&
isGatewayGatedPath(pathname)
) {
// Known market (Canada → .ca, India → .in): send them straight there. Asking
// someone in Toronto to pick a country we already know is pure friction.
//
// geoRedirectTarget carries every "leave them alone" rule, so this inherits
// them: crawlers are never moved (each market must stay crawlable),
// signed-in visitors are never moved (cookies don't cross TLDs), and anyone
// who already stated a preference keeps it.
const market = geoRedirectTarget(geoInput);
if (market) {
const to = new URL(`https://${market}${pathname}${request.nextUrl.search}`);
to.searchParams.set(GEO_FROM_PARAM, hostRegion);
return NextResponse.redirect(to, 302);
}
// Rest-of-world (UAE, Italy, …) and any unknown location STAY ON .ai — no
// chooser. geoRegionFromCountry maps every non-CA/IN country to `global`,
// which equals the gateway's own region, so `market` is null and we simply
// fall through and render the .ai page. /choose-country still exists as a
// manual switch (CountrySwitcher), it's just never forced.
}
const res = await updateSession(request);
captureAttribution(request, res);
captureTouch(request, res);
captureRegion(request, res);
captureGeo(request, res);
return res;
}
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* - public folder assets
*/
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
};