@flow-industries/id 0.17.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 +13 -2
- package/dist/sdk/client/idb.d.ts +2 -2
- package/dist/sdk/client/idb.js +17 -2
- package/dist/sdk/client/profile-button.js +18 -3
- 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.d.ts +4 -0
- package/dist/sdk/react/flow-widget.js +19 -4
- package/dist/sdk/react/hooks.d.ts +8 -4
- package/dist/sdk/react/hooks.js +1 -10
- package/dist/sdk/react/profile-button.d.ts +5 -0
- package/dist/sdk/react/profile-button.js +20 -4
- 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 +8 -4
- 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 +22 -5
- 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
|
@@ -6,22 +6,32 @@
|
|
|
6
6
|
* object with a method on it would throw a DataCloneError at runtime.
|
|
7
7
|
*/
|
|
8
8
|
function normalizeValue(value) {
|
|
9
|
-
|
|
9
|
+
/* The `as never`s below are all the same thing — this walks a value structurally and returns
|
|
10
|
+
a differently-shaped one (a stripped clone, or `undefined` for what cannot be cloned),
|
|
11
|
+
which a `<type> -> <type>` signature cannot express. */
|
|
12
|
+
if (Array.isArray(value)) {
|
|
13
|
+
// SAFETY: the walked copy replaces the array element-for-element.
|
|
10
14
|
return value.map(normalizeValue);
|
|
11
|
-
|
|
15
|
+
}
|
|
16
|
+
if (value instanceof Function) {
|
|
17
|
+
// SAFETY: a function cannot be structured-cloned, so it is dropped.
|
|
12
18
|
return undefined;
|
|
13
|
-
|
|
19
|
+
}
|
|
20
|
+
if (value === null || Object(value) !== value)
|
|
14
21
|
return value;
|
|
15
22
|
if (Object.getPrototypeOf(value) !== Object.prototype)
|
|
16
23
|
try {
|
|
17
24
|
return structuredClone(value);
|
|
18
25
|
}
|
|
19
26
|
catch {
|
|
27
|
+
// SAFETY: a value even structuredClone refuses is dropped.
|
|
20
28
|
return undefined;
|
|
21
29
|
}
|
|
22
30
|
const normalized = {};
|
|
23
|
-
for (const [k, v] of Object.entries(value))
|
|
31
|
+
for (const [k, v] of Object.entries(Object(value))) {
|
|
32
|
+
// SAFETY: the walked value is a structured-clonable primitive or plain object.
|
|
24
33
|
normalized[k] = normalizeValue(v);
|
|
34
|
+
}
|
|
25
35
|
return normalized;
|
|
26
36
|
}
|
|
27
37
|
export function from(messenger) {
|
|
@@ -65,11 +75,15 @@ export function fromWindow(w, options = {}) {
|
|
|
65
75
|
async send(topic, payload, target) {
|
|
66
76
|
const id = crypto.randomUUID();
|
|
67
77
|
w.postMessage(normalizeValue({ id, payload, topic }), target ?? targetOrigin ?? "*");
|
|
78
|
+
/* SAFETY: Bridge types `send`'s result from the topic's response map; the envelope this
|
|
79
|
+
transport resolves with is the request echo, which that map cannot name. */
|
|
68
80
|
return { id, payload, topic };
|
|
69
81
|
},
|
|
70
82
|
async sendAsync(topic, payload, target) {
|
|
71
83
|
const { id } = await this.send(topic, payload, target);
|
|
72
|
-
return new Promise((resolve) =>
|
|
84
|
+
return new Promise((resolve) =>
|
|
85
|
+
// SAFETY: `topic` is the same topic this method was called with.
|
|
86
|
+
this.on(topic, resolve, id));
|
|
73
87
|
},
|
|
74
88
|
});
|
|
75
89
|
}
|
|
@@ -128,6 +142,10 @@ export function bridge(parameters) {
|
|
|
128
142
|
* `window` doesn't exist. All sends resolve to undefined and never deliver.
|
|
129
143
|
* Lets the SDK be imported from anywhere without crashing on module load.
|
|
130
144
|
*/
|
|
145
|
+
/* Off-browser there is no peer to answer, so every send resolves to nothing while still
|
|
146
|
+
satisfying Bridge's per-topic response types. */
|
|
147
|
+
// SAFETY: the no-op's contract — nothing is ever delivered or answered.
|
|
148
|
+
const settle = () => Promise.resolve(undefined);
|
|
131
149
|
export function noop() {
|
|
132
150
|
return {
|
|
133
151
|
destroy() { },
|
|
@@ -135,14 +153,8 @@ export function noop() {
|
|
|
135
153
|
return () => { };
|
|
136
154
|
},
|
|
137
155
|
ready() { },
|
|
138
|
-
send
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
sendAsync() {
|
|
142
|
-
return Promise.resolve(undefined);
|
|
143
|
-
},
|
|
144
|
-
waitForReady() {
|
|
145
|
-
return Promise.resolve(undefined);
|
|
146
|
-
},
|
|
156
|
+
send: settle,
|
|
157
|
+
sendAsync: settle,
|
|
158
|
+
waitForReady: settle,
|
|
147
159
|
};
|
|
148
160
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Postgres driver errors are not typed by the driver, and the wrapper nests differently across
|
|
3
|
+
* postgres-js and PGlite. These read the fields we branch on without asserting a shape onto the
|
|
4
|
+
* error, and return "" / undefined when the field is absent.
|
|
5
|
+
*/
|
|
6
|
+
/** Reads a named string field off a driver error. */
|
|
7
|
+
export declare function errField(cause: unknown, key: string): string;
|
|
8
|
+
export declare function errCause(cause: unknown): object | undefined;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Postgres driver errors are not typed by the driver, and the wrapper nests differently across
|
|
3
|
+
* postgres-js and PGlite. These read the fields we branch on without asserting a shape onto the
|
|
4
|
+
* error, and return "" / undefined when the field is absent.
|
|
5
|
+
*/
|
|
6
|
+
/** Reads a named string field off a driver error. */
|
|
7
|
+
export function errField(cause, key) {
|
|
8
|
+
const value = Object.entries(Object(cause)).find(([k]) => k === key)?.[1];
|
|
9
|
+
return String(value) === value ? value : "";
|
|
10
|
+
}
|
|
11
|
+
/** The nested driver error, when the wrapper carries one. */
|
|
12
|
+
/** `Object(x) === x` holds only for objects, so this tests without narrowing a representation. */
|
|
13
|
+
const isObject = (cause) => Object(cause) === cause;
|
|
14
|
+
export function errCause(cause) {
|
|
15
|
+
const value = Object.entries(Object(cause)).find(([k]) => k === "cause")?.[1];
|
|
16
|
+
return isObject(value) ? value : undefined;
|
|
17
|
+
}
|
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));
|
|
@@ -8,5 +8,9 @@ import type { FlowWidgetProps } from "../types";
|
|
|
8
8
|
* Prefer this over embedding the widget's URL in your own `<iframe>`: only a
|
|
9
9
|
* frame mounted this way completes the handshake that lets it authenticate as
|
|
10
10
|
* the signed-in user from a cross-site page (AUTH-171).
|
|
11
|
+
*
|
|
12
|
+
* A changing `theme` recolors the mounted widget over its open bridge rather
|
|
13
|
+
* than remounting it — see `<ProfileButton>` for why the frame must survive a
|
|
14
|
+
* light/dark toggle.
|
|
11
15
|
*/
|
|
12
16
|
export declare function FlowWidget({ widget, className, host, theme, }: FlowWidgetProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -11,10 +11,16 @@ import { useFlow } from "./hooks";
|
|
|
11
11
|
* Prefer this over embedding the widget's URL in your own `<iframe>`: only a
|
|
12
12
|
* frame mounted this way completes the handshake that lets it authenticate as
|
|
13
13
|
* the signed-in user from a cross-site page (AUTH-171).
|
|
14
|
+
*
|
|
15
|
+
* A changing `theme` recolors the mounted widget over its open bridge rather
|
|
16
|
+
* than remounting it — see `<ProfileButton>` for why the frame must survive a
|
|
17
|
+
* light/dark toggle.
|
|
14
18
|
*/
|
|
15
19
|
export function FlowWidget({ widget, className, host, theme, }) {
|
|
16
20
|
const flow = useFlow();
|
|
17
21
|
const ref = useRef(null);
|
|
22
|
+
const handleRef = useRef(null);
|
|
23
|
+
const themeRef = useRef(theme);
|
|
18
24
|
useEffect(() => {
|
|
19
25
|
const container = ref.current;
|
|
20
26
|
if (!container)
|
|
@@ -23,15 +29,24 @@ export function FlowWidget({ widget, className, host, theme, }) {
|
|
|
23
29
|
widget,
|
|
24
30
|
container,
|
|
25
31
|
flow,
|
|
26
|
-
|
|
27
|
-
|
|
32
|
+
host: host ? host : undefined,
|
|
33
|
+
theme: themeRef.current ? themeRef.current : undefined,
|
|
28
34
|
});
|
|
35
|
+
handleRef.current = handle;
|
|
29
36
|
if (handle.frame) {
|
|
30
37
|
handle.frame.style.width = "100%";
|
|
31
38
|
handle.frame.style.height = "100%";
|
|
32
39
|
handle.frame.style.display = "block";
|
|
33
40
|
}
|
|
34
|
-
return () =>
|
|
35
|
-
|
|
41
|
+
return () => {
|
|
42
|
+
handleRef.current = null;
|
|
43
|
+
handle.destroy();
|
|
44
|
+
};
|
|
45
|
+
}, [widget, flow, host]);
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
themeRef.current = theme;
|
|
48
|
+
if (theme)
|
|
49
|
+
handleRef.current?.setTheme(theme);
|
|
50
|
+
}, [theme]);
|
|
36
51
|
return _jsx("div", { ref: ref, className: className });
|
|
37
52
|
}
|
|
@@ -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;
|
|
@@ -5,5 +5,10 @@ import type { ProfileButtonProps } from "../types";
|
|
|
5
5
|
* the pill iframe mounts into; the iframe itself is sized to its content and
|
|
6
6
|
* expands to the profile dialog on click. Resolves the Flow instance from a
|
|
7
7
|
* `<FlowIdProvider>` or the createFlow() singleton.
|
|
8
|
+
*
|
|
9
|
+
* A changing `theme` recolors the mounted pill over its open bridge rather than
|
|
10
|
+
* remounting it: `theme` is deliberately absent from the mount effect's deps,
|
|
11
|
+
* because tearing the iframe down on a light/dark toggle reloads the widget and
|
|
12
|
+
* drops the pill out of the host's layout mid-toggle.
|
|
8
13
|
*/
|
|
9
14
|
export declare function ProfileButton({ className, host, theme }: ProfileButtonProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -8,10 +8,17 @@ import { useFlow } from "./hooks";
|
|
|
8
8
|
* the pill iframe mounts into; the iframe itself is sized to its content and
|
|
9
9
|
* expands to the profile dialog on click. Resolves the Flow instance from a
|
|
10
10
|
* `<FlowIdProvider>` or the createFlow() singleton.
|
|
11
|
+
*
|
|
12
|
+
* A changing `theme` recolors the mounted pill over its open bridge rather than
|
|
13
|
+
* remounting it: `theme` is deliberately absent from the mount effect's deps,
|
|
14
|
+
* because tearing the iframe down on a light/dark toggle reloads the widget and
|
|
15
|
+
* drops the pill out of the host's layout mid-toggle.
|
|
11
16
|
*/
|
|
12
17
|
export function ProfileButton({ className, host, theme }) {
|
|
13
18
|
const flow = useFlow();
|
|
14
19
|
const ref = useRef(null);
|
|
20
|
+
const handleRef = useRef(null);
|
|
21
|
+
const themeRef = useRef(theme);
|
|
15
22
|
useEffect(() => {
|
|
16
23
|
const container = ref.current;
|
|
17
24
|
if (!container)
|
|
@@ -19,10 +26,19 @@ export function ProfileButton({ className, host, theme }) {
|
|
|
19
26
|
const handle = createProfileButton({
|
|
20
27
|
container,
|
|
21
28
|
flow,
|
|
22
|
-
|
|
23
|
-
|
|
29
|
+
host: host ? host : undefined,
|
|
30
|
+
theme: themeRef.current ? themeRef.current : undefined,
|
|
24
31
|
});
|
|
25
|
-
|
|
26
|
-
|
|
32
|
+
handleRef.current = handle;
|
|
33
|
+
return () => {
|
|
34
|
+
handleRef.current = null;
|
|
35
|
+
handle.destroy();
|
|
36
|
+
};
|
|
37
|
+
}, [flow, host]);
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
themeRef.current = theme;
|
|
40
|
+
if (theme)
|
|
41
|
+
handleRef.current?.setTheme(theme);
|
|
42
|
+
}, [theme]);
|
|
27
43
|
return _jsx("div", { ref: ref, className: className });
|
|
28
44
|
}
|
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);
|