@flow-industries/id 0.19.2 → 0.19.4
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/iframe-host.d.ts +14 -1
- package/dist/sdk/client/iframe-host.js +24 -0
- package/dist/sdk/client/index.d.ts +2 -1
- package/dist/sdk/client/index.js +1 -0
- package/dist/sdk/client/open-profile.d.ts +42 -0
- package/dist/sdk/client/open-profile.js +177 -0
- package/dist/sdk/client/profile-button.js +3 -16
- package/dist/sdk/react/index.d.ts +2 -1
- package/dist/sdk/react/index.js +1 -0
- package/dist/sdk/react/open-profile.d.ts +18 -0
- package/dist/sdk/react/open-profile.js +24 -0
- package/dist/sdk/types/auth.d.ts +27 -0
- package/dist/sdk/types/cosmetics.d.ts +1 -1
- package/dist/sdk/types/cosmetics.js +1 -1
- package/dist/sdk/types/dialog.d.ts +2 -1
- package/dist/sdk/types/events.d.ts +2 -2
- package/dist/sdk/types/index.d.ts +3 -3
- package/dist/sdk/types/messenger.d.ts +3 -0
- package/dist/sdk/types/rooms.d.ts +20 -0
- package/dist/sdk/types/sdk.d.ts +26 -0
- package/dist/sdk/types/xp.d.ts +40 -0
- package/dist/sdk/usernames.d.ts +27 -0
- package/dist/sdk/usernames.js +101 -0
- package/package.json +1 -1
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import * as Messenger from "../dialog/remote/Messenger";
|
|
2
|
-
import type { Flow } from "../types";
|
|
2
|
+
import type { Flow, ProfileIdentity } from "../types";
|
|
3
3
|
/**
|
|
4
4
|
* Permissions the dialog iframe needs to run WebAuthn passkey ceremonies and
|
|
5
5
|
* copy recovery values. Shared by every Flow iframe host (the dialog host and
|
|
6
6
|
* the profile widget).
|
|
7
7
|
*/
|
|
8
8
|
export declare const IFRAME_ALLOW = "publickey-credentials-create; publickey-credentials-get; clipboard-write";
|
|
9
|
+
/**
|
|
10
|
+
* Geometry for a dialog overlay iframe: a fullscreen layer above everything the
|
|
11
|
+
* host draws, so the dialog owns the whole viewport and dims the page behind
|
|
12
|
+
* its own backdrop. Hosts toggle `pointerEvents` on top of this and nothing
|
|
13
|
+
* else — see the visibility rule on `createProfileButton`.
|
|
14
|
+
*/
|
|
15
|
+
export declare const OVERLAY_STYLE: Partial<CSSStyleDeclaration>;
|
|
9
16
|
/**
|
|
10
17
|
* Creates a Flow dialog iframe element: WebAuthn-permitted, borderless, and
|
|
11
18
|
* `color-scheme: normal` so the iframe itself stays transparent (the rendered
|
|
@@ -22,6 +29,12 @@ export declare function makeIframe(src: string): HTMLIFrameElement;
|
|
|
22
29
|
export declare function bridgeToWindow(target: Window, options?: {
|
|
23
30
|
targetOrigin?: string;
|
|
24
31
|
}): Messenger.Bridge;
|
|
32
|
+
/**
|
|
33
|
+
* Who the host currently has signed in, in the shape the dialog reads to tell
|
|
34
|
+
* "this is you" from "this is someone else". Null when signed out, which is a
|
|
35
|
+
* real answer the dialog renders rather than a missing one it waits for.
|
|
36
|
+
*/
|
|
37
|
+
export declare function hostIdentity(flow: Flow): ProfileIdentity | null;
|
|
25
38
|
/**
|
|
26
39
|
* Answers a widget's request for the host's credential. Every Flow iframe that
|
|
27
40
|
* reads the Flow ID API from a cross-site frame asks for this, because its own
|
|
@@ -5,6 +5,19 @@ import * as Messenger from "../dialog/remote/Messenger";
|
|
|
5
5
|
* the profile widget).
|
|
6
6
|
*/
|
|
7
7
|
export const IFRAME_ALLOW = "publickey-credentials-create; publickey-credentials-get; clipboard-write";
|
|
8
|
+
/**
|
|
9
|
+
* Geometry for a dialog overlay iframe: a fullscreen layer above everything the
|
|
10
|
+
* host draws, so the dialog owns the whole viewport and dims the page behind
|
|
11
|
+
* its own backdrop. Hosts toggle `pointerEvents` on top of this and nothing
|
|
12
|
+
* else — see the visibility rule on `createProfileButton`.
|
|
13
|
+
*/
|
|
14
|
+
export const OVERLAY_STYLE = {
|
|
15
|
+
position: "fixed",
|
|
16
|
+
inset: "0",
|
|
17
|
+
width: "100%",
|
|
18
|
+
height: "100%",
|
|
19
|
+
zIndex: "2147483647",
|
|
20
|
+
};
|
|
8
21
|
/**
|
|
9
22
|
* Creates a Flow dialog iframe element: WebAuthn-permitted, borderless, and
|
|
10
23
|
* `color-scheme: normal` so the iframe itself stays transparent (the rendered
|
|
@@ -32,6 +45,17 @@ export function bridgeToWindow(target, options = {}) {
|
|
|
32
45
|
waitForReady: true,
|
|
33
46
|
});
|
|
34
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Who the host currently has signed in, in the shape the dialog reads to tell
|
|
50
|
+
* "this is you" from "this is someone else". Null when signed out, which is a
|
|
51
|
+
* real answer the dialog renders rather than a missing one it waits for.
|
|
52
|
+
*/
|
|
53
|
+
export function hostIdentity(flow) {
|
|
54
|
+
const user = flow.getState().user;
|
|
55
|
+
return user
|
|
56
|
+
? { username: user.username, isGuest: user.isGuest === true }
|
|
57
|
+
: null;
|
|
58
|
+
}
|
|
35
59
|
/**
|
|
36
60
|
* Answers a widget's request for the host's credential. Every Flow iframe that
|
|
37
61
|
* reads the Flow ID API from a cross-site frame asks for this, because its own
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
export { defaultIdHost, isLocalHostname } from "../id-host";
|
|
2
|
-
export type { AccessKeyOptions, AdditionalSession, Address, ConnectCapabilities, ConnectResponse, CreateFlowOptions, DialogHost, Flow, FlowCredential, FlowSessionState, FlowState, FlowUser, FlowWidgetHandle, FlowWidgetName, LoginOptions, MethodName, MountFlowWidgetOptions, MountProfileOptions, ProfileButtonHandle, ProfilePosition, Session, } from "../types";
|
|
2
|
+
export type { AccessKeyOptions, AdditionalSession, Address, ConnectCapabilities, ConnectResponse, CreateFlowOptions, DialogHost, Flow, FlowCredential, FlowSessionState, FlowState, FlowUser, FlowWidgetHandle, FlowWidgetName, LoginOptions, MethodName, MountFlowWidgetOptions, MountProfileOptions, OpenProfileOptions, ProfileButtonHandle, ProfilePosition, Session, } from "../types";
|
|
3
3
|
export { createFlow, getFlow, requireFlow, resetFlow } from "./create-flow";
|
|
4
4
|
export { createDialogHost } from "./dialog-host";
|
|
5
5
|
export { createFlowWidget } from "./flow-widget";
|
|
6
6
|
export { METHODS } from "./methods";
|
|
7
|
+
export { closeProfile, openProfile } from "./open-profile";
|
|
7
8
|
export { createProfileButton } from "./profile-button";
|
|
8
9
|
export { createRoomsApi, RoomsRequestError } from "./rooms";
|
|
9
10
|
export { createStaticFlow } from "./static-flow";
|
package/dist/sdk/client/index.js
CHANGED
|
@@ -3,6 +3,7 @@ export { createFlow, getFlow, requireFlow, resetFlow } from "./create-flow";
|
|
|
3
3
|
export { createDialogHost } from "./dialog-host";
|
|
4
4
|
export { createFlowWidget } from "./flow-widget";
|
|
5
5
|
export { METHODS } from "./methods";
|
|
6
|
+
export { closeProfile, openProfile } from "./open-profile";
|
|
6
7
|
export { createProfileButton } from "./profile-button";
|
|
7
8
|
export { createRoomsApi, RoomsRequestError } from "./rooms";
|
|
8
9
|
export { createStaticFlow } from "./static-flow";
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { OpenProfileOptions } from "../types";
|
|
2
|
+
/**
|
|
3
|
+
* Opens another user's public Flow profile in a fullscreen overlay above the
|
|
4
|
+
* host page — the read-only view of `@username`: avatar, bio, level and the
|
|
5
|
+
* month they joined.
|
|
6
|
+
*
|
|
7
|
+
* This exists because most Flow surfaces have nowhere to navigate to. The game
|
|
8
|
+
* draws into a canvas and talk's chat lives in an embedded frame, so following
|
|
9
|
+
* a link would replace the thing the player is doing; an overlay leaves it
|
|
10
|
+
* running underneath. The dialog is served by Flow ID itself, so it reads the
|
|
11
|
+
* profile first-party and inherits the auth dialog's chrome.
|
|
12
|
+
*
|
|
13
|
+
* Callers name a **user**, never a route, for the reason `createFlowWidget`
|
|
14
|
+
* takes a widget name: the dialog's URL space stays an internal detail and an
|
|
15
|
+
* embedder can't point the frame at an auth route. A profile route is
|
|
16
|
+
* parameterized, so the SDK validates the name and builds the route itself —
|
|
17
|
+
* a name that could not belong to a Flow account is refused here rather than
|
|
18
|
+
* opening the dialog onto a request the server was always going to reject.
|
|
19
|
+
*
|
|
20
|
+
* The overlay is created on first use and reused after: opening a second, third
|
|
21
|
+
* and tenth profile re-points the same iframe, which is what keeps every open
|
|
22
|
+
* animating and costs no reload. It is safe to call from a click handler on
|
|
23
|
+
* every name your UI renders.
|
|
24
|
+
*
|
|
25
|
+
* Returns whether the profile was opened — `false` for a username that isn't
|
|
26
|
+
* shaped like a Flow handle, and under SSR, where there is nothing to open.
|
|
27
|
+
*
|
|
28
|
+
* ```ts
|
|
29
|
+
* import { openProfile } from "@flow-industries/id";
|
|
30
|
+
*
|
|
31
|
+
* openProfile("alice");
|
|
32
|
+
* openProfile("bob", { theme: "dark" });
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export declare function openProfile(username: string, options?: OpenProfileOptions): boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Closes the profile overlay if one is open. The dialog already closes itself
|
|
38
|
+
* on its own close button, the backdrop and Escape, so this is for the host
|
|
39
|
+
* that needs to take the screen back on its own terms — a game resuming play,
|
|
40
|
+
* a route change in an embedded app. A no-op when nothing is open.
|
|
41
|
+
*/
|
|
42
|
+
export declare function closeProfile(): void;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { resolveIdHost } from "../id-host";
|
|
2
|
+
import { isValidUsername } from "../usernames";
|
|
3
|
+
import { getFlow, requireFlow } from "./create-flow";
|
|
4
|
+
import { answerTokenRequest, bridgeToWindow, hostIdentity, makeIframe, OVERLAY_STYLE, } from "./iframe-host";
|
|
5
|
+
// One overlay per page, kept for the page's lifetime. The alternative — a
|
|
6
|
+
// handle per call site — would put a second fullscreen iframe over the host the
|
|
7
|
+
// moment two parts of an app (a scoreboard and a chat line) both showed a
|
|
8
|
+
// profile, and each would carry its own copy of the dialog.
|
|
9
|
+
let viewer = null;
|
|
10
|
+
function createViewer(options) {
|
|
11
|
+
const flow = options.flow ?? getFlow() ?? requireFlow();
|
|
12
|
+
const host = resolveIdHost(options.host ?? flow.host);
|
|
13
|
+
const hostOrigin = new URL(host).origin;
|
|
14
|
+
let theme = options.theme ?? "light dark";
|
|
15
|
+
let frame = null;
|
|
16
|
+
let bridge = null;
|
|
17
|
+
// The overlay covers the whole viewport, so it must not accept a click until
|
|
18
|
+
// the dialog behind it can act on one. `ready` is the child's own handshake:
|
|
19
|
+
// until it arrives the frame is still loading — or never will, if the host is
|
|
20
|
+
// unreachable — and a hit-testing overlay would silently eat every click
|
|
21
|
+
// meant for the page underneath with nothing on screen to explain it.
|
|
22
|
+
let ready = false;
|
|
23
|
+
let visible = false;
|
|
24
|
+
function applyHitTesting() {
|
|
25
|
+
if (frame)
|
|
26
|
+
frame.style.pointerEvents = ready && visible ? "auto" : "none";
|
|
27
|
+
}
|
|
28
|
+
function mount(username) {
|
|
29
|
+
const el = makeIframe(`${host}/dialog/`);
|
|
30
|
+
el.dataset.flowProfileView = "";
|
|
31
|
+
Object.assign(el.style, OVERLAY_STYLE, { pointerEvents: "none" });
|
|
32
|
+
// Appended before bridging: `contentWindow` is null until the frame is in
|
|
33
|
+
// the document, and the bridge is bound to that window.
|
|
34
|
+
document.body.appendChild(el);
|
|
35
|
+
frame = el;
|
|
36
|
+
/* SAFETY: the iframe was just appended, so its contentWindow exists. */
|
|
37
|
+
const opened = bridgeToWindow(el.contentWindow, {
|
|
38
|
+
targetOrigin: hostOrigin,
|
|
39
|
+
});
|
|
40
|
+
bridge = opened;
|
|
41
|
+
// The dialog SPA has no deep-link history fallback, so the frame loads at
|
|
42
|
+
// `/dialog/` and `init` asks it to land on the profile. Sending the route
|
|
43
|
+
// here rather than as a follow-up message keeps the sign-in root from ever
|
|
44
|
+
// mounting, even for the frame it takes to navigate away from it.
|
|
45
|
+
void opened.send("__internal", {
|
|
46
|
+
type: "init",
|
|
47
|
+
mode: "iframe",
|
|
48
|
+
referrer: { title: document.title },
|
|
49
|
+
theme: { colorScheme: theme },
|
|
50
|
+
route: `/dialog/u/${username}`,
|
|
51
|
+
});
|
|
52
|
+
void opened
|
|
53
|
+
.waitForReady()
|
|
54
|
+
.then(() => {
|
|
55
|
+
ready = true;
|
|
56
|
+
applyHitTesting();
|
|
57
|
+
})
|
|
58
|
+
.catch(() => { });
|
|
59
|
+
opened.on("close", () => close());
|
|
60
|
+
opened.on("__internal", (payload) => {
|
|
61
|
+
// A viewer looking at their own profile can reach the account view from
|
|
62
|
+
// here, which offers the same sign-in and sign-out the pill's dialog
|
|
63
|
+
// does. Those run on the host — it owns the session — so dropping them
|
|
64
|
+
// would leave the buttons doing nothing at all.
|
|
65
|
+
if (payload.type === "profile-login") {
|
|
66
|
+
close();
|
|
67
|
+
void flow
|
|
68
|
+
.login(payload.mode === "signup" ? { signUp: true } : { signIn: true })
|
|
69
|
+
.catch(() => { });
|
|
70
|
+
}
|
|
71
|
+
else if (payload.type === "profile-logout") {
|
|
72
|
+
close();
|
|
73
|
+
void flow.logout().catch(() => { });
|
|
74
|
+
}
|
|
75
|
+
else if (payload.type === "profile-token-request") {
|
|
76
|
+
void answerTokenRequest(opened, flow, payload.id);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
return opened;
|
|
80
|
+
}
|
|
81
|
+
function open(username) {
|
|
82
|
+
const mounted = bridge;
|
|
83
|
+
const live = mounted ?? mount(username);
|
|
84
|
+
// A frame created just now is already on this profile; an existing one is
|
|
85
|
+
// re-pointed in place. Tearing it down and rebuilding would reload the
|
|
86
|
+
// dialog and lose the open animation on every profile after the first.
|
|
87
|
+
if (mounted)
|
|
88
|
+
void live.send("__internal", { type: "profile-view", username });
|
|
89
|
+
// Pushed on every open rather than tracked with a subscription: it is only
|
|
90
|
+
// read while a profile is on screen, and sending it here means the "this is
|
|
91
|
+
// you" link is decided from the session as it stands at that moment.
|
|
92
|
+
void live.send("__internal", {
|
|
93
|
+
type: "profile-identity",
|
|
94
|
+
identity: hostIdentity(flow),
|
|
95
|
+
});
|
|
96
|
+
visible = true;
|
|
97
|
+
applyHitTesting();
|
|
98
|
+
void live.send("__internal", { type: "dialog-shown" });
|
|
99
|
+
}
|
|
100
|
+
// Visibility is hit-testing plus a message, and nothing else. The overlay
|
|
101
|
+
// iframe is NEVER set to `display: none` — that pauses the iframe's
|
|
102
|
+
// requestAnimationFrame, so motion's engine is asleep when the next open
|
|
103
|
+
// fires and the animation jumps straight to its end (instant). Keeping it
|
|
104
|
+
// always-rendered, transparent and click-through while hidden keeps every
|
|
105
|
+
// open animation reliable, on the second open and the tenth.
|
|
106
|
+
function close() {
|
|
107
|
+
if (!frame || !bridge)
|
|
108
|
+
return;
|
|
109
|
+
visible = false;
|
|
110
|
+
applyHitTesting();
|
|
111
|
+
void bridge.send("__internal", { type: "dialog-hidden" });
|
|
112
|
+
}
|
|
113
|
+
function setTheme(next) {
|
|
114
|
+
if (next === theme)
|
|
115
|
+
return;
|
|
116
|
+
theme = next;
|
|
117
|
+
void bridge?.send("__internal", {
|
|
118
|
+
type: "set-theme",
|
|
119
|
+
theme: { colorScheme: next },
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return { open, close, setTheme };
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Opens another user's public Flow profile in a fullscreen overlay above the
|
|
126
|
+
* host page — the read-only view of `@username`: avatar, bio, level and the
|
|
127
|
+
* month they joined.
|
|
128
|
+
*
|
|
129
|
+
* This exists because most Flow surfaces have nowhere to navigate to. The game
|
|
130
|
+
* draws into a canvas and talk's chat lives in an embedded frame, so following
|
|
131
|
+
* a link would replace the thing the player is doing; an overlay leaves it
|
|
132
|
+
* running underneath. The dialog is served by Flow ID itself, so it reads the
|
|
133
|
+
* profile first-party and inherits the auth dialog's chrome.
|
|
134
|
+
*
|
|
135
|
+
* Callers name a **user**, never a route, for the reason `createFlowWidget`
|
|
136
|
+
* takes a widget name: the dialog's URL space stays an internal detail and an
|
|
137
|
+
* embedder can't point the frame at an auth route. A profile route is
|
|
138
|
+
* parameterized, so the SDK validates the name and builds the route itself —
|
|
139
|
+
* a name that could not belong to a Flow account is refused here rather than
|
|
140
|
+
* opening the dialog onto a request the server was always going to reject.
|
|
141
|
+
*
|
|
142
|
+
* The overlay is created on first use and reused after: opening a second, third
|
|
143
|
+
* and tenth profile re-points the same iframe, which is what keeps every open
|
|
144
|
+
* animating and costs no reload. It is safe to call from a click handler on
|
|
145
|
+
* every name your UI renders.
|
|
146
|
+
*
|
|
147
|
+
* Returns whether the profile was opened — `false` for a username that isn't
|
|
148
|
+
* shaped like a Flow handle, and under SSR, where there is nothing to open.
|
|
149
|
+
*
|
|
150
|
+
* ```ts
|
|
151
|
+
* import { openProfile } from "@flow-industries/id";
|
|
152
|
+
*
|
|
153
|
+
* openProfile("alice");
|
|
154
|
+
* openProfile("bob", { theme: "dark" });
|
|
155
|
+
* ```
|
|
156
|
+
*/
|
|
157
|
+
export function openProfile(username, options = {}) {
|
|
158
|
+
if (!isValidUsername(username))
|
|
159
|
+
return false;
|
|
160
|
+
if (!("document" in globalThis))
|
|
161
|
+
return false;
|
|
162
|
+
const live = viewer ?? createViewer(options);
|
|
163
|
+
viewer = live;
|
|
164
|
+
if (options.theme)
|
|
165
|
+
live.setTheme(options.theme);
|
|
166
|
+
live.open(username);
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Closes the profile overlay if one is open. The dialog already closes itself
|
|
171
|
+
* on its own close button, the backdrop and Escape, so this is for the host
|
|
172
|
+
* that needs to take the screen back on its own terms — a game resuming play,
|
|
173
|
+
* a route change in an embedded app. A no-op when nothing is open.
|
|
174
|
+
*/
|
|
175
|
+
export function closeProfile() {
|
|
176
|
+
viewer?.close();
|
|
177
|
+
}
|
|
@@ -1,19 +1,12 @@
|
|
|
1
1
|
import { resolveIdHost } from "../id-host";
|
|
2
2
|
import { getFlow, requireFlow } from "./create-flow";
|
|
3
|
-
import { answerTokenRequest, bridgeToWindow, makeIframe } from "./iframe-host";
|
|
3
|
+
import { answerTokenRequest, bridgeToWindow, hostIdentity, makeIframe, OVERLAY_STYLE, } from "./iframe-host";
|
|
4
4
|
const POSITION_STYLE = {
|
|
5
5
|
"top-right": { top: "0", right: "0" },
|
|
6
6
|
"top-left": { top: "0", left: "0" },
|
|
7
7
|
"bottom-right": { bottom: "0", right: "0" },
|
|
8
8
|
"bottom-left": { bottom: "0", left: "0" },
|
|
9
9
|
};
|
|
10
|
-
const OVERLAY_STYLE = {
|
|
11
|
-
position: "fixed",
|
|
12
|
-
inset: "0",
|
|
13
|
-
width: "100%",
|
|
14
|
-
height: "100%",
|
|
15
|
-
zIndex: "2147483647",
|
|
16
|
-
};
|
|
17
10
|
/**
|
|
18
11
|
* Mounts the universal Flow ID "profile button" — a persistent inline iframe
|
|
19
12
|
* showing the signed-in user's avatar + username (the collapsed pill). Clicking
|
|
@@ -77,12 +70,6 @@ export function createProfileButton(options) {
|
|
|
77
70
|
});
|
|
78
71
|
return bridge;
|
|
79
72
|
}
|
|
80
|
-
function currentIdentity() {
|
|
81
|
-
const user = flow.getState().user;
|
|
82
|
-
return user
|
|
83
|
-
? { username: user.username, isGuest: user.isGuest === true }
|
|
84
|
-
: null;
|
|
85
|
-
}
|
|
86
73
|
// ----- the persistent pill (stays mounted; sizes to its own content) -----
|
|
87
74
|
const pill = makeFrame();
|
|
88
75
|
let pillWidth = 0;
|
|
@@ -143,7 +130,7 @@ export function createProfileButton(options) {
|
|
|
143
130
|
});
|
|
144
131
|
void dialogBridge.send("__internal", {
|
|
145
132
|
type: "profile-identity",
|
|
146
|
-
identity:
|
|
133
|
+
identity: hostIdentity(flow),
|
|
147
134
|
});
|
|
148
135
|
}
|
|
149
136
|
// The dialog owns its open/close animation entirely; the host only signals
|
|
@@ -170,7 +157,7 @@ export function createProfileButton(options) {
|
|
|
170
157
|
// skip re-sending an identical identity (e.g. on every silent JWT refresh).
|
|
171
158
|
let lastIdentityKey;
|
|
172
159
|
function pushIdentity() {
|
|
173
|
-
const identity =
|
|
160
|
+
const identity = hostIdentity(flow);
|
|
174
161
|
const key = identity ? `${identity.username}|${identity.isGuest}` : "none";
|
|
175
162
|
if (key === lastIdentityKey)
|
|
176
163
|
return;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
export type { FlowIdProviderProps, FlowWidgetProps, ProfileButtonProps, } from "../types";
|
|
1
|
+
export type { FlowIdProviderProps, FlowWidgetProps, ProfileButtonProps, UseOpenProfileOptions, } from "../types";
|
|
2
2
|
export { FlowWidget } from "./flow-widget";
|
|
3
3
|
export { useFlow, useFlowId, useFlowState, useRoom, useRooms } from "./hooks";
|
|
4
|
+
export { useOpenProfile } from "./open-profile";
|
|
4
5
|
export { ProfileButton } from "./profile-button";
|
|
5
6
|
export { FlowIdProvider } from "./provider";
|
package/dist/sdk/react/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { FlowWidget } from "./flow-widget";
|
|
2
2
|
export { useFlow, useFlowId, useFlowState, useRoom, useRooms } from "./hooks";
|
|
3
|
+
export { useOpenProfile } from "./open-profile";
|
|
3
4
|
export { ProfileButton } from "./profile-button";
|
|
4
5
|
export { FlowIdProvider } from "./provider";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { UseOpenProfileOptions } from "../types";
|
|
2
|
+
/**
|
|
3
|
+
* Returns a callback that opens another user's public Flow profile in the
|
|
4
|
+
* shared overlay — the React face of `openProfile`, resolving the Flow instance
|
|
5
|
+
* from a `<FlowIdProvider>` ancestor or the createFlow() singleton so callers
|
|
6
|
+
* don't thread it through. The callback returns false for a username that isn't
|
|
7
|
+
* shaped like a Flow handle.
|
|
8
|
+
*
|
|
9
|
+
* There is deliberately nothing to clean up on unmount: the overlay belongs to
|
|
10
|
+
* the page, not to the component that first opened it, so every name in a chat
|
|
11
|
+
* log or a scoreboard shares one iframe and one dialog.
|
|
12
|
+
*
|
|
13
|
+
* ```tsx
|
|
14
|
+
* const openProfile = useOpenProfile({ theme: "dark" });
|
|
15
|
+
* return <button onClick={() => openProfile(player.username)}>@{player.username}</button>;
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
export declare function useOpenProfile(options?: UseOpenProfileOptions): (username: string) => boolean;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import { openProfile } from "../client/open-profile";
|
|
3
|
+
import { useFlow } from "./hooks";
|
|
4
|
+
/**
|
|
5
|
+
* Returns a callback that opens another user's public Flow profile in the
|
|
6
|
+
* shared overlay — the React face of `openProfile`, resolving the Flow instance
|
|
7
|
+
* from a `<FlowIdProvider>` ancestor or the createFlow() singleton so callers
|
|
8
|
+
* don't thread it through. The callback returns false for a username that isn't
|
|
9
|
+
* shaped like a Flow handle.
|
|
10
|
+
*
|
|
11
|
+
* There is deliberately nothing to clean up on unmount: the overlay belongs to
|
|
12
|
+
* the page, not to the component that first opened it, so every name in a chat
|
|
13
|
+
* log or a scoreboard shares one iframe and one dialog.
|
|
14
|
+
*
|
|
15
|
+
* ```tsx
|
|
16
|
+
* const openProfile = useOpenProfile({ theme: "dark" });
|
|
17
|
+
* return <button onClick={() => openProfile(player.username)}>@{player.username}</button>;
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export function useOpenProfile(options = {}) {
|
|
21
|
+
const flow = useFlow();
|
|
22
|
+
const { host, theme } = options;
|
|
23
|
+
return useCallback((username) => openProfile(username, { flow, host, theme }), [flow, host, theme]);
|
|
24
|
+
}
|
package/dist/sdk/types/auth.d.ts
CHANGED
|
@@ -25,7 +25,34 @@ export interface SessionUser {
|
|
|
25
25
|
isGuest?: boolean;
|
|
26
26
|
role?: string | null;
|
|
27
27
|
image?: string | null;
|
|
28
|
+
bio?: string | null;
|
|
28
29
|
}
|
|
30
|
+
/** Why a submitted bio was refused: it carried control or bidi characters, or
|
|
31
|
+
* it was longer than `MAX_BIO_LENGTH` once normalized. */
|
|
32
|
+
export type BioRejection = "unsafe_characters" | "too_long";
|
|
33
|
+
/**
|
|
34
|
+
* The result of normalizing a submitted bio. On success `bio` is the value to
|
|
35
|
+
* store — `null` when the input normalized to nothing, which clears the bio.
|
|
36
|
+
*/
|
|
37
|
+
export type BioNormalization = {
|
|
38
|
+
ok: true;
|
|
39
|
+
bio: string | null;
|
|
40
|
+
} | {
|
|
41
|
+
ok: false;
|
|
42
|
+
reason: BioRejection;
|
|
43
|
+
};
|
|
44
|
+
/** Why a bio write was refused: the caller is a guest, or the text itself was
|
|
45
|
+
* refused. A bio belongs to a full account, the same rule the avatar upload
|
|
46
|
+
* applies. */
|
|
47
|
+
export type BioWriteRefusal = BioRejection | "guest";
|
|
48
|
+
/** The result of a bio write. `bio: null` means the bio was cleared. */
|
|
49
|
+
export type BioWriteResult = {
|
|
50
|
+
ok: true;
|
|
51
|
+
bio: string | null;
|
|
52
|
+
} | {
|
|
53
|
+
ok: false;
|
|
54
|
+
reason: BioWriteRefusal;
|
|
55
|
+
};
|
|
29
56
|
/**
|
|
30
57
|
* A session minted for one of the ADDITIONAL audiences a login requested
|
|
31
58
|
* (AUTH-39): its own refresh-token lineage and access JWT, both bound to
|
|
@@ -36,7 +36,7 @@ export type EquipRegion = "whole_head" | "hat" | "hair" | "face" | "glasses" | "
|
|
|
36
36
|
* makes the same mistake a typecheck error — which is the whole point of
|
|
37
37
|
* mirroring the names into one constant. AUTH-210 is what happens without it:
|
|
38
38
|
* the catalog was written from a nine-region proposal in
|
|
39
|
-
*
|
|
39
|
+
* the GAME page "design: cosmetic bodygroups" section 6, the fifteen-region cut
|
|
40
40
|
* that actually shipped shared not one name with it, and every cosmetic
|
|
41
41
|
* silently suppressed nothing.
|
|
42
42
|
*/
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* makes the same mistake a typecheck error — which is the whole point of
|
|
28
28
|
* mirroring the names into one constant. AUTH-210 is what happens without it:
|
|
29
29
|
* the catalog was written from a nine-region proposal in
|
|
30
|
-
*
|
|
30
|
+
* the GAME page "design: cosmetic bodygroups" section 6, the fifteen-region cut
|
|
31
31
|
* that actually shipped shared not one name with it, and every cosmetic
|
|
32
32
|
* silently suppressed nothing.
|
|
33
33
|
*/
|
|
@@ -52,12 +52,13 @@ export type DialogCustomLabels = {
|
|
|
52
52
|
};
|
|
53
53
|
/**
|
|
54
54
|
* The profile fields the widget renders, read from `/api/me` (the cookie
|
|
55
|
-
* session's `session.user`). `image`/`createdAt` are not on the SDK's
|
|
55
|
+
* session's `session.user`). `image`/`bio`/`createdAt` are not on the SDK's
|
|
56
56
|
* `FlowUser`, so they only come from this authenticated fetch.
|
|
57
57
|
*/
|
|
58
58
|
export type ProfileData = {
|
|
59
59
|
username: string;
|
|
60
60
|
image?: string | null;
|
|
61
|
+
bio?: string | null;
|
|
61
62
|
createdAt?: string;
|
|
62
63
|
isGuest?: boolean;
|
|
63
64
|
};
|
|
@@ -9,8 +9,8 @@ export type AuthOutcome = "success" | "failure" | "info";
|
|
|
9
9
|
export type AuthMode = "sign-up" | "sign-in";
|
|
10
10
|
/** Client-only funnel steps reported via the `/api/events` beacon. */
|
|
11
11
|
export type FunnelStep = "mode_selected" | "email_entered" | "ceremony_started" | "ceremony_failed" | "done_shown";
|
|
12
|
-
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.passkey.added" | "auth.passkey.add_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.session.revoked" | "auth.session.revoked_all" | "auth.audience.rejected" | "auth.audience.minted" | "auth.guest.created" | "auth.guest.restored" | "auth.guest.upgraded" | "auth.guest.failed" | "auth.role.granted" | "auth.role.revoked" | "room.restriction.issued" | "room.restriction.lifted" | "auth.funnel.mode_selected" | "auth.funnel.email_entered" | "auth.funnel.ceremony_started" | "auth.funnel.ceremony_failed" | "auth.funnel.done_shown" | "room_event.activated" | "room_event.completed" | "room_event.reminder_dispatched" | "room_event.reminder_failed";
|
|
13
|
-
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_epoch_stale" | "refresh_user_epoch_stale" | "refresh_reuse_detected" | "guest_rate_limited" | "guest_global_limit" | "guest_username_exhausted" | "guest_session" | "passkey_exists" | "already_upgraded" | "malformed_token" | "unknown_key" | "verification_failed" | "webauthn_not_allowed" | "webauthn_security" | "webauthn_invalid_state" | "webauthn_not_supported" | "webauthn_constraint" | "webauthn_aborted" | "webauthn_unknown" | "internal_error";
|
|
12
|
+
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.bio.update.succeeded" | "auth.bio.update.failed" | "auth.profile.view.failed" | "auth.profile.view.uncapped" | "auth.challenge.issued" | "auth.signin.succeeded" | "auth.signin.failed" | "auth.passkey.added" | "auth.passkey.add_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.session.revoked" | "auth.session.revoked_all" | "auth.audience.rejected" | "auth.audience.minted" | "auth.guest.created" | "auth.guest.restored" | "auth.guest.upgraded" | "auth.guest.failed" | "auth.role.granted" | "auth.role.revoked" | "room.restriction.issued" | "room.restriction.lifted" | "auth.funnel.mode_selected" | "auth.funnel.email_entered" | "auth.funnel.ceremony_started" | "auth.funnel.ceremony_failed" | "auth.funnel.done_shown" | "room_event.activated" | "room_event.completed" | "room_event.reminder_dispatched" | "room_event.reminder_failed";
|
|
13
|
+
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" | "bio_forbidden" | "bio_invalid" | "bio_too_long" | "profile_rate_limited" | "profile_ip_missing" | "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_epoch_stale" | "refresh_user_epoch_stale" | "refresh_reuse_detected" | "guest_rate_limited" | "guest_global_limit" | "guest_username_exhausted" | "guest_session" | "passkey_exists" | "already_upgraded" | "malformed_token" | "unknown_key" | "verification_failed" | "webauthn_not_allowed" | "webauthn_security" | "webauthn_invalid_state" | "webauthn_not_supported" | "webauthn_constraint" | "webauthn_aborted" | "webauthn_unknown" | "internal_error";
|
|
14
14
|
/** One flat record per event = one row in the `auth_events` stream. */
|
|
15
15
|
export interface AuthEventRecord {
|
|
16
16
|
service: "auth";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type { AccountSession, AccountSessionsResponse, AdditionalSession, AuthConfig, AuthResponse, AuthResponseWithWebAuthn, FlowCredential, FlowUser, PasskeyPluginOptions, RevokeAllSessionsResponse, RevokeSessionResponse, SecurityActivityEntry, SecurityActivityResponse, SessionUser, VerifiedFlowJWT, VerifyOptions, WebAuthnSignature, } from "./auth";
|
|
1
|
+
export type { AccountSession, AccountSessionsResponse, AdditionalSession, AuthConfig, AuthResponse, AuthResponseWithWebAuthn, BioNormalization, BioRejection, BioWriteRefusal, BioWriteResult, FlowCredential, FlowUser, PasskeyPluginOptions, RevokeAllSessionsResponse, RevokeSessionResponse, SecurityActivityEntry, SecurityActivityResponse, SessionUser, VerifiedFlowJWT, VerifyOptions, WebAuthnSignature, } from "./auth";
|
|
2
2
|
export type { BodyRegion, CosmeticItem, CosmeticMaterial, CosmeticPaint, CosmeticSlot, EquipConflict, EquippedCosmetic, EquippedCosmetics, EquipRegion, PaintSlot, SettingsConflict, } from "./cosmetics";
|
|
3
3
|
export { BODY_REGIONS, COSMETIC_MATERIALS } from "./cosmetics";
|
|
4
4
|
export type { BoundaryError, DialogCustomFeatures, DialogCustomLabels, DialogError, DialogReferrer, DialogState, Loadable, ProfileData, ProfileIdentity, } from "./dialog";
|
|
@@ -10,7 +10,7 @@ export type { Call, ConnectCapabilities, ConnectRequest, ConnectResponse, GuestR
|
|
|
10
10
|
export { getConnectCapabilities, isPersonalSignParams, isSendCallsParams, isSendTransactionParams, } from "./protocol";
|
|
11
11
|
export type { CreateRoomEventInput, RoomEventArchive, RoomEventAttendance, RoomEventFrequency, RoomEventInstance, RoomEventInterest, RoomEventInterestState, RoomEventList, RoomEventOccurrence, RoomEventRecurrence, RoomEventRoomRef, RoomEventRun, RoomEventSeries, RoomEventStatus, SetInterestInput, UpdateRoomEventInput, } from "./room-events";
|
|
12
12
|
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";
|
|
13
|
-
export type { AccessKeyOptions, AccessKeyPreparation, Address, ColorScheme, CreateDialogHostOptions, CreateFlowOptions, DialogHost, DialogOpenOptions, FinalizeAccessKeyParams, Flow, FlowConnectorParameters, FlowCookieNames, FlowIdProviderProps, FlowSessionState, FlowState, FlowWidgetHandle, FlowWidgetName, FlowWidgetProps, Listener, LoginOptions, MountFlowWidgetOptions, MountProfileOptions, PrepareArgsWithAuth, PrepareTransactionRequestPhase, ProfileButtonHandle, ProfileButtonProps, ProfilePosition, ResolveAccountParams, ResolvedAccessKeyOptions, RootCredential, RunLoginParams, RunLoginResult, SendCallsArgs, SendTransactionArgs, Session, SigningContext, SignMessageArgs, SignTypedDataArgs, Store, StoredAccessKey, WagmiConnectCapabilities, WagmiConnectParams, } from "./sdk";
|
|
13
|
+
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";
|
|
14
14
|
export type { GracefulShutdownCleanup, GracefulShutdownOptions, GracefulShutdownServer, GracefulShutdownSignal, ResolvedFlowSession, ResolveSessionOptions, SessionRouteOptions, SessionRouteResponse, SessionRouteSession, } from "./server";
|
|
15
15
|
export type { CoinAsset, IdentifiedTx, TxApprove, TxConvert, TxSend, TxSwap, } from "./tx";
|
|
16
|
-
export type { ActionDayContext, ActionEventKind, ActionSessionState, ActionTransitionKind, ActiveActionResponse, LevelProgress, PublicProfile, XpGrantResult, XpRecentGrant, XpSummary, } from "./xp";
|
|
16
|
+
export type { ActionDayContext, ActionEventKind, ActionSessionState, ActionTransitionKind, ActiveActionResponse, LevelProgress, PublicProfile, PublicProfileRefusal, PublicProfileResult, PublicProfileSignal, PublicUserProfile, XpGrantResult, XpRecentGrant, XpSummary, } from "./xp";
|
|
@@ -73,6 +73,26 @@ export interface RoomDetail extends RoomSummary {
|
|
|
73
73
|
export interface RoomMemberEntry {
|
|
74
74
|
userId: string;
|
|
75
75
|
username: string;
|
|
76
|
+
/**
|
|
77
|
+
* True for a guest account. Guests are ordinary room members and are always
|
|
78
|
+
* listed — this only labels them, so a consumer can gate UI a guest has no
|
|
79
|
+
* target for: `GET /api/users/:username` 404s guest rows by design, yet
|
|
80
|
+
* guests carry a real `[a-z0-9_]+` username, so a "view profile" control
|
|
81
|
+
* built from `username` alone dead-ends.
|
|
82
|
+
*
|
|
83
|
+
* Read it as `entry.isGuest ?? true`. This server always sends it (the
|
|
84
|
+
* column is NOT NULL), but a server older than the field omits it, and a
|
|
85
|
+
* required `boolean` that can arrive `undefined` is a type that lies:
|
|
86
|
+
* `!entry.isGuest` would read a guest as a full account and render the exact
|
|
87
|
+
* dead-end link this prevents — silently, with a clean typecheck. Defaulting
|
|
88
|
+
* the other way is the cheap failure: hiding the link for a full account is
|
|
89
|
+
* cosmetic and self-corrects on the next deploy.
|
|
90
|
+
*
|
|
91
|
+
* Note the default is the opposite of `FlowUser.isGuest`, where `undefined`
|
|
92
|
+
* means *not* a guest. That one is optional because many `{ id, username }`
|
|
93
|
+
* producers omit it; this one because a whole deployment might.
|
|
94
|
+
*/
|
|
95
|
+
isGuest?: boolean;
|
|
76
96
|
role: RoomRole;
|
|
77
97
|
joinedAt: string;
|
|
78
98
|
}
|
package/dist/sdk/types/sdk.d.ts
CHANGED
|
@@ -229,6 +229,32 @@ export type ProfileButtonHandle = {
|
|
|
229
229
|
/** Tears down the iframe, listeners, and any auto-created container. */
|
|
230
230
|
destroy: () => void;
|
|
231
231
|
};
|
|
232
|
+
export type OpenProfileOptions = {
|
|
233
|
+
/**
|
|
234
|
+
* The Flow ID origin serving the overlay (e.g. https://id.flow.industries).
|
|
235
|
+
* Defaults to the resolved Flow instance's `host`, then the SDK default.
|
|
236
|
+
* Read once, when the overlay is first created — every later open reuses it.
|
|
237
|
+
*/
|
|
238
|
+
host?: string;
|
|
239
|
+
/**
|
|
240
|
+
* Color scheme for the dialog chrome. Set this to match the host (the dark
|
|
241
|
+
* game UI passes "dark"). Applied on every open, so a host with a theme
|
|
242
|
+
* toggle needs no separate call. Defaults to "light dark" (follows the OS).
|
|
243
|
+
*/
|
|
244
|
+
theme?: ColorScheme;
|
|
245
|
+
/**
|
|
246
|
+
* Explicit Flow instance, used to answer the dialog's token requests and to
|
|
247
|
+
* tell it who is looking. Defaults to the createFlow() singleton, and like
|
|
248
|
+
* `host` is read once, when the overlay is first created.
|
|
249
|
+
*/
|
|
250
|
+
flow?: Flow;
|
|
251
|
+
};
|
|
252
|
+
export type UseOpenProfileOptions = {
|
|
253
|
+
/** Override the Flow ID origin; defaults to the resolved Flow's `host`. */
|
|
254
|
+
host?: string;
|
|
255
|
+
/** Color scheme for the dialog chrome; defaults to "light dark" (the OS). */
|
|
256
|
+
theme?: ColorScheme;
|
|
257
|
+
};
|
|
232
258
|
/** The inline Flow ID widgets an embedder can mount alongside its own UI. */
|
|
233
259
|
export type FlowWidgetName = "xp" | "action-timer";
|
|
234
260
|
export type MountFlowWidgetOptions = {
|
package/dist/sdk/types/xp.d.ts
CHANGED
|
@@ -71,3 +71,43 @@ export interface PublicProfile {
|
|
|
71
71
|
tier: number;
|
|
72
72
|
level: number;
|
|
73
73
|
}
|
|
74
|
+
/** Everything `GET /api/users/:username` shows about a user: the identity
|
|
75
|
+
* fields anyone may see plus the same XP standing the nametag carries.
|
|
76
|
+
* `flowScore` is public by decision, so a future leaderboard row can link this
|
|
77
|
+
* endpoint rather than adding a second one. Deliberately no email, wallet
|
|
78
|
+
* address, role, session state or `updatedAt` — a change here is a change to
|
|
79
|
+
* what Flow publishes about a person. camelCase, unlike the snake_case
|
|
80
|
+
* `PublicProfile` nested in the game-server verify payload: this one is read by
|
|
81
|
+
* web clients, where every other Flow payload is camelCase. */
|
|
82
|
+
export interface PublicUserProfile {
|
|
83
|
+
username: string;
|
|
84
|
+
image: string | null;
|
|
85
|
+
bio: string | null;
|
|
86
|
+
level: number;
|
|
87
|
+
tier: number;
|
|
88
|
+
flowScore: number;
|
|
89
|
+
/** UTC calendar date the account was created, `YYYY-MM-DD`, rendered as
|
|
90
|
+
* "Joined ...". Deliberately not a full timestamp: no consumer shows the
|
|
91
|
+
* clock time, and a public by-name payload should carry no more of one than
|
|
92
|
+
* it displays. */
|
|
93
|
+
createdAt: string;
|
|
94
|
+
}
|
|
95
|
+
/** Why a public profile read was refused. `not_found` covers both an unknown
|
|
96
|
+
* username and a guest row — a guest is a placeholder, not a person, and the
|
|
97
|
+
* two are deliberately indistinguishable to the caller. */
|
|
98
|
+
export type PublicProfileRefusal = "invalid_username" | "not_found" | "rate_limited";
|
|
99
|
+
/** Whether a read did something its route should emit an event for, resolved
|
|
100
|
+
* against the caller's quota window so a flood produces one row rather than
|
|
101
|
+
* one per request. `cap_crossed` marks the request that spent the last slot;
|
|
102
|
+
* `ip_missing` marks the first header-less caller in a window, which is a
|
|
103
|
+
* deployment fault worth seeing and is served uncapped. */
|
|
104
|
+
export type PublicProfileSignal = "none" | "cap_crossed" | "ip_missing";
|
|
105
|
+
export type PublicProfileResult = {
|
|
106
|
+
ok: true;
|
|
107
|
+
profile: PublicUserProfile;
|
|
108
|
+
signal: PublicProfileSignal;
|
|
109
|
+
} | {
|
|
110
|
+
ok: false;
|
|
111
|
+
reason: PublicProfileRefusal;
|
|
112
|
+
signal: PublicProfileSignal;
|
|
113
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared adjective/noun wordlists and the playful `adjective_noun####`
|
|
3
|
+
* generator. Single source of truth for both the server (collision-safe guest
|
|
4
|
+
* account creation) and the dialog (the sign-up username placeholder), so the
|
|
5
|
+
* two can never drift. The wordlists are chosen so the longest combination
|
|
6
|
+
* stays within the 20-char username limit.
|
|
7
|
+
*/
|
|
8
|
+
export declare const funnyAdjectives: string[];
|
|
9
|
+
export declare const funnyNouns: string[];
|
|
10
|
+
/** Single source of truth for the username constraints, shared by the
|
|
11
|
+
* generator and the server-side validators (register zod, availability check). */
|
|
12
|
+
export declare const MAX_USERNAME_LENGTH = 20;
|
|
13
|
+
export declare const USERNAME_REGEX: RegExp;
|
|
14
|
+
/**
|
|
15
|
+
* Whether a string can address a Flow account at all — the constraints above
|
|
16
|
+
* applied together. This is the check to spend before a lookup, because it is
|
|
17
|
+
* the same one `/api/users/:username` applies before it touches the database:
|
|
18
|
+
* anything it rejects can only ever come back as a refusal, so a caller that
|
|
19
|
+
* runs it first never opens a view onto a guaranteed 400.
|
|
20
|
+
*/
|
|
21
|
+
export declare function isValidUsername(name: string): boolean;
|
|
22
|
+
/**
|
|
23
|
+
* A playful `adjective_noun####` handle. Matches `USERNAME_REGEX` and is capped
|
|
24
|
+
* to `MAX_USERNAME_LENGTH`. NOT collision-checked — callers needing uniqueness
|
|
25
|
+
* (guest creation) retry against the username unique index.
|
|
26
|
+
*/
|
|
27
|
+
export declare function randomGuestUsername(): string;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared adjective/noun wordlists and the playful `adjective_noun####`
|
|
3
|
+
* generator. Single source of truth for both the server (collision-safe guest
|
|
4
|
+
* account creation) and the dialog (the sign-up username placeholder), so the
|
|
5
|
+
* two can never drift. The wordlists are chosen so the longest combination
|
|
6
|
+
* stays within the 20-char username limit.
|
|
7
|
+
*/
|
|
8
|
+
export const funnyAdjectives = [
|
|
9
|
+
"cool",
|
|
10
|
+
"epic",
|
|
11
|
+
"lazy",
|
|
12
|
+
"sneaky",
|
|
13
|
+
"brave",
|
|
14
|
+
"chill",
|
|
15
|
+
"wild",
|
|
16
|
+
"tiny",
|
|
17
|
+
"mega",
|
|
18
|
+
"turbo",
|
|
19
|
+
"hyper",
|
|
20
|
+
"cozy",
|
|
21
|
+
"swift",
|
|
22
|
+
"bold",
|
|
23
|
+
"dark",
|
|
24
|
+
"lost",
|
|
25
|
+
"lucky",
|
|
26
|
+
"dizzy",
|
|
27
|
+
"fuzzy",
|
|
28
|
+
"shiny",
|
|
29
|
+
"sleepy",
|
|
30
|
+
"spicy",
|
|
31
|
+
"crispy",
|
|
32
|
+
"flowy",
|
|
33
|
+
"donkey",
|
|
34
|
+
];
|
|
35
|
+
export const funnyNouns = [
|
|
36
|
+
"goku",
|
|
37
|
+
"naruto",
|
|
38
|
+
"pikachu",
|
|
39
|
+
"saitama",
|
|
40
|
+
"kirby",
|
|
41
|
+
"zelda",
|
|
42
|
+
"mario",
|
|
43
|
+
"sonic",
|
|
44
|
+
"yoshi",
|
|
45
|
+
"toad",
|
|
46
|
+
"kakashi",
|
|
47
|
+
"luffy",
|
|
48
|
+
"gojo",
|
|
49
|
+
"todoroki",
|
|
50
|
+
"midoriya",
|
|
51
|
+
"vegeta",
|
|
52
|
+
"jotaro",
|
|
53
|
+
"tanjiro",
|
|
54
|
+
"link",
|
|
55
|
+
"kratos",
|
|
56
|
+
"sora",
|
|
57
|
+
"roxas",
|
|
58
|
+
"cloud",
|
|
59
|
+
"sephiroth",
|
|
60
|
+
"megaman",
|
|
61
|
+
"waluigi",
|
|
62
|
+
"bowser",
|
|
63
|
+
"ganon",
|
|
64
|
+
"kong",
|
|
65
|
+
"king",
|
|
66
|
+
];
|
|
67
|
+
/** Single source of truth for the username constraints, shared by the
|
|
68
|
+
* generator and the server-side validators (register zod, availability check). */
|
|
69
|
+
export const MAX_USERNAME_LENGTH = 20;
|
|
70
|
+
export const USERNAME_REGEX = /^[a-z0-9_]+$/;
|
|
71
|
+
/**
|
|
72
|
+
* Whether a string can address a Flow account at all — the constraints above
|
|
73
|
+
* applied together. This is the check to spend before a lookup, because it is
|
|
74
|
+
* the same one `/api/users/:username` applies before it touches the database:
|
|
75
|
+
* anything it rejects can only ever come back as a refusal, so a caller that
|
|
76
|
+
* runs it first never opens a view onto a guaranteed 400.
|
|
77
|
+
*/
|
|
78
|
+
export function isValidUsername(name) {
|
|
79
|
+
// Typed as a string but reached from untyped JavaScript: the SDK hands this
|
|
80
|
+
// whatever a chat payload or a nametag produced, where a missing name is an
|
|
81
|
+
// ordinary outcome. Answering "no" is the contract; throwing is not.
|
|
82
|
+
//
|
|
83
|
+
// Established structurally, and deliberately BEFORE the charset test, because
|
|
84
|
+
// coercion would answer yes: `String(null)` is "null" and `String(undefined)`
|
|
85
|
+
// is "undefined", both of which the charset accepts as perfectly good
|
|
86
|
+
// usernames. A missing name would open a stranger's profile.
|
|
87
|
+
if (Object.prototype.toString.call(name) !== "[object String]")
|
|
88
|
+
return false;
|
|
89
|
+
return name.length <= MAX_USERNAME_LENGTH && USERNAME_REGEX.test(name);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* A playful `adjective_noun####` handle. Matches `USERNAME_REGEX` and is capped
|
|
93
|
+
* to `MAX_USERNAME_LENGTH`. NOT collision-checked — callers needing uniqueness
|
|
94
|
+
* (guest creation) retry against the username unique index.
|
|
95
|
+
*/
|
|
96
|
+
export function randomGuestUsername() {
|
|
97
|
+
const adj = funnyAdjectives[Math.floor(Math.random() * funnyAdjectives.length)];
|
|
98
|
+
const noun = funnyNouns[Math.floor(Math.random() * funnyNouns.length)];
|
|
99
|
+
const num = Math.floor(Math.random() * 9999) + 1;
|
|
100
|
+
return `${adj}_${noun}${num}`.slice(0, MAX_USERNAME_LENGTH);
|
|
101
|
+
}
|