@flow-industries/id 0.24.0 → 0.24.2

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.
Files changed (40) hide show
  1. package/README.md +76 -0
  2. package/contracts/v1/sdk-exports.json +30 -0
  3. package/dist/sdk/browser-contract.d.ts +1 -0
  4. package/dist/sdk/browser-contract.js +1 -0
  5. package/dist/sdk/browser-session-route.js +3 -3
  6. package/dist/sdk/client/atproto.d.ts +5 -0
  7. package/dist/sdk/client/atproto.js +49 -0
  8. package/dist/sdk/client/create-flow.js +2 -1
  9. package/dist/sdk/client/index.d.ts +1 -1
  10. package/dist/sdk/client/profile-button.js +4 -1
  11. package/dist/sdk/db/handle.d.ts +11 -0
  12. package/dist/sdk/db/handle.js +0 -0
  13. package/dist/sdk/db/schema.d.ts +4786 -0
  14. package/dist/sdk/db/schema.js +859 -0
  15. package/dist/sdk/oauth/encryption.d.ts +4 -0
  16. package/dist/sdk/oauth/encryption.js +29 -0
  17. package/dist/sdk/oauth/stores.d.ts +16 -0
  18. package/dist/sdk/oauth/stores.js +159 -0
  19. package/dist/sdk/settings/appearance.d.ts +3 -0
  20. package/dist/sdk/settings/appearance.js +167 -0
  21. package/dist/sdk/settings/cosmetics.d.ts +150 -0
  22. package/dist/sdk/settings/cosmetics.js +418 -0
  23. package/dist/sdk/settings/ear-items.json +37 -0
  24. package/dist/sdk/settings/hair-items.json +312 -0
  25. package/dist/sdk/settings/registry.d.ts +11 -0
  26. package/dist/sdk/settings/registry.js +136 -0
  27. package/dist/sdk/settings/surfaces.d.ts +4 -0
  28. package/dist/sdk/settings/surfaces.js +48 -0
  29. package/dist/sdk/types/account-data.d.ts +28 -0
  30. package/dist/sdk/types/atproto-storage.d.ts +9 -0
  31. package/dist/sdk/types/atproto-storage.js +0 -0
  32. package/dist/sdk/types/atproto.d.ts +25 -0
  33. package/dist/sdk/types/atproto.js +0 -0
  34. package/dist/sdk/types/auth.d.ts +19 -0
  35. package/dist/sdk/types/cosmetics.d.ts +1 -1
  36. package/dist/sdk/types/events.d.ts +5 -2
  37. package/dist/sdk/types/index.d.ts +4 -2
  38. package/dist/sdk/types/notifications.d.ts +65 -0
  39. package/dist/sdk/types/notifications.js +9 -0
  40. package/package.json +6 -2
@@ -0,0 +1,4 @@
1
+ export declare function createCredentialCipher(encodedKey: string): {
2
+ seal<T>(value: T, context: string): string;
3
+ open<T>(value: string, context: string): T;
4
+ };
@@ -0,0 +1,29 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
2
+ export function createCredentialCipher(encodedKey) {
3
+ const key = Buffer.from(encodedKey, "base64");
4
+ if (key.length !== 32)
5
+ throw new Error("OAuth storage key must encode 32 bytes");
6
+ return {
7
+ seal(value, context) {
8
+ const nonce = randomBytes(12);
9
+ const cipher = createCipheriv("aes-256-gcm", key, nonce);
10
+ cipher.setAAD(Buffer.from(context));
11
+ const body = Buffer.concat([
12
+ cipher.update(JSON.stringify(value), "utf8"),
13
+ cipher.final(),
14
+ ]);
15
+ return Buffer.concat([nonce, cipher.getAuthTag(), body]).toString("base64");
16
+ },
17
+ open(value, context) {
18
+ const data = Buffer.from(value, "base64");
19
+ const cipher = createDecipheriv("aes-256-gcm", key, data.subarray(0, 12));
20
+ cipher.setAAD(Buffer.from(context));
21
+ cipher.setAuthTag(data.subarray(12, 28));
22
+ // SAFETY: authenticated ciphertext is written only by seal with the same domain context.
23
+ return JSON.parse(Buffer.concat([
24
+ cipher.update(data.subarray(28)),
25
+ cipher.final(),
26
+ ]).toString("utf8"));
27
+ },
28
+ };
29
+ }
@@ -0,0 +1,16 @@
1
+ import type { AppDb } from "../db/handle";
2
+ export interface OAuthCredentialStore<T> {
3
+ set(id: string, value: T): Promise<void>;
4
+ get(id: string): Promise<T | undefined>;
5
+ del(id: string): Promise<void>;
6
+ }
7
+ export declare function withOAuthTransaction<T>(database: AppDb, fn: () => Promise<T>, sessionGuard?: (providerId: string, accountId: string) => Promise<void>): Promise<T>;
8
+ export declare function withOAuthSessionLock<T>(database: AppDb, providerId: string, accountId: string, fn: () => Promise<T>): Promise<T>;
9
+ export declare function createOAuthStores<State, Session>(database: AppDb, storageKey: string, providerId: string): {
10
+ stateStore: OAuthCredentialStore<State>;
11
+ sessionStore: OAuthCredentialStore<Session>;
12
+ requestLock: <T>(name: string, fn: () => T | PromiseLike<T>) => Promise<T>;
13
+ };
14
+ export declare function cleanupOAuthCredentials(database: AppDb): Promise<void>;
15
+ export declare function peekOAuthState<State>(database: AppDb, storageKey: string, providerId: string, id: string): Promise<State | undefined>;
16
+ export declare function lockOAuthSession(database: AppDb, providerId: string, accountId: string): Promise<void>;
@@ -0,0 +1,159 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { and, eq, lt, notExists, sql } from "drizzle-orm";
3
+ import { account, oauthSession, oauthState } from "../db/schema";
4
+ import { createCredentialCipher } from "./encryption";
5
+ const transactionContext = new AsyncLocalStorage();
6
+ const sessionGuardContext = new AsyncLocalStorage();
7
+ export function withOAuthTransaction(database, fn, sessionGuard) {
8
+ const guard = sessionGuard ?? sessionGuardContext.getStore();
9
+ return transactionContext.run(database, () => guard ? sessionGuardContext.run(guard, fn) : fn());
10
+ }
11
+ export async function withOAuthSessionLock(database, providerId, accountId, fn) {
12
+ const current = transactionContext.getStore();
13
+ if (current) {
14
+ await lockOAuthSession(current, providerId, accountId);
15
+ await sessionGuardContext.getStore()?.(providerId, accountId);
16
+ return fn();
17
+ }
18
+ const outcome = await database.transaction(async (tx) => {
19
+ await lockOAuthSession(tx, providerId, accountId);
20
+ try {
21
+ return { ok: true, value: await withOAuthTransaction(tx, fn) };
22
+ }
23
+ catch (error) {
24
+ return { ok: false, error };
25
+ }
26
+ });
27
+ if (!outcome.ok)
28
+ throw outcome.error;
29
+ return outcome.value;
30
+ }
31
+ export function createOAuthStores(database, storageKey, providerId) {
32
+ const cipher = createCredentialCipher(storageKey);
33
+ const currentDatabase = () => transactionContext.getStore() ?? database;
34
+ const stateStore = {
35
+ async set(id, value) {
36
+ const data = cipher.seal(value, JSON.stringify(["oauth-state", providerId, id]));
37
+ const expiresAt = new Date(Date.now() + 10 * 60_000);
38
+ await currentDatabase()
39
+ .insert(oauthState)
40
+ .values({ providerId, id, data, expiresAt })
41
+ .onConflictDoUpdate({
42
+ target: [oauthState.providerId, oauthState.id],
43
+ set: { data, expiresAt },
44
+ });
45
+ },
46
+ async get(id) {
47
+ // Consuming before token exchange makes callback replay fail across replicas.
48
+ const [row] = await currentDatabase()
49
+ .delete(oauthState)
50
+ .where(and(eq(oauthState.providerId, providerId), eq(oauthState.id, id)))
51
+ .returning();
52
+ if (!row || row.expiresAt.getTime() <= Date.now())
53
+ return undefined;
54
+ return cipher.open(row.data, JSON.stringify(["oauth-state", providerId, id]));
55
+ },
56
+ async del(id) {
57
+ await currentDatabase()
58
+ .delete(oauthState)
59
+ .where(and(eq(oauthState.providerId, providerId), eq(oauthState.id, id)));
60
+ },
61
+ };
62
+ const sessionStore = {
63
+ async set(accountId, value) {
64
+ const data = cipher.seal(value, JSON.stringify(["oauth-session", providerId, accountId]));
65
+ const updatedAt = new Date();
66
+ await withOAuthSessionLock(database, providerId, accountId, async () => currentDatabase()
67
+ .insert(oauthSession)
68
+ .values({ providerId, accountId, data, updatedAt, formatVersion: 2 })
69
+ .onConflictDoUpdate({
70
+ target: [oauthSession.providerId, oauthSession.accountId],
71
+ set: { data, updatedAt, formatVersion: 2 },
72
+ }));
73
+ },
74
+ async get(accountId) {
75
+ const [row] = await currentDatabase()
76
+ .select()
77
+ .from(oauthSession)
78
+ .where(and(eq(oauthSession.providerId, providerId), eq(oauthSession.accountId, accountId)));
79
+ return row
80
+ ? cipher.open(row.data, sessionContext(providerId, accountId, row.formatVersion))
81
+ : undefined;
82
+ },
83
+ async del(accountId) {
84
+ await withOAuthSessionLock(database, providerId, accountId, async () => currentDatabase()
85
+ .delete(oauthSession)
86
+ .where(and(eq(oauthSession.providerId, providerId), eq(oauthSession.accountId, accountId))));
87
+ },
88
+ };
89
+ const requestLock = async (name, fn) => {
90
+ const current = transactionContext.getStore();
91
+ if (current) {
92
+ await current.execute(sql `select pg_advisory_xact_lock(hashtext(${lockName(providerId, name)}))`);
93
+ return await fn();
94
+ }
95
+ const outcome = await database.transaction(async (tx) => {
96
+ await tx.execute(sql `select pg_advisory_xact_lock(hashtext(${lockName(providerId, name)}))`);
97
+ try {
98
+ return {
99
+ ok: true,
100
+ value: await transactionContext.run(tx, fn),
101
+ };
102
+ }
103
+ catch (error) {
104
+ // SDK invalidation must commit even when token refresh fails.
105
+ return { ok: false, error };
106
+ }
107
+ });
108
+ if (!outcome.ok)
109
+ throw outcome.error;
110
+ return outcome.value;
111
+ };
112
+ return { stateStore, sessionStore, requestLock };
113
+ }
114
+ export async function cleanupOAuthCredentials(database) {
115
+ await database.execute(sql `delete from oauth_state where (provider_id, id) in (select provider_id, id from oauth_state where expires_at < (now() at time zone 'UTC') limit 100)`);
116
+ const cutoff = new Date(Date.now() - 60 * 60_000);
117
+ const candidates = await database
118
+ .select({
119
+ providerId: oauthSession.providerId,
120
+ accountId: oauthSession.accountId,
121
+ })
122
+ .from(oauthSession)
123
+ .where(and(lt(oauthSession.updatedAt, cutoff), notExists(database
124
+ .select()
125
+ .from(account)
126
+ .where(and(eq(account.providerId, oauthSession.providerId), eq(account.accountId, oauthSession.accountId))))))
127
+ .limit(100);
128
+ for (const { providerId, accountId } of candidates) {
129
+ await database.transaction(async (tx) => {
130
+ await lockOAuthSession(tx, providerId, accountId);
131
+ await tx.delete(oauthSession).where(and(eq(oauthSession.providerId, providerId), eq(oauthSession.accountId, accountId), lt(oauthSession.updatedAt, cutoff), notExists(tx
132
+ .select()
133
+ .from(account)
134
+ .where(and(eq(account.providerId, providerId), eq(account.accountId, accountId))))));
135
+ });
136
+ }
137
+ }
138
+ export async function peekOAuthState(database, storageKey, providerId, id) {
139
+ const [row] = await database
140
+ .select()
141
+ .from(oauthState)
142
+ .where(and(eq(oauthState.providerId, providerId), eq(oauthState.id, id)));
143
+ if (!row || row.expiresAt.getTime() <= Date.now())
144
+ return undefined;
145
+ return createCredentialCipher(storageKey).open(row.data, JSON.stringify(["oauth-state", providerId, id]));
146
+ }
147
+ function sessionContext(providerId, accountId, formatVersion) {
148
+ if (formatVersion === 2)
149
+ return JSON.stringify(["oauth-session", providerId, accountId]);
150
+ if (formatVersion === 1 && providerId === "atproto")
151
+ return `session:${accountId}`;
152
+ throw new Error("Unsupported OAuth credential format");
153
+ }
154
+ function lockName(providerId, name) {
155
+ return JSON.stringify(["oauth-lock", providerId, name]);
156
+ }
157
+ export async function lockOAuthSession(database, providerId, accountId) {
158
+ await database.execute(sql `select pg_advisory_xact_lock(hashtext(${lockName(providerId, accountId)}))`);
159
+ }
@@ -0,0 +1,3 @@
1
+ import type { SurfaceRegistry } from "../types";
2
+ import { type Catalog } from "./cosmetics";
3
+ export declare function appearanceSurface(catalog?: Catalog): SurfaceRegistry;
@@ -0,0 +1,167 @@
1
+ import { isString } from "../json";
2
+ import { appearanceConflict, CATALOG, COSMETIC_SLOTS, conflictingRegions, cosmeticsForSlot, paintKey, paintSlots, STARTING_OUTFIT, slotLabel, slotRequired, } from "./cosmetics";
3
+ const HAIR_COLORS = [
4
+ { value: "#26201d", label: "Black" },
5
+ { value: "#6b4226", label: "Brown" },
6
+ { value: "#8b5a2b", label: "Chestnut" },
7
+ { value: "#b5532a", label: "Copper" },
8
+ { value: "#d9b380", label: "Blonde" },
9
+ { value: "#9b9b9b", label: "Grey" },
10
+ { value: "#ececec", label: "White" },
11
+ ];
12
+ const SKIN_COLORS = [
13
+ { value: "#f5d0b5", label: "Porcelain" },
14
+ { value: "#eab98f", label: "Fair" },
15
+ { value: "#d9a066", label: "Tan" },
16
+ { value: "#b97a4e", label: "Bronze" },
17
+ { value: "#96613a", label: "Amber" },
18
+ { value: "#7a4b2a", label: "Sienna" },
19
+ { value: "#5c3823", label: "Umber" },
20
+ { value: "#3d2419", label: "Ebony" },
21
+ ];
22
+ function cosmeticSlotSettings(catalog) {
23
+ return COSMETIC_SLOTS.filter((slot) => cosmeticsForSlot(slot, catalog).length > 0).map((slot) => ({
24
+ key: slot,
25
+ group: "cosmetics",
26
+ label: slotLabel(slot),
27
+ type: "cosmetic",
28
+ default: STARTING_OUTFIT[slot],
29
+ slot,
30
+ required: slotRequired(slot),
31
+ items: cosmeticsForSlot(slot, catalog).map((item) => cosmeticOffer(item)),
32
+ }));
33
+ }
34
+ const PAINT_COLORS = [
35
+ { value: "#f2f2f2", label: "White" },
36
+ { value: "#9b9b9b", label: "Grey" },
37
+ { value: "#2a2a2e", label: "Charcoal" },
38
+ { value: "#26262b", label: "Black" },
39
+ { value: "#d63c3c", label: "Red" },
40
+ { value: "#e8863a", label: "Orange" },
41
+ { value: "#e6c545", label: "Yellow" },
42
+ { value: "#47a35e", label: "Green" },
43
+ { value: "#4c6b45", label: "Olive" },
44
+ { value: "#3d6ef0", label: "Blue" },
45
+ { value: "#3a5bbf", label: "Navy" },
46
+ { value: "#8a5bd6", label: "Purple" },
47
+ { value: "#eab98f", label: "Tan" },
48
+ { value: "#6b4226", label: "Brown" },
49
+ { value: "#4a3527", label: "Leather" },
50
+ ];
51
+ const RETIRED_GARMENT_COLORS = [
52
+ { key: "tshirt_color", paint: paintKey("hoodie", "tshirt") },
53
+ { key: "shorts_color", paint: paintKey("cargo_shorts", "shorts") },
54
+ ];
55
+ function rgb(hex) {
56
+ if (!/^#[0-9a-f]{6}$/i.test(hex))
57
+ return null;
58
+ return [
59
+ Number.parseInt(hex.slice(1, 3), 16),
60
+ Number.parseInt(hex.slice(3, 5), 16),
61
+ Number.parseInt(hex.slice(5, 7), 16),
62
+ ];
63
+ }
64
+ function nearestSwatch(color, options) {
65
+ const target = rgb(color);
66
+ if (!target)
67
+ return null;
68
+ const [red, green, blue] = target;
69
+ let best = null;
70
+ for (const option of options) {
71
+ const candidate = rgb(option.value);
72
+ if (!candidate)
73
+ continue;
74
+ const distance = (candidate[0] - red) ** 2 +
75
+ (candidate[1] - green) ** 2 +
76
+ (candidate[2] - blue) ** 2;
77
+ if (!best || distance < best.distance) {
78
+ best = { value: option.value, distance };
79
+ }
80
+ }
81
+ return best?.value ?? null;
82
+ }
83
+ function retireGarmentPalette(stored) {
84
+ const next = { ...stored };
85
+ for (const { key, paint } of RETIRED_GARMENT_COLORS) {
86
+ const color = next[key];
87
+ delete next[key];
88
+ if (paint in next || !isString(color))
89
+ continue;
90
+ const nearest = nearestSwatch(color, PAINT_COLORS);
91
+ if (nearest)
92
+ next[paint] = nearest;
93
+ }
94
+ return next;
95
+ }
96
+ function dressMandatorySlots(stored) {
97
+ const next = { ...stored };
98
+ for (const slot of COSMETIC_SLOTS) {
99
+ if (slotRequired(slot) && next[slot] === "")
100
+ delete next[slot];
101
+ }
102
+ return next;
103
+ }
104
+ function appearanceV2(stored) {
105
+ return dressMandatorySlots(retireGarmentPalette(stored));
106
+ }
107
+ const UNPAINTED = {
108
+ skin: "#eab98f",
109
+ brownhair: "#6b4226",
110
+ tshirt: "#3d6ef0",
111
+ shorts: "#2a2a2e",
112
+ Shoes: "#4a3527",
113
+ outline: "#26262b",
114
+ };
115
+ function paintDescriptor(item, material) {
116
+ return {
117
+ key: paintKey(item.id, material),
118
+ group: "paint",
119
+ label: item.name,
120
+ type: "paint",
121
+ default: UNPAINTED[material],
122
+ options: PAINT_COLORS,
123
+ cosmetic: item.id,
124
+ material,
125
+ };
126
+ }
127
+ function cosmeticOffer(item) {
128
+ return {
129
+ ...item,
130
+ conflicts: conflictingRegions(item.regions),
131
+ paint: item.paintable.map((material) => paintDescriptor(item, material)),
132
+ };
133
+ }
134
+ function paintSettings(settings, catalog) {
135
+ return paintSlots(settings, catalog).map((paint) => paintDescriptor(paint.item, paint.material));
136
+ }
137
+ function appearanceSettings(catalog) {
138
+ return [
139
+ {
140
+ key: "hair_color",
141
+ group: "colors",
142
+ label: "Hair",
143
+ type: "enum",
144
+ default: "#6b4226",
145
+ options: HAIR_COLORS,
146
+ },
147
+ {
148
+ key: "skin_color",
149
+ group: "colors",
150
+ label: "Skin",
151
+ type: "enum",
152
+ default: "#eab98f",
153
+ options: SKIN_COLORS,
154
+ },
155
+ ...cosmeticSlotSettings(catalog),
156
+ ];
157
+ }
158
+ export function appearanceSurface(catalog = CATALOG) {
159
+ return {
160
+ version: 2,
161
+ settings: appearanceSettings(catalog),
162
+ migrations: [{ to: 2, up: appearanceV2 }],
163
+ randomDefaults: true,
164
+ validate: (settings) => appearanceConflict(settings, catalog),
165
+ derived: (settings) => paintSettings(settings, catalog),
166
+ };
167
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * The cosmetic catalog, the space each item occupies, the surfaces it exposes
3
+ * for paint, and the single rule that decides whether two items may be worn at
4
+ * once.
5
+ *
6
+ * Pure — no DB, no I/O — and deliberately the ONLY implementation of that rule.
7
+ * TF2 evaluated it per equip path and its quickswitch path skipped the check
8
+ * entirely, so players equipped conflicting cosmetics that silently unequipped
9
+ * on restart (Source-1-Games#4055). Here the rule hangs off the surface
10
+ * registry and runs inside the one settings write path, which is why no route
11
+ * can forget it: a client may grey out conflicting picks for UX, but the save
12
+ * is the enforcer.
13
+ *
14
+ * The catalog is a code constant rather than a table, matching the appearance
15
+ * palette beside it — adding an item is a one-line edit and no migration. It
16
+ * promotes to a `cosmetic` table when non-engineers must edit it live.
17
+ */
18
+ import type { CosmeticItem, CosmeticMaterial, CosmeticSlot, EquipConflict, EquippedCosmetics, EquipRegion, PaintSlot, SettingsConflict } from "../types/cosmetics";
19
+ import type { Settings } from "./registry";
20
+ /** Every slot, in the order the customizer and the resolver walk them. */
21
+ export declare const COSMETIC_SLOTS: readonly CosmeticSlot[];
22
+ /** What a player reads this slot's row as. */
23
+ export declare function slotLabel(slot: CosmeticSlot): string;
24
+ /** Whether an avatar must wear something in this slot. */
25
+ export declare function slotRequired(slot: CosmeticSlot): boolean;
26
+ /**
27
+ * A catalog: the items on sale, resolved by id.
28
+ *
29
+ * A parameter rather than a module constant everywhere below, because the
30
+ * region rules have to stay testable against items that are not products.
31
+ * AUTH-223 is what the alternative costs: four cosmetics with no mesh anywhere
32
+ * — a beanie, earmuffs, a diving helmet, a bandana — were added to the shipped
33
+ * catalog purely so the composite and second-region collisions had something to
34
+ * collide, and a customizer duly offered all four for sale. The fixtures now
35
+ * live in `cosmetics.test.ts` and build a catalog of their own.
36
+ */
37
+ export type Catalog = ReadonlyMap<string, CosmeticItem>;
38
+ /** A catalog over `items`, refusing a duplicate id. */
39
+ export declare function catalogOf(items: readonly CosmeticItem[]): Catalog;
40
+ /**
41
+ * Every item a player may equip. EACH ONE MUST HAVE GEOMETRY: an id here with
42
+ * no mesh behind it renders as an item that vanishes when worn, and the model's
43
+ * side of the pairing is `cosmetic_items` in
44
+ * `game/games/arena/player/luna_bodygroups.tres` — a catalog id absent from
45
+ * there names nothing the renderer can draw.
46
+ */
47
+ export declare const COSMETICS: readonly CosmeticItem[];
48
+ /** Every item the shipped model can wear. */
49
+ export declare const CATALOG: Catalog;
50
+ /**
51
+ * What a player is wearing before they have dressed themselves — the outfit the
52
+ * model ships in, named in catalog terms.
53
+ *
54
+ * It is a DEFAULT of the appearance surface, resolved server-side exactly the
55
+ * way `randomDefaults` resolves a palette colour, and that is the whole point:
56
+ * a stored loadout is then the entire truth about what an avatar wears, and no
57
+ * renderer has to guess whether an empty slot means "new player" or "wants
58
+ * nothing there". AUTH-223 is what guessing cost — the client synthesised the
59
+ * starting outfit whenever the loadout named nothing at all, so equipping a
60
+ * single item anywhere silently stripped the other two.
61
+ *
62
+ * Every slot is listed, `""` for the ones a player starts bare in, so
63
+ * `satisfies` makes a new slot a typecheck error here until someone has said
64
+ * what a new player wears in it. A slot {@link SLOTS} marks required must name
65
+ * an item: it is the value an unnamed slot falls back to, so a mandatory slot
66
+ * starting at `""` would be a refusal every new player is born holding.
67
+ */
68
+ export declare const STARTING_OUTFIT: {
69
+ hair: string;
70
+ ears: string;
71
+ hat: string;
72
+ face: string;
73
+ top: string;
74
+ bottom: string;
75
+ feet: string;
76
+ };
77
+ export declare function cosmeticById(id: string, catalog?: Catalog): CosmeticItem | undefined;
78
+ export declare function cosmeticsForSlot(slot: CosmeticSlot, catalog?: Catalog): readonly CosmeticItem[];
79
+ /**
80
+ * Every region an item occupying `regions` cannot share a body with: those
81
+ * regions themselves, plus everything each of them overlaps.
82
+ *
83
+ * Closing the set here is what lets a client grey out an impossible pick
84
+ * without a copy of {@link REGION_OVERLAPS}. The schema projects this per
85
+ * catalog item, so a collision becomes a set intersection: nothing to
86
+ * symmetrise, and no room to assume overlap is transitive (it is not). A
87
+ * mirrored vocabulary is what AUTH-210 cost.
88
+ */
89
+ export declare function conflictingRegions(regions: readonly EquipRegion[]): readonly EquipRegion[];
90
+ /**
91
+ * The first region two items both occupy, or null. A region trivially occupies
92
+ * itself, so "sharing a region" and "occupying overlapping regions" are the
93
+ * same check rather than two rules that can drift apart.
94
+ *
95
+ * Resolved through {@link conflictingRegions}, so the rule the save path
96
+ * enforces and the projection a client greys out from are ONE function rather
97
+ * than two that happen to agree today.
98
+ */
99
+ export declare function sharedRegion(a: readonly EquipRegion[], b: readonly EquipRegion[]): EquipRegion | null;
100
+ /** The first pair of equipped items whose regions collide, or null. THE rule. */
101
+ export declare function equipConflict(settings: Settings, catalog?: Catalog): EquipConflict | null;
102
+ /**
103
+ * The first mandatory slot the loadout explicitly empties, or null.
104
+ *
105
+ * Explicitly is the whole subtlety: `""` is the unequip verb, while a slot the
106
+ * blob never names falls back to its default — the starting outfit, which fills
107
+ * every mandatory slot. So this refuses the request that undresses an avatar
108
+ * and never a partial patch that simply did not mention the slot.
109
+ *
110
+ * A slot the catalog sells nothing for is exempt for the same reason it is not
111
+ * a setting: there is nothing to put in it, so demanding one would lock every
112
+ * save out until content lands.
113
+ */
114
+ export declare function bareSlot(settings: Settings, catalog?: Catalog): CosmeticSlot | null;
115
+ /**
116
+ * The appearance surface's cross-field rule as the registry consumes it. Wired
117
+ * onto the surface rather than called from a route, so every write path gets
118
+ * it by construction.
119
+ *
120
+ * Two rules, one hook: a pair of items that cannot share a body, and a slot an
121
+ * avatar may not go without. Both are relations over the whole loadout rather
122
+ * than properties of one key, which is why neither can live in `sanitize`.
123
+ */
124
+ export declare function appearanceConflict(settings: Settings, catalog?: Catalog): SettingsConflict | null;
125
+ /**
126
+ * The `appearance` key holding one item's colour for one of its materials.
127
+ *
128
+ * Segmented rather than opaque so it can GROW without a second key space:
129
+ * GAME-178 paints a masked region of an item instead of its whole surface,
130
+ * which is one more segment here. The separator is legal in a JSON key and
131
+ * illegal in an id and a material name, which `cosmetics.test.ts` enforces —
132
+ * without that a key would be ambiguous the day someone names an item
133
+ * `round.glasses`.
134
+ */
135
+ export declare function paintKey(itemId: string, material: CosmeticMaterial): string;
136
+ /**
137
+ * Every recolourable surface the equipped set exposes, in slot order: one
138
+ * entry per paintable material on each worn item.
139
+ *
140
+ * This is what makes the appearance surface dynamic — the colour pickers a
141
+ * player sees are a function of what they are wearing, so a colour cannot
142
+ * outlive the item it painted.
143
+ */
144
+ export declare function paintSlots(settings: Settings, catalog?: Catalog): PaintSlot[];
145
+ /**
146
+ * The equipped set as `/api/session/verify` hands it to a game server: the
147
+ * items that render, each carrying the regions it occupies, the body it
148
+ * suppresses, and the paint to apply to it.
149
+ */
150
+ export declare function equippedFor(settings: Settings, catalog?: Catalog): EquippedCosmetics;