@hadoku/prefs-client 1.1.1 → 1.1.3
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 +43 -0
- package/dist/avatar.d.ts +137 -0
- package/dist/avatar.js +80 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +8 -3
package/README.md
CHANGED
|
@@ -96,3 +96,46 @@ const client = createPrefsClient({
|
|
|
96
96
|
// storage auto-detects Capacitor; pass an explicit adapter only to override
|
|
97
97
|
});
|
|
98
98
|
```
|
|
99
|
+
|
|
100
|
+
## Avatars (`@wolffm/prefs-client/avatar`)
|
|
101
|
+
|
|
102
|
+
One resolved avatar per hadoku user, chosen by the user at `hadoku.me/account`.
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
type ResolvedAvatar =
|
|
106
|
+
| { source: 'sprite'; sprite?: string } // watchparty character id; absent = your default
|
|
107
|
+
| { source: 'discord' | 'steam' | 'upload'; url: string }; // square https image, >= 184px
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Browser, the signed-in user's own avatar:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
import { createAvatarClient } from '@wolffm/prefs-client/avatar';
|
|
114
|
+
const { avatar } = await createAvatarClient().get(); // AvatarSettings
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Server, everyone in a room, by userId, with the server's service-tier key:
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
import { resolveAvatars } from '@wolffm/prefs-client/avatar';
|
|
121
|
+
const byUser = await resolveAvatars(
|
|
122
|
+
{ serviceKey: process.env.SERVICE_KEY! },
|
|
123
|
+
userIds
|
|
124
|
+
);
|
|
125
|
+
// every requested id is present; unknown users resolve to { source: 'sprite' }
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Rules for an app that renders avatars:
|
|
129
|
+
|
|
130
|
+
- **`url` → `<img src>`**, nothing else: never fetch it server-side, never proxy it. Set
|
|
131
|
+
`referrerpolicy="no-referrer"`. A URL can stop working (a changed Discord picture, an admin
|
|
132
|
+
removal): on `error`, fall back to the sprite.
|
|
133
|
+
- **`sprite`**: the app that owns the sprite art builds its URL (watchparty owns art revisions).
|
|
134
|
+
Without a `sprite`, render your own default.
|
|
135
|
+
- **Steam attribution is required** wherever a `steam` avatar is shown (Steam Web API terms): a
|
|
136
|
+
visible line such as "Steam avatars via the Steam Web API · not affiliated with Valve", linking
|
|
137
|
+
to `https://store.steampowered.com/`. Do not use Steam's or Discord's logos, and do not imply
|
|
138
|
+
either endorses the app.
|
|
139
|
+
- Resolve on join and on reconnect; avatars change rarely. `resolveAvatars` batches past 100 ids.
|
|
140
|
+
- Source choice, linking, uploads and removal all happen on `hadoku.me/account` — link there
|
|
141
|
+
rather than building your own.
|
package/dist/avatar.d.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Avatars — the wire contract, shared by prefs-api and the client.
|
|
3
|
+
*
|
|
4
|
+
* Pure: no fetch, no DOM. prefs-api imports this file directly (as it does
|
|
5
|
+
* games-contract.ts), so the server answers in the very types the client is
|
|
6
|
+
* built from. The client half lives in ./avatar.ts.
|
|
7
|
+
*
|
|
8
|
+
* Design: hadoku_site docs/planning/avatars-2026-10-30.md
|
|
9
|
+
*/
|
|
10
|
+
declare const AVATAR_SOURCES: readonly ["sprite", "discord", "steam", "upload"];
|
|
11
|
+
type AvatarSource = (typeof AVATAR_SOURCES)[number];
|
|
12
|
+
declare const LINK_PROVIDERS: readonly ["discord", "steam"];
|
|
13
|
+
type LinkProvider = (typeof LINK_PROVIDERS)[number];
|
|
14
|
+
/**
|
|
15
|
+
* The one avatar every app renders for a user.
|
|
16
|
+
*
|
|
17
|
+
* - `sprite`: `sprite` is the watchparty character id from the user's
|
|
18
|
+
* watchparty prefs, or absent when they never picked one (render your own
|
|
19
|
+
* default). There is no `url`: the app that owns the sprite art owns its
|
|
20
|
+
* URL and its revision.
|
|
21
|
+
* - `discord` / `steam` / `upload`: `url` is an absolute https image URL,
|
|
22
|
+
* square, at least 184px. Render it as an `<img>`; never fetch it server-side
|
|
23
|
+
* on a user's behalf.
|
|
24
|
+
*
|
|
25
|
+
* A source that stops being available (an unlink, an admin removal, a Discord
|
|
26
|
+
* avatar that changed) resolves to `sprite` with no action from the app.
|
|
27
|
+
*/
|
|
28
|
+
type ResolvedAvatar = {
|
|
29
|
+
source: 'sprite';
|
|
30
|
+
sprite?: string;
|
|
31
|
+
url?: undefined;
|
|
32
|
+
} | {
|
|
33
|
+
source: 'discord' | 'steam' | 'upload';
|
|
34
|
+
url: string;
|
|
35
|
+
sprite?: undefined;
|
|
36
|
+
};
|
|
37
|
+
/** A linked account as its owner sees it. Never carries a token. */
|
|
38
|
+
interface LinkedAccountView {
|
|
39
|
+
/** null when the account has no custom avatar, or the cached one went stale. */
|
|
40
|
+
avatarUrl: string | null;
|
|
41
|
+
linkedAt: number;
|
|
42
|
+
refreshedAt: number;
|
|
43
|
+
/** Set when Discord stopped serving the cached avatar: link again to pick up the new one. */
|
|
44
|
+
stale: boolean;
|
|
45
|
+
}
|
|
46
|
+
interface UploadView {
|
|
47
|
+
url: string;
|
|
48
|
+
width: number;
|
|
49
|
+
height: number;
|
|
50
|
+
uploadedAt: number;
|
|
51
|
+
}
|
|
52
|
+
/** GET /api/v1/_avatar — the caller's own avatar settings. */
|
|
53
|
+
interface AvatarSettings {
|
|
54
|
+
avatar: ResolvedAvatar;
|
|
55
|
+
/** What the user picked; null = never picked (resolves to sprite). */
|
|
56
|
+
choice: AvatarSource | null;
|
|
57
|
+
sprite: string | null;
|
|
58
|
+
linked: Partial<Record<LinkProvider, LinkedAccountView>>;
|
|
59
|
+
upload: UploadView | null;
|
|
60
|
+
/** Whether each provider is configured on the server. A false one cannot be linked yet. */
|
|
61
|
+
providers: Record<LinkProvider, boolean>;
|
|
62
|
+
limits: AvatarUploadLimits;
|
|
63
|
+
}
|
|
64
|
+
interface AvatarUploadLimits {
|
|
65
|
+
maxBytes: number;
|
|
66
|
+
minDimension: number;
|
|
67
|
+
maxDimension: number;
|
|
68
|
+
acceptedTypes: readonly string[];
|
|
69
|
+
/** Accepted uploads per user per rolling 24h. */
|
|
70
|
+
perDay: number;
|
|
71
|
+
/** Edge length of the stored square, in px. */
|
|
72
|
+
outputSize: number;
|
|
73
|
+
}
|
|
74
|
+
/** POST /api/v1/_avatar/link/:provider → where to send the browser. */
|
|
75
|
+
interface LinkStart {
|
|
76
|
+
authorizeUrl: string;
|
|
77
|
+
}
|
|
78
|
+
/** POST /api/v1/_avatar/resolve (service tier). At most RESOLVE_MAX_IDS ids. */
|
|
79
|
+
interface ResolveRequest {
|
|
80
|
+
userIds: string[];
|
|
81
|
+
}
|
|
82
|
+
interface ResolveResponse {
|
|
83
|
+
/** Every requested id is present; an unknown user resolves to `{ source: 'sprite' }`. */
|
|
84
|
+
avatars: Record<string, ResolvedAvatar>;
|
|
85
|
+
}
|
|
86
|
+
declare const RESOLVE_MAX_IDS = 100;
|
|
87
|
+
/** A watchparty character id, as prefs-api accepts one from the watchparty blob. */
|
|
88
|
+
declare const SPRITE_ID_PATTERN: RegExp;
|
|
89
|
+
/**
|
|
90
|
+
* Where Discord and Steam send the browser back. A hadoku.me page, not an API
|
|
91
|
+
* route: the session cookie is SameSite=Strict, so a navigation arriving from
|
|
92
|
+
* discord.com or steamcommunity.com carries no session. The page then POSTs
|
|
93
|
+
* the query string same-origin, where it does.
|
|
94
|
+
*/
|
|
95
|
+
declare const LINK_CALLBACK_PATH = "/account/link/callback";
|
|
96
|
+
|
|
97
|
+
declare class AvatarError extends Error {
|
|
98
|
+
readonly status: number;
|
|
99
|
+
constructor(status: number, message: string);
|
|
100
|
+
}
|
|
101
|
+
interface AvatarClientOptions {
|
|
102
|
+
/** The prefs mount (`https://hadoku.me/prefs` by default), without `/api/v1`. */
|
|
103
|
+
baseUrl?: string;
|
|
104
|
+
fetch?: typeof fetch;
|
|
105
|
+
}
|
|
106
|
+
declare function createAvatarClient(opts?: AvatarClientOptions): {
|
|
107
|
+
/** The caller's settings, including the resolved avatar. */
|
|
108
|
+
get: () => Promise<AvatarSettings>;
|
|
109
|
+
/** Pick a source. 409 when it is not available (not linked, nothing uploaded). */
|
|
110
|
+
choose: (source: AvatarSource) => Promise<AvatarSettings>;
|
|
111
|
+
/**
|
|
112
|
+
* Begin linking. Navigate the browser to the returned URL; the provider
|
|
113
|
+
* returns it to hadoku.me/account/link/callback, which completes the link.
|
|
114
|
+
* Call this only from the user's own click: nothing is fetched from
|
|
115
|
+
* Discord or Steam before they ask.
|
|
116
|
+
*/
|
|
117
|
+
startLink: (provider: LinkProvider) => Promise<LinkStart>;
|
|
118
|
+
/** Finish a link from the callback page's query string (`location.search`). */
|
|
119
|
+
completeLink: (query: string) => Promise<AvatarSettings>;
|
|
120
|
+
/** Delete the linked id and its cached avatar URL. */
|
|
121
|
+
unlink: (provider: LinkProvider) => Promise<AvatarSettings>;
|
|
122
|
+
/** Upload an image (PNG, JPEG, WebP or GIF). It is re-encoded and becomes the chosen source. */
|
|
123
|
+
upload: (image: Blob) => Promise<AvatarSettings>;
|
|
124
|
+
removeUpload: () => Promise<AvatarSettings>;
|
|
125
|
+
};
|
|
126
|
+
type AvatarClient = ReturnType<typeof createAvatarClient>;
|
|
127
|
+
interface ResolveAvatarsOptions extends AvatarClientOptions {
|
|
128
|
+
/** The game server's own service-tier key. */
|
|
129
|
+
serviceKey: string;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Server-side: resolve many users' avatars by userId. Batches past
|
|
133
|
+
* RESOLVE_MAX_IDS for you. Every requested id is in the result.
|
|
134
|
+
*/
|
|
135
|
+
declare function resolveAvatars(opts: ResolveAvatarsOptions, userIds: readonly string[]): Promise<Record<string, ResolvedAvatar>>;
|
|
136
|
+
|
|
137
|
+
export { AVATAR_SOURCES, type AvatarClient, type AvatarClientOptions, AvatarError, type AvatarSettings, type AvatarSource, type AvatarUploadLimits, LINK_CALLBACK_PATH, LINK_PROVIDERS, type LinkProvider, type LinkStart, type LinkedAccountView, RESOLVE_MAX_IDS, type ResolveAvatarsOptions, type ResolveRequest, type ResolveResponse, type ResolvedAvatar, SPRITE_ID_PATTERN, type UploadView, createAvatarClient, resolveAvatars };
|
package/dist/avatar.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// src/constants.ts
|
|
2
|
+
var PREFS_API_BASE = "https://hadoku.me/prefs";
|
|
3
|
+
|
|
4
|
+
// src/avatar-contract.ts
|
|
5
|
+
var AVATAR_SOURCES = ["sprite", "discord", "steam", "upload"];
|
|
6
|
+
var LINK_PROVIDERS = ["discord", "steam"];
|
|
7
|
+
var RESOLVE_MAX_IDS = 100;
|
|
8
|
+
var SPRITE_ID_PATTERN = /^[a-z][a-z0-9-]{0,31}$/;
|
|
9
|
+
var LINK_CALLBACK_PATH = "/account/link/callback";
|
|
10
|
+
|
|
11
|
+
// src/avatar.ts
|
|
12
|
+
var AvatarError = class extends Error {
|
|
13
|
+
constructor(status, message) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.status = status;
|
|
16
|
+
this.name = "AvatarError";
|
|
17
|
+
}
|
|
18
|
+
status;
|
|
19
|
+
};
|
|
20
|
+
async function readJson(res) {
|
|
21
|
+
if (!res.ok) {
|
|
22
|
+
const body = await res.json().catch(() => null);
|
|
23
|
+
throw new AvatarError(res.status, body?.message ?? `HTTP ${res.status}`);
|
|
24
|
+
}
|
|
25
|
+
return await res.json();
|
|
26
|
+
}
|
|
27
|
+
function createAvatarClient(opts = {}) {
|
|
28
|
+
const base = `${opts.baseUrl ?? PREFS_API_BASE}/api/v1/_avatar`;
|
|
29
|
+
const f = opts.fetch ?? fetch;
|
|
30
|
+
const call = (path, init = {}) => f(`${base}${path}`, { credentials: "include", ...init }).then(readJson);
|
|
31
|
+
const send = (path, method, body) => call(path, {
|
|
32
|
+
method,
|
|
33
|
+
...body === void 0 ? {} : { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }
|
|
34
|
+
});
|
|
35
|
+
return {
|
|
36
|
+
/** The caller's settings, including the resolved avatar. */
|
|
37
|
+
get: () => call(""),
|
|
38
|
+
/** Pick a source. 409 when it is not available (not linked, nothing uploaded). */
|
|
39
|
+
choose: (source) => send("", "PUT", { source }),
|
|
40
|
+
/**
|
|
41
|
+
* Begin linking. Navigate the browser to the returned URL; the provider
|
|
42
|
+
* returns it to hadoku.me/account/link/callback, which completes the link.
|
|
43
|
+
* Call this only from the user's own click: nothing is fetched from
|
|
44
|
+
* Discord or Steam before they ask.
|
|
45
|
+
*/
|
|
46
|
+
startLink: (provider) => send(`/link/${provider}`, "POST", {}),
|
|
47
|
+
/** Finish a link from the callback page's query string (`location.search`). */
|
|
48
|
+
completeLink: (query) => send("/link/complete", "POST", { query: query.replace(/^\?/, "") }),
|
|
49
|
+
/** Delete the linked id and its cached avatar URL. */
|
|
50
|
+
unlink: (provider) => send(`/link/${provider}`, "DELETE"),
|
|
51
|
+
/** Upload an image (PNG, JPEG, WebP or GIF). It is re-encoded and becomes the chosen source. */
|
|
52
|
+
upload: (image) => call("/upload", { method: "POST", body: image }),
|
|
53
|
+
removeUpload: () => send("/upload", "DELETE")
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
async function resolveAvatars(opts, userIds) {
|
|
57
|
+
const f = opts.fetch ?? fetch;
|
|
58
|
+
const url = `${opts.baseUrl ?? PREFS_API_BASE}/api/v1/_avatar/resolve`;
|
|
59
|
+
const unique = [...new Set(userIds)];
|
|
60
|
+
const out = {};
|
|
61
|
+
for (let i = 0; i < unique.length; i += RESOLVE_MAX_IDS) {
|
|
62
|
+
const res = await f(url, {
|
|
63
|
+
method: "POST",
|
|
64
|
+
headers: { "Content-Type": "application/json", "X-User-Key": opts.serviceKey },
|
|
65
|
+
body: JSON.stringify({ userIds: unique.slice(i, i + RESOLVE_MAX_IDS) })
|
|
66
|
+
});
|
|
67
|
+
Object.assign(out, (await readJson(res)).avatars);
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
export {
|
|
72
|
+
AVATAR_SOURCES,
|
|
73
|
+
AvatarError,
|
|
74
|
+
LINK_CALLBACK_PATH,
|
|
75
|
+
LINK_PROVIDERS,
|
|
76
|
+
RESOLVE_MAX_IDS,
|
|
77
|
+
SPRITE_ID_PATTERN,
|
|
78
|
+
createAvatarClient,
|
|
79
|
+
resolveAvatars
|
|
80
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -74,7 +74,7 @@ declare function getDeviceId(storage?: AsyncStorage): Promise<string>;
|
|
|
74
74
|
type IdentityScope = 'user' | 'session' | 'anon' | 'none';
|
|
75
75
|
interface WhoamiResult {
|
|
76
76
|
userId: string;
|
|
77
|
-
userType: 'admin' | 'wife' | 'service' | 'friend' | 'public';
|
|
77
|
+
userType: 'admin' | 'wife' | 'service' | 'friend' | 'member' | 'public';
|
|
78
78
|
name?: string | null;
|
|
79
79
|
/**
|
|
80
80
|
* How `userId` was derived. Callers that PERSIST anything keyed by
|
package/dist/index.js
CHANGED
|
@@ -114,7 +114,7 @@ function adaptWhoami(body) {
|
|
|
114
114
|
if (!body || body.valid === false) {
|
|
115
115
|
return { userId: ANON_USER_ID, userType: "public", scope: "anon" };
|
|
116
116
|
}
|
|
117
|
-
const userType = body.userType === "admin" || body.userType === "wife" || body.userType === "service" || body.userType === "friend" || body.userType === "public" ? body.userType : "public";
|
|
117
|
+
const userType = body.userType === "admin" || body.userType === "wife" || body.userType === "service" || body.userType === "friend" || body.userType === "member" || body.userType === "public" ? body.userType : "public";
|
|
118
118
|
const name = typeof body.name === "string" ? body.name : null;
|
|
119
119
|
if (typeof body.userId === "string" && body.userId) {
|
|
120
120
|
return { userId: body.userId, userType, name, scope: "user" };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hadoku/prefs-client",
|
|
3
|
-
"version": "1.1.
|
|
4
|
-
"description": "Client SDK for the hadoku unified preferences store. Wraps /prefs/api/v1/* with localStorage caching, debounced writes, Zod-validated reads, and a usePrefs() React hook, plus match history, ratings and leaderboards for every game (./games).",
|
|
3
|
+
"version": "1.1.3",
|
|
4
|
+
"description": "Client SDK for the hadoku unified preferences store. Wraps /prefs/api/v1/* with localStorage caching, debounced writes, Zod-validated reads, and a usePrefs() React hook, plus match history, ratings and leaderboards for every game (./games), and the one resolved avatar per user (./avatar).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"types": "./dist/index.d.ts",
|
|
@@ -17,6 +17,10 @@
|
|
|
17
17
|
"./games": {
|
|
18
18
|
"types": "./dist/games.d.ts",
|
|
19
19
|
"default": "./dist/games.js"
|
|
20
|
+
},
|
|
21
|
+
"./avatar": {
|
|
22
|
+
"types": "./dist/avatar.d.ts",
|
|
23
|
+
"default": "./dist/avatar.js"
|
|
20
24
|
}
|
|
21
25
|
},
|
|
22
26
|
"files": [
|
|
@@ -45,7 +49,8 @@
|
|
|
45
49
|
"preferences",
|
|
46
50
|
"prefs",
|
|
47
51
|
"sdk",
|
|
48
|
-
"game-stats"
|
|
52
|
+
"game-stats",
|
|
53
|
+
"avatar"
|
|
49
54
|
],
|
|
50
55
|
"license": "ISC"
|
|
51
56
|
}
|