@flow-industries/id 0.8.1 → 0.10.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/dist/sdk/client/create-flow.js +62 -34
- package/dist/sdk/client/dialog-host.js +0 -1
- package/dist/sdk/client/index.d.ts +3 -1
- package/dist/sdk/client/index.js +2 -0
- package/dist/sdk/client/profile-button.js +0 -1
- package/dist/sdk/client/refresh-store.d.ts +10 -1
- package/dist/sdk/client/refresh-store.js +44 -8
- package/dist/sdk/client/rooms.d.ts +14 -0
- package/dist/sdk/client/rooms.js +86 -0
- package/dist/sdk/client/static-flow.d.ts +14 -0
- package/dist/sdk/client/static-flow.js +65 -0
- package/dist/sdk/cookies.d.ts +45 -0
- package/dist/sdk/cookies.js +81 -0
- package/dist/sdk/react/hooks.d.ts +26 -1
- package/dist/sdk/react/hooks.js +78 -1
- package/dist/sdk/react/index.d.ts +1 -1
- package/dist/sdk/react/index.js +1 -1
- package/dist/sdk/server.d.ts +23 -0
- package/dist/sdk/server.js +200 -0
- package/dist/sdk/token-expiry.d.ts +5 -0
- package/dist/sdk/token-expiry.js +25 -0
- package/dist/sdk/types/events.d.ts +1 -1
- package/dist/sdk/types/index.d.ts +4 -2
- package/dist/sdk/types/index.js +1 -1
- package/dist/sdk/types/protocol.d.ts +6 -0
- package/dist/sdk/types/protocol.js +8 -0
- package/dist/sdk/types/rooms.d.ts +129 -0
- package/dist/sdk/types/rooms.js +1 -0
- package/dist/sdk/types/sdk.d.ts +43 -0
- package/dist/sdk/types/server.d.ts +28 -0
- package/dist/sdk/types/server.js +0 -0
- package/package.json +5 -1
|
@@ -1,35 +1,13 @@
|
|
|
1
|
+
import { clearBrowserCookie, cookieNamesFor, JWT_COOKIE_MAX_AGE_S, writeBrowserCookie, } from "../cookies";
|
|
2
|
+
import { isExpiring } from "../token-expiry";
|
|
1
3
|
import { createDialogHost } from "./dialog-host";
|
|
2
4
|
import { idb } from "./idb";
|
|
3
5
|
import { METHODS } from "./methods";
|
|
4
6
|
import { makeRefreshStore } from "./refresh-store";
|
|
7
|
+
import { createRoomsApi } from "./rooms";
|
|
5
8
|
import { credentialToAddress, restoreCredential, runLogin, runLogout, } from "./session";
|
|
6
9
|
import { createStore, initialFlowState } from "./store";
|
|
7
10
|
const DEFAULT_HOST = "https://id.flow.industries";
|
|
8
|
-
// Refresh a little before the JWT's `exp` so a token handed out by getToken()
|
|
9
|
-
// is still valid by the time it reaches the relying party, absorbing request
|
|
10
|
-
// latency and minor client/server clock skew.
|
|
11
|
-
const EXPIRY_SKEW_MS = 60_000;
|
|
12
|
-
/** Reads the `exp` (seconds since epoch) from a JWT, or null if unreadable. */
|
|
13
|
-
function jwtExp(token) {
|
|
14
|
-
const parts = token.split(".");
|
|
15
|
-
if (parts.length !== 3)
|
|
16
|
-
return null;
|
|
17
|
-
try {
|
|
18
|
-
const padded = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
19
|
-
const payload = JSON.parse(atob(padded));
|
|
20
|
-
return typeof payload.exp === "number" ? payload.exp : null;
|
|
21
|
-
}
|
|
22
|
-
catch {
|
|
23
|
-
return null;
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
/** True if the token is missing an exp, already expired, or within the skew. */
|
|
27
|
-
function isExpiring(token) {
|
|
28
|
-
const exp = jwtExp(token);
|
|
29
|
-
if (exp === null)
|
|
30
|
-
return true;
|
|
31
|
-
return Date.now() >= exp * 1000 - EXPIRY_SKEW_MS;
|
|
32
|
-
}
|
|
33
11
|
/**
|
|
34
12
|
* Runs `fn` while holding a cross-tab lock (Web Locks API) so concurrent tabs
|
|
35
13
|
* of the same origin serialize guest creation: the first tab mints and sets the
|
|
@@ -88,11 +66,22 @@ export function createFlow(options = {}) {
|
|
|
88
66
|
// and IDB writes while components/hooks that captured it keep reading
|
|
89
67
|
// stale state. For genuine multi-instance scenarios (tests), call
|
|
90
68
|
// resetFlow() first or pass explicit Flow instances.
|
|
69
|
+
if (typeof window === "undefined") {
|
|
70
|
+
throw new Error("createFlow() is browser-only (localStorage, IndexedDB, iframes). " +
|
|
71
|
+
"For server rendering, resolve state with resolveSession() from " +
|
|
72
|
+
"@flow-industries/id/server and render with createStaticFlow().");
|
|
73
|
+
}
|
|
91
74
|
if (currentFlow)
|
|
92
75
|
return currentFlow;
|
|
93
76
|
const host = (options.host ?? DEFAULT_HOST).replace(/\/+$/, "");
|
|
94
77
|
const dialogUrl = `${host}/dialog/`;
|
|
95
|
-
const
|
|
78
|
+
const cookieNames = options.cookies
|
|
79
|
+
? cookieNamesFor(window.location.origin)
|
|
80
|
+
: null;
|
|
81
|
+
const cookiesSecure = window.location.protocol === "https:";
|
|
82
|
+
const refreshStore = makeRefreshStore(host, cookieNames
|
|
83
|
+
? { name: cookieNames.refresh, secure: cookiesSecure }
|
|
84
|
+
: undefined);
|
|
96
85
|
const chains = options.chains ?? [];
|
|
97
86
|
const getChain = (chainId) => {
|
|
98
87
|
if (chainId == null)
|
|
@@ -105,6 +94,37 @@ export function createFlow(options = {}) {
|
|
|
105
94
|
};
|
|
106
95
|
const accessKeyOptions = resolveAccessKey(options.accessKey);
|
|
107
96
|
const store = createStore({ ...initialFlowState });
|
|
97
|
+
// One subscription mirrors the JWT into its first-party cookie on every
|
|
98
|
+
// commit path — refresh, guest mint, login, hydration — and clears it when
|
|
99
|
+
// logout resets the store. Keeping the mirror here (not in each path) means
|
|
100
|
+
// no mint site can forget it.
|
|
101
|
+
if (cookieNames) {
|
|
102
|
+
let mirroredJwt = null;
|
|
103
|
+
store.subscribe((s) => {
|
|
104
|
+
if (s.jwt === mirroredJwt)
|
|
105
|
+
return;
|
|
106
|
+
mirroredJwt = s.jwt;
|
|
107
|
+
if (s.jwt) {
|
|
108
|
+
writeBrowserCookie(cookieNames.jwt, s.jwt, {
|
|
109
|
+
maxAge: JWT_COOKIE_MAX_AGE_S,
|
|
110
|
+
secure: cookiesSecure,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
clearBrowserCookie(cookieNames.jwt, { secure: cookiesSecure });
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
// Seed synchronously so the first client render matches the SSR HTML —
|
|
119
|
+
// hooks read the hydrated user instead of flashing signed-out while a
|
|
120
|
+
// bootstrap refresh runs.
|
|
121
|
+
if (options.initialState) {
|
|
122
|
+
store.setState({
|
|
123
|
+
user: options.initialState.user,
|
|
124
|
+
jwt: options.initialState.jwt,
|
|
125
|
+
address: options.initialState.address ?? null,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
108
128
|
let dialog = null;
|
|
109
129
|
const getDialog = () => {
|
|
110
130
|
if (!dialog)
|
|
@@ -261,6 +281,11 @@ export function createFlow(options = {}) {
|
|
|
261
281
|
}
|
|
262
282
|
void (async () => {
|
|
263
283
|
await restoreCredential(store);
|
|
284
|
+
// A fresh hydrated JWT means the server already refreshed this request —
|
|
285
|
+
// running doRefresh() here would rotate a second time for nothing.
|
|
286
|
+
const hydrated = store.getSnapshot().jwt;
|
|
287
|
+
if (hydrated && !isExpiring(hydrated))
|
|
288
|
+
return;
|
|
264
289
|
if (options.autoRestore !== false) {
|
|
265
290
|
const restored = await doRefresh();
|
|
266
291
|
// ensureGuest re-checks (refresh-first) under a cross-tab lock before
|
|
@@ -309,14 +334,16 @@ export function createFlow(options = {}) {
|
|
|
309
334
|
}
|
|
310
335
|
// When the current session is a guest, forward its access token so a
|
|
311
336
|
// sign-up upgrades that guest in place even where the guest cookie is
|
|
312
|
-
// unreadable (the iOS top-level sign-up popup).
|
|
313
|
-
//
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
337
|
+
// unreadable (the iOS top-level sign-up popup). Mint via getToken() rather
|
|
338
|
+
// than reading store.jwt directly: the cached JWT can be up to an hour stale
|
|
339
|
+
// on a long-idle tab, and an expired token fails server verification — the
|
|
340
|
+
// guest would then be registered as a fresh account instead of upgraded.
|
|
341
|
+
// An app that never becomes a guest forwards nothing.
|
|
342
|
+
if (store.getSnapshot().user?.isGuest) {
|
|
343
|
+
const guestToken = await getToken();
|
|
344
|
+
if (guestToken) {
|
|
345
|
+
extraCapabilities = { ...(extraCapabilities ?? {}), guestToken };
|
|
346
|
+
}
|
|
320
347
|
}
|
|
321
348
|
const { session, webauthn, refreshToken } = await runLogin({
|
|
322
349
|
dialog: dialogHost,
|
|
@@ -392,6 +419,7 @@ export function createFlow(options = {}) {
|
|
|
392
419
|
ensureGuest,
|
|
393
420
|
refreshJwt,
|
|
394
421
|
getToken,
|
|
422
|
+
rooms: createRoomsApi(host, getToken),
|
|
395
423
|
signMessage: async (args) => {
|
|
396
424
|
const mod = await import("./signing");
|
|
397
425
|
return mod.signMessage(buildSigningContext(), args);
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
export type { AccessKeyOptions, Address, ConnectCapabilities, ConnectResponse, CreateFlowOptions, DialogHost, Flow, FlowCredential, FlowState, FlowUser, LoginOptions, MethodName, MountProfileOptions, ProfileButtonHandle, ProfilePosition, Session, } from "../types";
|
|
1
|
+
export type { AccessKeyOptions, Address, ConnectCapabilities, ConnectResponse, CreateFlowOptions, DialogHost, Flow, FlowCredential, FlowSessionState, FlowState, FlowUser, LoginOptions, MethodName, MountProfileOptions, ProfileButtonHandle, ProfilePosition, Session, } from "../types";
|
|
2
2
|
export { createFlow, getFlow, requireFlow, resetFlow } from "./create-flow";
|
|
3
3
|
export { createDialogHost } from "./dialog-host";
|
|
4
4
|
export { METHODS } from "./methods";
|
|
5
5
|
export { createProfileButton } from "./profile-button";
|
|
6
|
+
export { createRoomsApi, RoomsRequestError } from "./rooms";
|
|
7
|
+
export { createStaticFlow } from "./static-flow";
|
package/dist/sdk/client/index.js
CHANGED
|
@@ -2,3 +2,5 @@ export { createFlow, getFlow, requireFlow, resetFlow } from "./create-flow";
|
|
|
2
2
|
export { createDialogHost } from "./dialog-host";
|
|
3
3
|
export { METHODS } from "./methods";
|
|
4
4
|
export { createProfileButton } from "./profile-button";
|
|
5
|
+
export { createRoomsApi, RoomsRequestError } from "./rooms";
|
|
6
|
+
export { createStaticFlow } from "./static-flow";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { RefreshCookieConfig } from "../types";
|
|
1
2
|
/**
|
|
2
3
|
* First-party persistence for the rotating refresh token. It lives in the
|
|
3
4
|
* consumer app's OWN localStorage (origin-scoped), so it survives reloads and
|
|
@@ -10,4 +11,12 @@ export interface RefreshStore {
|
|
|
10
11
|
set(token: string): void;
|
|
11
12
|
clear(): void;
|
|
12
13
|
}
|
|
13
|
-
|
|
14
|
+
/**
|
|
15
|
+
* With a cookie config (`createFlow({ cookies: true })`) the token is
|
|
16
|
+
* additionally mirrored into a first-party cookie so the app's own server can
|
|
17
|
+
* refresh the session during SSR. Reads prefer the cookie: the only writer
|
|
18
|
+
* that updates one side without the other is that server (Set-Cookie only),
|
|
19
|
+
* and its value is always a successor of whatever localStorage still holds;
|
|
20
|
+
* every client-side write hits both in the same call.
|
|
21
|
+
*/
|
|
22
|
+
export declare function makeRefreshStore(host: string, cookie?: RefreshCookieConfig): RefreshStore;
|
|
@@ -1,13 +1,30 @@
|
|
|
1
|
-
|
|
1
|
+
import { clearBrowserCookie, REFRESH_COOKIE_MAX_AGE_S, readBrowserCookie, writeBrowserCookie, } from "../cookies";
|
|
2
|
+
/**
|
|
3
|
+
* With a cookie config (`createFlow({ cookies: true })`) the token is
|
|
4
|
+
* additionally mirrored into a first-party cookie so the app's own server can
|
|
5
|
+
* refresh the session during SSR. Reads prefer the cookie: the only writer
|
|
6
|
+
* that updates one side without the other is that server (Set-Cookie only),
|
|
7
|
+
* and its value is always a successor of whatever localStorage still holds;
|
|
8
|
+
* every client-side write hits both in the same call.
|
|
9
|
+
*/
|
|
10
|
+
export function makeRefreshStore(host, cookie) {
|
|
2
11
|
const key = `flow.id.refresh:${host}`;
|
|
3
|
-
|
|
12
|
+
const readLocal = () => {
|
|
13
|
+
try {
|
|
14
|
+
return localStorage.getItem(key);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
const readCookie = () => {
|
|
21
|
+
if (!cookie || typeof document === "undefined")
|
|
22
|
+
return null;
|
|
23
|
+
return readBrowserCookie(document.cookie, cookie.name);
|
|
24
|
+
};
|
|
25
|
+
const store = {
|
|
4
26
|
get() {
|
|
5
|
-
|
|
6
|
-
return localStorage.getItem(key);
|
|
7
|
-
}
|
|
8
|
-
catch {
|
|
9
|
-
return null;
|
|
10
|
-
}
|
|
27
|
+
return readCookie() ?? readLocal();
|
|
11
28
|
},
|
|
12
29
|
set(token) {
|
|
13
30
|
// Never persist a missing token: localStorage.setItem coerces undefined
|
|
@@ -19,12 +36,31 @@ export function makeRefreshStore(host) {
|
|
|
19
36
|
localStorage.setItem(key, token);
|
|
20
37
|
}
|
|
21
38
|
catch { }
|
|
39
|
+
if (cookie) {
|
|
40
|
+
writeBrowserCookie(cookie.name, token, {
|
|
41
|
+
maxAge: REFRESH_COOKIE_MAX_AGE_S,
|
|
42
|
+
secure: cookie.secure,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
22
45
|
},
|
|
23
46
|
clear() {
|
|
24
47
|
try {
|
|
25
48
|
localStorage.removeItem(key);
|
|
26
49
|
}
|
|
27
50
|
catch { }
|
|
51
|
+
if (cookie)
|
|
52
|
+
clearBrowserCookie(cookie.name, { secure: cookie.secure });
|
|
28
53
|
},
|
|
29
54
|
};
|
|
55
|
+
// One-time reconcile so exactly one canonical value exists before the first
|
|
56
|
+
// refresh: an SSR rotation updates only the cookie, leaving localStorage one
|
|
57
|
+
// generation behind — presenting that stale token would look like reuse.
|
|
58
|
+
const fromCookie = readCookie();
|
|
59
|
+
if (fromCookie && fromCookie !== readLocal()) {
|
|
60
|
+
try {
|
|
61
|
+
localStorage.setItem(key, fromCookie);
|
|
62
|
+
}
|
|
63
|
+
catch { }
|
|
64
|
+
}
|
|
65
|
+
return store;
|
|
30
66
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { RoomsApi } from "../types";
|
|
2
|
+
/** Thrown on any non-2xx rooms response; `reason` carries the machine-readable
|
|
3
|
+
* cause when the API provides one (e.g. "banned", "private"). */
|
|
4
|
+
export declare class RoomsRequestError extends Error {
|
|
5
|
+
readonly status: number;
|
|
6
|
+
readonly reason?: string | undefined;
|
|
7
|
+
constructor(status: number, message: string, reason?: string | undefined);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Thin typed client for the room registry, bound to a host and the Flow
|
|
11
|
+
* token supplier. Public reads work signed-out; mutations attach the caller's
|
|
12
|
+
* bearer and fail fast with a 401-shaped error when no session exists.
|
|
13
|
+
*/
|
|
14
|
+
export declare function createRoomsApi(host: string, getToken: () => Promise<string | null>): RoomsApi;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/** Thrown on any non-2xx rooms response; `reason` carries the machine-readable
|
|
2
|
+
* cause when the API provides one (e.g. "banned", "private"). */
|
|
3
|
+
export class RoomsRequestError extends Error {
|
|
4
|
+
status;
|
|
5
|
+
reason;
|
|
6
|
+
constructor(status, message, reason) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.reason = reason;
|
|
10
|
+
this.name = "RoomsRequestError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Thin typed client for the room registry, bound to a host and the Flow
|
|
15
|
+
* token supplier. Public reads work signed-out; mutations attach the caller's
|
|
16
|
+
* bearer and fail fast with a 401-shaped error when no session exists.
|
|
17
|
+
*/
|
|
18
|
+
export function createRoomsApi(host, getToken) {
|
|
19
|
+
async function request(path, options = {}) {
|
|
20
|
+
const headers = {};
|
|
21
|
+
const token = await getToken();
|
|
22
|
+
if (token)
|
|
23
|
+
headers.Authorization = `Bearer ${token}`;
|
|
24
|
+
else if (options.authRequired) {
|
|
25
|
+
throw new RoomsRequestError(401, "Sign in first");
|
|
26
|
+
}
|
|
27
|
+
if (options.body !== undefined)
|
|
28
|
+
headers["Content-Type"] = "application/json";
|
|
29
|
+
const res = await fetch(`${host}/api/rooms${path}`, {
|
|
30
|
+
method: options.method ?? "GET",
|
|
31
|
+
headers,
|
|
32
|
+
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
|
33
|
+
});
|
|
34
|
+
const payload = (await res.json().catch(() => ({})));
|
|
35
|
+
if (!res.ok) {
|
|
36
|
+
throw new RoomsRequestError(res.status, typeof payload.error === "string"
|
|
37
|
+
? payload.error
|
|
38
|
+
: `Rooms request failed (${res.status})`, typeof payload.reason === "string" ? payload.reason : undefined);
|
|
39
|
+
}
|
|
40
|
+
return payload;
|
|
41
|
+
}
|
|
42
|
+
const slugPath = (slug) => `/${encodeURIComponent(slug)}`;
|
|
43
|
+
const act = (slug, action, body) => request(`${slugPath(slug)}/${action}`, {
|
|
44
|
+
method: "POST",
|
|
45
|
+
body,
|
|
46
|
+
authRequired: true,
|
|
47
|
+
}).then(() => undefined);
|
|
48
|
+
return {
|
|
49
|
+
list(options) {
|
|
50
|
+
const params = new URLSearchParams();
|
|
51
|
+
if (options?.cursor)
|
|
52
|
+
params.set("cursor", options.cursor);
|
|
53
|
+
if (options?.limit)
|
|
54
|
+
params.set("limit", String(options.limit));
|
|
55
|
+
const query = params.size ? `?${params}` : "";
|
|
56
|
+
return request(query);
|
|
57
|
+
},
|
|
58
|
+
get: (slug) => request(slugPath(slug)),
|
|
59
|
+
mine: () => request("/mine", { authRequired: true }),
|
|
60
|
+
create: (input) => request("", {
|
|
61
|
+
method: "POST",
|
|
62
|
+
body: input,
|
|
63
|
+
authRequired: true,
|
|
64
|
+
}),
|
|
65
|
+
update: (slug, patch) => request(`${slugPath(slug)}/update`, {
|
|
66
|
+
method: "POST",
|
|
67
|
+
body: patch,
|
|
68
|
+
authRequired: true,
|
|
69
|
+
}),
|
|
70
|
+
remove: (slug) => act(slug, "delete", {}),
|
|
71
|
+
join: (slug) => act(slug, "join", {}),
|
|
72
|
+
leave: (slug) => act(slug, "leave", {}),
|
|
73
|
+
members: (slug) => request(`${slugPath(slug)}/members`, { authRequired: true }),
|
|
74
|
+
presence: (slug) => request(`${slugPath(slug)}/presence`),
|
|
75
|
+
settings: (slug, surface) => request(`${slugPath(slug)}/settings/${encodeURIComponent(surface)}`),
|
|
76
|
+
saveSettings: (slug, surface, settings) => request(`${slugPath(slug)}/settings/${encodeURIComponent(surface)}`, { method: "POST", body: { settings }, authRequired: true }),
|
|
77
|
+
transfer: (slug, userId) => act(slug, "transfer", { userId }),
|
|
78
|
+
claim: (slug) => act(slug, "claim", {}),
|
|
79
|
+
setRole: (slug, userId, role) => act(slug, "role", { userId, role }),
|
|
80
|
+
kick: (slug, userId) => act(slug, "kick", { userId }),
|
|
81
|
+
ban: (slug, userId, options) => act(slug, "ban", { userId, ...options }),
|
|
82
|
+
unban: (slug, userId) => act(slug, "unban", { userId }),
|
|
83
|
+
mute: (slug, userId, options) => act(slug, "mute", { userId, ...options }),
|
|
84
|
+
unmute: (slug, userId) => act(slug, "unmute", { userId }),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Flow, FlowSessionState } from "../types";
|
|
2
|
+
/**
|
|
3
|
+
* A frozen, per-request Flow for server rendering: hooks and components read
|
|
4
|
+
* the state `resolveSession()` resolved for THIS request, so the SSR HTML
|
|
5
|
+
* matches what the hydrated client (seeded via `createFlow({ initialState })`)
|
|
6
|
+
* renders first. Never touches the module singleton — concurrent requests
|
|
7
|
+
* must not share auth state.
|
|
8
|
+
*
|
|
9
|
+
* Reads work (`user`, `jwt`, `getToken`, `getState`, `rooms` — a plain fetch
|
|
10
|
+
* API); anything interactive (login, dialog, signing) throws.
|
|
11
|
+
*/
|
|
12
|
+
export declare function createStaticFlow(state: FlowSessionState | null, options?: {
|
|
13
|
+
host?: string;
|
|
14
|
+
}): Flow;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { createRoomsApi } from "./rooms";
|
|
2
|
+
const DEFAULT_HOST = "https://id.flow.industries";
|
|
3
|
+
function unavailable(method) {
|
|
4
|
+
throw new Error(`flow.${method} is not available on a static Flow — it renders ` +
|
|
5
|
+
"server-resolved state only. Interactive methods need the browser " +
|
|
6
|
+
"client from createFlow().");
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* A frozen, per-request Flow for server rendering: hooks and components read
|
|
10
|
+
* the state `resolveSession()` resolved for THIS request, so the SSR HTML
|
|
11
|
+
* matches what the hydrated client (seeded via `createFlow({ initialState })`)
|
|
12
|
+
* renders first. Never touches the module singleton — concurrent requests
|
|
13
|
+
* must not share auth state.
|
|
14
|
+
*
|
|
15
|
+
* Reads work (`user`, `jwt`, `getToken`, `getState`, `rooms` — a plain fetch
|
|
16
|
+
* API); anything interactive (login, dialog, signing) throws.
|
|
17
|
+
*/
|
|
18
|
+
export function createStaticFlow(state, options = {}) {
|
|
19
|
+
const host = (options.host ?? DEFAULT_HOST).replace(/\/+$/, "");
|
|
20
|
+
const snapshot = Object.freeze({
|
|
21
|
+
user: state?.user ?? null,
|
|
22
|
+
jwt: state?.jwt ?? null,
|
|
23
|
+
credential: null,
|
|
24
|
+
address: state?.address ?? null,
|
|
25
|
+
});
|
|
26
|
+
const getToken = async () => snapshot.jwt;
|
|
27
|
+
return {
|
|
28
|
+
get user() {
|
|
29
|
+
return snapshot.user;
|
|
30
|
+
},
|
|
31
|
+
get jwt() {
|
|
32
|
+
return snapshot.jwt;
|
|
33
|
+
},
|
|
34
|
+
get credential() {
|
|
35
|
+
return snapshot.credential;
|
|
36
|
+
},
|
|
37
|
+
get address() {
|
|
38
|
+
return snapshot.address;
|
|
39
|
+
},
|
|
40
|
+
get isAuthenticated() {
|
|
41
|
+
return snapshot.user !== null;
|
|
42
|
+
},
|
|
43
|
+
get isGuest() {
|
|
44
|
+
return snapshot.user?.isGuest === true;
|
|
45
|
+
},
|
|
46
|
+
login: () => unavailable("login"),
|
|
47
|
+
logout: () => unavailable("logout"),
|
|
48
|
+
restore: async () => false,
|
|
49
|
+
ensureGuest: () => unavailable("ensureGuest"),
|
|
50
|
+
refreshJwt: async () => false,
|
|
51
|
+
getToken,
|
|
52
|
+
rooms: createRoomsApi(host, getToken),
|
|
53
|
+
signMessage: () => unavailable("signMessage"),
|
|
54
|
+
signTypedData: () => unavailable("signTypedData"),
|
|
55
|
+
sendTransaction: () => unavailable("sendTransaction"),
|
|
56
|
+
sendCalls: () => unavailable("sendCalls"),
|
|
57
|
+
walletClient: () => unavailable("walletClient"),
|
|
58
|
+
subscribe: () => () => { },
|
|
59
|
+
getState: () => snapshot,
|
|
60
|
+
get dialog() {
|
|
61
|
+
return unavailable("dialog");
|
|
62
|
+
},
|
|
63
|
+
host,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-party cookie names and (de)serialization shared by the browser SDK
|
|
3
|
+
* (which mirrors the session into cookies) and the server helper (which reads
|
|
4
|
+
* them per-request to render auth-aware UI without a client round-trip).
|
|
5
|
+
* Isomorphic: no browser globals at import time.
|
|
6
|
+
*/
|
|
7
|
+
import type { FlowCookieNames } from "./types";
|
|
8
|
+
export declare const JWT_COOKIE_MAX_AGE_S: number;
|
|
9
|
+
export declare const REFRESH_COOKIE_MAX_AGE_S: number;
|
|
10
|
+
/**
|
|
11
|
+
* Cookie names for an app origin. On https the `__Host-` prefix makes the
|
|
12
|
+
* browser enforce Secure + Path=/ + host-only, so no sibling subdomain can
|
|
13
|
+
* plant a shadowing cookie with `Domain=` (session fixation). On localhost
|
|
14
|
+
* (plain http, where `__Host-` is unavailable) the port is folded into the
|
|
15
|
+
* name instead — cookie jars ignore ports, so without the suffix parallel
|
|
16
|
+
* dev stacks on different ports would overwrite each other's sessions.
|
|
17
|
+
*/
|
|
18
|
+
export declare function cookieNamesFor(origin: string): FlowCookieNames;
|
|
19
|
+
/**
|
|
20
|
+
* Builds a Set-Cookie value. Host-only (no Domain), `SameSite=Lax`, `Path=/`;
|
|
21
|
+
* `Secure` everywhere except plain-http localhost (Safari drops Secure
|
|
22
|
+
* cookies set over http). Never HttpOnly — the browser SDK reads and writes
|
|
23
|
+
* these same cookies.
|
|
24
|
+
*/
|
|
25
|
+
export declare function serializeCookie(name: string, value: string, opts: {
|
|
26
|
+
maxAge: number;
|
|
27
|
+
secure: boolean;
|
|
28
|
+
}): string;
|
|
29
|
+
/** Builds a Set-Cookie value that deletes the cookie. */
|
|
30
|
+
export declare function clearCookieString(name: string, opts: {
|
|
31
|
+
secure: boolean;
|
|
32
|
+
}): string;
|
|
33
|
+
/** Parses a Cookie request header into name → value; first occurrence wins (RFC 6265 practice). */
|
|
34
|
+
export declare function parseCookieHeader(header: string | null | undefined): Record<string, string>;
|
|
35
|
+
/** Reads one cookie from a `document.cookie` string. */
|
|
36
|
+
export declare function readBrowserCookie(cookieString: string, name: string): string | null;
|
|
37
|
+
/** Writes a cookie in the browser; no-op outside it. */
|
|
38
|
+
export declare function writeBrowserCookie(name: string, value: string, opts: {
|
|
39
|
+
maxAge: number;
|
|
40
|
+
secure: boolean;
|
|
41
|
+
}): void;
|
|
42
|
+
/** Deletes a cookie in the browser; no-op outside it. */
|
|
43
|
+
export declare function clearBrowserCookie(name: string, opts: {
|
|
44
|
+
secure: boolean;
|
|
45
|
+
}): void;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-party cookie names and (de)serialization shared by the browser SDK
|
|
3
|
+
* (which mirrors the session into cookies) and the server helper (which reads
|
|
4
|
+
* them per-request to render auth-aware UI without a client round-trip).
|
|
5
|
+
* Isomorphic: no browser globals at import time.
|
|
6
|
+
*/
|
|
7
|
+
export const JWT_COOKIE_MAX_AGE_S = 60 * 60;
|
|
8
|
+
export const REFRESH_COOKIE_MAX_AGE_S = 30 * 24 * 60 * 60;
|
|
9
|
+
/**
|
|
10
|
+
* Cookie names for an app origin. On https the `__Host-` prefix makes the
|
|
11
|
+
* browser enforce Secure + Path=/ + host-only, so no sibling subdomain can
|
|
12
|
+
* plant a shadowing cookie with `Domain=` (session fixation). On localhost
|
|
13
|
+
* (plain http, where `__Host-` is unavailable) the port is folded into the
|
|
14
|
+
* name instead — cookie jars ignore ports, so without the suffix parallel
|
|
15
|
+
* dev stacks on different ports would overwrite each other's sessions.
|
|
16
|
+
*/
|
|
17
|
+
export function cookieNamesFor(origin) {
|
|
18
|
+
let prefix = "";
|
|
19
|
+
let suffix = "";
|
|
20
|
+
try {
|
|
21
|
+
const url = new URL(origin);
|
|
22
|
+
if (url.protocol === "https:")
|
|
23
|
+
prefix = "__Host-";
|
|
24
|
+
const local = url.hostname === "localhost" ||
|
|
25
|
+
url.hostname === "127.0.0.1" ||
|
|
26
|
+
url.hostname === "[::1]";
|
|
27
|
+
if (local && url.port)
|
|
28
|
+
suffix = `_p${url.port}`;
|
|
29
|
+
}
|
|
30
|
+
catch { }
|
|
31
|
+
return {
|
|
32
|
+
jwt: `${prefix}flow_id.jwt${suffix}`,
|
|
33
|
+
refresh: `${prefix}flow_id.refresh${suffix}`,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Builds a Set-Cookie value. Host-only (no Domain), `SameSite=Lax`, `Path=/`;
|
|
38
|
+
* `Secure` everywhere except plain-http localhost (Safari drops Secure
|
|
39
|
+
* cookies set over http). Never HttpOnly — the browser SDK reads and writes
|
|
40
|
+
* these same cookies.
|
|
41
|
+
*/
|
|
42
|
+
export function serializeCookie(name, value, opts) {
|
|
43
|
+
const secure = opts.secure ? "; Secure" : "";
|
|
44
|
+
return `${name}=${value}; Path=/; SameSite=Lax; Max-Age=${opts.maxAge}${secure}`;
|
|
45
|
+
}
|
|
46
|
+
/** Builds a Set-Cookie value that deletes the cookie. */
|
|
47
|
+
export function clearCookieString(name, opts) {
|
|
48
|
+
return serializeCookie(name, "", { maxAge: 0, secure: opts.secure });
|
|
49
|
+
}
|
|
50
|
+
/** Parses a Cookie request header into name → value; first occurrence wins (RFC 6265 practice). */
|
|
51
|
+
export function parseCookieHeader(header) {
|
|
52
|
+
const out = {};
|
|
53
|
+
if (!header)
|
|
54
|
+
return out;
|
|
55
|
+
for (const part of header.split(";")) {
|
|
56
|
+
const eq = part.indexOf("=");
|
|
57
|
+
if (eq === -1)
|
|
58
|
+
continue;
|
|
59
|
+
const name = part.slice(0, eq).trim();
|
|
60
|
+
if (!name || name in out)
|
|
61
|
+
continue;
|
|
62
|
+
out[name] = part.slice(eq + 1).trim();
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
/** Reads one cookie from a `document.cookie` string. */
|
|
67
|
+
export function readBrowserCookie(cookieString, name) {
|
|
68
|
+
return parseCookieHeader(cookieString)[name] ?? null;
|
|
69
|
+
}
|
|
70
|
+
/** Writes a cookie in the browser; no-op outside it. */
|
|
71
|
+
export function writeBrowserCookie(name, value, opts) {
|
|
72
|
+
if (typeof document === "undefined")
|
|
73
|
+
return;
|
|
74
|
+
document.cookie = serializeCookie(name, value, opts);
|
|
75
|
+
}
|
|
76
|
+
/** Deletes a cookie in the browser; no-op outside it. */
|
|
77
|
+
export function clearBrowserCookie(name, opts) {
|
|
78
|
+
if (typeof document === "undefined")
|
|
79
|
+
return;
|
|
80
|
+
document.cookie = clearCookieString(name, opts);
|
|
81
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Flow, FlowState } from "../types";
|
|
1
|
+
import type { Flow, FlowState, RoomDetail, RoomPresenceSnapshot, RoomSummary } from "../types";
|
|
2
2
|
/**
|
|
3
3
|
* Returns the Flow instance to use. Resolution order:
|
|
4
4
|
* 1. The instance from a `<FlowIdProvider>` ancestor (if any)
|
|
@@ -35,3 +35,28 @@ export declare function useFlowId(): {
|
|
|
35
35
|
refreshJwt: () => Promise<boolean>;
|
|
36
36
|
getToken: () => Promise<string | null>;
|
|
37
37
|
};
|
|
38
|
+
/**
|
|
39
|
+
* Fetches the public room list once (call `reload` to refresh). Pagination
|
|
40
|
+
* stays manual: pass the returned `nextCursor` back through `flow.rooms.list`.
|
|
41
|
+
*/
|
|
42
|
+
export declare function useRooms(): {
|
|
43
|
+
rooms: RoomSummary[];
|
|
44
|
+
nextCursor: string | null;
|
|
45
|
+
loading: boolean;
|
|
46
|
+
error: Error | null;
|
|
47
|
+
reload: () => void;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Fetches one room's detail, and optionally polls its live presence when
|
|
51
|
+
* `presencePollMs` is set (polling until the presence store grows a push
|
|
52
|
+
* channel). Re-fetches when the slug changes; call `reload` to refresh.
|
|
53
|
+
*/
|
|
54
|
+
export declare function useRoom(slug: string, options?: {
|
|
55
|
+
presencePollMs?: number;
|
|
56
|
+
}): {
|
|
57
|
+
room: RoomDetail | null;
|
|
58
|
+
presence: RoomPresenceSnapshot | null;
|
|
59
|
+
loading: boolean;
|
|
60
|
+
error: Error | null;
|
|
61
|
+
reload: () => void;
|
|
62
|
+
};
|
package/dist/sdk/react/hooks.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { useContext, useSyncExternalStore } from "react";
|
|
1
|
+
import { useCallback, useContext, useEffect, useState, useSyncExternalStore, } from "react";
|
|
2
2
|
import { getFlow as getSingletonFlow } from "../client/create-flow";
|
|
3
3
|
import { FlowContext } from "./provider";
|
|
4
|
+
const asError = (e) => e instanceof Error ? e : new Error(String(e));
|
|
4
5
|
/**
|
|
5
6
|
* Returns the Flow instance to use. Resolution order:
|
|
6
7
|
* 1. The instance from a `<FlowIdProvider>` ancestor (if any)
|
|
@@ -53,3 +54,79 @@ export function useFlowId() {
|
|
|
53
54
|
getToken: flow.getToken,
|
|
54
55
|
};
|
|
55
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Fetches the public room list once (call `reload` to refresh). Pagination
|
|
59
|
+
* stays manual: pass the returned `nextCursor` back through `flow.rooms.list`.
|
|
60
|
+
*/
|
|
61
|
+
export function useRooms() {
|
|
62
|
+
const flow = useFlow();
|
|
63
|
+
const [result, setResult] = useState(null);
|
|
64
|
+
const [error, setError] = useState(null);
|
|
65
|
+
const load = useCallback(() => {
|
|
66
|
+
flow.rooms
|
|
67
|
+
.list()
|
|
68
|
+
.then((list) => {
|
|
69
|
+
setResult(list);
|
|
70
|
+
setError(null);
|
|
71
|
+
})
|
|
72
|
+
.catch((err) => setError(asError(err)));
|
|
73
|
+
}, [flow]);
|
|
74
|
+
useEffect(load, [load]);
|
|
75
|
+
return {
|
|
76
|
+
rooms: result?.rooms ?? [],
|
|
77
|
+
nextCursor: result?.nextCursor ?? null,
|
|
78
|
+
loading: result === null && error === null,
|
|
79
|
+
error,
|
|
80
|
+
reload: load,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Fetches one room's detail, and optionally polls its live presence when
|
|
85
|
+
* `presencePollMs` is set (polling until the presence store grows a push
|
|
86
|
+
* channel). Re-fetches when the slug changes; call `reload` to refresh.
|
|
87
|
+
*/
|
|
88
|
+
export function useRoom(slug, options) {
|
|
89
|
+
const flow = useFlow();
|
|
90
|
+
const pollMs = options?.presencePollMs ?? 0;
|
|
91
|
+
const [room, setRoom] = useState(null);
|
|
92
|
+
const [presence, setPresence] = useState(null);
|
|
93
|
+
const [error, setError] = useState(null);
|
|
94
|
+
const loadRoom = useCallback(() => {
|
|
95
|
+
flow.rooms
|
|
96
|
+
.get(slug)
|
|
97
|
+
.then((detail) => {
|
|
98
|
+
setRoom(detail);
|
|
99
|
+
setError(null);
|
|
100
|
+
})
|
|
101
|
+
.catch((err) => setError(asError(err)));
|
|
102
|
+
}, [flow, slug]);
|
|
103
|
+
const loadPresence = useCallback(() => {
|
|
104
|
+
flow.rooms
|
|
105
|
+
.presence(slug)
|
|
106
|
+
.then(setPresence)
|
|
107
|
+
.catch(() => { });
|
|
108
|
+
}, [flow, slug]);
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
setRoom(null);
|
|
111
|
+
setError(null);
|
|
112
|
+
loadRoom();
|
|
113
|
+
}, [loadRoom]);
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
loadPresence();
|
|
116
|
+
if (pollMs <= 0)
|
|
117
|
+
return;
|
|
118
|
+
const timer = setInterval(loadPresence, pollMs);
|
|
119
|
+
return () => clearInterval(timer);
|
|
120
|
+
}, [loadPresence, pollMs]);
|
|
121
|
+
const reload = useCallback(() => {
|
|
122
|
+
loadRoom();
|
|
123
|
+
loadPresence();
|
|
124
|
+
}, [loadRoom, loadPresence]);
|
|
125
|
+
return {
|
|
126
|
+
room,
|
|
127
|
+
presence,
|
|
128
|
+
loading: room === null && error === null,
|
|
129
|
+
error,
|
|
130
|
+
reload,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export type { FlowIdProviderProps, ProfileButtonProps } from "../types";
|
|
2
|
-
export { useFlow, useFlowId, useFlowState } from "./hooks";
|
|
2
|
+
export { useFlow, useFlowId, useFlowState, useRoom, useRooms } from "./hooks";
|
|
3
3
|
export { ProfileButton } from "./profile-button";
|
|
4
4
|
export { FlowIdProvider } from "./provider";
|
package/dist/sdk/react/index.js
CHANGED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ResolvedFlowSession, ResolveSessionOptions } from "./types";
|
|
2
|
+
export type { FlowSessionState, ResolvedFlowSession, ResolveSessionOptions, } from "./types";
|
|
3
|
+
export { verifyFlowJWT } from "./verify";
|
|
4
|
+
/**
|
|
5
|
+
* Resolves the visitor's Flow session from the request's Cookie header — the
|
|
6
|
+
* server-side counterpart of `createFlow({ cookies: true })`, for rendering
|
|
7
|
+
* auth-aware UI without a client round-trip.
|
|
8
|
+
*
|
|
9
|
+
* Resolution order:
|
|
10
|
+
* 1. A still-fresh JWT cookie verifies locally against the issuer's JWKS
|
|
11
|
+
* (cached per process) — the hot path, no auth-server call, no cookies
|
|
12
|
+
* to set.
|
|
13
|
+
* 2. Otherwise a refresh cookie is presented to `/api/session/refresh`,
|
|
14
|
+
* which ROTATES it — append every entry of `setCookies` to the response
|
|
15
|
+
* or the client is left holding a dead predecessor. A definitive
|
|
16
|
+
* rejection (401/403) yields clearing cookies instead; a network failure
|
|
17
|
+
* leaves the cookies alone so a later request can retry.
|
|
18
|
+
* 3. Neither cookie → signed-out `state: null`.
|
|
19
|
+
*
|
|
20
|
+
* Feed `state` into the SSR payload and `createFlow({ initialState })` so the
|
|
21
|
+
* hydrated client renders it without a second refresh.
|
|
22
|
+
*/
|
|
23
|
+
export declare function resolveSession(cookieHeader: string | null | undefined, opts: ResolveSessionOptions): Promise<ResolvedFlowSession>;
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { clearCookieString, cookieNamesFor, JWT_COOKIE_MAX_AGE_S, parseCookieHeader, REFRESH_COOKIE_MAX_AGE_S, serializeCookie, } from "./cookies";
|
|
2
|
+
import { isExpiring } from "./token-expiry";
|
|
3
|
+
import { verifyFlowJWT } from "./verify";
|
|
4
|
+
export { verifyFlowJWT } from "./verify";
|
|
5
|
+
const DEFAULT_ISSUER_URL = "https://id.flow.industries";
|
|
6
|
+
function claimsToUser(claims) {
|
|
7
|
+
return {
|
|
8
|
+
id: claims.sub,
|
|
9
|
+
username: typeof claims.username === "string" ? claims.username : "",
|
|
10
|
+
// Fail-safe: only an explicit `false` marks a full account; a missing or
|
|
11
|
+
// mangled claim must never grant full privileges.
|
|
12
|
+
isGuest: claims.guest !== false,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
async function refreshSession(issuerUrl, token) {
|
|
16
|
+
try {
|
|
17
|
+
const res = await fetch(`${issuerUrl}/api/session/refresh`, {
|
|
18
|
+
method: "POST",
|
|
19
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
20
|
+
});
|
|
21
|
+
if (res.status === 401 || res.status === 403)
|
|
22
|
+
return "dead";
|
|
23
|
+
if (!res.ok)
|
|
24
|
+
return null;
|
|
25
|
+
return (await res.json());
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// Dedupe a same-instance SSR fan-out (parallel requests carrying the same
|
|
32
|
+
// cookie) into one rotation. Settled results are retained briefly so a
|
|
33
|
+
// request that lands after the rotation but before the browser applied the
|
|
34
|
+
// Set-Cookie is served the same successor instead of re-presenting a dead
|
|
35
|
+
// token; the auth server's grace window covers presenters this map can't see
|
|
36
|
+
// (other replicas, other machines).
|
|
37
|
+
const REFRESH_RESULT_TTL_MS = 30_000;
|
|
38
|
+
const refreshInFlight = new Map();
|
|
39
|
+
function refreshOnce(issuerUrl, token) {
|
|
40
|
+
const key = `${issuerUrl}\n${token}`;
|
|
41
|
+
let flight = refreshInFlight.get(key);
|
|
42
|
+
if (!flight) {
|
|
43
|
+
flight = refreshSession(issuerUrl, token);
|
|
44
|
+
refreshInFlight.set(key, flight);
|
|
45
|
+
void flight.finally(() => {
|
|
46
|
+
const timer = setTimeout(() => refreshInFlight.delete(key), REFRESH_RESULT_TTL_MS);
|
|
47
|
+
timer.unref?.();
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return flight;
|
|
51
|
+
}
|
|
52
|
+
// jose verification-class failures (bad signature, expired, wrong audience,
|
|
53
|
+
// unknown key) mean the token itself is unacceptable; anything else — JWKS
|
|
54
|
+
// fetch failure, timeouts — is transient infrastructure trouble, and falling
|
|
55
|
+
// through to a rotation there would turn a JWKS outage into a rotation storm
|
|
56
|
+
// against the same struggling server.
|
|
57
|
+
function isJwtVerificationFailure(err) {
|
|
58
|
+
const code = err?.code;
|
|
59
|
+
if (typeof code !== "string")
|
|
60
|
+
return false;
|
|
61
|
+
return (code.startsWith("ERR_JWT") ||
|
|
62
|
+
code.startsWith("ERR_JWS") ||
|
|
63
|
+
code === "ERR_JWKS_NO_MATCHING_KEY" ||
|
|
64
|
+
code === "ERR_JWKS_MULTIPLE_MATCHING_KEYS");
|
|
65
|
+
}
|
|
66
|
+
async function fetchProfile(issuerUrl, jwt, audience) {
|
|
67
|
+
try {
|
|
68
|
+
const res = await fetch(`${issuerUrl}/api/session/verify`, {
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers: { "Content-Type": "application/json" },
|
|
71
|
+
body: JSON.stringify({ token: jwt, audience }),
|
|
72
|
+
});
|
|
73
|
+
if (!res.ok)
|
|
74
|
+
return undefined;
|
|
75
|
+
const data = (await res.json());
|
|
76
|
+
return data.payload?.profile ?? undefined;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Resolves the visitor's Flow session from the request's Cookie header — the
|
|
84
|
+
* server-side counterpart of `createFlow({ cookies: true })`, for rendering
|
|
85
|
+
* auth-aware UI without a client round-trip.
|
|
86
|
+
*
|
|
87
|
+
* Resolution order:
|
|
88
|
+
* 1. A still-fresh JWT cookie verifies locally against the issuer's JWKS
|
|
89
|
+
* (cached per process) — the hot path, no auth-server call, no cookies
|
|
90
|
+
* to set.
|
|
91
|
+
* 2. Otherwise a refresh cookie is presented to `/api/session/refresh`,
|
|
92
|
+
* which ROTATES it — append every entry of `setCookies` to the response
|
|
93
|
+
* or the client is left holding a dead predecessor. A definitive
|
|
94
|
+
* rejection (401/403) yields clearing cookies instead; a network failure
|
|
95
|
+
* leaves the cookies alone so a later request can retry.
|
|
96
|
+
* 3. Neither cookie → signed-out `state: null`.
|
|
97
|
+
*
|
|
98
|
+
* Feed `state` into the SSR payload and `createFlow({ initialState })` so the
|
|
99
|
+
* hydrated client renders it without a second refresh.
|
|
100
|
+
*/
|
|
101
|
+
export async function resolveSession(cookieHeader, opts) {
|
|
102
|
+
const issuerUrl = (opts.issuerUrl ?? DEFAULT_ISSUER_URL).replace(/\/+$/, "");
|
|
103
|
+
const names = cookieNamesFor(opts.audience);
|
|
104
|
+
const secure = opts.audience.startsWith("https:");
|
|
105
|
+
const cookies = parseCookieHeader(cookieHeader);
|
|
106
|
+
const jwt = cookies[names.jwt];
|
|
107
|
+
if (jwt && !isExpiring(jwt)) {
|
|
108
|
+
try {
|
|
109
|
+
const claims = await verifyFlowJWT(jwt, {
|
|
110
|
+
audience: opts.audience,
|
|
111
|
+
issuerUrl,
|
|
112
|
+
});
|
|
113
|
+
const state = {
|
|
114
|
+
user: claimsToUser(claims),
|
|
115
|
+
jwt,
|
|
116
|
+
address: null,
|
|
117
|
+
};
|
|
118
|
+
const profile = opts.profile
|
|
119
|
+
? await fetchProfile(issuerUrl, jwt, opts.audience)
|
|
120
|
+
: undefined;
|
|
121
|
+
return {
|
|
122
|
+
state,
|
|
123
|
+
claims,
|
|
124
|
+
setCookies: [],
|
|
125
|
+
...(profile ? { profile } : {}),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
if (!isJwtVerificationFailure(err)) {
|
|
130
|
+
return { state: null, claims: null, setCookies: [] };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const refreshToken = cookies[names.refresh];
|
|
135
|
+
if (!refreshToken)
|
|
136
|
+
return { state: null, claims: null, setCookies: [] };
|
|
137
|
+
const result = await refreshOnce(issuerUrl, refreshToken);
|
|
138
|
+
if (result === "dead") {
|
|
139
|
+
return {
|
|
140
|
+
state: null,
|
|
141
|
+
claims: null,
|
|
142
|
+
setCookies: [
|
|
143
|
+
clearCookieString(names.jwt, { secure }),
|
|
144
|
+
clearCookieString(names.refresh, { secure }),
|
|
145
|
+
],
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (!result)
|
|
149
|
+
return { state: null, claims: null, setCookies: [] };
|
|
150
|
+
const successorCookies = [
|
|
151
|
+
serializeCookie(names.jwt, result.jwt, {
|
|
152
|
+
maxAge: JWT_COOKIE_MAX_AGE_S,
|
|
153
|
+
secure,
|
|
154
|
+
}),
|
|
155
|
+
serializeCookie(names.refresh, result.refreshToken, {
|
|
156
|
+
maxAge: REFRESH_COOKIE_MAX_AGE_S,
|
|
157
|
+
secure,
|
|
158
|
+
}),
|
|
159
|
+
];
|
|
160
|
+
// Never trust a minted session unverified: the refresh endpoint binds the
|
|
161
|
+
// JWT to the token ROW's audience, so a cookie planted by a sibling
|
|
162
|
+
// subdomain (or a misconfigured audience) would otherwise resolve into a
|
|
163
|
+
// wrong-app session. Verification failure ⇒ clear the cookies; a transient
|
|
164
|
+
// JWKS failure still hands the successor over (the rotation already
|
|
165
|
+
// happened) but renders signed-out rather than trusting it.
|
|
166
|
+
let claims;
|
|
167
|
+
try {
|
|
168
|
+
claims = await verifyFlowJWT(result.jwt, {
|
|
169
|
+
audience: opts.audience,
|
|
170
|
+
issuerUrl,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
if (isJwtVerificationFailure(err)) {
|
|
175
|
+
return {
|
|
176
|
+
state: null,
|
|
177
|
+
claims: null,
|
|
178
|
+
setCookies: [
|
|
179
|
+
clearCookieString(names.jwt, { secure }),
|
|
180
|
+
clearCookieString(names.refresh, { secure }),
|
|
181
|
+
],
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
return { state: null, claims: null, setCookies: successorCookies };
|
|
185
|
+
}
|
|
186
|
+
const state = {
|
|
187
|
+
user: result.user,
|
|
188
|
+
jwt: result.jwt,
|
|
189
|
+
address: result.address,
|
|
190
|
+
};
|
|
191
|
+
const profile = opts.profile
|
|
192
|
+
? await fetchProfile(issuerUrl, result.jwt, opts.audience)
|
|
193
|
+
: undefined;
|
|
194
|
+
return {
|
|
195
|
+
state,
|
|
196
|
+
claims,
|
|
197
|
+
setCookies: successorCookies,
|
|
198
|
+
...(profile ? { profile } : {}),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare const EXPIRY_SKEW_MS = 60000;
|
|
2
|
+
/** Reads the `exp` (seconds since epoch) from a JWT, or null if unreadable. */
|
|
3
|
+
export declare function jwtExp(token: string): number | null;
|
|
4
|
+
/** True if the token is missing an exp, already expired, or within the skew. */
|
|
5
|
+
export declare function isExpiring(token: string): boolean;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Refresh a little before the JWT's `exp` so a token handed out to a caller
|
|
2
|
+
// is still valid by the time it reaches the relying party, absorbing request
|
|
3
|
+
// latency and minor client/server clock skew.
|
|
4
|
+
export const EXPIRY_SKEW_MS = 60_000;
|
|
5
|
+
/** Reads the `exp` (seconds since epoch) from a JWT, or null if unreadable. */
|
|
6
|
+
export function jwtExp(token) {
|
|
7
|
+
const parts = token.split(".");
|
|
8
|
+
if (parts.length !== 3)
|
|
9
|
+
return null;
|
|
10
|
+
try {
|
|
11
|
+
const padded = (parts[1] ?? "").replace(/-/g, "+").replace(/_/g, "/");
|
|
12
|
+
const payload = JSON.parse(atob(padded));
|
|
13
|
+
return typeof payload.exp === "number" ? payload.exp : null;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** True if the token is missing an exp, already expired, or within the skew. */
|
|
20
|
+
export function isExpiring(token) {
|
|
21
|
+
const exp = jwtExp(token);
|
|
22
|
+
if (exp === null)
|
|
23
|
+
return true;
|
|
24
|
+
return Date.now() >= exp * 1000 - EXPIRY_SKEW_MS;
|
|
25
|
+
}
|
|
@@ -8,7 +8,7 @@ export type AuthOutcome = "success" | "failure" | "info";
|
|
|
8
8
|
export type AuthMode = "sign-up" | "sign-in";
|
|
9
9
|
/** Client-only funnel steps reported via the `/api/events` beacon. */
|
|
10
10
|
export type FunnelStep = "mode_selected" | "email_entered" | "ceremony_started" | "done_shown";
|
|
11
|
-
export type AuthEventName = "auth.username.checked" | "auth.signup.succeeded" | "auth.signup.failed" | "auth.otp.sent" | "auth.otp.verified" | "auth.otp.failed" | "auth.email.verify.sent" | "auth.email.verify.succeeded" | "auth.email.verify.failed" | "auth.email.changed" | "auth.avatar.upload.succeeded" | "auth.avatar.upload.failed" | "auth.challenge.issued" | "auth.signin.succeeded" | "auth.signin.failed" | "auth.restore.succeeded" | "auth.restore.failed" | "auth.restore.no_session" | "auth.refresh.succeeded" | "auth.refresh.failed" | "auth.refresh.reuse" | "auth.jwt.verified" | "auth.jwt.rejected" | "auth.signout" | "auth.audience.rejected" | "auth.guest.created" | "auth.guest.restored" | "auth.guest.upgraded" | "auth.guest.failed" | "auth.funnel.mode_selected" | "auth.funnel.email_entered" | "auth.funnel.ceremony_started" | "auth.funnel.done_shown";
|
|
11
|
+
export type AuthEventName = "auth.username.checked" | "auth.signup.succeeded" | "auth.signup.failed" | "auth.otp.sent" | "auth.otp.verified" | "auth.otp.failed" | "auth.email.verify.sent" | "auth.email.verify.succeeded" | "auth.email.verify.failed" | "auth.email.changed" | "auth.avatar.upload.succeeded" | "auth.avatar.upload.failed" | "auth.challenge.issued" | "auth.signin.succeeded" | "auth.signin.failed" | "auth.restore.succeeded" | "auth.restore.failed" | "auth.restore.no_session" | "auth.refresh.succeeded" | "auth.refresh.failed" | "auth.refresh.reuse" | "auth.refresh.grace" | "auth.jwt.verified" | "auth.jwt.rejected" | "auth.signout" | "auth.audience.rejected" | "auth.guest.created" | "auth.guest.restored" | "auth.guest.upgraded" | "auth.guest.failed" | "auth.funnel.mode_selected" | "auth.funnel.email_entered" | "auth.funnel.ceremony_started" | "auth.funnel.done_shown";
|
|
12
12
|
export type AuthErrorCode = "username_taken" | "credential_taken" | "email_taken" | "email_not_verified" | "no_email" | "image_upload_forbidden" | "image_upload_rate_limited" | "invalid_image_type" | "image_too_large" | "image_upload_failed" | "otp_invalid" | "otp_expired" | "otp_attempts_exceeded" | "otp_resend_cooldown" | "otp_resend_limit" | "otp_global_limit" | "otp_send_failed" | "challenge_expired" | "unknown_credential" | "invalid_assertion_type" | "invalid_assertion_origin" | "user_verification_required" | "invalid_signature" | "user_not_found" | "missing_audience" | "audience_not_allowed" | "audience_mismatch" | "no_session" | "no_passkey" | "refresh_token_invalid" | "refresh_token_expired" | "refresh_reuse_detected" | "guest_rate_limited" | "guest_global_limit" | "guest_username_exhausted" | "already_upgraded" | "malformed_token" | "unknown_key" | "verification_failed" | "internal_error";
|
|
13
13
|
/** One flat record per event = one row in the `auth_events` stream. */
|
|
14
14
|
export interface AuthEventRecord {
|
|
@@ -3,7 +3,9 @@ export type { BoundaryError, DialogCustomFeatures, DialogCustomLabels, DialogErr
|
|
|
3
3
|
export type { AuthErrorCode, AuthEventName, AuthEventRecord, AuthMode, AuthOutcome, BeaconBody, FunnelStep, } from "./events";
|
|
4
4
|
export type { Bridge, BridgeParameters, FlowAccount, FlowRemote, FlowRemoteConfig, FromWindowOptions, MessageResponse, Messenger, OneOf, Payload, QueuedRequest, ReadyOptions, RemoteFlowState, RemoteState, Schema, Storage, Topic, WithReady, } from "./messenger";
|
|
5
5
|
export type { Call, ConnectCapabilities, ConnectRequest, ConnectResponse, GuestRequest, GuestResponse, MethodName, MethodParams, MethodResult, RestoreResponse, RpcRequest, SendCallsParams, SendCallsRequest, SendCallsResponse, SendTransactionParams, SendTransactionRequest, SendTransactionResponse, SignMessageRequest, SignMessageResponse, SignOutRequest, SignOutResponse, SignTypedDataRequest, SignTypedDataResponse, TransactionArgs, TypedData, TypedDataDomain, TypedDataField, } from "./protocol";
|
|
6
|
-
export { isPersonalSignParams, isSendCallsParams, isSendTransactionParams, } from "./protocol";
|
|
7
|
-
export type {
|
|
6
|
+
export { getConnectCapabilities, isPersonalSignParams, isSendCallsParams, isSendTransactionParams, } from "./protocol";
|
|
7
|
+
export type { CreateRoomInput, MyRooms, RoomChannel, RoomDetail, RoomList, RoomMemberEntry, RoomMembers, RoomOccupancy, RoomOwner, RoomPresenceEntry, RoomPresenceSnapshot, RoomRestrictionKind, RoomRole, RoomSummary, RoomSurfaceSettings, RoomsApi, RoomVisibility, UpdateRoomInput, VerifyRoomContext, } from "./rooms";
|
|
8
|
+
export type { AccessKeyOptions, AccessKeyPreparation, Address, CreateDialogHostOptions, CreateFlowOptions, DialogHost, DialogOpenOptions, FinalizeAccessKeyParams, Flow, FlowConnectorParameters, FlowCookieNames, FlowIdProviderProps, FlowSessionState, FlowState, Listener, LoginOptions, MountProfileOptions, PrepareArgsWithAuth, PrepareTransactionRequestPhase, ProfileButtonHandle, ProfileButtonProps, ProfilePosition, RefreshCookieConfig, ResolveAccountParams, ResolvedAccessKeyOptions, RootCredential, RunLoginParams, RunLoginResult, SendCallsArgs, SendTransactionArgs, Session, SigningContext, SignMessageArgs, SignTypedDataArgs, Store, StoredAccessKey, WagmiConnectCapabilities, WagmiConnectParams, } from "./sdk";
|
|
9
|
+
export type { ResolvedFlowSession, ResolveSessionOptions } from "./server";
|
|
8
10
|
export type { CoinAsset, IdentifiedTx, TxApprove, TxConvert, TxSend, TxSwap, } from "./tx";
|
|
9
11
|
export type { ActionDayContext, ActionEventKind, ActionSessionState, ActiveActionResponse, LevelProgress, PublicProfile, XpGrantResult, XpRecentGrant, XpSummary, } from "./xp";
|
package/dist/sdk/types/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { isPersonalSignParams, isSendCallsParams, isSendTransactionParams, } from "./protocol";
|
|
1
|
+
export { getConnectCapabilities, isPersonalSignParams, isSendCallsParams, isSendTransactionParams, } from "./protocol";
|
|
@@ -131,6 +131,12 @@ export type SendCallsParams = {
|
|
|
131
131
|
chainId?: string;
|
|
132
132
|
capabilities?: Record<string, unknown>;
|
|
133
133
|
};
|
|
134
|
+
/**
|
|
135
|
+
* Unwraps the connect capabilities from a wallet_connect RPC request (they ride
|
|
136
|
+
* as the first param), or undefined. Several dialog surfaces read them (mode,
|
|
137
|
+
* accessKeyHash, guestToken), so the cast lives here rather than being repeated.
|
|
138
|
+
*/
|
|
139
|
+
export declare function getConnectCapabilities(request: RpcRequest | undefined): ConnectCapabilities | undefined;
|
|
134
140
|
export declare function isPersonalSignParams(params: readonly unknown[] | undefined): params is readonly [string, string];
|
|
135
141
|
export declare function isSendTransactionParams(params: readonly unknown[] | undefined): params is readonly [SendTransactionParams];
|
|
136
142
|
export declare function isSendCallsParams(params: readonly unknown[] | undefined): params is readonly [SendCallsParams];
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unwraps the connect capabilities from a wallet_connect RPC request (they ride
|
|
3
|
+
* as the first param), or undefined. Several dialog surfaces read them (mode,
|
|
4
|
+
* accessKeyHash, guestToken), so the cast lives here rather than being repeated.
|
|
5
|
+
*/
|
|
6
|
+
export function getConnectCapabilities(request) {
|
|
7
|
+
return request?.params?.[0]?.capabilities;
|
|
8
|
+
}
|
|
1
9
|
export function isPersonalSignParams(params) {
|
|
2
10
|
if (!params || params.length < 2)
|
|
3
11
|
return false;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/** Room registry types shared by the server routes and the client SDK. */
|
|
2
|
+
export type RoomVisibility = "public" | "unlisted" | "private";
|
|
3
|
+
/**
|
|
4
|
+
* Owner is not a member-row role: `room.ownerId` is the single authority.
|
|
5
|
+
* Member rows carry moderator|member.
|
|
6
|
+
*/
|
|
7
|
+
export type RoomRole = "owner" | "moderator" | "member";
|
|
8
|
+
export type RoomChannel = "game" | "voice" | "text";
|
|
9
|
+
export type RoomRestrictionKind = "ban" | "mute";
|
|
10
|
+
export interface RoomOwner {
|
|
11
|
+
id: string;
|
|
12
|
+
username: string;
|
|
13
|
+
}
|
|
14
|
+
/** Live occupant counts per channel, staleness-filtered at read time. */
|
|
15
|
+
export interface RoomOccupancy {
|
|
16
|
+
game: number;
|
|
17
|
+
voice: number;
|
|
18
|
+
text: number;
|
|
19
|
+
}
|
|
20
|
+
export interface RoomSummary {
|
|
21
|
+
id: string;
|
|
22
|
+
slug: string;
|
|
23
|
+
displayName: string;
|
|
24
|
+
description: string | null;
|
|
25
|
+
owner: RoomOwner | null;
|
|
26
|
+
visibility: RoomVisibility;
|
|
27
|
+
isSystem: boolean;
|
|
28
|
+
occupancy: RoomOccupancy;
|
|
29
|
+
createdAt: string;
|
|
30
|
+
}
|
|
31
|
+
export interface RoomDetail extends RoomSummary {
|
|
32
|
+
/** The caller's role in this room, when the request carried a valid bearer. */
|
|
33
|
+
viewerRole: RoomRole | null;
|
|
34
|
+
memberCount: number;
|
|
35
|
+
}
|
|
36
|
+
export interface RoomMemberEntry {
|
|
37
|
+
userId: string;
|
|
38
|
+
username: string;
|
|
39
|
+
role: RoomRole;
|
|
40
|
+
joinedAt: string;
|
|
41
|
+
}
|
|
42
|
+
export interface RoomMembers {
|
|
43
|
+
owner: RoomOwner | null;
|
|
44
|
+
members: RoomMemberEntry[];
|
|
45
|
+
}
|
|
46
|
+
export interface CreateRoomInput {
|
|
47
|
+
slug: string;
|
|
48
|
+
displayName?: string;
|
|
49
|
+
description?: string;
|
|
50
|
+
visibility?: RoomVisibility;
|
|
51
|
+
}
|
|
52
|
+
export interface UpdateRoomInput {
|
|
53
|
+
slug?: string;
|
|
54
|
+
displayName?: string;
|
|
55
|
+
description?: string;
|
|
56
|
+
visibility?: RoomVisibility;
|
|
57
|
+
}
|
|
58
|
+
export interface MyRooms {
|
|
59
|
+
owned: RoomSummary[];
|
|
60
|
+
member: RoomSummary[];
|
|
61
|
+
}
|
|
62
|
+
export interface RoomList {
|
|
63
|
+
rooms: RoomSummary[];
|
|
64
|
+
nextCursor: string | null;
|
|
65
|
+
}
|
|
66
|
+
export interface RoomPresenceEntry {
|
|
67
|
+
userId: string;
|
|
68
|
+
username: string;
|
|
69
|
+
joinedAt: string;
|
|
70
|
+
}
|
|
71
|
+
export interface RoomPresenceSnapshot {
|
|
72
|
+
roomId: string;
|
|
73
|
+
slug: string;
|
|
74
|
+
channels: Record<RoomChannel, RoomPresenceEntry[]>;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Room admission context returned by POST /api/session/verify when the caller
|
|
78
|
+
* passes a `room` slug: whether the verified subject may enter, and with which
|
|
79
|
+
* role and per-room game settings. `settings` is delivered only through this
|
|
80
|
+
* server-to-server path — surface blobs may hold secret-ish values.
|
|
81
|
+
*/
|
|
82
|
+
export interface VerifyRoomContext {
|
|
83
|
+
ok: boolean;
|
|
84
|
+
reason?: "not_found" | "banned" | "private_not_member";
|
|
85
|
+
id: string | null;
|
|
86
|
+
slug: string;
|
|
87
|
+
visibility: RoomVisibility | null;
|
|
88
|
+
role: RoomRole | null;
|
|
89
|
+
muted: boolean;
|
|
90
|
+
settings: Record<string, number | boolean | string>;
|
|
91
|
+
}
|
|
92
|
+
export interface RoomSurfaceSettings {
|
|
93
|
+
surface: string;
|
|
94
|
+
version: number;
|
|
95
|
+
settings: Record<string, number | boolean | string>;
|
|
96
|
+
}
|
|
97
|
+
/** The room-registry client exposed as `flow.rooms`. Public reads work
|
|
98
|
+
* signed-out; everything else attaches the caller's bearer token. */
|
|
99
|
+
export interface RoomsApi {
|
|
100
|
+
list(options?: {
|
|
101
|
+
cursor?: string;
|
|
102
|
+
limit?: number;
|
|
103
|
+
}): Promise<RoomList>;
|
|
104
|
+
get(slug: string): Promise<RoomDetail>;
|
|
105
|
+
mine(): Promise<MyRooms>;
|
|
106
|
+
create(input: CreateRoomInput): Promise<RoomDetail>;
|
|
107
|
+
update(slug: string, patch: UpdateRoomInput): Promise<RoomDetail>;
|
|
108
|
+
remove(slug: string): Promise<void>;
|
|
109
|
+
join(slug: string): Promise<void>;
|
|
110
|
+
leave(slug: string): Promise<void>;
|
|
111
|
+
members(slug: string): Promise<RoomMembers>;
|
|
112
|
+
presence(slug: string): Promise<RoomPresenceSnapshot>;
|
|
113
|
+
settings(slug: string, surface: string): Promise<RoomSurfaceSettings>;
|
|
114
|
+
saveSettings(slug: string, surface: string, settings: Record<string, unknown>): Promise<RoomSurfaceSettings>;
|
|
115
|
+
transfer(slug: string, userId: string): Promise<void>;
|
|
116
|
+
claim(slug: string): Promise<void>;
|
|
117
|
+
setRole(slug: string, userId: string, role: "moderator" | "member"): Promise<void>;
|
|
118
|
+
kick(slug: string, userId: string): Promise<void>;
|
|
119
|
+
ban(slug: string, userId: string, options?: {
|
|
120
|
+
reason?: string;
|
|
121
|
+
expiresInSeconds?: number;
|
|
122
|
+
}): Promise<void>;
|
|
123
|
+
unban(slug: string, userId: string): Promise<void>;
|
|
124
|
+
mute(slug: string, userId: string, options?: {
|
|
125
|
+
reason?: string;
|
|
126
|
+
expiresInSeconds?: number;
|
|
127
|
+
}): Promise<void>;
|
|
128
|
+
unmute(slug: string, userId: string): Promise<void>;
|
|
129
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/** Room registry types shared by the server routes and the client SDK. */
|
package/dist/sdk/types/sdk.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { Chain, Hex, PrepareTransactionRequestParameters, SendTransactionPa
|
|
|
3
3
|
import type { SendCallsParameters } from "viem/actions";
|
|
4
4
|
import type { FlowCredential, FlowUser, WebAuthnSignature } from "./auth";
|
|
5
5
|
import type { ConnectCapabilities, ConnectResponse } from "./protocol";
|
|
6
|
+
import type { RoomsApi } from "./rooms";
|
|
6
7
|
export type Address = `0x${string}`;
|
|
7
8
|
/**
|
|
8
9
|
* Phases viem invokes a chain's `prepareTransactionRequest` hook at. Inlined
|
|
@@ -70,6 +71,22 @@ export type CreateFlowOptions = {
|
|
|
70
71
|
* data keyed on the Flow user id survives. See `ensureGuest`.
|
|
71
72
|
*/
|
|
72
73
|
autoGuest?: boolean;
|
|
74
|
+
/**
|
|
75
|
+
* If true, the JWT and the rotating refresh token are mirrored into
|
|
76
|
+
* first-party cookies on the app's own origin so the app's server can
|
|
77
|
+
* resolve the session per-request (see `@flow-industries/id/server`).
|
|
78
|
+
* Off by default — a backend that never reads them shouldn't start
|
|
79
|
+
* receiving refresh tokens on every request.
|
|
80
|
+
*/
|
|
81
|
+
cookies?: boolean;
|
|
82
|
+
/**
|
|
83
|
+
* Session state resolved server-side (via `resolveSession`) to seed the
|
|
84
|
+
* store synchronously, so the first client render matches the SSR HTML and
|
|
85
|
+
* no bootstrap refresh runs while the hydrated JWT is still fresh. Pass
|
|
86
|
+
* `null` for a known signed-out visitor; omit for the default client-only
|
|
87
|
+
* bootstrap.
|
|
88
|
+
*/
|
|
89
|
+
initialState?: FlowSessionState | null;
|
|
73
90
|
};
|
|
74
91
|
export type FlowState = {
|
|
75
92
|
user: FlowUser | null;
|
|
@@ -77,6 +94,30 @@ export type FlowState = {
|
|
|
77
94
|
credential: FlowCredential | null;
|
|
78
95
|
address: Address | null;
|
|
79
96
|
};
|
|
97
|
+
/**
|
|
98
|
+
* The auth state a server can resolve per-request from the first-party
|
|
99
|
+
* cookies: identity + access token, but never the credential (passkeys live
|
|
100
|
+
* in the browser's IndexedDB only).
|
|
101
|
+
*/
|
|
102
|
+
export type FlowSessionState = {
|
|
103
|
+
user: FlowUser;
|
|
104
|
+
jwt: string;
|
|
105
|
+
/**
|
|
106
|
+
* Null whenever the session came from a locally-verified JWT — the common
|
|
107
|
+
* SSR hot path — because the claim set carries no address. The client
|
|
108
|
+
* restores it from the stored passkey credential after hydration; never
|
|
109
|
+
* server-render wallet UI from this field.
|
|
110
|
+
*/
|
|
111
|
+
address: Address | null;
|
|
112
|
+
};
|
|
113
|
+
export type FlowCookieNames = {
|
|
114
|
+
jwt: string;
|
|
115
|
+
refresh: string;
|
|
116
|
+
};
|
|
117
|
+
export type RefreshCookieConfig = {
|
|
118
|
+
name: string;
|
|
119
|
+
secure: boolean;
|
|
120
|
+
};
|
|
80
121
|
export type LoginOptions = {
|
|
81
122
|
mode?: "iframe" | "popup";
|
|
82
123
|
signUp?: boolean;
|
|
@@ -205,6 +246,8 @@ export type Flow = {
|
|
|
205
246
|
walletClient(args?: {
|
|
206
247
|
chainId?: number;
|
|
207
248
|
}): Promise<WalletClient>;
|
|
249
|
+
/** Typed client for the room registry (browse, membership, settings, moderation). */
|
|
250
|
+
rooms: RoomsApi;
|
|
208
251
|
subscribe(listener: Listener<FlowState>): () => void;
|
|
209
252
|
getState(): FlowState;
|
|
210
253
|
dialog: DialogHost;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { VerifiedFlowJWT } from "./auth";
|
|
2
|
+
import type { FlowSessionState } from "./sdk";
|
|
3
|
+
import type { PublicProfile } from "./xp";
|
|
4
|
+
export type ResolveSessionOptions = {
|
|
5
|
+
/** The calling app's origin, e.g. `https://flow.game` — must match the JWT `aud`. */
|
|
6
|
+
audience: string;
|
|
7
|
+
/** Flow ID origin; defaults to `https://id.flow.industries`. */
|
|
8
|
+
issuerUrl?: string;
|
|
9
|
+
/**
|
|
10
|
+
* Also fetch the public profile (avatar, tier, level) for a resolved
|
|
11
|
+
* session via `/api/session/verify` — one extra round-trip; off by default.
|
|
12
|
+
*/
|
|
13
|
+
profile?: boolean;
|
|
14
|
+
};
|
|
15
|
+
export type ResolvedFlowSession = {
|
|
16
|
+
/** Resolved session, or null for a signed-out visitor. */
|
|
17
|
+
state: FlowSessionState | null;
|
|
18
|
+
/** Verified JWT claims when the session came from a locally-verified JWT. */
|
|
19
|
+
claims: VerifiedFlowJWT | null;
|
|
20
|
+
/**
|
|
21
|
+
* Set-Cookie values to append to the response: rotation successors after a
|
|
22
|
+
* server-side refresh, or expired-cookie clears after a dead token. Empty on
|
|
23
|
+
* the JWT-valid hot path.
|
|
24
|
+
*/
|
|
25
|
+
setCookies: string[];
|
|
26
|
+
/** Public profile, only when requested via `profile: true` and resolvable. */
|
|
27
|
+
profile?: PublicProfile;
|
|
28
|
+
};
|
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flow-industries/id",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"main": "./dist/sdk/client/index.js",
|
|
5
5
|
"module": "./dist/sdk/client/index.js",
|
|
6
6
|
"types": "./dist/sdk/client/index.d.ts",
|
|
@@ -29,6 +29,10 @@
|
|
|
29
29
|
"./verify": {
|
|
30
30
|
"types": "./dist/sdk/verify.d.ts",
|
|
31
31
|
"import": "./dist/sdk/verify.js"
|
|
32
|
+
},
|
|
33
|
+
"./server": {
|
|
34
|
+
"types": "./dist/sdk/server.d.ts",
|
|
35
|
+
"import": "./dist/sdk/server.js"
|
|
32
36
|
}
|
|
33
37
|
},
|
|
34
38
|
"files": [
|