@usenavii/core 0.6.0 → 0.7.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/dist/index.d.cts CHANGED
@@ -82,6 +82,28 @@ declare function renderGroup(seeds: string[], options?: GroupOptions): string;
82
82
  * it with `createdAt` makes the result globally unique while remaining
83
83
  * stable across renders (assuming `createdAt` is set once at signup).
84
84
  */
85
+ /**
86
+ * Normalize an email the same way Gravatar does — trim + lowercase, NFC.
87
+ * Exported so callers can reproduce the canonical form before hashing.
88
+ */
89
+ declare function normalizeEmail(email: string): string;
90
+ /**
91
+ * Turn an email into a stable, opaque seed using Gravatar's scheme:
92
+ * `sha256(trim(lowercase(email)))` → lowercase hex.
93
+ *
94
+ * Why: passing raw emails as seeds leaks them through URLs (server logs,
95
+ * Referer headers, browser history, CDN cache keys, analytics). The hash
96
+ * is stable across systems that normalize the same way, so two products
97
+ * looking up the same person get the same avatar.
98
+ *
99
+ * @example
100
+ * ```ts
101
+ * const s = seedFromEmail(user.email);
102
+ * createAvatar(s); // safe to log
103
+ * // or hit the API: `/avatar/${s}.svg` // no plaintext email on the wire
104
+ * ```
105
+ */
106
+ declare function seedFromEmail(email: string): string;
85
107
  interface SeedFields {
86
108
  /** Stable primary key (database id, UUID, OAuth sub). Best choice. */
87
109
  id?: string | number | null | undefined;
@@ -92,6 +114,18 @@ interface SeedFields {
92
114
  /** Account creation time. Combined with `name` to bake uniqueness in at signup. */
93
115
  createdAt?: string | number | Date | null | undefined;
94
116
  }
117
+ interface SeedOptions {
118
+ /**
119
+ * When the email branch is used, hash the email instead of returning it
120
+ * raw. Hashing keeps the seed stable but stops the address from leaking
121
+ * into URLs, server logs, and Referer headers. Default `true` from v1.
122
+ *
123
+ * Set to `false` to opt back into the legacy plaintext-email behavior —
124
+ * useful for migrations where existing avatars are keyed off the raw
125
+ * email and you don't want every user's face to change.
126
+ */
127
+ hashEmail?: boolean;
128
+ }
95
129
  /**
96
130
  * Compose a stable seed string from the most unique field available.
97
131
  *
@@ -107,9 +141,27 @@ interface SeedFields {
107
141
  * // → "Alice|1700000000000"
108
142
  * ```
109
143
  *
144
+ * @example Opt out of email hashing (legacy behavior):
145
+ * ```ts
146
+ * seed({ email: 'a@b.c' }, { hashEmail: false });
147
+ * // → "a@b.c" — avoid; only for migrating off the old default.
148
+ * ```
149
+ *
110
150
  * @throws if no usable field is provided.
111
151
  */
112
- declare function seed(fields: SeedFields): string;
152
+ declare function seed(fields: SeedFields, options?: SeedOptions): string;
153
+
154
+ /**
155
+ * Sync SHA-256 (FIPS 180-4) → lowercase hex.
156
+ *
157
+ * Used by `seedFromEmail()` to match Gravatar's seed format
158
+ * (sha256 of trimmed, lowercased email). Sync API on purpose —
159
+ * a seed helper that returns a Promise is a footgun in render code.
160
+ *
161
+ * ~50 lines, no deps, zero allocations after init. Not for crypto
162
+ * use beyond opaque-identifier hashing.
163
+ */
164
+ declare function sha256Hex(input: string): string;
113
165
 
114
166
  /**
115
167
  * Direct construction of an avatar from explicit part choices — no seed.
@@ -382,7 +434,8 @@ declare const Navii: {
382
434
  readonly select: typeof selectAvatar;
383
435
  readonly group: typeof renderGroup;
384
436
  readonly seed: typeof seed;
437
+ readonly seedFromEmail: typeof seedFromEmail;
385
438
  readonly build: typeof build;
386
439
  };
387
440
 
388
- export { AccessoryId, AntennaStyleId, AvatarOptions, AvatarSpec, BUILT_IN_PACKS, BackgroundId, BodyShapeId, type BuildSpec, EyeStyleId, type GroupOptions, MouthStyleId, Navii, OutfitId, PACK_REGISTRY, type Pack, type PackRegistry, Palette, type SeedFields, TopperId, build, createAvatar, createRng, cyrb53, random, renderAvatar, renderAvatarInner, renderGroup, resolvePacks, seed, selectAvatar };
441
+ export { AccessoryId, AntennaStyleId, AvatarOptions, AvatarSpec, BUILT_IN_PACKS, BackgroundId, BodyShapeId, type BuildSpec, EyeStyleId, type GroupOptions, MouthStyleId, Navii, OutfitId, PACK_REGISTRY, type Pack, type PackRegistry, Palette, type SeedFields, type SeedOptions, TopperId, build, createAvatar, createRng, cyrb53, normalizeEmail, random, renderAvatar, renderAvatarInner, renderGroup, resolvePacks, seed, seedFromEmail, selectAvatar, sha256Hex };
package/dist/index.d.ts CHANGED
@@ -82,6 +82,28 @@ declare function renderGroup(seeds: string[], options?: GroupOptions): string;
82
82
  * it with `createdAt` makes the result globally unique while remaining
83
83
  * stable across renders (assuming `createdAt` is set once at signup).
84
84
  */
85
+ /**
86
+ * Normalize an email the same way Gravatar does — trim + lowercase, NFC.
87
+ * Exported so callers can reproduce the canonical form before hashing.
88
+ */
89
+ declare function normalizeEmail(email: string): string;
90
+ /**
91
+ * Turn an email into a stable, opaque seed using Gravatar's scheme:
92
+ * `sha256(trim(lowercase(email)))` → lowercase hex.
93
+ *
94
+ * Why: passing raw emails as seeds leaks them through URLs (server logs,
95
+ * Referer headers, browser history, CDN cache keys, analytics). The hash
96
+ * is stable across systems that normalize the same way, so two products
97
+ * looking up the same person get the same avatar.
98
+ *
99
+ * @example
100
+ * ```ts
101
+ * const s = seedFromEmail(user.email);
102
+ * createAvatar(s); // safe to log
103
+ * // or hit the API: `/avatar/${s}.svg` // no plaintext email on the wire
104
+ * ```
105
+ */
106
+ declare function seedFromEmail(email: string): string;
85
107
  interface SeedFields {
86
108
  /** Stable primary key (database id, UUID, OAuth sub). Best choice. */
87
109
  id?: string | number | null | undefined;
@@ -92,6 +114,18 @@ interface SeedFields {
92
114
  /** Account creation time. Combined with `name` to bake uniqueness in at signup. */
93
115
  createdAt?: string | number | Date | null | undefined;
94
116
  }
117
+ interface SeedOptions {
118
+ /**
119
+ * When the email branch is used, hash the email instead of returning it
120
+ * raw. Hashing keeps the seed stable but stops the address from leaking
121
+ * into URLs, server logs, and Referer headers. Default `true` from v1.
122
+ *
123
+ * Set to `false` to opt back into the legacy plaintext-email behavior —
124
+ * useful for migrations where existing avatars are keyed off the raw
125
+ * email and you don't want every user's face to change.
126
+ */
127
+ hashEmail?: boolean;
128
+ }
95
129
  /**
96
130
  * Compose a stable seed string from the most unique field available.
97
131
  *
@@ -107,9 +141,27 @@ interface SeedFields {
107
141
  * // → "Alice|1700000000000"
108
142
  * ```
109
143
  *
144
+ * @example Opt out of email hashing (legacy behavior):
145
+ * ```ts
146
+ * seed({ email: 'a@b.c' }, { hashEmail: false });
147
+ * // → "a@b.c" — avoid; only for migrating off the old default.
148
+ * ```
149
+ *
110
150
  * @throws if no usable field is provided.
111
151
  */
112
- declare function seed(fields: SeedFields): string;
152
+ declare function seed(fields: SeedFields, options?: SeedOptions): string;
153
+
154
+ /**
155
+ * Sync SHA-256 (FIPS 180-4) → lowercase hex.
156
+ *
157
+ * Used by `seedFromEmail()` to match Gravatar's seed format
158
+ * (sha256 of trimmed, lowercased email). Sync API on purpose —
159
+ * a seed helper that returns a Promise is a footgun in render code.
160
+ *
161
+ * ~50 lines, no deps, zero allocations after init. Not for crypto
162
+ * use beyond opaque-identifier hashing.
163
+ */
164
+ declare function sha256Hex(input: string): string;
113
165
 
114
166
  /**
115
167
  * Direct construction of an avatar from explicit part choices — no seed.
@@ -382,7 +434,8 @@ declare const Navii: {
382
434
  readonly select: typeof selectAvatar;
383
435
  readonly group: typeof renderGroup;
384
436
  readonly seed: typeof seed;
437
+ readonly seedFromEmail: typeof seedFromEmail;
385
438
  readonly build: typeof build;
386
439
  };
387
440
 
388
- export { AccessoryId, AntennaStyleId, AvatarOptions, AvatarSpec, BUILT_IN_PACKS, BackgroundId, BodyShapeId, type BuildSpec, EyeStyleId, type GroupOptions, MouthStyleId, Navii, OutfitId, PACK_REGISTRY, type Pack, type PackRegistry, Palette, type SeedFields, TopperId, build, createAvatar, createRng, cyrb53, random, renderAvatar, renderAvatarInner, renderGroup, resolvePacks, seed, selectAvatar };
441
+ export { AccessoryId, AntennaStyleId, AvatarOptions, AvatarSpec, BUILT_IN_PACKS, BackgroundId, BodyShapeId, type BuildSpec, EyeStyleId, type GroupOptions, MouthStyleId, Navii, OutfitId, PACK_REGISTRY, type Pack, type PackRegistry, Palette, type SeedFields, type SeedOptions, TopperId, build, createAvatar, createRng, cyrb53, normalizeEmail, random, renderAvatar, renderAvatarInner, renderGroup, resolvePacks, seed, seedFromEmail, selectAvatar, sha256Hex };
package/dist/index.js CHANGED
@@ -1666,13 +1666,176 @@ function clamp(n, lo, hi) {
1666
1666
  return Math.max(lo, Math.min(hi, n));
1667
1667
  }
1668
1668
 
1669
+ // src/sha256.ts
1670
+ var K = new Uint32Array([
1671
+ 1116352408,
1672
+ 1899447441,
1673
+ 3049323471,
1674
+ 3921009573,
1675
+ 961987163,
1676
+ 1508970993,
1677
+ 2453635748,
1678
+ 2870763221,
1679
+ 3624381080,
1680
+ 310598401,
1681
+ 607225278,
1682
+ 1426881987,
1683
+ 1925078388,
1684
+ 2162078206,
1685
+ 2614888103,
1686
+ 3248222580,
1687
+ 3835390401,
1688
+ 4022224774,
1689
+ 264347078,
1690
+ 604807628,
1691
+ 770255983,
1692
+ 1249150122,
1693
+ 1555081692,
1694
+ 1996064986,
1695
+ 2554220882,
1696
+ 2821834349,
1697
+ 2952996808,
1698
+ 3210313671,
1699
+ 3336571891,
1700
+ 3584528711,
1701
+ 113926993,
1702
+ 338241895,
1703
+ 666307205,
1704
+ 773529912,
1705
+ 1294757372,
1706
+ 1396182291,
1707
+ 1695183700,
1708
+ 1986661051,
1709
+ 2177026350,
1710
+ 2456956037,
1711
+ 2730485921,
1712
+ 2820302411,
1713
+ 3259730800,
1714
+ 3345764771,
1715
+ 3516065817,
1716
+ 3600352804,
1717
+ 4094571909,
1718
+ 275423344,
1719
+ 430227734,
1720
+ 506948616,
1721
+ 659060556,
1722
+ 883997877,
1723
+ 958139571,
1724
+ 1322822218,
1725
+ 1537002063,
1726
+ 1747873779,
1727
+ 1955562222,
1728
+ 2024104815,
1729
+ 2227730452,
1730
+ 2361852424,
1731
+ 2428436474,
1732
+ 2756734187,
1733
+ 3204031479,
1734
+ 3329325298
1735
+ ]);
1736
+ function rotr(x, n) {
1737
+ return (x >>> n | x << 32 - n) >>> 0;
1738
+ }
1739
+ function utf8Encode(str) {
1740
+ if (typeof TextEncoder !== "undefined") return new TextEncoder().encode(str);
1741
+ const out = [];
1742
+ for (let i = 0; i < str.length; i++) {
1743
+ let c = str.charCodeAt(i);
1744
+ if (c < 128) out.push(c);
1745
+ else if (c < 2048) {
1746
+ out.push(192 | c >> 6, 128 | c & 63);
1747
+ } else if (c < 55296 || c >= 57344) {
1748
+ out.push(224 | c >> 12, 128 | c >> 6 & 63, 128 | c & 63);
1749
+ } else {
1750
+ i++;
1751
+ c = 65536 + ((c & 1023) << 10 | str.charCodeAt(i) & 1023);
1752
+ out.push(
1753
+ 240 | c >> 18,
1754
+ 128 | c >> 12 & 63,
1755
+ 128 | c >> 6 & 63,
1756
+ 128 | c & 63
1757
+ );
1758
+ }
1759
+ }
1760
+ return new Uint8Array(out);
1761
+ }
1762
+ function sha256Hex(input) {
1763
+ const msg = utf8Encode(input);
1764
+ const bitLen = msg.length * 8;
1765
+ const padLen = (msg.length + 9 + 63 & -64) - msg.length;
1766
+ const buf = new Uint8Array(msg.length + padLen);
1767
+ buf.set(msg);
1768
+ buf[msg.length] = 128;
1769
+ const dv = new DataView(buf.buffer);
1770
+ dv.setUint32(buf.length - 4, bitLen >>> 0, false);
1771
+ dv.setUint32(buf.length - 8, Math.floor(bitLen / 4294967296), false);
1772
+ const H = new Uint32Array([
1773
+ 1779033703,
1774
+ 3144134277,
1775
+ 1013904242,
1776
+ 2773480762,
1777
+ 1359893119,
1778
+ 2600822924,
1779
+ 528734635,
1780
+ 1541459225
1781
+ ]);
1782
+ const W = new Uint32Array(64);
1783
+ for (let chunk = 0; chunk < buf.length; chunk += 64) {
1784
+ for (let i = 0; i < 16; i++) W[i] = dv.getUint32(chunk + i * 4, false);
1785
+ for (let i = 16; i < 64; i++) {
1786
+ const s0 = rotr(W[i - 15], 7) ^ rotr(W[i - 15], 18) ^ W[i - 15] >>> 3;
1787
+ const s1 = rotr(W[i - 2], 17) ^ rotr(W[i - 2], 19) ^ W[i - 2] >>> 10;
1788
+ W[i] = W[i - 16] + s0 + W[i - 7] + s1 >>> 0;
1789
+ }
1790
+ let a = H[0], b = H[1], c = H[2], d = H[3];
1791
+ let e = H[4], f = H[5], g = H[6], h = H[7];
1792
+ for (let i = 0; i < 64; i++) {
1793
+ const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
1794
+ const ch = e & f ^ ~e & g;
1795
+ const t1 = h + S1 + ch + K[i] + W[i] >>> 0;
1796
+ const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
1797
+ const mj = a & b ^ a & c ^ b & c;
1798
+ const t2 = S0 + mj >>> 0;
1799
+ h = g;
1800
+ g = f;
1801
+ f = e;
1802
+ e = d + t1 >>> 0;
1803
+ d = c;
1804
+ c = b;
1805
+ b = a;
1806
+ a = t1 + t2 >>> 0;
1807
+ }
1808
+ H[0] = H[0] + a >>> 0;
1809
+ H[1] = H[1] + b >>> 0;
1810
+ H[2] = H[2] + c >>> 0;
1811
+ H[3] = H[3] + d >>> 0;
1812
+ H[4] = H[4] + e >>> 0;
1813
+ H[5] = H[5] + f >>> 0;
1814
+ H[6] = H[6] + g >>> 0;
1815
+ H[7] = H[7] + h >>> 0;
1816
+ }
1817
+ let out = "";
1818
+ for (let i = 0; i < 8; i++) out += H[i].toString(16).padStart(8, "0");
1819
+ return out;
1820
+ }
1821
+
1669
1822
  // src/seed.ts
1670
- function seed(fields) {
1823
+ function normalizeEmail(email) {
1824
+ return email.trim().toLowerCase().normalize("NFC");
1825
+ }
1826
+ function seedFromEmail(email) {
1827
+ if (typeof email !== "string" || email.length === 0) {
1828
+ throw new Error("navii: seedFromEmail() requires a non-empty string");
1829
+ }
1830
+ return sha256Hex(normalizeEmail(email));
1831
+ }
1832
+ function seed(fields, options = {}) {
1833
+ const hashEmail = options.hashEmail ?? true;
1671
1834
  if (fields.id !== null && fields.id !== void 0 && String(fields.id).length > 0) {
1672
1835
  return String(fields.id);
1673
1836
  }
1674
1837
  if (fields.email && fields.email.length > 0) {
1675
- return fields.email;
1838
+ return hashEmail ? seedFromEmail(fields.email) : fields.email;
1676
1839
  }
1677
1840
  if (fields.name && fields.name.length > 0) {
1678
1841
  if (fields.createdAt !== null && fields.createdAt !== void 0) {
@@ -1731,9 +1894,10 @@ var Navii = {
1731
1894
  select: selectAvatar,
1732
1895
  group: renderGroup,
1733
1896
  seed,
1897
+ seedFromEmail,
1734
1898
  build
1735
1899
  };
1736
1900
 
1737
- export { BUILT_IN_PACKS, Navii, PACK_REGISTRY, build, createAvatar, createRng, cyrb53, random, renderAvatar, renderAvatarInner, renderGroup, resolvePacks, seed, selectAvatar };
1901
+ export { BUILT_IN_PACKS, Navii, PACK_REGISTRY, build, createAvatar, createRng, cyrb53, normalizeEmail, random, renderAvatar, renderAvatarInner, renderGroup, resolvePacks, seed, seedFromEmail, selectAvatar, sha256Hex };
1738
1902
  //# sourceMappingURL=index.js.map
1739
1903
  //# sourceMappingURL=index.js.map