@synqauth/nextjs 0.0.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,7 @@ Synq for Next.js: a backend-for-frontend session, so the browser never holds a S
4
4
 
5
5
  Synq is customer identity for businesses: an OpenID Connect issuer for each of your brands, with Solana wallet, social, email and passkey sign-in. The documentation is at [docs.synq.build](https://docs.synq.build).
6
6
 
7
- This package is at 0.x and hasn't been published yet: its API can change in any release until Synq's first customer depends on it.
7
+ This package is at 0.x, so a minor release can change its API: see its CHANGELOG.md.
8
8
 
9
9
  ## Quick start
10
10
 
@@ -52,12 +52,31 @@ export const proxy = synqProxy(synqConfig, { protect: ['/dashboard'] })
52
52
  export const config = { matcher: ['/((?!_next/|.*\\..*).*)'] }
53
53
  ```
54
54
 
55
- To add a way to sign in to someone's account (Google, a wallet, anything the brand offers), POST `{ method, returnTo }` as JSON to `/api/auth/link` from your own pages and send the browser to the `redirect` it answers, or call `useAuth().link('google')` from `@synqauth/react`: Synq asks them to sign in again, then adds it.
55
+ To add a way to sign in to someone's account (Google, a wallet, a passkey, anything the brand offers), POST `{ method, returnTo }` as JSON to `/api/auth/link` from your own pages and send the browser to the `redirect` it answers, or call `useAuth().link('google')` from `@synqauth/react`: Synq asks them to sign in again, then adds it.
56
+
57
+ To sign people in on your own page instead of the brand's (AERO-1411), turn on own-UI sign-in for the app in the dashboard and use `<SignInForm>` or `useSignIn()` from `@synqauth/react`. They post each step to `/api/auth/sign-in/flow`, which takes it to Synq as your app. Pass `clientAddress` to `defineSynqConfig` when your host doesn't set `x-real-ip`, so Synq can limit tries per address. Read a header your own proxy sets, never one the browser could send:
58
+
59
+ ```ts
60
+ export const synqConfig = defineSynqConfig({
61
+ // Behind Cloudflare
62
+ clientAddress: (request) => request.headers.get('cf-connecting-ip') ?? undefined,
63
+ })
64
+ ```
56
65
 
57
66
  In a server component, `await synq.me()` is the signed-in person or null, and `await synq.getAccessToken()` is a token for your API (`resource`). Link `synq.signInPath({ returnTo: '/dashboard' })` to sign in, and sign out with a form that POSTs to `synq.signOutPath`.
58
67
 
59
68
  ## When a sign-in doesn't finish
60
69
 
70
+ Synq's own dashboard can resume an unverified email address with
71
+ `/auth/sign-in?intent=verify-email&returnTo=/onboard`. The intent defaults to
72
+ `prompt=select_account`, so an existing GitHub-only account can prove its email
73
+ without signing in again or creating another account. An explicit `prompt` and
74
+ `max_age` are preserved: verification never replaces a request for fresh
75
+ authentication. A maximum-age request takes the normal sign-in flow; an
76
+ incompatible explicit prompt is refused rather than replaced. This verification
77
+ flow applies only where the issuer requires unproved email addresses to be
78
+ verified; tenants keep their existing behavior.
79
+
61
80
  When Synq refuses a sign-in (the person cancelled, their account can't use the app, a token gate they went back from) or the callback fails, the route mount sends the browser back to `returnTo` with `synq_error=<code>`. The proxy never sends such a page to sign in again: with the same session at the brand, that would land on the same refusal, round and round. Without a session, it shows why instead, with "Try again" and "Use another account" (`prompt=select_account`). Give it a page of your own with `signInError`, which it never protects:
62
81
 
63
82
  ```ts
@@ -104,6 +123,28 @@ export const synqConfig = defineSynqConfig({
104
123
 
105
124
  To rotate `SYNQ_SESSION_SECRET`, move the old secret to `SYNQ_SESSION_SECRET_PREVIOUS` (comma-separated), which opens cookies but never seals them.
106
125
 
126
+ ## Several sites from one deployment
127
+
128
+ One app can sign people in on many hosts, each with its own app, at its own brand or a shared one: a site per chapter, say. Pass `hosts`, every host you serve (`*.` and a domain for any one label under it), and `resolve`, which answers each request's site from its host. It may be async and read the client secret from your own store:
129
+
130
+ ```ts
131
+ export const synqConfig = defineSynqConfig({
132
+ hosts: ['*.acmeguild.com'],
133
+ async resolve({ host }) {
134
+ const chapter = await chapterByHost(host)
135
+ if (!chapter) return null
136
+ return {
137
+ issuer: chapter.issuer,
138
+ clientId: chapter.clientId,
139
+ clientSecret: await chapter.clientSecret(),
140
+ storePrefix: `chapter-${chapter.slug}:`,
141
+ }
142
+ },
143
+ })
144
+ ```
145
+
146
+ The handlers, the proxy, `me()`, `getSession()` and `getAccessToken()` then pick the request's site per request. The host comes from `X-Forwarded-Host`, else `Host`, and the site's app URL is `https://<host>`. A host off `hosts`, or one `resolve` answers `null` for, is refused with `unknown_host`: never given another site's configuration. Each site's core is kept, and its cookies are sealed for it alone, so sites never share a session. The guide is at [docs.synq.build/guides/several-sites](https://docs.synq.build/guides/several-sites).
147
+
107
148
  ## License
108
149
 
109
150
  MIT
@@ -1,3 +1,4 @@
1
+ import { LinkedAccount } from "@synqauth/js";
1
2
  //#region src/plugins.d.ts
2
3
  /** A signed-in person, from the ID token Synq issued at sign-in. */
3
4
  interface SynqUser {
@@ -7,11 +8,40 @@ interface SynqUser {
7
8
  readonly email_verified?: boolean;
8
9
  readonly name?: string;
9
10
  readonly preferred_username?: string;
11
+ /** The picture they chose: none, and your app shows its own default, from `avatar_seed`. */
10
12
  readonly picture?: string;
13
+ /** Where `picture` came from (AERO-2700): `none`, `upload`, `url` or `linked:<provider>`. */
14
+ readonly picture_source?: string;
15
+ /**
16
+ * What to draw their default avatar from (AERO-2704): theirs in every app
17
+ * of the brand. Absent from a token issued before seeds: use `sub`.
18
+ */
19
+ readonly avatar_seed?: string;
20
+ /** The brand's own default avatar for them, where it uploaded a set. */
21
+ readonly default_picture?: string;
11
22
  /** When they signed in, in seconds since the epoch. */
12
23
  readonly auth_time?: number;
13
24
  /** How they signed in: `pwd`, `otp`, `wallet`, `hwk`, `fed`... */
14
25
  readonly amr?: readonly string[];
26
+ /**
27
+ * Their accounts at upstream providers (GitHub, Google…), with what each
28
+ * says of the account, when the app asks for the `linked_accounts` scope
29
+ * (AERO-2332).
30
+ */
31
+ readonly linked_accounts?: readonly LinkedAccount[];
32
+ /**
33
+ * How the sign-in this session came from went, when the app turned its
34
+ * sign-in claims on (`sign_in_claims`, AERO-2691): the way in (`google`,
35
+ * `wallet`, `email`, `password`, `passkey`…).
36
+ */
37
+ readonly sign_in_method?: string;
38
+ /** For a wallet sign-in, the address that signed and the wallet app's name, when it said. */
39
+ readonly sign_in_wallet?: {
40
+ readonly address: string;
41
+ readonly app: string | null;
42
+ };
43
+ /** Whether that sign-in made the account: true in its first ID token only. */
44
+ readonly new_user?: boolean;
15
45
  readonly [claim: string]: unknown;
16
46
  }
17
47
  /** What a plugin's route is handed. */
@@ -173,7 +203,75 @@ interface SynqAuthOptions {
173
203
  plugins?: readonly SynqPlugin[];
174
204
  /** The `fetch` to call Synq with. */
175
205
  fetch?: typeof fetch;
206
+ /**
207
+ * Where the person signing in on the app's own screens is
208
+ * (`<basePath>/sign-in/flow`, AERO-1411), as this server saw them: Synq
209
+ * counts its per-address limits for the app with it (`Synq-End-User-IP`).
210
+ * `x-real-ip` by default, which Vercel sets; give one that reads your
211
+ * proxy's header otherwise. Return undefined, and per-address protection
212
+ * is off for the app.
213
+ */
214
+ clientAddress?: (request: Request) => string | undefined;
215
+ /**
216
+ * For an app that serves several sites from one deployment (AERO-2655):
217
+ * a brand per site, an app per host, or both. Called per request with the
218
+ * host the browser asked for, once it is on `hosts`, it answers that
219
+ * site's issuer, app and secret, which it may read from the app's own
220
+ * store (it may be async). Null for a host the app doesn't serve, which is
221
+ * refused as one off `hosts` is. Each site's core is made once and kept,
222
+ * keyed by everything that tells sites apart, so two never share a
223
+ * session. With `resolve`, the site's options come from it alone: never
224
+ * from `issuer`, `clientId`, `clientSecret`, `appUrl` or
225
+ * `postLogoutRedirectUri` here, nor from `SYNQ_*`.
226
+ */
227
+ resolve?: SynqResolve;
228
+ /**
229
+ * With `resolve`: every host the app serves, as the browser names it
230
+ * (`club.example.com`, `localhost:3000`), or `*.example.com` for any one
231
+ * label under a domain. A request whose host (`X-Forwarded-Host`, else
232
+ * `Host`) isn't one is refused with `unknown_host`: never answered with
233
+ * another site's configuration.
234
+ */
235
+ hosts?: readonly string[];
176
236
  }
237
+ /** What `resolve` is asked about: the request's host, already on `hosts`. */
238
+ interface SynqSiteRequest {
239
+ /** The host the browser asked for, lowercase, with its port when it named one. */
240
+ readonly host: string;
241
+ /**
242
+ * `https://<host>`, or `http://` for a host on this machine when the
243
+ * request came over http: the site's `appUrl` unless `resolve` says.
244
+ */
245
+ readonly origin: string;
246
+ /** The request's headers, cookies included: for a choice the app keeps in one. */
247
+ readonly headers: Headers;
248
+ }
249
+ /** One site's Synq, as `resolve` answers it. */
250
+ interface SynqSite {
251
+ /** The brand's issuer URL: `https://<issuer-slug>.auth.<Synq's domain>`. */
252
+ readonly issuer: string;
253
+ /** The site's app: a web app of the brand. */
254
+ readonly clientId: string;
255
+ /** The app's client secret. Never logged, never in a cookie, never in an error. */
256
+ readonly clientSecret: string;
257
+ /**
258
+ * Where the site is served: the request's own `origin` by default, and
259
+ * nothing else, since the session's cookies are on that host alone.
260
+ */
261
+ readonly appUrl?: string;
262
+ /**
263
+ * The site's sessions' prefix in `store`, so one store keeps every
264
+ * site's apart, and a site's can be found or cleared on their own:
265
+ * `club-dragons:`, say. Sessions never open on another site either way.
266
+ */
267
+ readonly storePrefix?: string;
268
+ /** Whether the site's sessions hold a token for the brand's account API: the top-level `account` by default. */
269
+ readonly account?: boolean;
270
+ /** Where the browser lands after signing out: the site's root by default. */
271
+ readonly postLogoutRedirectUri?: string;
272
+ }
273
+ /** Picks a request's site: null (or undefined) for a host the app doesn't serve. */
274
+ type SynqResolve = (request: SynqSiteRequest) => SynqSite | null | undefined | Promise<SynqSite | null | undefined>;
177
275
  interface SynqConfig {
178
276
  readonly issuer: string;
179
277
  readonly clientId: string;
@@ -194,11 +292,19 @@ interface SynqConfig {
194
292
  readonly refreshLeewaySeconds: number;
195
293
  readonly plugins: readonly SynqPlugin[];
196
294
  readonly fetch: typeof fetch | undefined;
295
+ /** Where a person signing in on the app's own screens is: `x-real-ip` by default. */
296
+ readonly clientAddress: (request: Request) => string | undefined;
197
297
  /** Over https: `Secure` cookies with the `__Host-` prefix. */
198
298
  readonly secure: boolean;
299
+ /**
300
+ * The site the session's cookies are sealed for (`resolve`): its issuer,
301
+ * app and origin, so a cookie never opens on another site. Empty for an
302
+ * app with one configuration.
303
+ */
304
+ readonly site: string;
199
305
  }
200
306
  type Env = Record<string, string | undefined>;
201
307
  /** The configuration from `options` and the environment; throws `invalid_options` for anything missing or unsafe. */
202
308
  declare function resolveConfig(options?: SynqAuthOptions, env?: Env): SynqConfig;
203
309
  //#endregion
204
- export { KeyValueSessionStoreOptions as a, keyValueSessionStore as c, SynqPluginContext as d, SynqPluginRoute as f, KeyValue as i, memorySessionStore as l, defineSynqPlugin as m, SynqConfig as n, SessionValues as o, SynqUser as p, resolveConfig as r, SynqSessionStore as s, SynqAuthOptions as t, SynqPlugin as u };
310
+ export { defineSynqPlugin as _, SynqSiteRequest as a, KeyValueSessionStoreOptions as c, keyValueSessionStore as d, memorySessionStore as f, SynqUser as g, SynqPluginRoute as h, SynqSite as i, SessionValues as l, SynqPluginContext as m, SynqConfig as n, resolveConfig as o, SynqPlugin as p, SynqResolve as r, KeyValue as s, SynqAuthOptions as t, SynqSessionStore as u };
package/dist/define.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { t as SynqAuthOptions } from "./config-BNh2Aznt.js";
1
+ import { a as SynqSiteRequest, i as SynqSite, r as SynqResolve, t as SynqAuthOptions } from "./config-a7tT3KtP.js";
2
2
  //#region src/define.d.ts
3
3
  /** Declares the app's Synq configuration: `SYNQ_*` environment variables fill in whatever it leaves out. */
4
4
  export declare function defineSynqConfig<const T extends SynqAuthOptions>(options: T): T;
5
5
  //#endregion
6
- export type { SynqAuthOptions };
6
+ export type { SynqAuthOptions, SynqResolve, SynqSite, SynqSiteRequest };
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
- import { a as KeyValueSessionStoreOptions, c as keyValueSessionStore, d as SynqPluginContext, f as SynqPluginRoute, i as KeyValue, l as memorySessionStore, m as defineSynqPlugin, n as SynqConfig, o as SessionValues, p as SynqUser, r as resolveConfig, s as SynqSessionStore, t as SynqAuthOptions, u as SynqPlugin } from "./config-BNh2Aznt.js";
1
+ import { _ as defineSynqPlugin, a as SynqSiteRequest, c as KeyValueSessionStoreOptions, d as keyValueSessionStore, f as memorySessionStore, g as SynqUser, h as SynqPluginRoute, i as SynqSite, l as SessionValues, m as SynqPluginContext, n as SynqConfig, o as resolveConfig, p as SynqPlugin, r as SynqResolve, s as KeyValue, t as SynqAuthOptions, u as SynqSessionStore } from "./config-a7tT3KtP.js";
2
2
  import { defineSynqConfig } from "./define.js";
3
3
  import { cookies } from "next/headers.js";
4
+ import "@synqauth/js";
4
5
  //#region src/cookies.d.ts
5
6
  /** The cookies a request carries and the changes a response makes: what a route handler, Next's `cookies()` or a test gives. */
6
7
  interface CookieJar {
@@ -19,12 +20,54 @@ interface CookieOptions {
19
20
  secure: boolean;
20
21
  }
21
22
  //#endregion
23
+ //#region src/sign-in-context.d.ts
24
+ /** The way this browser last signed in to the app: never who, never an address. */
25
+ interface LastUsedMethod {
26
+ /** The method's id: `google`, `wallet`, `email`… */
27
+ readonly method: string;
28
+ /** For a wallet, the wallet app's name as it announced itself (`Phantom`). */
29
+ readonly walletApp?: string;
30
+ }
31
+ /** A sign-in that just finished, told to the page once (`useSignInEvent()`). */
32
+ interface SignInEvent {
33
+ /**
34
+ * This sign-in's own id, random: two sign-ins the same way can share
35
+ * `at` (a provider's own sign-in time), never this.
36
+ */
37
+ readonly id: string;
38
+ /** Whether it made the account: their first sign-in. */
39
+ readonly isNewUser: boolean;
40
+ /** The way in: `google`, `wallet`, `email`, `password`, `passkey`… */
41
+ readonly method: string;
42
+ /** For a wallet sign-in, the address that signed and its wallet app, when it said. */
43
+ readonly wallet: {
44
+ readonly address: string;
45
+ readonly app: string | null;
46
+ } | null;
47
+ /** When they signed in, in seconds since the epoch (the ID token's `auth_time`). */
48
+ readonly at: number;
49
+ }
50
+ //#endregion
22
51
  //#region src/core.d.ts
23
52
  /** Who is signed in, as the browser may see it: never a token. */
24
53
  interface SynqSessionInfo {
25
54
  readonly user: SynqUser | null;
26
55
  /** When the main access token expires, in seconds since the epoch. */
27
56
  readonly expiresAt: number | null;
57
+ /**
58
+ * The way this browser last signed in to the app, for a "Last used" mark
59
+ * (AERO-2691): kept on the app's own host from the app's sign-in claims,
60
+ * signed in or out, and never where the brand turned "Last used" off.
61
+ * Null without the claims, or before any sign-in here.
62
+ */
63
+ readonly lastUsed: LastUsedMethod | null;
64
+ /**
65
+ * The sign-in that just finished, until the page says it heard of it
66
+ * (`DELETE <base>/session/event`, `useSignInEvent().consume()`) or five
67
+ * minutes pass: whether it made the account, how, and with which wallet.
68
+ * Needs the app's sign-in claims on.
69
+ */
70
+ readonly event: SignInEvent | null;
28
71
  }
29
72
  interface SynqCore {
30
73
  /** Answers a request under the route mount. */
@@ -81,7 +124,11 @@ export interface SynqAuth {
81
124
  };
82
125
  /** The signed-in person, or null: read from the session's ID token, once per request, with no request to Synq. */
83
126
  me(): Promise<SynqUser | null>;
84
- /** Who is signed in and until when, as the browser may see it. */
127
+ /**
128
+ * Who is signed in and until when, as the browser may see it, with the way
129
+ * this browser last signed in (`lastUsed`) and the sign-in that just
130
+ * finished (`event`), once the app's sign-in claims are on (AERO-2691).
131
+ */
85
132
  getSession(): Promise<SynqSessionInfo>;
86
133
  /**
87
134
  * A current access token for the app's API (or `resource`), refreshed
@@ -97,7 +144,7 @@ export interface SynqAuth {
97
144
  returnTo?: string;
98
145
  method?: string;
99
146
  loginHint?: string;
100
- prompt?: 'login' | 'none' | 'consent' | 'select_account';
147
+ prompt?: 'login' | 'none' | 'consent' | 'select_account' | 'create';
101
148
  maxAge?: number;
102
149
  }): string;
103
150
  /** Where a form POSTs to sign out. */
@@ -115,11 +162,14 @@ export interface SynqAuth {
115
162
  /**
116
163
  * Synq for this app, configured from SYNQ_ISSUER, SYNQ_CLIENT_ID,
117
164
  * SYNQ_CLIENT_SECRET, SYNQ_APP_URL and SYNQ_SESSION_SECRET unless `options`
118
- * (`defineSynqConfig`, shared with the proxy) says.
165
+ * (`defineSynqConfig`, shared with the proxy) says. With `resolve` and
166
+ * `hosts`, each request's site is its own (AERO-2655): its handlers answer a
167
+ * host off `hosts` with 421 `unknown_host`, and `me()`, `getSession()` and
168
+ * `getAccessToken()` throw that error.
119
169
  */
120
170
  export declare function synqAuth(options?: SynqAuthOptions): SynqAuth;
121
171
  type NextCookieStore = Awaited<ReturnType<typeof cookies>>;
122
172
  /** Next's cookie store as the cookie jar `createSynqCore` reads and writes. */
123
173
  export declare function jarOf(store: NextCookieStore): CookieJar;
124
174
  //#endregion
125
- export { type KeyValue, type KeyValueSessionStoreOptions, type SessionValues, type SynqAuthOptions, type SynqPlugin, type SynqPluginContext, type SynqPluginRoute, type SynqSessionInfo, type SynqSessionStore, type SynqSignInError, type SynqUser, defineSynqConfig, defineSynqPlugin, keyValueSessionStore, memorySessionStore, resolveConfig };
175
+ export { type KeyValue, type KeyValueSessionStoreOptions, type LastUsedMethod, type SessionValues, type SignInEvent, type SynqAuthOptions, type SynqPlugin, type SynqPluginContext, type SynqPluginRoute, type SynqResolve, type SynqSessionInfo, type SynqSessionStore, type SynqSignInError, type SynqSite, type SynqSiteRequest, type SynqUser, defineSynqConfig, defineSynqPlugin, keyValueSessionStore, memorySessionStore, resolveConfig };
package/dist/index.js CHANGED
@@ -1,25 +1,40 @@
1
- import { c as memorySessionStore, d as normalizePath, f as resolveConfig, l as defineSynqPlugin, n as signInErrorMessage, o as createSynqCore, r as signInErrorOf, s as keyValueSessionStore, t as SIGN_IN_ERROR_PARAM } from "./sign-in-error-DSCgrk0b.js";
1
+ import { a as signInErrorMessage, d as memorySessionStore, f as defineSynqPlugin, h as resolveConfig, i as SIGN_IN_ERROR_PARAM, l as createSynqCore, m as normalizePath, n as unknownHostError, o as signInErrorOf, r as unknownHostResponse, t as siteCores, u as keyValueSessionStore } from "./sites-JUnMBDbp.js";
2
2
  import { defineSynqConfig } from "./define.js";
3
- import { cookies } from "next/headers.js";
3
+ import { cookies, headers } from "next/headers.js";
4
4
  import { cache } from "react";
5
5
  //#region src/index.ts
6
6
  /**
7
7
  * Synq for this app, configured from SYNQ_ISSUER, SYNQ_CLIENT_ID,
8
8
  * SYNQ_CLIENT_SECRET, SYNQ_APP_URL and SYNQ_SESSION_SECRET unless `options`
9
- * (`defineSynqConfig`, shared with the proxy) says.
9
+ * (`defineSynqConfig`, shared with the proxy) says. With `resolve` and
10
+ * `hosts`, each request's site is its own (AERO-2655): its handlers answer a
11
+ * host off `hosts` with 421 `unknown_host`, and `me()`, `getSession()` and
12
+ * `getAccessToken()` throw that error.
10
13
  */
11
14
  function synqAuth(options = {}) {
12
15
  let core;
16
+ const sites = options.resolve ? siteCores(options) : void 0;
13
17
  const get = () => core ??= createSynqCore(resolveConfig(options));
14
- const handler = (request) => get().handle(request);
18
+ /** The core for the page's request: the one, or its site's, read from Next's headers. */
19
+ const forPage = async () => {
20
+ if (!sites) return get();
21
+ const found = await sites.coreFor(await headers());
22
+ if (!found) throw unknownHostError();
23
+ return found;
24
+ };
25
+ const handler = async (request) => {
26
+ if (!sites) return get().handle(request);
27
+ const found = await sites.coreFor(request.headers, request.url);
28
+ return found ? found.handle(request) : unknownHostResponse();
29
+ };
15
30
  const basePath = () => normalizePath(options.basePath ?? "/api/auth");
16
31
  const me = cache(async () => {
17
32
  const jar = await nextJar();
18
- return get().user(jar);
33
+ return (await forPage()).user(jar);
19
34
  });
20
35
  const getSession = cache(async () => {
21
36
  const jar = await nextJar();
22
- return get().session(jar);
37
+ return (await forPage()).session(jar);
23
38
  });
24
39
  return {
25
40
  handlers: {
@@ -32,7 +47,7 @@ function synqAuth(options = {}) {
32
47
  getSession,
33
48
  async getAccessToken(opts) {
34
49
  const jar = await nextJar();
35
- return get().getAccessToken(jar, opts);
50
+ return (await forPage()).getAccessToken(jar, opts);
36
51
  },
37
52
  signInPath(opts = {}) {
38
53
  const params = new URLSearchParams();
package/dist/proxy.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { t as SynqAuthOptions } from "./config-BNh2Aznt.js";
1
+ import { t as SynqAuthOptions } from "./config-a7tT3KtP.js";
2
2
  //#region src/proxy.d.ts
3
3
  /** What `synqProxy()` takes: which paths need a session. */
4
4
  export interface SynqProxyOptions {
@@ -18,6 +18,34 @@ export interface SynqProxyOptions {
18
18
  * of its own. Either way, it never sends that request to sign in again.
19
19
  */
20
20
  readonly signInError?: string;
21
+ /**
22
+ * Paths whose visitors without a session are sent to sign up rather than
23
+ * to sign in (`prompt=create`): where a "Get started" or pricing link
24
+ * lands. Path prefixes, or a test. Synq's page opens on its sign-up screen
25
+ * where the brand lets someone new sign up, with "Sign in" a link away,
26
+ * and someone already signed in at Synq is simply signed in.
27
+ */
28
+ readonly signUp?: readonly string[] | ((pathname: string) => boolean);
29
+ /**
30
+ * What the visitor picked on a pricing page, read from the URL of the page
31
+ * they are sent to sign in (or up) from: a plan's code and its billing
32
+ * interval, and the onboarding visit's id. Synq's own sign-up page says
33
+ * the plan back, and its funnel ties the account made to the visit
34
+ * (AERO-2160); a tenant's page ignores them. Return undefined when the
35
+ * page names neither.
36
+ */
37
+ readonly choice?: (url: URL) => SignUpChoice | undefined;
38
+ }
39
+ /** What a visitor picked: a plan's code and its interval, and the onboarding visit. */
40
+ export interface SignUpChoice {
41
+ readonly plan?: string;
42
+ /** Monthly or yearly; only with a plan. */
43
+ readonly interval?: 'month' | 'year';
44
+ /**
45
+ * The onboarding visit's id (22 base62 characters), which Synq's funnel
46
+ * ties the account a sign-up makes to.
47
+ */
48
+ readonly onboarding?: string;
21
49
  }
22
50
  /**
23
51
  * Next's proxy for Synq: refreshes a session that is due, and sends a
package/dist/proxy.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as withoutSignInError, f as resolveConfig, i as signInErrorPage, o as createSynqCore, r as signInErrorOf, t as SIGN_IN_ERROR_PARAM, u as requestJar } from "./sign-in-error-DSCgrk0b.js";
1
+ import { c as withoutSignInError, h as resolveConfig, i as SIGN_IN_ERROR_PARAM, l as createSynqCore, m as normalizePath, o as signInErrorOf, p as requestJar, r as unknownHostResponse, s as signInErrorPage, t as siteCores } from "./sites-JUnMBDbp.js";
2
2
  import { NextResponse } from "next/server.js";
3
3
  //#region src/proxy.ts
4
4
  /**
@@ -7,14 +7,22 @@ import { NextResponse } from "next/server.js";
7
7
  * just failed, to the page that says why.
8
8
  */
9
9
  function synqProxy(config, options) {
10
- const { protect, signInError } = options;
10
+ const { protect, signInError, signUp, choice } = options;
11
11
  if (signInError !== void 0 && !/^\/(?![/\\])/.test(signInError)) throw new TypeError("synqProxy: signInError is a path on the app, such as /sign-in-error");
12
12
  const protectsPath = typeof protect === "function" ? protect : (path) => protect.some((prefix) => under(path, prefix));
13
+ const signsUp = signUp === void 0 ? () => false : typeof signUp === "function" ? signUp : (path) => signUp.some((prefix) => under(path, prefix));
13
14
  const protects = (path) => !(signInError !== void 0 && under(path, signInError)) && protectsPath(path);
14
- let core;
15
+ let single;
16
+ const sites = config.resolve ? siteCores(config) : void 0;
15
17
  return async function proxy(request) {
16
- core ??= createSynqCore(resolveConfig(config));
17
18
  const url = new URL(request.url);
19
+ let core;
20
+ if (sites) {
21
+ if (under(url.pathname, normalizePath(config.basePath ?? "/api/auth"))) return void 0;
22
+ const found = await sites.coreFor(request.headers, request.url);
23
+ if (!found) return protects(url.pathname) ? unknownHostResponse() : void 0;
24
+ core = found;
25
+ } else core = single ??= createSynqCore(resolveConfig(config));
18
26
  if (under(url.pathname, core.config.basePath)) return void 0;
19
27
  const jar = requestJar(request);
20
28
  const signedIn = await core.refreshIfDue(jar);
@@ -29,6 +37,13 @@ function synqProxy(config, options) {
29
37
  }
30
38
  const signIn = new URL(`${core.config.basePath}/sign-in`, core.config.appUrl);
31
39
  signIn.searchParams.set("returnTo", returnTo);
40
+ if (signsUp(url.pathname)) signIn.searchParams.set("prompt", "create");
41
+ const picked = choice?.(url);
42
+ if (picked?.plan) {
43
+ signIn.searchParams.set("plan", picked.plan);
44
+ if (picked.interval) signIn.searchParams.set("interval", picked.interval);
45
+ }
46
+ if (picked?.onboarding) signIn.searchParams.set("onboarding", picked.onboarding);
32
47
  const response = new Response(null, {
33
48
  status: 307,
34
49
  headers: { location: signIn.href }