@flow-industries/id 0.18.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -0
- package/dist/sdk/client/access-key.d.ts +1 -1
- package/dist/sdk/client/access-key.js +20 -6
- package/dist/sdk/client/create-flow.js +19 -11
- package/dist/sdk/client/dialog-host.js +8 -2
- package/dist/sdk/client/flow-widget.js +2 -1
- package/dist/sdk/client/idb.d.ts +2 -2
- package/dist/sdk/client/idb.js +17 -2
- package/dist/sdk/client/profile-button.js +1 -1
- package/dist/sdk/client/rooms.js +19 -4
- package/dist/sdk/client/session.js +8 -5
- package/dist/sdk/client/signing.js +22 -7
- package/dist/sdk/client/store.js +4 -5
- package/dist/sdk/cookies.d.ts +3 -1
- package/dist/sdk/cookies.js +11 -9
- package/dist/sdk/dialog/remote/Messenger.d.ts +0 -5
- package/dist/sdk/dialog/remote/Messenger.js +26 -14
- package/dist/sdk/driver-error.d.ts +8 -0
- package/dist/sdk/driver-error.js +17 -0
- package/dist/sdk/hex.d.ts +6 -0
- package/dist/sdk/hex.js +6 -0
- package/dist/sdk/id-host.d.ts +1 -1
- package/dist/sdk/id-host.js +4 -5
- package/dist/sdk/json.d.ts +17 -0
- package/dist/sdk/json.js +5 -0
- package/dist/sdk/react/flow-widget.js +2 -2
- package/dist/sdk/react/hooks.d.ts +8 -4
- package/dist/sdk/react/hooks.js +1 -10
- package/dist/sdk/react/profile-button.js +2 -2
- package/dist/sdk/server.js +2 -1
- package/dist/sdk/session-core.d.ts +1 -6
- package/dist/sdk/session-core.js +7 -4
- package/dist/sdk/session-route.d.ts +0 -20
- package/dist/sdk/session-route.js +25 -9
- package/dist/sdk/start/graceful-shutdown.d.ts +8 -0
- package/dist/sdk/start/graceful-shutdown.js +81 -0
- package/dist/sdk/start/index.d.ts +2 -0
- package/dist/sdk/start/index.js +3 -2
- package/dist/sdk/token-expiry.js +3 -1
- package/dist/sdk/types/cosmetics.d.ts +162 -0
- package/dist/sdk/types/cosmetics.js +78 -0
- package/dist/sdk/types/game.d.ts +58 -0
- package/dist/sdk/types/game.js +8 -0
- package/dist/sdk/types/index.d.ts +7 -3
- package/dist/sdk/types/index.js +2 -0
- package/dist/sdk/types/messenger.d.ts +1 -1
- package/dist/sdk/types/protocol.d.ts +6 -5
- package/dist/sdk/types/protocol.js +10 -4
- package/dist/sdk/types/room-events.d.ts +49 -1
- package/dist/sdk/types/rooms.d.ts +53 -3
- package/dist/sdk/types/rooms.js +0 -1
- package/dist/sdk/types/sdk.d.ts +2 -1
- package/dist/sdk/types/server.d.ts +18 -0
- package/dist/sdk/verify.js +1 -0
- package/dist/sdk/wagmi/index.d.ts +7 -3
- package/dist/sdk/wagmi/index.js +19 -32
- package/package.json +11 -8
- package/dist/sdk/client/refresh-store.d.ts +0 -22
- package/dist/sdk/client/refresh-store.js +0 -66
package/dist/sdk/hex.js
ADDED
package/dist/sdk/id-host.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* without carrying any per-app configuration.
|
|
6
6
|
*/
|
|
7
7
|
export declare const DEFAULT_ISSUER_URL = "https://id.flow.industries";
|
|
8
|
-
/**
|
|
8
|
+
/** Legacy fallback for local consumers that do not pass the dev.sh-selected host. */
|
|
9
9
|
export declare const LOCAL_ISSUER_URL = "http://localhost:5175";
|
|
10
10
|
/**
|
|
11
11
|
* Path of the first-party session route every app serves from its own origin
|
package/dist/sdk/id-host.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* without carrying any per-app configuration.
|
|
6
6
|
*/
|
|
7
7
|
export const DEFAULT_ISSUER_URL = "https://id.flow.industries";
|
|
8
|
-
/**
|
|
8
|
+
/** Legacy fallback for local consumers that do not pass the dev.sh-selected host. */
|
|
9
9
|
export const LOCAL_ISSUER_URL = "http://localhost:5175";
|
|
10
10
|
/**
|
|
11
11
|
* Path of the first-party session route every app serves from its own origin
|
|
@@ -24,7 +24,7 @@ export function isLocalHostname(hostname) {
|
|
|
24
24
|
* to the request's own origin.
|
|
25
25
|
*/
|
|
26
26
|
export function defaultAudience(requestOrigin) {
|
|
27
|
-
return ((
|
|
27
|
+
return ((globalThis.process !== undefined ? process.env?.APP_ORIGIN : undefined) ??
|
|
28
28
|
requestOrigin);
|
|
29
29
|
}
|
|
30
30
|
/**
|
|
@@ -34,11 +34,10 @@ export function defaultAudience(requestOrigin) {
|
|
|
34
34
|
* behalf of a request (servers have no `window`).
|
|
35
35
|
*/
|
|
36
36
|
export function defaultIdHost(appOrigin) {
|
|
37
|
-
if (
|
|
37
|
+
if (globalThis.process !== undefined && process.env?.FLOW_ID_HOST) {
|
|
38
38
|
return process.env.FLOW_ID_HOST;
|
|
39
39
|
}
|
|
40
|
-
const origin = appOrigin ??
|
|
41
|
-
(typeof window !== "undefined" ? window.location.origin : undefined);
|
|
40
|
+
const origin = appOrigin ?? ("window" in globalThis ? window.location.origin : undefined);
|
|
42
41
|
if (origin) {
|
|
43
42
|
try {
|
|
44
43
|
if (isLocalHostname(new URL(origin).hostname))
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The value domain for data that arrives as JSON — request bodies, provider responses, stored
|
|
3
|
+
* blobs, postMessage payloads.
|
|
4
|
+
*
|
|
5
|
+
* Primitive tests are written as coercion round-trips rather than `typeof`: `String(x) === x` and
|
|
6
|
+
* `Number(x) === x` hold only for the primitive itself, so each one establishes the value's
|
|
7
|
+
* contract instead of narrowing a representation.
|
|
8
|
+
*/
|
|
9
|
+
export type RawValue = string | number | boolean | null | undefined | RawValue[] | RawRecord;
|
|
10
|
+
export type RawRecord = {
|
|
11
|
+
readonly [key: string]: RawValue;
|
|
12
|
+
};
|
|
13
|
+
export declare const isString: (v: RawValue) => v is string;
|
|
14
|
+
export declare const isBool: (v: RawValue) => v is boolean;
|
|
15
|
+
export declare const isNum: (v: RawValue) => v is number;
|
|
16
|
+
export declare const isRecord: (v: RawValue) => v is RawRecord;
|
|
17
|
+
export declare const isStrings: (v: RawValue) => v is string[];
|
package/dist/sdk/json.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export const isString = (v) => String(v) === v;
|
|
2
|
+
export const isBool = (v) => v === true || v === false;
|
|
3
|
+
export const isNum = (v) => Number(v) === v && Number.isFinite(v);
|
|
4
|
+
export const isRecord = (v) => v !== null && v !== undefined && !Array.isArray(v) && Object(v) === v;
|
|
5
|
+
export const isStrings = (v) => Array.isArray(v) && v.every((entry) => isString(entry));
|
|
@@ -29,8 +29,8 @@ export function FlowWidget({ widget, className, host, theme, }) {
|
|
|
29
29
|
widget,
|
|
30
30
|
container,
|
|
31
31
|
flow,
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
host: host ? host : undefined,
|
|
33
|
+
theme: themeRef.current ? themeRef.current : undefined,
|
|
34
34
|
});
|
|
35
35
|
handleRef.current = handle;
|
|
36
36
|
if (handle.frame) {
|
|
@@ -39,24 +39,28 @@ export declare function useFlowId(): {
|
|
|
39
39
|
* Fetches the public room list once (call `reload` to refresh). Pagination
|
|
40
40
|
* stays manual: pass the returned `nextCursor` back through `flow.rooms.list`.
|
|
41
41
|
*/
|
|
42
|
-
|
|
42
|
+
/** What {@link useRooms} returns. */
|
|
43
|
+
export type RoomsState = {
|
|
43
44
|
rooms: RoomSummary[];
|
|
44
45
|
nextCursor: string | null;
|
|
45
46
|
loading: boolean;
|
|
46
47
|
error: Error | null;
|
|
47
48
|
reload: () => void;
|
|
48
49
|
};
|
|
50
|
+
export declare function useRooms(): RoomsState;
|
|
49
51
|
/**
|
|
50
52
|
* Fetches one room's detail, and optionally polls its live presence when
|
|
51
53
|
* `presencePollMs` is set (polling until the presence store grows a push
|
|
52
54
|
* channel). Re-fetches when the slug changes; call `reload` to refresh.
|
|
53
55
|
*/
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}): {
|
|
56
|
+
/** What {@link useRoom} returns. */
|
|
57
|
+
export type RoomState = {
|
|
57
58
|
room: RoomDetail | null;
|
|
58
59
|
presence: RoomPresenceSnapshot | null;
|
|
59
60
|
loading: boolean;
|
|
60
61
|
error: Error | null;
|
|
61
62
|
reload: () => void;
|
|
62
63
|
};
|
|
64
|
+
export declare function useRoom(slug: string, options?: {
|
|
65
|
+
presencePollMs?: number;
|
|
66
|
+
}): RoomState;
|
package/dist/sdk/react/hooks.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
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 = (
|
|
4
|
+
const asError = (cause) => cause instanceof Error ? cause : new Error(String(cause));
|
|
5
5
|
/**
|
|
6
6
|
* Returns the Flow instance to use. Resolution order:
|
|
7
7
|
* 1. The instance from a `<FlowIdProvider>` ancestor (if any)
|
|
@@ -54,10 +54,6 @@ export function useFlowId() {
|
|
|
54
54
|
getToken: flow.getToken,
|
|
55
55
|
};
|
|
56
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
57
|
export function useRooms() {
|
|
62
58
|
const flow = useFlow();
|
|
63
59
|
const [result, setResult] = useState(null);
|
|
@@ -80,11 +76,6 @@ export function useRooms() {
|
|
|
80
76
|
reload: load,
|
|
81
77
|
};
|
|
82
78
|
}
|
|
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
79
|
export function useRoom(slug, options) {
|
|
89
80
|
const flow = useFlow();
|
|
90
81
|
const pollMs = options?.presencePollMs ?? 0;
|
|
@@ -26,8 +26,8 @@ export function ProfileButton({ className, host, theme }) {
|
|
|
26
26
|
const handle = createProfileButton({
|
|
27
27
|
container,
|
|
28
28
|
flow,
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
host: host ? host : undefined,
|
|
30
|
+
theme: themeRef.current ? themeRef.current : undefined,
|
|
31
31
|
});
|
|
32
32
|
handleRef.current = handle;
|
|
33
33
|
return () => {
|
package/dist/sdk/server.js
CHANGED
|
@@ -11,6 +11,7 @@ async function fetchProfile(issuerUrl, jwt, audience) {
|
|
|
11
11
|
});
|
|
12
12
|
if (!res.ok)
|
|
13
13
|
return undefined;
|
|
14
|
+
/* SAFETY: this server's own /session/verify endpoint, called over the loopback. */
|
|
14
15
|
const data = (await res.json());
|
|
15
16
|
return data.payload?.profile ?? undefined;
|
|
16
17
|
}
|
|
@@ -39,6 +40,6 @@ export async function resolveSession(cookieHeader, opts) {
|
|
|
39
40
|
state,
|
|
40
41
|
claims,
|
|
41
42
|
setCookies,
|
|
42
|
-
|
|
43
|
+
profile: profile ? profile : undefined,
|
|
43
44
|
};
|
|
44
45
|
}
|
|
@@ -1,11 +1,6 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Internal session-resolution core shared by `resolveSession` (SSR) and the
|
|
3
|
-
* session route. Not part of the published API surface — `server.ts` and
|
|
4
|
-
* `session-route.ts` compose it into the public entry points.
|
|
5
|
-
*/
|
|
6
1
|
import type { FlowUser, SessionRouteSession, VerifiedFlowJWT } from "./types";
|
|
7
2
|
export declare function claimsToUser(claims: VerifiedFlowJWT): FlowUser;
|
|
8
|
-
export declare function isJwtVerificationFailure(
|
|
3
|
+
export declare function isJwtVerificationFailure(cause: unknown): boolean;
|
|
9
4
|
/** The Set-Cookie pair carrying a session (1h JWT + 30-day refresh token). */
|
|
10
5
|
export declare function sessionCookies(audience: string, jwt: string, refreshToken: string): string[];
|
|
11
6
|
/** The Set-Cookie pair deleting both session cookies. */
|
package/dist/sdk/session-core.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { errField } from "./driver-error";
|
|
1
2
|
/**
|
|
2
3
|
* Internal session-resolution core shared by `resolveSession` (SSR) and the
|
|
3
4
|
* session route. Not part of the published API surface — `server.ts` and
|
|
@@ -9,7 +10,7 @@ import { verifyFlowJWT } from "./verify";
|
|
|
9
10
|
export function claimsToUser(claims) {
|
|
10
11
|
return {
|
|
11
12
|
id: claims.sub,
|
|
12
|
-
username:
|
|
13
|
+
username: String(claims.username) === claims.username ? claims.username : "",
|
|
13
14
|
// Fail-safe: only an explicit `false` marks a full account; a missing or
|
|
14
15
|
// mangled claim must never grant full privileges.
|
|
15
16
|
isGuest: claims.guest !== false,
|
|
@@ -25,6 +26,8 @@ async function refreshSession(issuerUrl, token) {
|
|
|
25
26
|
return "dead";
|
|
26
27
|
if (!res.ok)
|
|
27
28
|
return null;
|
|
29
|
+
/* SAFETY: the response is this issuer's own /session/restore payload; a non-ok status has
|
|
30
|
+
already thrown above. */
|
|
28
31
|
return (await res.json());
|
|
29
32
|
}
|
|
30
33
|
catch {
|
|
@@ -57,9 +60,9 @@ function refreshOnce(issuerUrl, token) {
|
|
|
57
60
|
// fetch failure, timeouts — is transient infrastructure trouble, and falling
|
|
58
61
|
// through to a rotation there would turn a JWKS outage into a rotation storm
|
|
59
62
|
// against the same struggling server.
|
|
60
|
-
export function isJwtVerificationFailure(
|
|
61
|
-
const code =
|
|
62
|
-
if (
|
|
63
|
+
export function isJwtVerificationFailure(cause) {
|
|
64
|
+
const code = errField(cause, "code");
|
|
65
|
+
if (!code)
|
|
63
66
|
return false;
|
|
64
67
|
return (code.startsWith("ERR_JWT") ||
|
|
65
68
|
code.startsWith("ERR_JWS") ||
|
|
@@ -1,23 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The first-party session route every consumer app serves from its own
|
|
3
|
-
* origin — the only path between a browser and the refresh token. The
|
|
4
|
-
* rotating refresh token lives in an HttpOnly cookie owned by the app's
|
|
5
|
-
* server; the browser client calls this route instead of ever holding the
|
|
6
|
-
* token itself:
|
|
7
|
-
*
|
|
8
|
-
* - `GET` resolve: fresh JWT from the cookies, rotating server-side when
|
|
9
|
-
* the access token is expiring.
|
|
10
|
-
* - `POST` install: one-time handoff of a freshly minted session (refresh
|
|
11
|
-
* token + access JWT from the dialog) into the HttpOnly cookies.
|
|
12
|
-
* The JWT verifies locally against the issuer's cached JWKS — no
|
|
13
|
-
* rotation of a seconds-old token; a bogus refresh token simply
|
|
14
|
-
* dies at the first GET, like any planted cookie.
|
|
15
|
-
* - `DELETE` sign-out: revoke the token's lineage at the issuer and clear
|
|
16
|
-
* the cookies.
|
|
17
|
-
*
|
|
18
|
-
* Framework-agnostic (`Request` → `Response`); `flowSessionHandlers` from
|
|
19
|
-
* `@flow-industries/id/start` adapts it to a TanStack Start route file.
|
|
20
|
-
*/
|
|
21
1
|
import type { SessionRouteOptions } from "./types";
|
|
22
2
|
/**
|
|
23
3
|
* Handles one session-route request (no path check — the caller routed it).
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
1
2
|
/**
|
|
2
3
|
* The first-party session route every consumer app serves from its own
|
|
3
4
|
* origin — the only path between a browser and the refresh token. The
|
|
@@ -22,6 +23,21 @@ import { cookieNamesFor, parseCookieHeader } from "./cookies";
|
|
|
22
23
|
import { DEFAULT_SESSION_PATH, defaultAudience, resolveIdHost, } from "./id-host";
|
|
23
24
|
import { claimsToUser, clearingCookies, isJwtVerificationFailure, resolveSessionCore, sessionCookies, } from "./session-core";
|
|
24
25
|
import { verifyFlowJWT } from "./verify";
|
|
26
|
+
/** The body the browser SDK posts to its own app's session route. */
|
|
27
|
+
const installBodySchema = z.object({
|
|
28
|
+
refreshToken: z.string().min(1).optional(),
|
|
29
|
+
jwt: z.string().min(1).optional(),
|
|
30
|
+
mint: z.array(z.string()).optional(),
|
|
31
|
+
});
|
|
32
|
+
const additionalBodySchema = z.object({
|
|
33
|
+
additionalSessions: z
|
|
34
|
+
.array(z.object({
|
|
35
|
+
audience: z.string(),
|
|
36
|
+
jwt: z.string(),
|
|
37
|
+
refreshToken: z.string(),
|
|
38
|
+
}))
|
|
39
|
+
.optional(),
|
|
40
|
+
});
|
|
25
41
|
function json(body, status, setCookies = []) {
|
|
26
42
|
const headers = new Headers({
|
|
27
43
|
"Content-Type": "application/json",
|
|
@@ -65,14 +81,15 @@ export async function handleSessionRequest(request, opts = {}) {
|
|
|
65
81
|
return json({ state: session }, 200, setCookies);
|
|
66
82
|
}
|
|
67
83
|
case "POST": {
|
|
68
|
-
const
|
|
84
|
+
const parsed = installBodySchema.safeParse(await request.json().catch(() => null));
|
|
85
|
+
const body = parsed.success ? parsed.data : null;
|
|
69
86
|
// Reconcile mint: the browser asks its own server to obtain additional-
|
|
70
87
|
// audience sessions (e.g. talk.flow.game) authorized by the PRIMARY
|
|
71
88
|
// refresh cookie, for an embed that booted with no session of its own.
|
|
72
89
|
// The refresh token never touches page JS — it's read from the HttpOnly
|
|
73
90
|
// cookie here and presented to the issuer as a bearer.
|
|
74
|
-
if (
|
|
75
|
-
const audiences = body.mint
|
|
91
|
+
if (body?.mint) {
|
|
92
|
+
const audiences = body.mint;
|
|
76
93
|
const names = cookieNamesFor(audience);
|
|
77
94
|
const token = parseCookieHeader(request.headers.get("Cookie"))[names.refresh];
|
|
78
95
|
let additionalSessions = [];
|
|
@@ -87,8 +104,10 @@ export async function handleSessionRequest(request, opts = {}) {
|
|
|
87
104
|
body: JSON.stringify({ audiences }),
|
|
88
105
|
});
|
|
89
106
|
if (res.ok) {
|
|
90
|
-
const data = (await res.json());
|
|
91
|
-
additionalSessions = data.
|
|
107
|
+
const data = additionalBodySchema.safeParse(await res.json());
|
|
108
|
+
additionalSessions = data.success
|
|
109
|
+
? (data.data.additionalSessions ?? [])
|
|
110
|
+
: [];
|
|
92
111
|
}
|
|
93
112
|
}
|
|
94
113
|
catch { }
|
|
@@ -97,10 +116,7 @@ export async function handleSessionRequest(request, opts = {}) {
|
|
|
97
116
|
}
|
|
98
117
|
const refreshToken = body?.refreshToken;
|
|
99
118
|
const jwt = body?.jwt;
|
|
100
|
-
if (
|
|
101
|
-
refreshToken.length === 0 ||
|
|
102
|
-
typeof jwt !== "string" ||
|
|
103
|
-
jwt.length === 0) {
|
|
119
|
+
if (!refreshToken || !jwt) {
|
|
104
120
|
return json({ state: null }, 400);
|
|
105
121
|
}
|
|
106
122
|
// The audience check on the verified JWT is what stops a token minted
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { GracefulShutdownCleanup, GracefulShutdownOptions, GracefulShutdownServer } from "../types";
|
|
2
|
+
export declare const DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 20000;
|
|
3
|
+
/**
|
|
4
|
+
* Install one process-wide graceful-shutdown handler for a Bun-compatible
|
|
5
|
+
* server. The first signal stops new accepts, drains active HTTP and consumer
|
|
6
|
+
* cleanup together, force-closes at the deadline, and exits successfully.
|
|
7
|
+
*/
|
|
8
|
+
export declare function installGracefulShutdown(server: GracefulShutdownServer | null | undefined, options?: GracefulShutdownOptions): GracefulShutdownCleanup;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export const DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 20_000;
|
|
2
|
+
let installation;
|
|
3
|
+
function timeoutMs(options) {
|
|
4
|
+
const configured = options.timeoutMs;
|
|
5
|
+
if (configured === undefined)
|
|
6
|
+
return DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_MS;
|
|
7
|
+
if (!Number.isFinite(configured) || configured < 0) {
|
|
8
|
+
return DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_MS;
|
|
9
|
+
}
|
|
10
|
+
return configured;
|
|
11
|
+
}
|
|
12
|
+
function deadline(ms) {
|
|
13
|
+
let timer;
|
|
14
|
+
return {
|
|
15
|
+
promise: new Promise((resolve) => {
|
|
16
|
+
timer = setTimeout(() => resolve(false), ms);
|
|
17
|
+
}),
|
|
18
|
+
cancel: () => {
|
|
19
|
+
if (timer !== undefined)
|
|
20
|
+
clearTimeout(timer);
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
async function shutdown(current, signal) {
|
|
25
|
+
let gracefulStop;
|
|
26
|
+
try {
|
|
27
|
+
gracefulStop = Promise.resolve(current.server.stop(false));
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
gracefulStop = Promise.reject(error);
|
|
31
|
+
}
|
|
32
|
+
const consumerDrain = Promise.resolve().then(() => current.options.onShutdown?.(signal));
|
|
33
|
+
const drained = Promise.allSettled([gracefulStop, consumerDrain]).then(() => true);
|
|
34
|
+
const timeout = deadline(timeoutMs(current.options));
|
|
35
|
+
const completed = await Promise.race([drained, timeout.promise]);
|
|
36
|
+
timeout.cancel();
|
|
37
|
+
if (!completed) {
|
|
38
|
+
try {
|
|
39
|
+
void current.server.stop(true);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// The configured deadline is authoritative even if forced close fails.
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
process.exit(0);
|
|
46
|
+
}
|
|
47
|
+
function handleSignal(signal) {
|
|
48
|
+
const current = installation;
|
|
49
|
+
if (!current || current.shuttingDown)
|
|
50
|
+
return;
|
|
51
|
+
current.shuttingDown = true;
|
|
52
|
+
void shutdown(current, signal);
|
|
53
|
+
}
|
|
54
|
+
function handleSigterm() {
|
|
55
|
+
handleSignal("SIGTERM");
|
|
56
|
+
}
|
|
57
|
+
function handleSigint() {
|
|
58
|
+
handleSignal("SIGINT");
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Install one process-wide graceful-shutdown handler for a Bun-compatible
|
|
62
|
+
* server. The first signal stops new accepts, drains active HTTP and consumer
|
|
63
|
+
* cleanup together, force-closes at the deadline, and exits successfully.
|
|
64
|
+
*/
|
|
65
|
+
export function installGracefulShutdown(server, options = {}) {
|
|
66
|
+
if (!server)
|
|
67
|
+
return () => undefined;
|
|
68
|
+
if (installation)
|
|
69
|
+
return installation.cleanup;
|
|
70
|
+
const cleanup = () => {
|
|
71
|
+
if (!installation || installation.cleanup !== cleanup)
|
|
72
|
+
return;
|
|
73
|
+
process.off("SIGTERM", handleSigterm);
|
|
74
|
+
process.off("SIGINT", handleSigint);
|
|
75
|
+
installation = undefined;
|
|
76
|
+
};
|
|
77
|
+
installation = { cleanup, options, server, shuttingDown: false };
|
|
78
|
+
process.on("SIGTERM", handleSigterm);
|
|
79
|
+
process.on("SIGINT", handleSigint);
|
|
80
|
+
return cleanup;
|
|
81
|
+
}
|
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
*/
|
|
23
23
|
import { type ReactNode } from "react";
|
|
24
24
|
import type { AdditionalSession, FlowSessionState } from "../types";
|
|
25
|
+
export type { GracefulShutdownCleanup, GracefulShutdownOptions, GracefulShutdownServer, GracefulShutdownSignal, } from "../types";
|
|
26
|
+
export { DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_MS, installGracefulShutdown, } from "./graceful-shutdown";
|
|
25
27
|
/**
|
|
26
28
|
* The `beforeLoad` body: during SSR resolve the session server-side (via the
|
|
27
29
|
* app's `getFlowSession` server fn, which appends rotated Set-Cookie values);
|
package/dist/sdk/start/index.js
CHANGED
|
@@ -24,13 +24,14 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
24
24
|
import { useMemo } from "react";
|
|
25
25
|
import { createFlow, createStaticFlow, getFlow } from "../client";
|
|
26
26
|
import { FlowIdProvider } from "../react";
|
|
27
|
+
export { DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_MS, installGracefulShutdown, } from "./graceful-shutdown";
|
|
27
28
|
/**
|
|
28
29
|
* The `beforeLoad` body: during SSR resolve the session server-side (via the
|
|
29
30
|
* app's `getFlowSession` server fn, which appends rotated Set-Cookie values);
|
|
30
31
|
* in the browser snapshot the live singleton instead — no network.
|
|
31
32
|
*/
|
|
32
33
|
export async function flowSessionContext(getSession) {
|
|
33
|
-
if (
|
|
34
|
+
if (!("window" in globalThis))
|
|
34
35
|
return { session: await getSession() };
|
|
35
36
|
const flow = getFlow();
|
|
36
37
|
const session = flow?.user && flow.jwt
|
|
@@ -45,7 +46,7 @@ export async function flowSessionContext(getSession) {
|
|
|
45
46
|
* is fresh.
|
|
46
47
|
*/
|
|
47
48
|
export function FlowRoot({ session, autoGuest, host, additionalAudiences, onAdditionalSessions, children, }) {
|
|
48
|
-
const flow = useMemo(() =>
|
|
49
|
+
const flow = useMemo(() => !("window" in globalThis)
|
|
49
50
|
? createStaticFlow(session, { host })
|
|
50
51
|
: createFlow({
|
|
51
52
|
initialState: session,
|
package/dist/sdk/token-expiry.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isNum, isRecord } from "./json";
|
|
1
2
|
// Refresh a little before the JWT's `exp` so a token handed out to a caller
|
|
2
3
|
// is still valid by the time it reaches the relying party, absorbing request
|
|
3
4
|
// latency and minor client/server clock skew.
|
|
@@ -10,7 +11,8 @@ export function jwtExp(token) {
|
|
|
10
11
|
try {
|
|
11
12
|
const padded = (parts[1] ?? "").replace(/-/g, "+").replace(/_/g, "/");
|
|
12
13
|
const payload = JSON.parse(atob(padded));
|
|
13
|
-
|
|
14
|
+
const exp = isRecord(payload) ? payload.exp : undefined;
|
|
15
|
+
return isNum(exp) ? exp : null;
|
|
14
16
|
}
|
|
15
17
|
catch {
|
|
16
18
|
return null;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cosmetic items, the space they occupy on the model, and the equipped set a
|
|
3
|
+
* game server receives on `/api/session/verify`.
|
|
4
|
+
*
|
|
5
|
+
* Two region vocabularies live here and must never be collapsed into one:
|
|
6
|
+
* {@link EquipRegion} is *item vs item* (can these be worn together), while
|
|
7
|
+
* {@link BodyRegion} is *cosmetic vs body* (what geometry must stop drawing so
|
|
8
|
+
* nothing pokes through). TF2 keeps them separate — equip regions and
|
|
9
|
+
* bodygroups — and conflating them is what produces the clipping bugs.
|
|
10
|
+
*
|
|
11
|
+
* {@link CosmeticMaterial} is a third vocabulary and belongs to neither: it
|
|
12
|
+
* names a *surface* of the model rather than a space on it, and it is what a
|
|
13
|
+
* colour is applied to.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* A space on the model an item occupies. Two items occupying the same region,
|
|
17
|
+
* or overlapping ones, cannot be equipped together.
|
|
18
|
+
*
|
|
19
|
+
* A deliberately small subset of TF2's 68: enough to express the composite
|
|
20
|
+
* case (`whole_head` swallowing the pieces of a head) that makes the rule
|
|
21
|
+
* non-trivial, without inventing regions no item claims yet.
|
|
22
|
+
*/
|
|
23
|
+
export type EquipRegion = "whole_head" | "hat" | "hair" | "face" | "glasses" | "ears" | "torso" | "arms" | "back" | "legs" | "feet";
|
|
24
|
+
/**
|
|
25
|
+
* Every body area a cosmetic may suppress — the bodygroup half's whole
|
|
26
|
+
* vocabulary, head down.
|
|
27
|
+
*
|
|
28
|
+
* SOURCE OF TRUTH: the `regions` keys of
|
|
29
|
+
* `game/games/arena/player/luna_bodygroups.tres`, which is authored against
|
|
30
|
+
* the shipped model. This is a mirror, kept as a constant so auth can resolve
|
|
31
|
+
* a loadout without reaching into the game repo at runtime.
|
|
32
|
+
*
|
|
33
|
+
* WHEN THE MODEL IS RE-CUT: update this list to the new `regions` keys, then
|
|
34
|
+
* fix whatever `hides` in the catalog no longer resolves. `cosmetics.test.ts`
|
|
35
|
+
* fails on the first entry that names a region absent here, and the type below
|
|
36
|
+
* makes the same mistake a typecheck error — which is the whole point of
|
|
37
|
+
* mirroring the names into one constant. AUTH-210 is what happens without it:
|
|
38
|
+
* the catalog was written from a nine-region proposal in
|
|
39
|
+
* `docs/internal/cosmetics/bodygroups.md` section 6, the fifteen-region cut
|
|
40
|
+
* that actually shipped shared not one name with it, and every cosmetic
|
|
41
|
+
* silently suppressed nothing.
|
|
42
|
+
*/
|
|
43
|
+
export declare const BODY_REGIONS: readonly ["head", "ears", "hair", "body_neck", "body_chest", "body_arm_upper_l", "body_arm_upper_r", "body_arm_lower_l", "body_arm_lower_r", "body_hand_l", "body_hand_r", "body_pelvis", "body_leg_upper_l", "body_leg_upper_r", "body_leg_lower_l", "body_leg_lower_r", "body_foot_l", "body_foot_r"];
|
|
44
|
+
/**
|
|
45
|
+
* A body area a cosmetic suppresses so the body cannot clip through it — the
|
|
46
|
+
* bodygroup half. Derived from {@link BODY_REGIONS} rather than written out a
|
|
47
|
+
* second time, so the list and the type cannot disagree.
|
|
48
|
+
*/
|
|
49
|
+
export type BodyRegion = (typeof BODY_REGIONS)[number];
|
|
50
|
+
/**
|
|
51
|
+
* Every material the shipped model set exposes, body and cosmetic alike — the
|
|
52
|
+
* surfaces a colour can be applied to.
|
|
53
|
+
*
|
|
54
|
+
* SOURCE OF TRUTH: the glTF material names inside
|
|
55
|
+
* `game/games/arena/resources/models/LUNA12.7.glb` (`skin`, `brownhair`,
|
|
56
|
+
* `tshirt`, `shorts`) and the per-cosmetic GLBs beside it
|
|
57
|
+
* (`cosmetics/LUNA_boots.glb` names `Shoes`, `cosmetics/LUNA_glasses.glb`
|
|
58
|
+
* names `outline`). `Shoes` is capitalised because the model capitalises it: a
|
|
59
|
+
* tidied-up copy here would name a material no renderer can find, and the
|
|
60
|
+
* paint would silently land nowhere.
|
|
61
|
+
*
|
|
62
|
+
* WHEN THE MODEL IS RE-CUT: update this list to the new material names, then
|
|
63
|
+
* fix whatever `paintable` in the catalog no longer resolves. This is the same
|
|
64
|
+
* mirror-and-guard {@link BODY_REGIONS} is, kept for the same reason —
|
|
65
|
+
* AUTH-210 is what a hardcoded copy with no test looks like a month later.
|
|
66
|
+
*/
|
|
67
|
+
export declare const COSMETIC_MATERIALS: readonly ["skin", "brownhair", "tshirt", "shorts", "Shoes", "outline"];
|
|
68
|
+
/**
|
|
69
|
+
* A material a cosmetic may expose for recolouring. Derived from
|
|
70
|
+
* {@link COSMETIC_MATERIALS} rather than written out a second time, so the
|
|
71
|
+
* list and the type cannot disagree.
|
|
72
|
+
*/
|
|
73
|
+
export type CosmeticMaterial = (typeof COSMETIC_MATERIALS)[number];
|
|
74
|
+
/**
|
|
75
|
+
* One equipment slot. The slot name IS the key inside the `appearance` blob,
|
|
76
|
+
* so equipped state needs no table and no migration of its own.
|
|
77
|
+
*
|
|
78
|
+
* The vocabulary is complete rather than "the slots something is sold for":
|
|
79
|
+
* `hat` has no product today and is still a slot, because the wire format, the
|
|
80
|
+
* game's whitelist and the region rules all speak in slots, and a vocabulary
|
|
81
|
+
* that shrank whenever a slot emptied would churn the contract every time
|
|
82
|
+
* content lands.
|
|
83
|
+
*/
|
|
84
|
+
export type CosmeticSlot = "hat" | "face" | "top" | "bottom" | "feet";
|
|
85
|
+
/** One item in the catalog. */
|
|
86
|
+
export interface CosmeticItem {
|
|
87
|
+
id: string;
|
|
88
|
+
slot: CosmeticSlot;
|
|
89
|
+
name: string;
|
|
90
|
+
/**
|
|
91
|
+
* ALWAYS plural, even for a single region, and the only shape there is: TF2
|
|
92
|
+
* carried a singular `equip_region` key alongside the plural one, and items
|
|
93
|
+
* that declared several regions through the singular form silently escaped
|
|
94
|
+
* enforcement (Source-1-Games#4678). A singular field is never added here.
|
|
95
|
+
*/
|
|
96
|
+
regions: readonly EquipRegion[];
|
|
97
|
+
/**
|
|
98
|
+
* Body regions this item suppresses. Required rather than optional: a
|
|
99
|
+
* cosmetic that declares nothing is a content bug, not a default, so an
|
|
100
|
+
* author must write `[]` deliberately (an accessory that covers no skin)
|
|
101
|
+
* instead of forgetting the field on a cosmetic that does.
|
|
102
|
+
*/
|
|
103
|
+
hides: readonly BodyRegion[];
|
|
104
|
+
/**
|
|
105
|
+
* The materials on this item a player may recolour. Plural for the reason
|
|
106
|
+
* {@link CosmeticItem.regions} is, and required for the reason
|
|
107
|
+
* {@link CosmeticItem.hides} is: `[]` is an item stating that nothing on it
|
|
108
|
+
* takes paint, while a missing field is an author who never considered the
|
|
109
|
+
* question. The fault is silence, not emptiness.
|
|
110
|
+
*/
|
|
111
|
+
paintable: readonly CosmeticMaterial[];
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* One painted surface on an equipped item, as `/api/session/verify` hands it
|
|
115
|
+
* to a renderer: recolour this material on this item to this colour.
|
|
116
|
+
*
|
|
117
|
+
* A LIST per item rather than a `material -> colour` map, because a map is
|
|
118
|
+
* exactly the shape that cannot grow: GAME-178 paints a masked region of an
|
|
119
|
+
* item instead of its whole surface, which arrives as another field here and a
|
|
120
|
+
* SECOND entry naming the same material.
|
|
121
|
+
*/
|
|
122
|
+
export interface CosmeticPaint {
|
|
123
|
+
material: CosmeticMaterial;
|
|
124
|
+
color: string;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* One recolourable surface the equipped set exposes: which worn item, which of
|
|
128
|
+
* its materials, and the `appearance` key holding the colour.
|
|
129
|
+
*/
|
|
130
|
+
export interface PaintSlot {
|
|
131
|
+
key: string;
|
|
132
|
+
slot: CosmeticSlot;
|
|
133
|
+
item: CosmeticItem;
|
|
134
|
+
material: CosmeticMaterial;
|
|
135
|
+
}
|
|
136
|
+
/** One equipped item as `/api/session/verify` hands it to a game server. */
|
|
137
|
+
export interface EquippedCosmetic {
|
|
138
|
+
id: string;
|
|
139
|
+
name: string;
|
|
140
|
+
regions: readonly EquipRegion[];
|
|
141
|
+
hides: readonly BodyRegion[];
|
|
142
|
+
/** The colours to apply, one entry per material the player has painted. */
|
|
143
|
+
paint: readonly CosmeticPaint[];
|
|
144
|
+
}
|
|
145
|
+
/** The verified equipped set, keyed by slot. An absent slot is empty. */
|
|
146
|
+
export type EquippedCosmetics = Partial<Record<CosmeticSlot, EquippedCosmetic>>;
|
|
147
|
+
/** The two items that cannot be worn together, and the region they fight over. */
|
|
148
|
+
export interface EquipConflict {
|
|
149
|
+
region: EquipRegion;
|
|
150
|
+
items: [string, string];
|
|
151
|
+
slots: [CosmeticSlot, CosmeticSlot];
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Why a settings write was refused by its surface's cross-field rule. Returned
|
|
155
|
+
* from the one write path and rendered as the 409 body, so a client can mark
|
|
156
|
+
* the offending keys instead of guessing which of them lost.
|
|
157
|
+
*/
|
|
158
|
+
export interface SettingsConflict {
|
|
159
|
+
code: string;
|
|
160
|
+
message: string;
|
|
161
|
+
keys: string[];
|
|
162
|
+
}
|