@flow-industries/id 0.24.4 → 0.25.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.
@@ -1,6 +1,7 @@
1
1
  export declare function safeReturnTo(value: string, audience: string, sessionPath?: string): string | null;
2
2
  export declare function startBrowserSession(request: Request, audience: string, issuerUrl: string, body: {
3
- action: "login" | "account";
3
+ action: "login" | "account" | "switch";
4
+ intent?: "login" | "manage" | "switch" | "add";
4
5
  redirect?: boolean;
5
6
  returnTo?: string;
6
7
  signUp?: boolean;
@@ -2,7 +2,7 @@ import { z } from "zod";
2
2
  import { BROWSER_FLOW_TTL_MS, browserSigningSchema } from "./browser-contract";
3
3
  import { clearCookieString, cookieNamesFor, parseCookieHeader, serializeCookie, } from "./cookies";
4
4
  import { fetchIssuerApi, resolveIssuerApiUrl } from "./issuer-api";
5
- import { claimsToUser, sessionCookies } from "./session-core";
5
+ import { claimsToUser, resolveSessionCore, sessionCookies, } from "./session-core";
6
6
  import { verifyFlowJWT } from "./verify";
7
7
  const opaquePattern = /^[A-Za-z0-9_-]{43}$/;
8
8
  const pendingSchema = z.object({
@@ -10,6 +10,7 @@ const pendingSchema = z.object({
10
10
  verifier: z.string().regex(opaquePattern),
11
11
  returnTo: z.string(),
12
12
  expires: z.number(),
13
+ manageAccount: z.boolean().optional(),
13
14
  });
14
15
  const exchangeSchema = z.object({
15
16
  jwt: z.string(),
@@ -62,8 +63,13 @@ export function safeReturnTo(value, audience, sessionPath = "/flow/session") {
62
63
  }
63
64
  }
64
65
  export async function startBrowserSession(request, audience, issuerUrl, body, apiUrl = resolveIssuerApiUrl(issuerUrl)) {
66
+ const sessionPath = new URL(request.url).pathname;
67
+ const returnTo = safeReturnTo(body.returnTo ?? "/", audience, sessionPath);
68
+ if (!returnTo)
69
+ return reply({ error: "invalid_return_destination" }, 400);
65
70
  const cookies = parseCookieHeader(request.headers.get("Cookie"));
66
- const refresh = cookies[cookieNamesFor(audience).refresh];
71
+ const refreshCookieName = cookieNamesFor(audience).refresh;
72
+ const refresh = cookies[refreshCookieName];
67
73
  const headers = new Headers({ "Content-Type": "application/json" });
68
74
  if (refresh)
69
75
  headers.set("Authorization", `Bearer ${refresh}`);
@@ -72,65 +78,80 @@ export async function startBrowserSession(request, audience, issuerUrl, body, ap
72
78
  if (value)
73
79
  headers.set(name, value);
74
80
  }
81
+ let manageAccount = false;
82
+ let sessionUpdates = [];
75
83
  if (body.action === "account") {
76
84
  if (!refresh)
77
85
  return reply({ error: "signed_out" }, 401);
78
- const response = await fetchIssuerApi(`${apiUrl}/api/auth/browser/account-handoff`, { method: "POST", headers, body: JSON.stringify({ audience }) });
86
+ const resolved = await resolveSessionCore(request.headers.get("Cookie"), audience, issuerUrl, apiUrl);
87
+ sessionUpdates = resolved.setCookies;
88
+ if (!resolved.session)
89
+ return reply({ error: "account_unavailable" }, 401, sessionUpdates);
90
+ manageAccount = !resolved.session.user.isGuest;
91
+ const refreshedCookie = sessionUpdates.find((cookie) => cookie.startsWith(`${refreshCookieName}=`));
92
+ if (refreshedCookie) {
93
+ const token = parseCookieHeader(refreshedCookie)[refreshCookieName];
94
+ if (token)
95
+ headers.set("Authorization", `Bearer ${token}`);
96
+ }
97
+ }
98
+ try {
99
+ let intent = body.intent ?? "login";
100
+ if (body.action === "account")
101
+ intent = manageAccount ? "manage" : "login";
102
+ if (body.action === "switch")
103
+ intent = "switch";
104
+ const state = secret();
105
+ const verifier = secret();
106
+ const callback = `${audience}${sessionPath}`;
107
+ const response = await fetchIssuerApi(`${apiUrl}/api/auth/browser/transaction`, {
108
+ method: "POST",
109
+ headers,
110
+ body: JSON.stringify({
111
+ audience,
112
+ callback,
113
+ state,
114
+ challenge: await pkce(verifier),
115
+ signUp: body.signUp ?? false,
116
+ intent,
117
+ accessKeyHash: body.accessKeyHash,
118
+ }),
119
+ });
79
120
  if (!response.ok)
80
- return reply({ error: "account_unavailable" }, response.status);
81
- const { handoff } = z
82
- .object({ handoff: z.string().regex(opaquePattern) })
121
+ return reply({ error: "login_unavailable" }, response.status, sessionUpdates);
122
+ const { transaction } = z
123
+ .object({ transaction: z.string().regex(opaquePattern) })
83
124
  .parse(await response.json());
84
- const accountUrl = `${issuerUrl}/account#handoff=${handoff}`;
85
- if (body.redirect)
86
- return new Response(null, {
87
- status: 303,
88
- headers: {
89
- Location: accountUrl,
90
- "Cache-Control": "no-store",
91
- "Referrer-Policy": "no-referrer",
92
- },
125
+ const pending = btoa(encodeURIComponent(JSON.stringify({
126
+ state,
127
+ verifier,
128
+ returnTo,
129
+ manageAccount,
130
+ expires: Date.now() + BROWSER_FLOW_TTL_MS,
131
+ })));
132
+ const authorizeUrl = `${issuerUrl}/authorize?transaction=${transaction}`;
133
+ const pendingCookies = [
134
+ ...sessionUpdates,
135
+ serializeCookie(cookieName(audience, state), pending, {
136
+ maxAge: BROWSER_FLOW_TTL_MS / 1000,
137
+ secure: audience.startsWith("https:"),
138
+ }),
139
+ ];
140
+ if (body.redirect) {
141
+ const headers = new Headers({
142
+ Location: authorizeUrl,
143
+ "Cache-Control": "no-store",
144
+ "Referrer-Policy": "no-referrer",
93
145
  });
94
- return reply({ accountUrl });
146
+ for (const cookie of pendingCookies)
147
+ headers.append("Set-Cookie", cookie);
148
+ return new Response(null, { status: 303, headers });
149
+ }
150
+ return reply({ loginId: state, authorizeUrl, accountUrl: authorizeUrl }, 200, pendingCookies);
151
+ }
152
+ catch {
153
+ return reply({ error: "login_unavailable" }, 502, sessionUpdates);
95
154
  }
96
- const returnTo = safeReturnTo(body.returnTo ?? "/", audience, new URL(request.url).pathname);
97
- if (!returnTo)
98
- return reply({ error: "invalid_return_destination" }, 400);
99
- const state = secret();
100
- const verifier = secret();
101
- const callback = `${audience}${new URL(request.url).pathname}`;
102
- const response = await fetchIssuerApi(`${apiUrl}/api/auth/browser/transaction`, {
103
- method: "POST",
104
- headers,
105
- body: JSON.stringify({
106
- audience,
107
- callback,
108
- state,
109
- challenge: await pkce(verifier),
110
- signUp: body.signUp ?? false,
111
- accessKeyHash: body.accessKeyHash,
112
- }),
113
- });
114
- if (!response.ok)
115
- return reply({ error: "login_unavailable" }, response.status);
116
- const { transaction } = z
117
- .object({ transaction: z.string().regex(opaquePattern) })
118
- .parse(await response.json());
119
- const pending = btoa(encodeURIComponent(JSON.stringify({
120
- state,
121
- verifier,
122
- returnTo,
123
- expires: Date.now() + BROWSER_FLOW_TTL_MS,
124
- })));
125
- return reply({
126
- loginId: state,
127
- authorizeUrl: `${issuerUrl}/authorize?transaction=${transaction}`,
128
- }, 200, [
129
- serializeCookie(cookieName(audience, state), pending, {
130
- maxAge: BROWSER_FLOW_TTL_MS / 1000,
131
- secure: audience.startsWith("https:"),
132
- }),
133
- ]);
134
155
  }
135
156
  export async function finishBrowserSession(request, audience, issuerUrl, apiUrl = resolveIssuerApiUrl(issuerUrl)) {
136
157
  const url = new URL(request.url);
@@ -200,6 +221,8 @@ export async function finishBrowserSession(request, audience, issuerUrl, apiUrl
200
221
  headers.append("Set-Cookie", serializeCookie(`${cookieNamesFor(audience).refresh}.completion`, completion, { maxAge: 60, secure: audience.startsWith("https:") }));
201
222
  }
202
223
  headers.append("Set-Cookie", clear);
224
+ if (pending.manageAccount)
225
+ headers.set("Location", `${issuerUrl}/account`);
203
226
  return new Response(null, { status: 303, headers });
204
227
  }
205
228
  catch {
@@ -112,11 +112,20 @@ export function createFlow(options = {}) {
112
112
  * knows neither, and overwriting would erase state restored from IDB.
113
113
  */
114
114
  let additionalSubject = null;
115
- function commitSession(session) {
116
- const previous = store.getSnapshot().user?.id;
115
+ let sessionRevision = 0;
116
+ async function commitSession(session) {
117
+ const revision = ++sessionRevision;
118
+ const previousState = store.getSnapshot();
119
+ const previous = previousState.user?.id;
117
120
  if (previous && previous !== session.user.id) {
118
121
  store.setState({ credential: null, address: null });
119
- void idb.delete("flow.activeCredential");
122
+ await idb.delete("flow.activeCredential");
123
+ if (previousState.address) {
124
+ const { clearAccessKey } = await import("./access-key");
125
+ await clearAccessKey(previousState.address);
126
+ }
127
+ if (revision !== sessionRevision)
128
+ return;
120
129
  }
121
130
  const next = {
122
131
  user: session.user,
@@ -147,6 +156,7 @@ export function createFlow(options = {}) {
147
156
  }
148
157
  }
149
158
  async function recoverAccessKey(state, loginCompletion) {
159
+ const revision = sessionRevision;
150
160
  const completionKey = "flow.pendingAccessKey.completion";
151
161
  if (loginCompletion)
152
162
  await idb.set(completionKey, {
@@ -164,10 +174,11 @@ export function createFlow(options = {}) {
164
174
  throw new Error("Could not restore signing state");
165
175
  // SAFETY: the app session handler returns the SDK protocol response.
166
176
  const resolved = (await fresh.json());
167
- if (resolved.state?.user.id === completion.userId &&
177
+ if (revision === sessionRevision &&
178
+ resolved.state?.user.id === completion.userId &&
168
179
  resolved.state.credential &&
169
180
  resolved.state.address) {
170
- commitSession(resolved.state);
181
+ await commitSession(resolved.state);
171
182
  const { finalizeAccessKey } = await import("./access-key");
172
183
  await finalizeAccessKey({
173
184
  address: resolved.state.address,
@@ -189,7 +200,13 @@ export function createFlow(options = {}) {
189
200
  * false (never throws) when the visitor is signed out or the route is
190
201
  * unreachable — the caller then falls back to ensureGuest()/login().
191
202
  */
203
+ let sessionRequest = 0;
204
+ let logoutInFlight = null;
205
+ let logoutGeneration = 0;
192
206
  async function refreshViaSession() {
207
+ if (logoutInFlight)
208
+ return false;
209
+ const request = ++sessionRequest;
193
210
  try {
194
211
  const res = await fetch(sessionPath, {
195
212
  headers: { Accept: "application/json" },
@@ -198,9 +215,9 @@ export function createFlow(options = {}) {
198
215
  return false;
199
216
  // SAFETY: the app's own /flow/session route answers this shape.
200
217
  const { state, loginCompletion } = (await res.json());
201
- if (!state)
218
+ if (!state || request !== sessionRequest)
202
219
  return false;
203
- commitSession(state);
220
+ await commitSession(state);
204
221
  await recoverAccessKey(state, loginCompletion).catch(() => { });
205
222
  return true;
206
223
  }
@@ -240,7 +257,11 @@ export function createFlow(options = {}) {
240
257
  // and the guard short-circuits a caller that arrives after a concurrent
241
258
  // holder already minted a fresh JWT.
242
259
  async function doRefresh() {
260
+ if (logoutInFlight)
261
+ return false;
243
262
  return withOriginLock("flow.id.refresh", async () => {
263
+ if (logoutInFlight)
264
+ return false;
244
265
  const current = store.getSnapshot().jwt;
245
266
  if (current && !isExpiring(current))
246
267
  return true;
@@ -290,18 +311,27 @@ export function createFlow(options = {}) {
290
311
  * in-memory store (never IDB) — IDB is reserved for a real passkey credential.
291
312
  */
292
313
  async function ensureGuest() {
314
+ if (logoutInFlight)
315
+ return false;
293
316
  if (store.getSnapshot().user)
294
317
  return true;
295
318
  if (!guestInFlight) {
319
+ const generation = logoutGeneration;
296
320
  guestInFlight = withOriginLock("flow.id.guest", async () => {
297
321
  // Re-check under the lock: another tab may have minted the guest while
298
322
  // we waited. Refresh-first (from this origin's session cookie)
299
323
  // recovers an existing session instead of minting a duplicate guest.
324
+ if (logoutInFlight || generation !== logoutGeneration)
325
+ return false;
300
326
  if (store.getSnapshot().user)
301
327
  return true;
302
- if (await doRefresh())
328
+ const restored = await doRefresh();
329
+ if (logoutInFlight || generation !== logoutGeneration)
330
+ return false;
331
+ if (restored)
303
332
  return true;
304
333
  try {
334
+ const request = ++sessionRequest;
305
335
  const response = await fetch(sessionPath, {
306
336
  method: "POST",
307
337
  headers: { "Content-Type": "application/json" },
@@ -311,9 +341,9 @@ export function createFlow(options = {}) {
311
341
  return false;
312
342
  // SAFETY: the app session handler returns the SDK protocol response.
313
343
  const result = (await response.json());
314
- if (!result.state)
344
+ if (!result.state || request !== sessionRequest)
315
345
  return false;
316
- commitSession(result.state);
346
+ await commitSession(result.state);
317
347
  return true;
318
348
  }
319
349
  catch {
@@ -329,13 +359,16 @@ export function createFlow(options = {}) {
329
359
  void idb.pruneExpired("flow.pendingAccessKey.").catch(() => { });
330
360
  if (options.autoRestore !== false) {
331
361
  const restored = await refreshViaSession();
332
- if (restored) {
362
+ if (restored && !logoutInFlight) {
363
+ const request = ++sessionRequest;
333
364
  const response = await fetch(`${sessionPath}?refresh=1`);
334
365
  if (response.ok) {
335
366
  // SAFETY: the app session handler returns the SDK protocol response.
336
367
  const result = (await response.json());
368
+ if (request !== sessionRequest)
369
+ return;
337
370
  if (result.state)
338
- commitSession(result.state);
371
+ await commitSession(result.state);
339
372
  else
340
373
  await runLogout(store);
341
374
  }
@@ -357,11 +390,17 @@ export function createFlow(options = {}) {
357
390
  }
358
391
  /** Starts top-level authentication; successful navigation never resolves in this document. */
359
392
  async function login(loginOpts = {}) {
393
+ return startLogin(loginOpts, "login");
394
+ }
395
+ async function switchAccounts(loginOpts = {}) {
396
+ return startLogin(loginOpts, "switch");
397
+ }
398
+ async function startLogin(loginOpts, intent) {
360
399
  if (window.top !== window) {
361
400
  throw new Error("Open sign-in from the containing app.");
362
401
  }
363
402
  let preparation;
364
- if (accessKeyOptions) {
403
+ if (accessKeyOptions && intent === "login") {
365
404
  const chain = getChain();
366
405
  if (!chain)
367
406
  throw new Error("accessKey requires a configured chain");
@@ -372,7 +411,7 @@ export function createFlow(options = {}) {
372
411
  method: "POST",
373
412
  headers: { "Content-Type": "application/json" },
374
413
  body: JSON.stringify({
375
- action: "login",
414
+ action: intent,
376
415
  accessKeyHash: preparation?.accessKeyHash,
377
416
  signUp: loginOpts.signUp,
378
417
  returnTo: loginOpts.returnTo ??
@@ -396,6 +435,7 @@ export function createFlow(options = {}) {
396
435
  function openAccount() {
397
436
  const url = new URL(sessionPath, window.location.origin);
398
437
  url.searchParams.set("action", "account");
438
+ url.searchParams.set("returnTo", `${window.location.pathname}${window.location.search}${window.location.hash}`);
399
439
  const tab = window.open(url.href, "_blank");
400
440
  if (!tab)
401
441
  return false;
@@ -403,19 +443,37 @@ export function createFlow(options = {}) {
403
443
  return true;
404
444
  }
405
445
  async function logout() {
406
- const response = await fetch(sessionPath, { method: "DELETE" });
407
- if (!response.ok)
408
- throw new Error("Could not sign out. Try again.");
409
- await runLogout(store);
410
- additionalSubject = null;
446
+ if (logoutInFlight)
447
+ return logoutInFlight;
448
+ ++logoutGeneration;
449
+ ++sessionRequest;
450
+ ++sessionRevision;
451
+ logoutInFlight = (async () => {
452
+ const response = await fetch(sessionPath, { method: "DELETE" });
453
+ if (!response.ok)
454
+ throw new Error("Could not sign out. Try again.");
455
+ ++sessionRequest;
456
+ ++sessionRevision;
457
+ await runLogout(store);
458
+ additionalSubject = null;
459
+ })();
460
+ try {
461
+ await logoutInFlight;
462
+ }
463
+ finally {
464
+ logoutInFlight = null;
465
+ }
411
466
  if (options.autoGuest)
412
467
  await ensureGuest();
413
468
  }
414
469
  let focusRefresh = null;
415
470
  const refreshOnFocus = () => {
416
- if (document.visibilityState === "hidden" || focusRefresh)
471
+ if (document.visibilityState === "hidden" || focusRefresh || logoutInFlight)
417
472
  return;
418
473
  focusRefresh = withOriginLock("flow.id.refresh", async () => {
474
+ if (logoutInFlight)
475
+ return;
476
+ const request = ++sessionRequest;
419
477
  const response = await fetch(`${sessionPath}?refresh=1`, {
420
478
  headers: { Accept: "application/json" },
421
479
  });
@@ -423,8 +481,10 @@ export function createFlow(options = {}) {
423
481
  return;
424
482
  // SAFETY: the app session handler returns the SDK protocol response.
425
483
  const result = (await response.json());
484
+ if (request !== sessionRequest)
485
+ return;
426
486
  if (result.state)
427
- commitSession(result.state);
487
+ await commitSession(result.state);
428
488
  else
429
489
  await runLogout(store);
430
490
  })
@@ -456,6 +516,7 @@ export function createFlow(options = {}) {
456
516
  },
457
517
  login,
458
518
  openAccount,
519
+ switchAccounts,
459
520
  logout,
460
521
  restore: refreshViaSession,
461
522
  ensureGuest,
@@ -32,14 +32,19 @@ export function createProfileButton(options) {
32
32
  button.dataset.flowProfile = "";
33
33
  Object.assign(button.style, {
34
34
  display: "flex",
35
+ boxSizing: "border-box",
36
+ height: "var(--flow-profile-height, 2.25rem)",
37
+ flexShrink: "0",
35
38
  alignItems: "center",
36
39
  gap: "8px",
37
40
  border: "0",
38
- borderRadius: "999px",
39
- padding: "10px 16px",
40
- background: "Canvas",
41
+ borderRadius: "0",
42
+ padding: "0.25rem 0.75rem",
43
+ background: "transparent",
41
44
  color: "CanvasText",
42
45
  font: "inherit",
46
+ fontSize: "0.875rem",
47
+ lineHeight: "1.25rem",
43
48
  cursor: "pointer",
44
49
  pointerEvents: "auto",
45
50
  maxWidth: "240px",
@@ -59,14 +64,21 @@ export function createProfileButton(options) {
59
64
  display: "flex",
60
65
  alignItems: "center",
61
66
  justifyContent: "center",
62
- width: "28px",
63
- height: "28px",
64
- borderRadius: "50%",
67
+ width: "calc(var(--flow-profile-height, 2.25rem) - 0.5rem)",
68
+ height: "calc(var(--flow-profile-height, 2.25rem) - 0.5rem)",
69
+ borderRadius: "var(--squircle-radius, 35%)",
70
+ // biome-ignore lint/plugin: CSSStyleDeclaration requires the browser's property name.
71
+ cornerShape: "superellipse(1.6)",
65
72
  overflow: "hidden",
66
73
  background: "color-mix(in srgb, currentColor 10%, transparent)",
67
74
  flexShrink: "0",
68
75
  });
69
76
  const name = document.createElement("span");
77
+ Object.assign(name.style, {
78
+ minWidth: "0",
79
+ overflow: "hidden",
80
+ textOverflow: "ellipsis",
81
+ });
70
82
  button.append(avatar, name);
71
83
  const fallback = document.createElement("a");
72
84
  fallback.href = `${host}/account`;
@@ -28,12 +28,12 @@ export async function restoreCredential(store) {
28
28
  */
29
29
  export async function runLogout(store) {
30
30
  const state = store.getSnapshot();
31
+ store.setState(initialFlowState);
31
32
  if (state.address) {
32
33
  const { clearAccessKey } = await import("./access-key");
33
34
  await clearAccessKey(state.address);
34
35
  }
35
36
  await idb.delete(CREDENTIAL_KEY);
36
- store.setState(initialFlowState);
37
37
  }
38
38
  /**
39
39
  * Derives the user's wallet address from their passkey public key by
@@ -45,6 +45,7 @@ export function createStaticFlow(state, options = {}) {
45
45
  },
46
46
  login: () => unavailable("login"),
47
47
  openAccount: () => unavailable("openAccount"),
48
+ switchAccounts: () => unavailable("switchAccounts"),
48
49
  logout: () => unavailable("logout"),
49
50
  restore: async () => false,
50
51
  ensureGuest: () => unavailable("ensureGuest"),
@@ -651,6 +651,173 @@ export declare const session: import("drizzle-orm/pg-core").PgTableWithColumns<{
651
651
  };
652
652
  dialect: "pg";
653
653
  }>;
654
+ export declare const browserAccountSet: import("drizzle-orm/pg-core").PgTableWithColumns<{
655
+ name: "browser_account_set";
656
+ schema: undefined;
657
+ columns: {
658
+ initialized: import("drizzle-orm/pg-core").PgColumn<{
659
+ name: "initialized";
660
+ tableName: "browser_account_set";
661
+ dataType: "boolean";
662
+ columnType: "PgBoolean";
663
+ data: boolean;
664
+ driverParam: boolean;
665
+ notNull: true;
666
+ hasDefault: true;
667
+ isPrimaryKey: false;
668
+ isAutoincrement: false;
669
+ hasRuntimeDefault: false;
670
+ enumValues: undefined;
671
+ baseColumn: never;
672
+ identity: undefined;
673
+ generated: undefined;
674
+ }, {}, {}>;
675
+ id: import("drizzle-orm/pg-core").PgColumn<{
676
+ name: "id";
677
+ tableName: "browser_account_set";
678
+ dataType: "string";
679
+ columnType: "PgText";
680
+ data: string;
681
+ driverParam: string;
682
+ notNull: true;
683
+ hasDefault: false;
684
+ isPrimaryKey: true;
685
+ isAutoincrement: false;
686
+ hasRuntimeDefault: false;
687
+ enumValues: [string, ...string[]];
688
+ baseColumn: never;
689
+ identity: undefined;
690
+ generated: undefined;
691
+ }, {}, {}>;
692
+ activeUserId: import("drizzle-orm/pg-core").PgColumn<{
693
+ name: "active_user_id";
694
+ tableName: "browser_account_set";
695
+ dataType: "string";
696
+ columnType: "PgText";
697
+ data: string;
698
+ driverParam: string;
699
+ notNull: false;
700
+ hasDefault: false;
701
+ isPrimaryKey: false;
702
+ isAutoincrement: false;
703
+ hasRuntimeDefault: false;
704
+ enumValues: [string, ...string[]];
705
+ baseColumn: never;
706
+ identity: undefined;
707
+ generated: undefined;
708
+ }, {}, {}>;
709
+ updatedAt: import("drizzle-orm/pg-core").PgColumn<{
710
+ name: "updated_at";
711
+ tableName: "browser_account_set";
712
+ dataType: "date";
713
+ columnType: "PgTimestamp";
714
+ data: Date;
715
+ driverParam: string;
716
+ notNull: true;
717
+ hasDefault: true;
718
+ isPrimaryKey: false;
719
+ isAutoincrement: false;
720
+ hasRuntimeDefault: false;
721
+ enumValues: undefined;
722
+ baseColumn: never;
723
+ identity: undefined;
724
+ generated: undefined;
725
+ }, {}, {}>;
726
+ };
727
+ dialect: "pg";
728
+ }>;
729
+ export declare const browserAccountMember: import("drizzle-orm/pg-core").PgTableWithColumns<{
730
+ name: "browser_account_member";
731
+ schema: undefined;
732
+ columns: {
733
+ setId: import("drizzle-orm/pg-core").PgColumn<{
734
+ name: "set_id";
735
+ tableName: "browser_account_member";
736
+ dataType: "string";
737
+ columnType: "PgText";
738
+ data: string;
739
+ driverParam: string;
740
+ notNull: true;
741
+ hasDefault: false;
742
+ isPrimaryKey: false;
743
+ isAutoincrement: false;
744
+ hasRuntimeDefault: false;
745
+ enumValues: [string, ...string[]];
746
+ baseColumn: never;
747
+ identity: undefined;
748
+ generated: undefined;
749
+ }, {}, {}>;
750
+ userId: import("drizzle-orm/pg-core").PgColumn<{
751
+ name: "user_id";
752
+ tableName: "browser_account_member";
753
+ dataType: "string";
754
+ columnType: "PgText";
755
+ data: string;
756
+ driverParam: string;
757
+ notNull: true;
758
+ hasDefault: false;
759
+ isPrimaryKey: false;
760
+ isAutoincrement: false;
761
+ hasRuntimeDefault: false;
762
+ enumValues: [string, ...string[]];
763
+ baseColumn: never;
764
+ identity: undefined;
765
+ generated: undefined;
766
+ }, {}, {}>;
767
+ sessionId: import("drizzle-orm/pg-core").PgColumn<{
768
+ name: "session_id";
769
+ tableName: "browser_account_member";
770
+ dataType: "string";
771
+ columnType: "PgText";
772
+ data: string;
773
+ driverParam: string;
774
+ notNull: true;
775
+ hasDefault: false;
776
+ isPrimaryKey: false;
777
+ isAutoincrement: false;
778
+ hasRuntimeDefault: false;
779
+ enumValues: [string, ...string[]];
780
+ baseColumn: never;
781
+ identity: undefined;
782
+ generated: undefined;
783
+ }, {}, {}>;
784
+ userEpoch: import("drizzle-orm/pg-core").PgColumn<{
785
+ name: "user_epoch";
786
+ tableName: "browser_account_member";
787
+ dataType: "number";
788
+ columnType: "PgInteger";
789
+ data: number;
790
+ driverParam: string | number;
791
+ notNull: true;
792
+ hasDefault: false;
793
+ isPrimaryKey: false;
794
+ isAutoincrement: false;
795
+ hasRuntimeDefault: false;
796
+ enumValues: undefined;
797
+ baseColumn: never;
798
+ identity: undefined;
799
+ generated: undefined;
800
+ }, {}, {}>;
801
+ lastUsedAt: import("drizzle-orm/pg-core").PgColumn<{
802
+ name: "last_used_at";
803
+ tableName: "browser_account_member";
804
+ dataType: "date";
805
+ columnType: "PgTimestamp";
806
+ data: Date;
807
+ driverParam: string;
808
+ notNull: true;
809
+ hasDefault: true;
810
+ isPrimaryKey: false;
811
+ isAutoincrement: false;
812
+ hasRuntimeDefault: false;
813
+ enumValues: undefined;
814
+ baseColumn: never;
815
+ identity: undefined;
816
+ generated: undefined;
817
+ }, {}, {}>;
818
+ };
819
+ dialect: "pg";
820
+ }>;
654
821
  export declare const refreshToken: import("drizzle-orm/pg-core").PgTableWithColumns<{
655
822
  name: "refresh_token";
656
823
  schema: undefined;
@@ -79,6 +79,25 @@ export const session = pgTable("session", {
79
79
  createdAt: timestamp("created_at").notNull().defaultNow(),
80
80
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
81
81
  }, (table) => [index("session_userId_idx").on(table.userId)]);
82
+ export const browserAccountSet = pgTable("browser_account_set", {
83
+ initialized: boolean("initialized").notNull().default(false),
84
+ id: text("id").primaryKey(),
85
+ activeUserId: text("active_user_id"),
86
+ updatedAt: timestamp("updated_at").notNull().defaultNow(),
87
+ });
88
+ export const browserAccountMember = pgTable("browser_account_member", {
89
+ setId: text("set_id")
90
+ .notNull()
91
+ .references(() => browserAccountSet.id, { onDelete: "cascade" }),
92
+ userId: text("user_id")
93
+ .notNull()
94
+ .references(() => user.id, { onDelete: "cascade" }),
95
+ sessionId: text("session_id")
96
+ .notNull()
97
+ .references(() => session.id, { onDelete: "cascade" }),
98
+ userEpoch: integer("user_epoch").notNull(),
99
+ lastUsedAt: timestamp("last_used_at").notNull().defaultNow(),
100
+ }, (table) => [primaryKey({ columns: [table.setId, table.userId] })]);
82
101
  // First-party session persistence: consumer apps store the opaque token in their
83
102
  // OWN origin and mint access JWTs via POST /api/session/refresh — no third-party
84
103
  // id.flow.industries cookie, so it survives iOS ITP. Rotated on every use.
@@ -32,7 +32,8 @@ const installBodySchema = z.object({
32
32
  refreshToken: z.string().min(1).optional(),
33
33
  jwt: z.string().min(1).optional(),
34
34
  mint: z.array(z.string()).optional(),
35
- action: z.enum(["login", "account", "guest"]).optional(),
35
+ action: z.enum(["login", "account", "switch", "guest"]).optional(),
36
+ intent: z.enum(["login", "manage", "switch", "add"]).optional(),
36
37
  returnTo: z.string().optional(),
37
38
  signUp: z.boolean().optional(),
38
39
  accessKeyHash: z
@@ -95,7 +96,11 @@ export async function handleSessionRequest(request, opts = {}) {
95
96
  case "GET": {
96
97
  if (callback.searchParams.get("action") === "account") {
97
98
  try {
98
- return await startBrowserSession(request, audience, issuerUrl, { action: "account", redirect: true }, apiUrl);
99
+ return await startBrowserSession(request, audience, issuerUrl, {
100
+ action: "account",
101
+ redirect: true,
102
+ returnTo: callback.searchParams.get("returnTo") ?? "/",
103
+ }, apiUrl);
99
104
  }
100
105
  catch {
101
106
  return json({ state: null }, 502);
@@ -134,7 +139,9 @@ export async function handleSessionRequest(request, opts = {}) {
134
139
  case "POST": {
135
140
  const parsed = installBodySchema.safeParse(await request.json().catch(() => null));
136
141
  const body = parsed.success ? parsed.data : null;
137
- if (body?.action === "login" || body?.action === "account") {
142
+ if (body?.action === "login" ||
143
+ body?.action === "account" ||
144
+ body?.action === "switch") {
138
145
  try {
139
146
  return await startBrowserSession(request, audience, issuerUrl, {
140
147
  ...body,
@@ -176,6 +176,7 @@ export type AccountRegistration = {
176
176
  credential: FlowCredential | null;
177
177
  };
178
178
  export type AuthorizationTransaction = {
179
+ intent: "login" | "manage" | "switch" | "add";
179
180
  expiresAt: number;
180
181
  accessKeyHash?: string;
181
182
  credentialId?: string;
@@ -0,0 +1,9 @@
1
+ export interface BrowserAccount {
2
+ id: string;
3
+ username: string;
4
+ image: string | null;
5
+ }
6
+ export interface BrowserAccounts {
7
+ accounts: BrowserAccount[];
8
+ activeUserId: string | null;
9
+ }
File without changes
@@ -2,6 +2,7 @@ export type { AccountDeleteRequest, AccountDeleteResponse, AccountErasureCounts,
2
2
  export type { ActionCurve, ActionEventRequest, ActionEventResponse, ActionsApi, ActiveActionState, FinishActionRequest, ListStudySessionsOptions, ListStudySubjectsOptions, StartActionInput, StartActionResult, StudyApi, StudySubjectResponse, UseActiveActionOptions, XpEstimateContext, } from "./actions";
3
3
  export type { AtprotoCompletion, AtprotoStartRequest, AtprotoStatus, } from "./atproto";
4
4
  export type { AccountRegistration, AccountSession, AccountSessionsResponse, AdditionalSession, AuthConfig, AuthorizationTransaction, AuthPageBootstrap, AuthResponse, AuthResponseWithWebAuthn, BioNormalization, BioRejection, BioWriteRefusal, BioWriteResult, FlowCredential, FlowUser, PasskeyEnrollment, PasskeyPluginOptions, RevokeAllSessionsResponse, RevokeSessionResponse, SecurityActivityEntry, SecurityActivityResponse, SessionUser, VerifiedFlowJWT, VerifyOptions, WebAuthnSignature, } from "./auth";
5
+ export type { BrowserAccount, BrowserAccounts } from "./browser-accounts";
5
6
  export type { BodyRegion, CosmeticItem, CosmeticMaterial, CosmeticPaint, CosmeticSlot, EquipConflict, EquippedCosmetic, EquippedCosmetics, EquipRegion, PaintSlot, SettingsConflict, } from "./cosmetics";
6
7
  export { BODY_REGIONS, COSMETIC_MATERIALS } from "./cosmetics";
7
8
  export type { AccountDataDownload, AccountDataIdentity, BoundaryError, DialogCustomFeatures, DialogCustomLabels, DialogError, DialogReferrer, DialogState, Loadable, ProfileData, ProfileIdentity, SignInWithEmailProps, SignUpProps, } from "./dialog";
@@ -332,6 +332,7 @@ export type Flow = {
332
332
  readonly isGuest: boolean;
333
333
  login(options?: LoginOptions): Promise<never>;
334
334
  openAccount(): boolean;
335
+ switchAccounts(options?: Pick<LoginOptions, "returnTo">): Promise<never>;
335
336
  logout(): Promise<void>;
336
337
  restore(): Promise<boolean>;
337
338
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flow-industries/id",
3
- "version": "0.24.4",
3
+ "version": "0.25.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",