@hadoku/prefs-client 1.1.2 → 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 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.
@@ -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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hadoku/prefs-client",
3
- "version": "1.1.2",
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
  }