@flow-industries/id 0.22.1 → 0.23.1

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,4 +1,8 @@
1
1
  import { z } from "zod";
2
+ import { browserCompletionSchema } from "./browser-contract";
3
+ import { finishBrowserSession, startBrowserSession, } from "./browser-session-route";
4
+ import { clearCookieString } from "./cookies";
5
+ import { fetchIssuerApi, resolveIssuerApiUrl } from "./issuer-api";
2
6
  /**
3
7
  * The first-party session route every consumer app serves from its own
4
8
  * origin — the only path between a browser and the refresh token. The
@@ -21,13 +25,20 @@ import { z } from "zod";
21
25
  */
22
26
  import { cookieNamesFor, parseCookieHeader } from "./cookies";
23
27
  import { DEFAULT_SESSION_PATH, defaultAudience, resolveIdHost, } from "./id-host";
24
- import { claimsToUser, clearingCookies, isJwtVerificationFailure, resolveSessionCore, sessionCookies, } from "./session-core";
28
+ import { claimsToUser, clearingCookies, isJwtVerificationFailure, refreshSessionCore, resolveSessionCore, sessionCookies, } from "./session-core";
25
29
  import { verifyFlowJWT } from "./verify";
26
30
  /** The body the browser SDK posts to its own app's session route. */
27
31
  const installBodySchema = z.object({
28
32
  refreshToken: z.string().min(1).optional(),
29
33
  jwt: z.string().min(1).optional(),
30
34
  mint: z.array(z.string()).optional(),
35
+ action: z.enum(["login", "account", "guest"]).optional(),
36
+ returnTo: z.string().optional(),
37
+ signUp: z.boolean().optional(),
38
+ accessKeyHash: z
39
+ .string()
40
+ .regex(/^0x[a-fA-F0-9]{64}$/)
41
+ .optional(),
31
42
  });
32
43
  const additionalBodySchema = z.object({
33
44
  additionalSessions: z
@@ -72,17 +83,105 @@ function isCrossSite(request, audience) {
72
83
  export async function handleSessionRequest(request, opts = {}) {
73
84
  const audience = opts.audience ?? defaultAudience(new URL(request.url).origin);
74
85
  const issuerUrl = resolveIdHost(opts.issuerUrl, audience);
86
+ const apiUrl = resolveIssuerApiUrl(issuerUrl, opts.apiUrl);
87
+ const callback = new URL(request.url);
88
+ if (request.method === "GET" && callback.searchParams.has("flow_state")) {
89
+ return finishBrowserSession(request, audience, issuerUrl, apiUrl);
90
+ }
75
91
  if (isCrossSite(request, audience)) {
76
92
  return json({ state: null }, 403);
77
93
  }
78
94
  switch (request.method) {
79
95
  case "GET": {
80
- const { session, setCookies } = await resolveSessionCore(request.headers.get("Cookie"), audience, issuerUrl);
81
- return json({ state: session }, 200, setCookies);
96
+ if (new URL(request.url).searchParams.get("refresh") === "1") {
97
+ const token = parseCookieHeader(request.headers.get("Cookie"))[cookieNamesFor(audience).refresh];
98
+ if (!token)
99
+ return json({ state: null }, 200);
100
+ const result = await refreshSessionCore(issuerUrl, audience, token, apiUrl);
101
+ if (result === "dead")
102
+ return json({ state: null }, 200, clearingCookies(audience));
103
+ if (!result)
104
+ return json({ state: null }, 502);
105
+ return json({ state: result.session }, 200, result.setCookies);
106
+ }
107
+ const { session, setCookies } = await resolveSessionCore(request.headers.get("Cookie"), audience, issuerUrl, apiUrl);
108
+ const completionName = `${cookieNamesFor(audience).refresh}.completion`;
109
+ const raw = parseCookieHeader(request.headers.get("Cookie"))[completionName];
110
+ let loginCompletion;
111
+ if (raw) {
112
+ setCookies.push(clearCookieString(completionName, {
113
+ secure: audience.startsWith("https:"),
114
+ }));
115
+ try {
116
+ const parsed = browserCompletionSchema.safeParse(JSON.parse(decodeURIComponent(atob(raw))));
117
+ if (parsed.success &&
118
+ parsed.data.expires > Date.now() &&
119
+ parsed.data.userId === session?.user.id)
120
+ loginCompletion = parsed.data;
121
+ }
122
+ catch { }
123
+ }
124
+ return json({ state: session, loginCompletion }, 200, setCookies);
82
125
  }
83
126
  case "POST": {
84
127
  const parsed = installBodySchema.safeParse(await request.json().catch(() => null));
85
128
  const body = parsed.success ? parsed.data : null;
129
+ if (body?.action === "login" || body?.action === "account") {
130
+ try {
131
+ return await startBrowserSession(request, audience, issuerUrl, {
132
+ ...body,
133
+ action: body.action,
134
+ }, apiUrl);
135
+ }
136
+ catch {
137
+ return json({ state: null }, 502);
138
+ }
139
+ }
140
+ if (body?.action === "guest") {
141
+ const existing = await resolveSessionCore(request.headers.get("Cookie"), audience, issuerUrl, apiUrl);
142
+ if (existing.session)
143
+ return json({ state: existing.session }, 200, existing.setCookies);
144
+ const previousRefresh = parseCookieHeader(request.headers.get("Cookie"))[cookieNamesFor(audience).refresh];
145
+ if (previousRefresh && existing.setCookies.length === 0)
146
+ return json({ state: null }, 502);
147
+ try {
148
+ const headers = new Headers({ "Content-Type": "application/json" });
149
+ for (const name of [
150
+ "cf-connecting-ip",
151
+ "x-forwarded-for",
152
+ "user-agent",
153
+ ]) {
154
+ const value = request.headers.get(name);
155
+ if (value)
156
+ headers.set(name, value);
157
+ }
158
+ const result = await fetchIssuerApi(`${apiUrl}/api/auth/passkey/guest`, {
159
+ method: "POST",
160
+ headers,
161
+ body: JSON.stringify({ audience }),
162
+ });
163
+ if (!result.ok)
164
+ return json({ state: null }, result.status);
165
+ // SAFETY: the configured issuer returns this session token pair, verified below.
166
+ const data = (await result.json());
167
+ const claims = await verifyFlowJWT(data.jwt, {
168
+ audience,
169
+ issuerUrl,
170
+ apiUrl,
171
+ });
172
+ return json({
173
+ state: {
174
+ user: claimsToUser(claims),
175
+ jwt: data.jwt,
176
+ credential: null,
177
+ address: null,
178
+ },
179
+ }, 200, sessionCookies(audience, data.jwt, data.refreshToken));
180
+ }
181
+ catch {
182
+ return json({ state: null }, 502);
183
+ }
184
+ }
86
185
  // Reconcile mint: the browser asks its own server to obtain additional-
87
186
  // audience sessions (e.g. talk.flow.game) authorized by the PRIMARY
88
187
  // refresh cookie, for an embed that booted with no session of its own.
@@ -95,7 +194,7 @@ export async function handleSessionRequest(request, opts = {}) {
95
194
  let additionalSessions = [];
96
195
  if (token && audiences.length > 0) {
97
196
  try {
98
- const res = await fetch(`${issuerUrl}/api/session/additional`, {
197
+ const res = await fetchIssuerApi(`${apiUrl}/api/session/additional`, {
99
198
  method: "POST",
100
199
  headers: {
101
200
  "Content-Type": "application/json",
@@ -123,7 +222,11 @@ export async function handleSessionRequest(request, opts = {}) {
123
222
  // for another app from being installed here; a failed install must not
124
223
  // clear cookies that may hold a live session.
125
224
  try {
126
- const claims = await verifyFlowJWT(jwt, { audience, issuerUrl });
225
+ const claims = await verifyFlowJWT(jwt, {
226
+ audience,
227
+ issuerUrl,
228
+ apiUrl,
229
+ });
127
230
  return json({ state: { user: claimsToUser(claims), jwt } }, 200, sessionCookies(audience, jwt, refreshToken));
128
231
  }
129
232
  catch (err) {
@@ -137,12 +240,16 @@ export async function handleSessionRequest(request, opts = {}) {
137
240
  // an unreachable issuer just leaves the lineage to expire on its own.
138
241
  if (token) {
139
242
  try {
140
- await fetch(`${issuerUrl}/api/session/revoke`, {
243
+ const response = await fetchIssuerApi(`${apiUrl}/api/session/revoke`, {
141
244
  method: "POST",
142
245
  headers: { Authorization: `Bearer ${token}` },
143
246
  });
247
+ if (!response.ok)
248
+ return json({ state: null }, 502);
249
+ }
250
+ catch {
251
+ return json({ state: null }, 502);
144
252
  }
145
- catch { }
146
253
  }
147
254
  const headers = new Headers({ "Cache-Control": "no-store" });
148
255
  for (const cookie of clearingCookies(audience)) {
@@ -99,6 +99,8 @@ export type PasskeyPluginOptions = {
99
99
  export type VerifyOptions = {
100
100
  audience: string;
101
101
  issuerUrl?: string;
102
+ /** Trusted server API origin; defaults to FLOW_ID_API_URL, then the public issuer. */
103
+ apiUrl?: string;
102
104
  };
103
105
  /**
104
106
  * One entry in the signed-in user's session list (`GET /api/account/sessions`).
@@ -1,4 +1,3 @@
1
- import type { RpcRequest } from "./protocol";
2
1
  /**
3
2
  * Async view state for a dialog panel — the three states every fetched
4
3
  * section renders (spinner / retry row / content), so "empty" stays
@@ -91,7 +90,6 @@ export type DialogState = {
91
90
  customLabels?: DialogCustomLabels;
92
91
  };
93
92
  export type SignUpProps = {
94
- request: RpcRequest;
95
93
  rpId: string;
96
94
  accessKeyHash?: string | undefined;
97
95
  guestToken?: string | undefined;
@@ -99,7 +97,6 @@ export type SignUpProps = {
99
97
  onSignIn: () => void;
100
98
  };
101
99
  export type SignInWithEmailProps = {
102
- request: RpcRequest;
103
100
  onBack: () => void;
104
101
  onHome: () => void;
105
102
  };
@@ -16,7 +16,7 @@ export { getConnectCapabilities, isPersonalSignParams, isSendCallsParams, isSend
16
16
  export type { ReportOperation, ReportRequest, ReportResponse, ReportsApi, ReportTransport, } from "./reports";
17
17
  export type { CreateRoomEventInput, RoomEventArchive, RoomEventAttendance, RoomEventFrequency, RoomEventInstance, RoomEventInterest, RoomEventInterestState, RoomEventList, RoomEventOccurrence, RoomEventRecurrence, RoomEventRoomRef, RoomEventRun, RoomEventSeries, RoomEventStatus, SetInterestInput, UpdateRoomEventInput, } from "./room-events";
18
18
  export type { CreateRoomInput, GlobalRole, ModerationSubject, MyRooms, PlayerPositionReport, RoleChangeVerdict, RoomCapabilities, RoomCapability, RoomChannel, RoomDetail, RoomList, RoomMemberEntry, RoomMembers, RoomOccupancy, RoomOwner, RoomPlayerPosition, RoomPresenceEntry, RoomPresenceSnapshot, RoomRestrictionKind, RoomRole, RoomSummary, RoomSurfaceSettings, RoomsApi, RoomVisibility, StaffEntry, UpdateRoomInput, VerifyRoomContext, Viewer, VoiceDecisionReason, VoiceDecisionSubject, VoiceRoomDecision, } from "./rooms";
19
- export type { AccessKeyOptions, AccessKeyPreparation, Address, ColorScheme, CreateDialogHostOptions, CreateFlowOptions, DialogHost, DialogOpenOptions, FinalizeAccessKeyParams, Flow, FlowConnectorParameters, FlowCookieNames, FlowIdProviderProps, FlowSessionState, FlowState, FlowWidgetHandle, FlowWidgetName, FlowWidgetProps, Listener, LoginOptions, MountFlowWidgetOptions, MountProfileOptions, OpenProfileOptions, PrepareArgsWithAuth, PrepareTransactionRequestPhase, ProfileButtonHandle, ProfileButtonProps, ProfilePosition, ResolveAccountParams, ResolvedAccessKeyOptions, RootCredential, RunLoginParams, RunLoginResult, SendCallsArgs, SendTransactionArgs, Session, SigningContext, SignMessageArgs, SignTypedDataArgs, Store, StoredAccessKey, UseOpenProfileOptions, WagmiConnectCapabilities, WagmiConnectParams, } from "./sdk";
19
+ export type { AccessKeyOptions, AccessKeyPreparation, Address, ColorScheme, CreateDialogHostOptions, CreateFlowOptions, DialogHost, DialogOpenOptions, FinalizeAccessKeyParams, Flow, FlowConnectorParameters, FlowCookieNames, FlowIdProviderProps, FlowSessionState, FlowState, FlowWidgetHandle, FlowWidgetName, FlowWidgetProps, Listener, LoginOptions, MountFlowWidgetOptions, MountProfileOptions, OpenProfileOptions, PrepareArgsWithAuth, PrepareTransactionRequestPhase, ProfileButtonHandle, ProfileButtonProps, ProfilePosition, ResolveAccountParams, ResolvedAccessKeyOptions, RootCredential, SendCallsArgs, SendTransactionArgs, Session, SigningContext, SignMessageArgs, SignTypedDataArgs, Store, StoredAccessKey, UseOpenProfileOptions, WagmiConnectCapabilities, WagmiConnectParams, } from "./sdk";
20
20
  export type { GracefulShutdownCleanup, GracefulShutdownOptions, GracefulShutdownServer, GracefulShutdownSignal, ResolvedFlowSession, ResolveSessionOptions, SessionRouteOptions, SessionRouteResponse, SessionRouteSession, } from "./server";
21
21
  export type { CosmeticOffer, PaintDescriptor, SettingDescriptor, Settings, SettingsMigration, StoredSettings, SurfaceRegistry, } from "./settings";
22
22
  export type { CreateStudySubjectInput, ListStudySessionsInput, StudyCatalog, StudyCategory, StudyCategoryTotals, StudyField, StudyRange, StudySessionEntry, StudySessionPage, StudySessionsResult, StudySubject, StudySubjectCreation, StudySubjectList, StudySubjectRefusal, StudySubjectRemoval, StudySubjectRemovalResponse, StudySubjectTotals, StudySubjectUpdate, StudySummary, UpdateStudySubjectInput, } from "./study";
@@ -1,5 +1,6 @@
1
1
  import type { FlowUser } from "./auth";
2
2
  export type AccountProfileData = FlowUser & {
3
+ bio?: string | null;
3
4
  email?: string | null;
4
5
  emailVerified?: boolean;
5
6
  subscribedToNews?: boolean;
@@ -3,7 +3,7 @@ import type { Chain, Hex, PrepareTransactionRequestParameters, SendTransactionPa
3
3
  import type { SendCallsParameters } from "viem/actions";
4
4
  import type { RawValue } from "../json";
5
5
  import type { AdditionalSession, FlowCredential, FlowUser, WebAuthnSignature } from "./auth";
6
- import type { ConnectCapabilities, ConnectResponse } from "./protocol";
6
+ import type { ConnectCapabilities } from "./protocol";
7
7
  import type { RoomsApi } from "./rooms";
8
8
  export type Address = `0x${string}`;
9
9
  /**
@@ -133,10 +133,9 @@ export type FlowCookieNames = {
133
133
  refresh: string;
134
134
  };
135
135
  export type LoginOptions = {
136
- mode?: "iframe" | "popup";
136
+ returnTo?: string;
137
137
  signUp?: boolean;
138
138
  signIn?: boolean;
139
- signInHeadless?: boolean;
140
139
  };
141
140
  export type Session = {
142
141
  user: FlowUser;
@@ -197,7 +196,7 @@ export type ColorScheme = "light" | "dark" | "light dark";
197
196
  export type ProfilePosition = "top-right" | "top-left" | "bottom-right" | "bottom-left";
198
197
  export type MountProfileOptions = {
199
198
  /**
200
- * Element to mount the pill iframe into. When omitted, a fixed-position
199
+ * Element to mount the native account button into. When omitted, a fixed-position
201
200
  * container is created and anchored via `position` — the path of least
202
201
  * resistance for non-DOM hosts (the Godot canvas overlay).
203
202
  */
@@ -221,12 +220,10 @@ export type MountProfileOptions = {
221
220
  };
222
221
  export type ProfileButtonHandle = {
223
222
  /**
224
- * Recolors the mounted pill (and its profile dialog) in place. A host with a
225
- * theme toggle calls this instead of remounting: rebuilding the iframe would
226
- * reload the widget and flash the pill out of the layout on every toggle.
223
+ * Recolors the mounted account button without remounting it.
227
224
  */
228
225
  setTheme: (theme: ColorScheme) => void;
229
- /** Tears down the iframe, listeners, and any auto-created container. */
226
+ /** Tears down the button, listeners, and any auto-created container. */
230
227
  destroy: () => void;
231
228
  };
232
229
  export type OpenProfileOptions = {
@@ -332,7 +329,8 @@ export type Flow = {
332
329
  * actions on `!isGuest`, not on `isAuthenticated`.
333
330
  */
334
331
  readonly isGuest: boolean;
335
- login(options?: LoginOptions): Promise<Session>;
332
+ login(options?: LoginOptions): Promise<never>;
333
+ openAccount(): boolean;
336
334
  logout(): Promise<void>;
337
335
  restore(): Promise<boolean>;
338
336
  /**
@@ -413,18 +411,6 @@ export type ProfileButtonProps = {
413
411
  /** Color scheme for the widget chrome; defaults to "light dark" (the OS). */
414
412
  theme?: ColorScheme;
415
413
  };
416
- export type RunLoginParams = {
417
- dialog: DialogHost;
418
- store: Store<FlowState>;
419
- options?: LoginOptions;
420
- extraCapabilities?: ConnectCapabilities;
421
- };
422
- export type RunLoginResult = {
423
- session: Session;
424
- refreshToken: string;
425
- webauthn: ConnectResponse["webauthn"];
426
- additionalSessions?: AdditionalSession[];
427
- };
428
414
  export type FinalizeAccessKeyParams = {
429
415
  address: Address;
430
416
  credential: FlowCredential;
@@ -6,6 +6,8 @@ export type ResolveSessionOptions = {
6
6
  audience: string;
7
7
  /** Flow ID origin; defaults to `https://id.flow.industries`. */
8
8
  issuerUrl?: string;
9
+ /** Trusted server API origin; defaults to FLOW_ID_API_URL, then the public issuer. */
10
+ apiUrl?: string;
9
11
  /**
10
12
  * Also fetch the public profile (avatar, tier, level) for a resolved
11
13
  * session via `/api/session/verify` — one extra round-trip; off by default.
@@ -36,6 +38,8 @@ export type SessionRouteOptions = {
36
38
  audience?: string;
37
39
  /** Flow ID origin; defaults per `defaultIdHost` (`FLOW_ID_HOST` on servers). */
38
40
  issuerUrl?: string;
41
+ /** Trusted server API origin; defaults to FLOW_ID_API_URL, then the public issuer. */
42
+ apiUrl?: string;
39
43
  /** Route path for `createSessionHandler`'s own path check. */
40
44
  path?: string;
41
45
  };
@@ -53,6 +57,12 @@ export type SessionRouteSession = {
53
57
  };
54
58
  export type SessionRouteResponse = {
55
59
  state: SessionRouteSession | null;
60
+ loginCompletion?: {
61
+ loginId: string;
62
+ userId: string;
63
+ expires: number;
64
+ webauthn?: import("./auth").WebAuthnSignature;
65
+ };
56
66
  /**
57
67
  * Additional-audience sessions minted by a reconcile request (POST `{ mint }`)
58
68
  * — one per allowed audience the caller asked to provision. The consumer
@@ -1,4 +1,5 @@
1
- import { createRemoteJWKSet, jwtVerify } from "jose";
1
+ import { createRemoteJWKSet, customFetch, jwtVerify } from "jose";
2
+ import { fetchIssuerApi, resolveIssuerApiUrl } from "./issuer-api";
2
3
  const jwksCache = new Map();
3
4
  const DEFAULT_ISSUER_URL = "https://id.flow.industries";
4
5
  /**
@@ -22,10 +23,14 @@ const DEFAULT_ISSUER_URL = "https://id.flow.industries";
22
23
  */
23
24
  export async function verifyFlowJWT(token, opts) {
24
25
  const issuerUrl = opts.issuerUrl ?? DEFAULT_ISSUER_URL;
25
- let jwks = jwksCache.get(issuerUrl);
26
+ const apiUrl = resolveIssuerApiUrl(issuerUrl, opts.apiUrl);
27
+ const cacheKey = `${issuerUrl}\n${apiUrl}`;
28
+ let jwks = jwksCache.get(cacheKey);
26
29
  if (!jwks) {
27
- jwks = createRemoteJWKSet(new URL(`${issuerUrl}/api/auth/jwks`));
28
- jwksCache.set(issuerUrl, jwks);
30
+ jwks = createRemoteJWKSet(new URL(`${apiUrl}/api/auth/jwks`), {
31
+ [customFetch]: fetchIssuerApi,
32
+ });
33
+ jwksCache.set(cacheKey, jwks);
29
34
  }
30
35
  const { payload } = await jwtVerify(token, jwks, {
31
36
  issuer: issuerUrl,
@@ -5,12 +5,9 @@ flowConnector.type = "flow";
5
5
  function loginOptionsFromCapabilities(capabilities) {
6
6
  const signUp = capabilities?.type === "sign-up" || capabilities?.createAccount;
7
7
  const signIn = capabilities?.type === "sign-in" || capabilities?.signIn;
8
- const headless = !signUp &&
9
- (capabilities?.signInHeadless || capabilities?.type === "sign-in");
10
8
  return {
11
9
  signUp: signUp ? true : undefined,
12
10
  signIn: !signUp && signIn ? true : undefined,
13
- signInHeadless: headless ? true : undefined,
14
11
  };
15
12
  }
16
13
  export function flowConnector(parameters = {}) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flow-industries/id",
3
- "version": "0.22.1",
3
+ "version": "0.23.1",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -66,9 +66,10 @@
66
66
  "scripts": {
67
67
  "dev": "./dev.sh",
68
68
  "build:dialog": "bunx vite build",
69
+ "build:legacy-dialog": "bunx vite build --config vite.legacy.config.ts",
69
70
  "build:playground": "bunx vite build --config vite.playground.config.ts",
70
71
  "build:landing": "bunx vite build --config vite.landing.config.ts",
71
- "build": "bunx vite build && bunx vite build --config vite.playground.config.ts && bunx vite build --config vite.landing.config.ts",
72
+ "build": "bun run build:legacy-dialog && bunx vite build && bunx vite build --config vite.playground.config.ts && bunx vite build --config vite.landing.config.ts",
72
73
  "build:sdk": "tsc --project tsconfig.sdk.json",
73
74
  "prepublishOnly": "bun run build:sdk",
74
75
  "start": "bun run src/index.ts",
@@ -84,7 +85,7 @@
84
85
  "test:sdk-package": "bash scripts/test-sdk-package.sh",
85
86
  "format": "biome format --write",
86
87
  "check": "biome check --write",
87
- "typecheck": "tsr generate && tsc --noEmit",
88
+ "typecheck": "tsr generate && (cd src/legacy-dialog && bunx tsr generate) && tsc --noEmit && tsc -p tsconfig.legacy.json --noEmit",
88
89
  "contracts:generate": "bun scripts/generate-contracts.ts",
89
90
  "contracts:check": "bun scripts/generate-contracts.ts --check && bun scripts/check-contract-compatibility.ts",
90
91
  "db:check": "bun scripts/db-check.ts",