@joystyk/identity 0.0.0 → 0.2.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/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ # @joystyk/identity
2
+
3
+ ## 0.2.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [bb095c7]
8
+ - Updated dependencies [bb095c7]
9
+ - @joystyk/contracts@0.2.0
10
+ - @joystyk/storage@0.2.0
11
+
12
+ ## 0.1.0
13
+
14
+ ### Minor Changes
15
+
16
+ - Prepare the first compiled library release: ESM and API declarations for every public entry point, runtime assets, and checks against installed npm tarballs. Games and applications remain private.
17
+
18
+ ### Patch Changes
19
+
20
+ - Updated dependencies
21
+ - @joystyk/contracts@0.1.0
22
+ - @joystyk/storage@0.1.0
package/README.md CHANGED
@@ -1,5 +1,127 @@
1
1
  # @joystyk/identity
2
2
 
3
- The first Joystyk library release is not yet available. This placeholder reserves the package name.
3
+ The guest player of v1 (host policy): a stable player id made on the device, a display name that
4
+ passes a strict all-ages filter, and an avatar from a preset set. There is no email, no
5
+ sign-in, no analytics and no fingerprinting (host policy). Everything else the site keeps, from
6
+ saves to streaks, keys on the player id, never on the device, so v2 can attach the id to an
7
+ account and carry the history across (v2-accounts-and-social.md (Joystyk host reference)).
4
8
 
5
- Source: https://github.com/joystyk-games/game-libraries
9
+ ## npm package
10
+
11
+ The first `0.1.0` release is being prepared; it has not been published. Once available:
12
+
13
+ ```sh
14
+ npm install @joystyk/identity
15
+ ```
16
+
17
+ Node consumers require Node 24.15 or later. Browser entry points require a modern
18
+ browser and should be loaded in the browser.
19
+
20
+ | Import | Format |
21
+ | --- | --- |
22
+ | `@joystyk/identity` | ES module and TypeScript declarations |
23
+ | `@joystyk/identity/browser` | ES module and TypeScript declarations |
24
+ | `@joystyk/identity/names` | ES module and TypeScript declarations |
25
+
26
+ Only the entries listed above are public API. Installed packages contain compiled ESM,
27
+ type declarations and runtime assets. Repository-relative links describe the Joystyk
28
+ workspace and are not needed to use the npm package.
29
+
30
+ ## Entry points
31
+
32
+ | Import | Runs in | Contents |
33
+ | --- | --- | --- |
34
+ | `@joystyk/identity` | Anywhere (no DOM) | `createIdentity`, the name filter and generator, avatars, record kinds |
35
+ | `@joystyk/identity/browser` | Browser | `createBrowserIdentity`, which injects `crypto.randomUUID` and `crypto.getRandomValues` |
36
+ | `@joystyk/identity/names` | Anywhere (no DOM, no storage) | `checkDisplayName`, `generateName`, the blocklist and their types; the room server imports only this |
37
+
38
+ ## Use
39
+
40
+ ```ts
41
+ import { openBrowserStorage } from '@joystyk/storage/browser';
42
+ import { createBrowserIdentity } from '@joystyk/identity/browser';
43
+
44
+ const storage = await openBrowserStorage({ onNotice });
45
+ const identity = createBrowserIdentity(storage);
46
+
47
+ const profile = await identity.current(); // { id, displayName: 'Pixel Fox 42', nameSource, avatar, createdAt }
48
+ await identity.rename('Ada Byte'); // Result: the normalized name, or why it was refused
49
+ await identity.rollName(); // a fresh generated name
50
+ await identity.setAvatar('robot'); // preset ids only
51
+ const player = storage.player(profile.id); // saves, stats, streaks, preferences
52
+ ```
53
+
54
+ `current()` never fails. When storage can't keep a new guest (private browsing, blocked site
55
+ data), the guest lasts for this page and storage has already told the player.
56
+
57
+ Two tabs opened together on a first visit get one guest (bead host integration work). The `device` record
58
+ and the profile are each written with `setIfAbsent`, one IndexedDB transaction that writes
59
+ only when the key is empty, so the tab that loses the race plays as the guest the other
60
+ created. This needs no Web Locks, so it holds in every browser with IndexedDB; storage that
61
+ fell back to memory isn't shared between tabs, so each tab there is its own guest.
62
+
63
+ The core takes its randomness as arguments (`randomUUID`, `random`), so tests and server
64
+ code pass deterministic ones.
65
+
66
+ ## Records
67
+
68
+ Both live in the storage package's `identity` object store and decode through Zod schemas.
69
+
70
+ | Key | Record | Notes |
71
+ | --- | --- | --- |
72
+ | `device` | `{ playerId }` | Which player this device plays as; the only record not keyed on a player id |
73
+ | `player/<id>` | `Profile` | The name is re-checked on every read. A name that a newer blocklist refuses is discarded, and the player gets a fresh generated name under the same id. |
74
+
75
+ ## Display names
76
+
77
+ `checkDisplayName(input)` normalizes (NFKC, trim, collapse spaces) and then refuses a name
78
+ that:
79
+
80
+ - has fewer than 3 or more than 20 characters;
81
+ - uses anything but ASCII letters and digits, with single spaces, hyphens or underscores
82
+ between them (this alone rules out URLs, emails, emoji and look-alike letters from other
83
+ scripts, which would slip past the blocklist);
84
+ - has no letter, or more than 4 digits (no phone numbers or birthdays);
85
+ - matches the blocklist.
86
+
87
+ Rejection messages never repeat the word that was blocked. Rooms and anything else that
88
+ shows a name to another player should run the same check; `@joystyk/identity/names` offers it
89
+ without storage or the DOM. The room server replaces a refused name with a generated one and
90
+ tells the player why.
91
+
92
+ The blocklist (`src/names/blocklist.ts`) has three parts. Matching lowercases the name, undoes
93
+ digit substitutions (`sh1t`, `n4z1`) and matches stretched letters (`fuuuck`):
94
+
95
+ | List | Matches | Example |
96
+ | --- | --- | --- |
97
+ | `BLOCKED_ANYWHERE` | Anywhere, even across word breaks | `F U C K`, `BigShitFan` |
98
+ | `BLOCKED_WORDS` | Only a whole word, or the whole name with its breaks removed | `Big Ass`, but not `Classic` or `Peacock` |
99
+ | `BLOCKED_NUMBERS` | A number standing alone | `Fox 69` |
100
+
101
+ The filter is strict on purpose and will sometimes refuse an innocent name ("Cosmic Untamed"
102
+ contains a blocked stem across the space). The player can type another or roll one.
103
+
104
+ ### Blocklist provenance
105
+
106
+ The list was curated from the English list of
107
+ [List of Dirty, Naughty, Obscene, and Otherwise Bad Words](https://github.com/LDNOOBW/List-of-Dirty-Naughty-Obscene-and-Otherwise-Bad-Words)
108
+ (file `en`, commit `5faf2ba42d7b1c0977169ec3611df25a3c08eb13`, SHA-256
109
+ `af851ecef1d5f212caba17339b12ac39cc2fef7d78c74876f67237644fcee8bd`), © Shutterstock, Inc.
110
+ and contributors, licensed under
111
+ [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). Changes: phrases reduced to their
112
+ key word or dropped, words with everyday meanings dropped, stems in place of inflections, and
113
+ a few slurs, hate codes and insults added (marked `// added` in the file). When you change
114
+ the list, keep the attribution and add a test case.
115
+
116
+ ## Generated names
117
+
118
+ `generateName(random)` returns names like "Pixel Fox 42": an arcade adjective, a friendly
119
+ noun and a number from 10 to 99, skipping blocked numbers. A test runs every combination
120
+ through the filter, so a generated name never needs a second roll. Add words only if they
121
+ are kind, all-ages and free of trademarks (`pnpm check:names` scans this package).
122
+
123
+ ## Avatars
124
+
125
+ `AVATAR_IDS` lists 16 preset ids (`fox`, `robot`, `joystick`, …). The art will come later
126
+ from `@joystyk/ui`. Ids are stored in profiles, so never rename or remove one; add new ones
127
+ at the end.
@@ -0,0 +1,11 @@
1
+ import { type Random } from './names/generator.js';
2
+ /**
3
+ * The preset avatars. Players choose from these; there are no uploads (D-26: no personal
4
+ * data, no public profiles). Only ids for now; the `ui` package will draw them. Ids are
5
+ * stable forever, because profiles store them: add new ones, never rename or remove one.
6
+ */
7
+ export declare const AVATAR_IDS: readonly ["fox", "panda", "otter", "owl", "penguin", "koala", "tiger", "dragon", "robot", "alien", "rocket", "comet", "planet", "star", "dice", "joystick"];
8
+ export type AvatarId = (typeof AVATAR_IDS)[number];
9
+ export declare const isAvatarId: (value: unknown) => value is AvatarId;
10
+ /** A random preset avatar, for a new guest. */
11
+ export declare const randomAvatar: (random: Random) => AvatarId;
@@ -0,0 +1,32 @@
1
+ import { pick } from "./names/generator.js";
2
+ //#region src/avatars.ts
3
+ /**
4
+ * The preset avatars. Players choose from these; there are no uploads (D-26: no personal
5
+ * data, no public profiles). Only ids for now; the `ui` package will draw them. Ids are
6
+ * stable forever, because profiles store them: add new ones, never rename or remove one.
7
+ */
8
+ var AVATAR_IDS = [
9
+ "fox",
10
+ "panda",
11
+ "otter",
12
+ "owl",
13
+ "penguin",
14
+ "koala",
15
+ "tiger",
16
+ "dragon",
17
+ "robot",
18
+ "alien",
19
+ "rocket",
20
+ "comet",
21
+ "planet",
22
+ "star",
23
+ "dice",
24
+ "joystick"
25
+ ];
26
+ var isAvatarId = (value) => typeof value === "string" && AVATAR_IDS.includes(value);
27
+ /** A random preset avatar, for a new guest. */
28
+ var randomAvatar = (random) => pick(AVATAR_IDS, random);
29
+ //#endregion
30
+ export { AVATAR_IDS, isAvatarId, randomAvatar };
31
+
32
+ //# sourceMappingURL=avatars.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"avatars.js","names":[],"sources":["../src/avatars.ts"],"sourcesContent":["import { pick, type Random } from './names/generator.ts';\n\n/**\n * The preset avatars. Players choose from these; there are no uploads (D-26: no personal\n * data, no public profiles). Only ids for now; the `ui` package will draw them. Ids are\n * stable forever, because profiles store them: add new ones, never rename or remove one.\n */\nexport const AVATAR_IDS = [\n 'fox',\n 'panda',\n 'otter',\n 'owl',\n 'penguin',\n 'koala',\n 'tiger',\n 'dragon',\n 'robot',\n 'alien',\n 'rocket',\n 'comet',\n 'planet',\n 'star',\n 'dice',\n 'joystick',\n] as const;\n\nexport type AvatarId = (typeof AVATAR_IDS)[number];\n\nexport const isAvatarId = (value: unknown): value is AvatarId =>\n typeof value === 'string' && (AVATAR_IDS as readonly string[]).includes(value);\n\n/** A random preset avatar, for a new guest. */\nexport const randomAvatar = (random: Random): AvatarId => pick(AVATAR_IDS, random);\n"],"mappings":";;;;;;;AAOA,IAAa,aAAa;CACxB;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF;AAIA,IAAa,cAAc,UACzB,OAAO,UAAU,YAAa,WAAiC,SAAS,KAAK;;AAG/E,IAAa,gBAAgB,WAA6B,KAAK,YAAY,MAAM"}
@@ -0,0 +1,9 @@
1
+ import type { Storage } from '@joystyk/storage';
2
+ import type { Random } from '../names/generator.js';
3
+ import { type Identity } from '../identity.js';
4
+ /** A uniform float in [0, 1) from `crypto.getRandomValues`. */
5
+ export declare const cryptoRandom: Random;
6
+ /** A new player id from `crypto.randomUUID`. */
7
+ export declare const cryptoRandomUUID: () => string;
8
+ /** The guest identity with the browser's randomness. */
9
+ export declare const createBrowserIdentity: (storage: Storage, now?: () => number) => Identity;
@@ -0,0 +1,17 @@
1
+ import { createIdentity } from "../identity.js";
2
+ //#region src/browser/index.ts
3
+ /** A uniform float in [0, 1) from `crypto.getRandomValues`. */
4
+ var cryptoRandom = () => (crypto.getRandomValues(/* @__PURE__ */ new Uint32Array(1))[0] ?? 0) / 2 ** 32;
5
+ /** A new player id from `crypto.randomUUID`. */
6
+ var cryptoRandomUUID = () => crypto.randomUUID();
7
+ /** The guest identity with the browser's randomness. */
8
+ var createBrowserIdentity = (storage, now) => createIdentity({
9
+ storage,
10
+ randomUUID: cryptoRandomUUID,
11
+ random: cryptoRandom,
12
+ ...now === void 0 ? {} : { now }
13
+ });
14
+ //#endregion
15
+ export { createBrowserIdentity, cryptoRandom, cryptoRandomUUID };
16
+
17
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../src/browser/index.ts"],"sourcesContent":["// @joystyk/identity/browser: the identity's randomness from the Web Crypto API.\nimport type { Storage } from '@joystyk/storage';\nimport type { Random } from '../names/generator.ts';\nimport { createIdentity, type Identity } from '../identity.ts';\n\n/** A uniform float in [0, 1) from `crypto.getRandomValues`. */\nexport const cryptoRandom: Random = () => (crypto.getRandomValues(new Uint32Array(1))[0] ?? 0) / 2 ** 32;\n\n/** A new player id from `crypto.randomUUID`. */\nexport const cryptoRandomUUID = (): string => crypto.randomUUID();\n\n/** The guest identity with the browser's randomness. */\nexport const createBrowserIdentity = (storage: Storage, now?: () => number): Identity =>\n createIdentity({\n storage,\n randomUUID: cryptoRandomUUID,\n random: cryptoRandom,\n ...(now === undefined ? {} : { now }),\n });\n"],"mappings":";;;AAMA,IAAa,sBAA8B,OAAO,gCAAgB,IAAI,YAAY,CAAC,CAAC,CAAC,CAAC,MAAM,KAAK,KAAK;;AAGtG,IAAa,yBAAiC,OAAO,WAAW;;AAGhE,IAAa,yBAAyB,SAAkB,QACtD,eAAe;CACb;CACA,YAAY;CACZ,QAAQ;CACR,GAAI,QAAQ,KAAA,IAAY,CAAC,IAAI,EAAE,IAAI;AACrC,CAAC"}
@@ -0,0 +1,65 @@
1
+ import { type Result } from '@joystyk/contracts';
2
+ import { type Storage, type StorageError } from '@joystyk/storage';
3
+ import { type AvatarId } from './avatars.js';
4
+ import { type Random } from './names/generator.js';
5
+ import { type NameRejection } from './names/filter.js';
6
+ /**
7
+ * A guest player id: a random UUID made on the device the first time the site runs. It
8
+ * identifies the player, not the device, and is never sent to an ad network or analytics
9
+ * (there are none). Saves, stats, streaks and preferences all key on it, so v2 can attach it
10
+ * to an account and carry the history across.
11
+ */
12
+ export type PlayerId = string & {
13
+ readonly __brand: 'PlayerId';
14
+ };
15
+ /** True for a lowercase RFC 9562 UUID, the form `crypto.randomUUID` returns. */
16
+ export declare const isPlayerId: (value: unknown) => value is PlayerId;
17
+ /** Everything the site knows about a player. No email, no age, nothing personal (D-26). */
18
+ export type Profile = {
19
+ readonly id: PlayerId;
20
+ /** Always passes `checkDisplayName`. */
21
+ readonly displayName: string;
22
+ /** `generated` until the player types a name of their own. */
23
+ readonly nameSource: 'generated' | 'chosen';
24
+ readonly avatar: AvatarId;
25
+ /** Epoch milliseconds. */
26
+ readonly createdAt: number;
27
+ };
28
+ /** Which player this device plays as. The only record not keyed on a player id. */
29
+ export declare const DEVICE_RECORD: import("@joystyk/storage").RecordKind<{
30
+ readonly playerId: PlayerId;
31
+ }>;
32
+ /**
33
+ * A player's profile, keyed `player/<id>`. The name is re-checked on every read, so a word
34
+ * added to the blocklist later retires old names that contain it: the profile is discarded
35
+ * and the player gets a fresh generated name under the same id.
36
+ */
37
+ export declare const PROFILE_RECORD: import("@joystyk/storage").RecordKind<Profile>;
38
+ export type IdentityOptions = {
39
+ readonly storage: Storage;
40
+ /** Makes a new player id; `crypto.randomUUID` in the browser (see the browser entry). */
41
+ readonly randomUUID: () => string;
42
+ /** Picks generated names and avatars. */
43
+ readonly random: Random;
44
+ /** Epoch milliseconds. Defaults to `Date.now`. */
45
+ readonly now?: () => number;
46
+ };
47
+ export type IdentityError = NameRejection | StorageError | {
48
+ readonly code: 'UnknownAvatar';
49
+ readonly message: string;
50
+ };
51
+ /** The guest identity on this device. */
52
+ export interface Identity {
53
+ /**
54
+ * The current player, created on first use. Never fails: when storage can't keep the new
55
+ * guest, the guest lives for this page only and storage has already told the player.
56
+ */
57
+ current(): Promise<Profile>;
58
+ /** Sets a name the player typed, after `checkDisplayName`. */
59
+ rename(name: string): Promise<Result<Profile, IdentityError>>;
60
+ /** Replaces the name with a fresh generated one. */
61
+ rollName(): Promise<Result<Profile, IdentityError>>;
62
+ setAvatar(avatar: string): Promise<Result<Profile, IdentityError>>;
63
+ }
64
+ /** Creates the guest identity over the platform storage. */
65
+ export declare const createIdentity: (options: IdentityOptions) => Identity;
@@ -0,0 +1,121 @@
1
+ import { generateName } from "./names/generator.js";
2
+ import { AVATAR_IDS, isAvatarId, randomAvatar } from "./avatars.js";
3
+ import { checkDisplayName, isDisplayName } from "./names/filter.js";
4
+ import { err, ok } from "@joystyk/contracts";
5
+ import { defineRecordKind } from "@joystyk/storage";
6
+ import * as z from "zod/mini";
7
+ //#region src/identity.ts
8
+ var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
9
+ /** True for a lowercase RFC 9562 UUID, the form `crypto.randomUUID` returns. */
10
+ var isPlayerId = (value) => typeof value === "string" && UUID.test(value);
11
+ var playerId = z.custom(isPlayerId, { error: "Expected a player id" });
12
+ /** Which player this device plays as. The only record not keyed on a player id. */
13
+ var DEVICE_RECORD = defineRecordKind({
14
+ store: "identity",
15
+ version: 1,
16
+ schema: z.object({ playerId }),
17
+ label: "player id"
18
+ });
19
+ /**
20
+ * A player's profile, keyed `player/<id>`. The name is re-checked on every read, so a word
21
+ * added to the blocklist later retires old names that contain it: the profile is discarded
22
+ * and the player gets a fresh generated name under the same id.
23
+ */
24
+ var PROFILE_RECORD = defineRecordKind({
25
+ store: "identity",
26
+ version: 1,
27
+ schema: z.object({
28
+ id: playerId,
29
+ displayName: z.custom(isDisplayName, { error: "Expected an allowed display name" }),
30
+ nameSource: z.enum(["generated", "chosen"]),
31
+ avatar: z.enum(AVATAR_IDS),
32
+ createdAt: z.number().check(z.nonnegative())
33
+ }),
34
+ label: "player profile"
35
+ });
36
+ var DEVICE_KEY = "device";
37
+ var profileKey = (id) => `player/${id}`;
38
+ /** Creates the guest identity over the platform storage. */
39
+ var createIdentity = (options) => {
40
+ const now = options.now ?? Date.now;
41
+ const device = options.storage.records(DEVICE_RECORD);
42
+ const profiles = options.storage.records(PROFILE_RECORD);
43
+ const newId = () => {
44
+ const id = options.randomUUID();
45
+ if (!isPlayerId(id)) throw new TypeError(`randomUUID returned "${id}", which is not a lowercase UUID`);
46
+ return id;
47
+ };
48
+ const newProfile = (id) => ({
49
+ id,
50
+ displayName: generateName(options.random),
51
+ nameSource: "generated",
52
+ avatar: randomAvatar(options.random),
53
+ createdAt: now()
54
+ });
55
+ /**
56
+ * The player this device plays as. On a first visit two tabs may start at once (gp-d4v),
57
+ * so the device record and the profile are each written only if absent, in one storage
58
+ * transaction (`setIfAbsent`): a tab that loses the race plays as the guest the other
59
+ * created. That needs no Web Locks, so no browser is left without the guard; storage that
60
+ * fell back to memory isn't shared between tabs, so each tab is its own guest there.
61
+ */
62
+ const load = async () => {
63
+ const pointer = await device.get(DEVICE_KEY);
64
+ let id = pointer.ok ? pointer.value?.playerId ?? null : null;
65
+ if (id === null) {
66
+ const fresh = newId();
67
+ const claimed = await device.setIfAbsent(DEVICE_KEY, { playerId: fresh });
68
+ id = claimed.ok ? claimed.value.playerId : fresh;
69
+ }
70
+ const key = profileKey(id);
71
+ const stored = await profiles.get(key);
72
+ if (stored.ok && stored.value !== null && stored.value.id === id) return stored.value;
73
+ const profile = newProfile(id);
74
+ if (stored.ok && stored.value !== null) {
75
+ await profiles.set(key, profile);
76
+ return profile;
77
+ }
78
+ const created = await profiles.setIfAbsent(key, profile);
79
+ return created.ok && created.value.id === id ? created.value : profile;
80
+ };
81
+ let cached = null;
82
+ const current = () => cached ??= load();
83
+ const update = async (change) => {
84
+ const next = change(await current());
85
+ const written = await profiles.set(profileKey(next.id), next);
86
+ if (!written.ok) return written;
87
+ cached = Promise.resolve(next);
88
+ return ok(next);
89
+ };
90
+ return {
91
+ current,
92
+ rename: async (name) => {
93
+ const checked = checkDisplayName(name);
94
+ if (!checked.ok) return checked;
95
+ return update((profile) => ({
96
+ ...profile,
97
+ displayName: checked.value,
98
+ nameSource: "chosen"
99
+ }));
100
+ },
101
+ rollName: () => update((profile) => ({
102
+ ...profile,
103
+ displayName: generateName(options.random),
104
+ nameSource: "generated"
105
+ })),
106
+ setAvatar: async (avatar) => {
107
+ if (!isAvatarId(avatar)) return err({
108
+ code: "UnknownAvatar",
109
+ message: "That avatar isn't available."
110
+ });
111
+ return update((profile) => ({
112
+ ...profile,
113
+ avatar
114
+ }));
115
+ }
116
+ };
117
+ };
118
+ //#endregion
119
+ export { DEVICE_RECORD, PROFILE_RECORD, createIdentity, isPlayerId };
120
+
121
+ //# sourceMappingURL=identity.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"identity.js","names":[],"sources":["../src/identity.ts"],"sourcesContent":["import { err, ok, type Result } from '@joystyk/contracts';\nimport { defineRecordKind, type Storage, type StorageError } from '@joystyk/storage';\nimport * as z from 'zod/mini';\nimport { AVATAR_IDS, isAvatarId, randomAvatar, type AvatarId } from './avatars.ts';\nimport { generateName, type Random } from './names/generator.ts';\nimport { checkDisplayName, isDisplayName, type NameRejection } from './names/filter.ts';\n\n/**\n * A guest player id: a random UUID made on the device the first time the site runs. It\n * identifies the player, not the device, and is never sent to an ad network or analytics\n * (there are none). Saves, stats, streaks and preferences all key on it, so v2 can attach it\n * to an account and carry the history across.\n */\nexport type PlayerId = string & { readonly __brand: 'PlayerId' };\n\nconst UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;\n\n/** True for a lowercase RFC 9562 UUID, the form `crypto.randomUUID` returns. */\nexport const isPlayerId = (value: unknown): value is PlayerId => typeof value === 'string' && UUID.test(value);\n\n/** Everything the site knows about a player. No email, no age, nothing personal (D-26). */\nexport type Profile = {\n readonly id: PlayerId;\n /** Always passes `checkDisplayName`. */\n readonly displayName: string;\n /** `generated` until the player types a name of their own. */\n readonly nameSource: 'generated' | 'chosen';\n readonly avatar: AvatarId;\n /** Epoch milliseconds. */\n readonly createdAt: number;\n};\n\nconst playerId = z.custom<PlayerId>(isPlayerId, { error: 'Expected a player id' });\n\n/** Which player this device plays as. The only record not keyed on a player id. */\nexport const DEVICE_RECORD = defineRecordKind<{ readonly playerId: PlayerId }>({\n store: 'identity',\n version: 1,\n schema: z.object({ playerId }),\n label: 'player id',\n});\n\n/**\n * A player's profile, keyed `player/<id>`. The name is re-checked on every read, so a word\n * added to the blocklist later retires old names that contain it: the profile is discarded\n * and the player gets a fresh generated name under the same id.\n */\nexport const PROFILE_RECORD = defineRecordKind<Profile>({\n store: 'identity',\n version: 1,\n schema: z.object({\n id: playerId,\n displayName: z.custom<string>(isDisplayName, { error: 'Expected an allowed display name' }),\n nameSource: z.enum(['generated', 'chosen']),\n avatar: z.enum(AVATAR_IDS),\n createdAt: z.number().check(z.nonnegative()),\n }),\n label: 'player profile',\n});\n\nconst DEVICE_KEY = 'device';\nconst profileKey = (id: PlayerId): string => `player/${id}`;\n\nexport type IdentityOptions = {\n readonly storage: Storage;\n /** Makes a new player id; `crypto.randomUUID` in the browser (see the browser entry). */\n readonly randomUUID: () => string;\n /** Picks generated names and avatars. */\n readonly random: Random;\n /** Epoch milliseconds. Defaults to `Date.now`. */\n readonly now?: () => number;\n};\n\nexport type IdentityError = NameRejection | StorageError | { readonly code: 'UnknownAvatar'; readonly message: string };\n\n/** The guest identity on this device. */\nexport interface Identity {\n /**\n * The current player, created on first use. Never fails: when storage can't keep the new\n * guest, the guest lives for this page only and storage has already told the player.\n */\n current(): Promise<Profile>;\n /** Sets a name the player typed, after `checkDisplayName`. */\n rename(name: string): Promise<Result<Profile, IdentityError>>;\n /** Replaces the name with a fresh generated one. */\n rollName(): Promise<Result<Profile, IdentityError>>;\n setAvatar(avatar: string): Promise<Result<Profile, IdentityError>>;\n}\n\n/** Creates the guest identity over the platform storage. */\nexport const createIdentity = (options: IdentityOptions): Identity => {\n const now = options.now ?? Date.now;\n const device = options.storage.records(DEVICE_RECORD);\n const profiles = options.storage.records(PROFILE_RECORD);\n\n const newId = (): PlayerId => {\n const id = options.randomUUID();\n if (!isPlayerId(id)) throw new TypeError(`randomUUID returned \"${id}\", which is not a lowercase UUID`);\n return id;\n };\n\n const newProfile = (id: PlayerId): Profile => ({\n id,\n displayName: generateName(options.random),\n nameSource: 'generated',\n avatar: randomAvatar(options.random),\n createdAt: now(),\n });\n\n /**\n * The player this device plays as. On a first visit two tabs may start at once (gp-d4v),\n * so the device record and the profile are each written only if absent, in one storage\n * transaction (`setIfAbsent`): a tab that loses the race plays as the guest the other\n * created. That needs no Web Locks, so no browser is left without the guard; storage that\n * fell back to memory isn't shared between tabs, so each tab is its own guest there.\n */\n const load = async (): Promise<Profile> => {\n const pointer = await device.get(DEVICE_KEY);\n let id = pointer.ok ? (pointer.value?.playerId ?? null) : null;\n if (id === null) {\n const fresh = newId();\n const claimed = await device.setIfAbsent(DEVICE_KEY, { playerId: fresh });\n id = claimed.ok ? claimed.value.playerId : fresh;\n }\n const key = profileKey(id);\n const stored = await profiles.get(key);\n if (stored.ok && stored.value !== null && stored.value.id === id) return stored.value;\n // A first visit, or a profile that was lost: keep the id.\n const profile = newProfile(id);\n if (stored.ok && stored.value !== null) {\n // A profile for another id sits under this key; replace it.\n await profiles.set(key, profile);\n return profile;\n }\n const created = await profiles.setIfAbsent(key, profile);\n return created.ok && created.value.id === id ? created.value : profile;\n };\n\n let cached: Promise<Profile> | null = null;\n const current = (): Promise<Profile> => (cached ??= load());\n\n const update = async (change: (profile: Profile) => Profile): Promise<Result<Profile, IdentityError>> => {\n const next = change(await current());\n const written = await profiles.set(profileKey(next.id), next);\n if (!written.ok) return written;\n cached = Promise.resolve(next);\n return ok(next);\n };\n\n return {\n current,\n rename: async (name) => {\n const checked = checkDisplayName(name);\n if (!checked.ok) return checked;\n return update((profile) => ({ ...profile, displayName: checked.value, nameSource: 'chosen' }));\n },\n rollName: () =>\n update((profile) => ({ ...profile, displayName: generateName(options.random), nameSource: 'generated' })),\n setAvatar: async (avatar) => {\n if (!isAvatarId(avatar)) return err({ code: 'UnknownAvatar', message: \"That avatar isn't available.\" });\n return update((profile) => ({ ...profile, avatar }));\n },\n };\n};\n"],"mappings":"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"}
@@ -0,0 +1,4 @@
1
+ export { AVATAR_IDS, isAvatarId, randomAvatar, type AvatarId } from './avatars.js';
2
+ export { generateName, NAME_ADJECTIVES, NAME_NOUNS, NAME_NUMBERS, pick, type Random } from './names/generator.js';
3
+ export { checkDisplayName, isBlocked, isDisplayName, NAME_LIMITS, normalizeDisplayName, type NameRejection, type NameRejectionCode, } from './names/filter.js';
4
+ export { createIdentity, DEVICE_RECORD, isPlayerId, PROFILE_RECORD, type Identity, type IdentityError, type IdentityOptions, type PlayerId, type Profile, } from './identity.js';
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ import { NAME_ADJECTIVES, NAME_NOUNS, NAME_NUMBERS, generateName, pick } from "./names/generator.js";
2
+ import { AVATAR_IDS, isAvatarId, randomAvatar } from "./avatars.js";
3
+ import { NAME_LIMITS, checkDisplayName, isBlocked, isDisplayName, normalizeDisplayName } from "./names/filter.js";
4
+ import { DEVICE_RECORD, PROFILE_RECORD, createIdentity, isPlayerId } from "./identity.js";
5
+ export { AVATAR_IDS, DEVICE_RECORD, NAME_ADJECTIVES, NAME_LIMITS, NAME_NOUNS, NAME_NUMBERS, PROFILE_RECORD, checkDisplayName, createIdentity, generateName, isAvatarId, isBlocked, isDisplayName, isPlayerId, normalizeDisplayName, pick, randomAvatar };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Stems blocked anywhere in the name, even across word breaks ("f u c k", "BigShitFan").
3
+ * Only stems that almost never occur inside an innocent word belong here.
4
+ */
5
+ export declare const BLOCKED_ANYWHERE: readonly string[];
6
+ /**
7
+ * Words blocked only as a whole word (or as the whole name with its breaks removed), because
8
+ * they hide inside innocent words: "ass" in "class", "cock" in "peacock", "coon" in
9
+ * "raccoon". Singular forms; a trailing "s" is stripped before comparing, so plurals in
10
+ * "es" are listed.
11
+ */
12
+ export declare const BLOCKED_WORDS: readonly string[];
13
+ /** Numbers blocked as a whole token: sexual references and hate codes (added). */
14
+ export declare const BLOCKED_NUMBERS: readonly string[];
@@ -0,0 +1,141 @@
1
+ //#region src/names/blocklist.ts
2
+ /**
3
+ * Stems blocked anywhere in the name, even across word breaks ("f u c k", "BigShitFan").
4
+ * Only stems that almost never occur inside an innocent word belong here.
5
+ */
6
+ var BLOCKED_ANYWHERE = [
7
+ "arsehole",
8
+ "asshole",
9
+ "bastard",
10
+ "bitch",
11
+ "blowjob",
12
+ "bollock",
13
+ "bukkake",
14
+ "clit",
15
+ "creampie",
16
+ "cumshot",
17
+ "cunnil",
18
+ "cunt",
19
+ "dildo",
20
+ "ejaculat",
21
+ "erotic",
22
+ "faggot",
23
+ "fellat",
24
+ "fuck",
25
+ "gangbang",
26
+ "handjob",
27
+ "hentai",
28
+ "hitler",
29
+ "incest",
30
+ "jailbait",
31
+ "jigaboo",
32
+ "jiggaboo",
33
+ "jizz",
34
+ "kkk",
35
+ "masturbat",
36
+ "milf",
37
+ "nazi",
38
+ "nigg",
39
+ "orgasm",
40
+ "paedophil",
41
+ "pedophil",
42
+ "penis",
43
+ "piss",
44
+ "poontang",
45
+ "porn",
46
+ "punany",
47
+ "queef",
48
+ "raghead",
49
+ "retard",
50
+ "rimjob",
51
+ "schlong",
52
+ "scrotum",
53
+ "shemale",
54
+ "shit",
55
+ "slut",
56
+ "smegma",
57
+ "sodom",
58
+ "splooge",
59
+ "swastika",
60
+ "threesome",
61
+ "titties",
62
+ "titty",
63
+ "towelhead",
64
+ "twat",
65
+ "upskirt",
66
+ "vagina",
67
+ "voyeur",
68
+ "wetback",
69
+ "whore",
70
+ "xxx"
71
+ ];
72
+ /**
73
+ * Words blocked only as a whole word (or as the whole name with its breaks removed), because
74
+ * they hide inside innocent words: "ass" in "class", "cock" in "peacock", "coon" in
75
+ * "raccoon". Singular forms; a trailing "s" is stripped before comparing, so plurals in
76
+ * "es" are listed.
77
+ */
78
+ var BLOCKED_WORDS = [
79
+ "anal",
80
+ "anus",
81
+ "arse",
82
+ "ass",
83
+ "asses",
84
+ "beaner",
85
+ "boner",
86
+ "boob",
87
+ "butt",
88
+ "chink",
89
+ "cock",
90
+ "coon",
91
+ "cum",
92
+ "darkie",
93
+ "dick",
94
+ "dyke",
95
+ "fag",
96
+ "gook",
97
+ "homo",
98
+ "honkey",
99
+ "horny",
100
+ "kike",
101
+ "kinky",
102
+ "kys",
103
+ "negro",
104
+ "nude",
105
+ "orgy",
106
+ "paki",
107
+ "pedo",
108
+ "poon",
109
+ "pussy",
110
+ "quim",
111
+ "rape",
112
+ "raped",
113
+ "raping",
114
+ "rapist",
115
+ "scat",
116
+ "semen",
117
+ "sex",
118
+ "sexy",
119
+ "skeet",
120
+ "smut",
121
+ "spic",
122
+ "spunk",
123
+ "stfu",
124
+ "suck",
125
+ "tit",
126
+ "tranny",
127
+ "wank",
128
+ "wanker",
129
+ "wtf"
130
+ ];
131
+ /** Numbers blocked as a whole token: sexual references and hate codes (added). */
132
+ var BLOCKED_NUMBERS = [
133
+ "69",
134
+ "88",
135
+ "420",
136
+ "1488"
137
+ ];
138
+ //#endregion
139
+ export { BLOCKED_ANYWHERE, BLOCKED_NUMBERS, BLOCKED_WORDS };
140
+
141
+ //# sourceMappingURL=blocklist.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"blocklist.js","names":[],"sources":["../../src/names/blocklist.ts"],"sourcesContent":["/*\n * The display-name blocklist: compact on purpose, because the character rules in filter.ts\n * already shut out most abuse (no URLs, emails, symbols, emoji or non-Latin look-alikes).\n *\n * Source: the English list of \"List of Dirty, Naughty, Obscene, and Otherwise Bad Words\"\n * https://github.com/LDNOOBW/List-of-Dirty-Naughty-Obscene-and-Otherwise-Bad-Words\n * file `en`, commit 5faf2ba42d7b1c0977169ec3611df25a3c08eb13 (403 entries,\n * sha256 af851ecef1d5f212caba17339b12ac39cc2fef7d78c74876f67237644fcee8bd).\n * License: Creative Commons Attribution 4.0 International (CC BY 4.0),\n * https://creativecommons.org/licenses/by/4.0/ . Copyright Shutterstock, Inc. and\n * the LDNOOBW contributors.\n * Changes: curated, not copied. Multi-word phrases were reduced to the single word that\n * carries the meaning, or dropped when no single word does; words with innocent\n * everyday meanings were dropped; stems replace families of inflections; a few\n * slurs, hate codes and insults the source lacks were added (marked \"added\" below).\n *\n * Matching (see filter.ts) lowercases the name, undoes common digit substitutions\n * (0→o, 1→i or l, 3→e, 4→a, 5→s, 7→t, 8→b, 9→g) and squeezes repeated letters.\n */\n\n/**\n * Stems blocked anywhere in the name, even across word breaks (\"f u c k\", \"BigShitFan\").\n * Only stems that almost never occur inside an innocent word belong here.\n */\nexport const BLOCKED_ANYWHERE: readonly string[] = [\n 'arsehole',\n 'asshole',\n 'bastard',\n 'bitch',\n 'blowjob',\n 'bollock',\n 'bukkake',\n 'clit',\n 'creampie',\n 'cumshot',\n 'cunnil',\n 'cunt',\n 'dildo',\n 'ejaculat',\n 'erotic',\n 'faggot',\n 'fellat',\n 'fuck',\n 'gangbang',\n 'handjob',\n 'hentai',\n 'hitler', // added\n 'incest',\n 'jailbait',\n 'jigaboo',\n 'jiggaboo',\n 'jizz',\n 'kkk', // added\n 'masturbat',\n 'milf',\n 'nazi',\n 'nigg',\n 'orgasm',\n 'paedophil',\n 'pedophil',\n 'penis',\n 'piss',\n 'poontang',\n 'porn',\n 'punany',\n 'queef',\n 'raghead',\n 'retard', // added\n 'rimjob',\n 'schlong',\n 'scrotum', // added\n 'shemale',\n 'shit',\n 'slut',\n 'smegma', // added\n 'sodom',\n 'splooge',\n 'swastika',\n 'threesome',\n 'titties',\n 'titty',\n 'towelhead',\n 'twat',\n 'upskirt',\n 'vagina',\n 'voyeur',\n 'wetback',\n 'whore',\n 'xxx',\n];\n\n/**\n * Words blocked only as a whole word (or as the whole name with its breaks removed), because\n * they hide inside innocent words: \"ass\" in \"class\", \"cock\" in \"peacock\", \"coon\" in\n * \"raccoon\". Singular forms; a trailing \"s\" is stripped before comparing, so plurals in\n * \"es\" are listed.\n */\nexport const BLOCKED_WORDS: readonly string[] = [\n 'anal',\n 'anus',\n 'arse',\n 'ass',\n 'asses',\n 'beaner',\n 'boner',\n 'boob',\n 'butt',\n 'chink', // added\n 'cock',\n 'coon',\n 'cum',\n 'darkie',\n 'dick',\n 'dyke',\n 'fag',\n 'gook', // added\n 'homo', // added\n 'honkey',\n 'horny',\n 'kike',\n 'kinky',\n 'kys', // added: \"kill yourself\"\n 'negro',\n 'nude',\n 'orgy',\n 'paki',\n 'pedo',\n 'poon',\n 'pussy',\n 'quim',\n 'rape',\n 'raped',\n 'raping',\n 'rapist',\n 'scat',\n 'semen',\n 'sex',\n 'sexy',\n 'skeet',\n 'smut',\n 'spic',\n 'spunk',\n 'stfu', // added\n 'suck',\n 'tit',\n 'tranny',\n 'wank',\n 'wanker',\n 'wtf', // added\n];\n\n/** Numbers blocked as a whole token: sexual references and hate codes (added). */\nexport const BLOCKED_NUMBERS: readonly string[] = ['69', '88', '420', '1488'];\n"],"mappings":";;;;;AAwBA,IAAa,mBAAsC;CACjD;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF;;;;;;;AAQA,IAAa,gBAAmC;CAC9C;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF;;AAGA,IAAa,kBAAqC;CAAC;CAAM;CAAM;CAAO;AAAM"}
@@ -0,0 +1,25 @@
1
+ import { type GameError, type Result } from '@joystyk/contracts';
2
+ export declare const NAME_LIMITS: {
3
+ /** Characters, after trimming. */
4
+ readonly minLength: 3;
5
+ readonly maxLength: 20;
6
+ /** At most this many digits, so a name can't carry a phone number or a birth year and day. */
7
+ readonly maxDigits: 4;
8
+ };
9
+ /** Why a name was refused. */
10
+ export type NameRejectionCode = 'TooShort' | 'TooLong' | 'Characters' | 'NoLetter' | 'TooManyDigits' | 'NotAllowed';
11
+ /** A refused name, worded for the player. It never repeats the word that was blocked. */
12
+ export type NameRejection = GameError & {
13
+ readonly code: NameRejectionCode;
14
+ };
15
+ /** Unicode-normalizes (NFKC, so full-width letters become ASCII), trims and collapses spaces. */
16
+ export declare const normalizeDisplayName: (input: string) => string;
17
+ /** True when the name contains a blocked word, stem or number in any of its readings. */
18
+ export declare const isBlocked: (name: string) => boolean;
19
+ /**
20
+ * Checks a display name and returns it normalized, or says why it was refused. Rooms and
21
+ * every other place that shows a name to someone else should run the same check.
22
+ */
23
+ export declare const checkDisplayName: (input: string) => Result<string, NameRejection>;
24
+ /** True when `checkDisplayName` accepts the name exactly as given (already normalized). */
25
+ export declare const isDisplayName: (value: unknown) => value is string;
@@ -0,0 +1,92 @@
1
+ import { BLOCKED_ANYWHERE, BLOCKED_NUMBERS, BLOCKED_WORDS } from "./blocklist.js";
2
+ import { err, ok } from "@joystyk/contracts";
3
+ //#region src/names/filter.ts
4
+ var NAME_LIMITS = {
5
+ /** Characters, after trimming. */
6
+ minLength: 3,
7
+ maxLength: 20,
8
+ /** At most this many digits, so a name can't carry a phone number or a birth year and day. */
9
+ maxDigits: 4
10
+ };
11
+ var MESSAGES = {
12
+ TooShort: `Names need at least ${NAME_LIMITS.minLength} characters.`,
13
+ TooLong: `Names can have at most ${NAME_LIMITS.maxLength} characters.`,
14
+ Characters: "Use letters, numbers, and single spaces, hyphens or underscores between them.",
15
+ NoLetter: "Names need at least one letter.",
16
+ TooManyDigits: `Names can have at most ${NAME_LIMITS.maxDigits} digits.`,
17
+ NotAllowed: "That name isn't allowed. Try another, or roll a random one."
18
+ };
19
+ var reject = (code) => err({
20
+ code,
21
+ message: MESSAGES[code]
22
+ });
23
+ /**
24
+ * Letters and digits, with single spaces, hyphens or underscores between them. ASCII only:
25
+ * look-alike letters from other scripts would slip past the blocklist.
26
+ */
27
+ var SHAPE = /^[A-Za-z0-9]+(?:[ _-][A-Za-z0-9]+)*$/;
28
+ /** Unicode-normalizes (NFKC, so full-width letters become ASCII), trims and collapses spaces. */
29
+ var normalizeDisplayName = (input) => input.normalize("NFKC").trim().replace(/\s+/g, " ");
30
+ var pattern = (term, anchored) => {
31
+ const body = (term.match(/(.)\1*/g) ?? []).map((run) => `${run[0]}{${run.length},}`).join("");
32
+ return new RegExp(anchored ? `^${body}$` : body);
33
+ };
34
+ var ANYWHERE = BLOCKED_ANYWHERE.map((term) => pattern(term, false));
35
+ var WORDS = BLOCKED_WORDS.map((term) => pattern(term, true));
36
+ var NUMBERS = new Set(BLOCKED_NUMBERS);
37
+ var LEET = {
38
+ "0": "o",
39
+ "3": "e",
40
+ "4": "a",
41
+ "5": "s",
42
+ "7": "t",
43
+ "8": "b",
44
+ "9": "g"
45
+ };
46
+ /** Both readings of a token's digits: 1 as i and as l. */
47
+ var unleet = (token) => {
48
+ const mapped = token.replace(/[0345789]/g, (digit) => LEET[digit] ?? digit);
49
+ return [mapped.replaceAll("1", "i"), mapped.replaceAll("1", "l")];
50
+ };
51
+ /** Splits at spaces, hyphens, underscores and camelCase humps ("BigFoxFan" → big, fox, fan). */
52
+ var tokenize = (name) => name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[ _-]+/).filter((token) => token !== "").map((token) => token.toLowerCase());
53
+ /**
54
+ * The forms a word is compared in: as written, and without a plural "s". Stripping "es" too
55
+ * would turn "spices" into a slur, so the few "es" plurals that matter are listed instead.
56
+ */
57
+ var wordForms = (word) => [word, word.replace(/s$/, "")];
58
+ /** True when the name contains a blocked word, stem or number in any of its readings. */
59
+ var isBlocked = (name) => {
60
+ const tokens = tokenize(name);
61
+ if (tokens.some((token) => NUMBERS.has(token))) return true;
62
+ const readings = tokens.filter((token) => /[a-z]/.test(token)).map(unleet);
63
+ const words = readings.flat().flatMap(wordForms);
64
+ const joined = [0, 1].map((variant) => readings.map((forms) => forms[variant]).join(""));
65
+ if (joined.some((text) => ANYWHERE.some((term) => term.test(text)))) return true;
66
+ return [...words, ...joined.flatMap(wordForms)].some((word) => WORDS.some((term) => term.test(word)));
67
+ };
68
+ /**
69
+ * Checks a display name and returns it normalized, or says why it was refused. Rooms and
70
+ * every other place that shows a name to someone else should run the same check.
71
+ */
72
+ var checkDisplayName = (input) => {
73
+ const name = normalizeDisplayName(input);
74
+ const length = [...name].length;
75
+ if (length < NAME_LIMITS.minLength) return reject("TooShort");
76
+ if (length > NAME_LIMITS.maxLength) return reject("TooLong");
77
+ if (!SHAPE.test(name)) return reject("Characters");
78
+ if (!/[A-Za-z]/.test(name)) return reject("NoLetter");
79
+ if ((name.match(/[0-9]/g)?.length ?? 0) > NAME_LIMITS.maxDigits) return reject("TooManyDigits");
80
+ if (isBlocked(name)) return reject("NotAllowed");
81
+ return ok(name);
82
+ };
83
+ /** True when `checkDisplayName` accepts the name exactly as given (already normalized). */
84
+ var isDisplayName = (value) => {
85
+ if (typeof value !== "string") return false;
86
+ const checked = checkDisplayName(value);
87
+ return checked.ok && checked.value === value;
88
+ };
89
+ //#endregion
90
+ export { NAME_LIMITS, checkDisplayName, isBlocked, isDisplayName, normalizeDisplayName };
91
+
92
+ //# sourceMappingURL=filter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"filter.js","names":[],"sources":["../../src/names/filter.ts"],"sourcesContent":["// Display names (D-11, D-26): shown to other players, so every name passes a strict all-ages\n// filter. The rules are deliberately narrow; a player who trips one can roll a generated name.\nimport { err, ok, type GameError, type Result } from '@joystyk/contracts';\nimport { BLOCKED_ANYWHERE, BLOCKED_NUMBERS, BLOCKED_WORDS } from './blocklist.ts';\n\nexport const NAME_LIMITS = {\n /** Characters, after trimming. */\n minLength: 3,\n maxLength: 20,\n /** At most this many digits, so a name can't carry a phone number or a birth year and day. */\n maxDigits: 4,\n} as const;\n\n/** Why a name was refused. */\nexport type NameRejectionCode = 'TooShort' | 'TooLong' | 'Characters' | 'NoLetter' | 'TooManyDigits' | 'NotAllowed';\n\n/** A refused name, worded for the player. It never repeats the word that was blocked. */\nexport type NameRejection = GameError & { readonly code: NameRejectionCode };\n\nconst MESSAGES = {\n TooShort: `Names need at least ${NAME_LIMITS.minLength} characters.`,\n TooLong: `Names can have at most ${NAME_LIMITS.maxLength} characters.`,\n Characters: 'Use letters, numbers, and single spaces, hyphens or underscores between them.',\n NoLetter: 'Names need at least one letter.',\n TooManyDigits: `Names can have at most ${NAME_LIMITS.maxDigits} digits.`,\n NotAllowed: \"That name isn't allowed. Try another, or roll a random one.\",\n} as const satisfies Record<NameRejectionCode, string>;\n\nconst reject = (code: NameRejectionCode): Result<never, NameRejection> => err({ code, message: MESSAGES[code] });\n\n/**\n * Letters and digits, with single spaces, hyphens or underscores between them. ASCII only:\n * look-alike letters from other scripts would slip past the blocklist.\n */\nconst SHAPE = /^[A-Za-z0-9]+(?:[ _-][A-Za-z0-9]+)*$/;\n\n/** Unicode-normalizes (NFKC, so full-width letters become ASCII), trims and collapses spaces. */\nexport const normalizeDisplayName = (input: string): string => input.normalize('NFKC').trim().replace(/\\s+/g, ' ');\n\n// Matching. A term becomes a pattern in which each run of a letter matches that run or a\n// longer one, so \"fuuuck\" matches \"fuck\" and \"boooob\" matches \"boob\", but \"bob\" doesn't.\nconst pattern = (term: string, anchored: boolean): RegExp => {\n const runs = term.match(/(.)\\1*/g) ?? [];\n const body = runs.map((run) => `${run[0]}{${run.length},}`).join('');\n return new RegExp(anchored ? `^${body}$` : body);\n};\nconst ANYWHERE = BLOCKED_ANYWHERE.map((term) => pattern(term, false));\nconst WORDS = BLOCKED_WORDS.map((term) => pattern(term, true));\nconst NUMBERS = new Set(BLOCKED_NUMBERS);\n\nconst LEET: Readonly<Record<string, string>> = { '0': 'o', '3': 'e', '4': 'a', '5': 's', '7': 't', '8': 'b', '9': 'g' };\n\n/** Both readings of a token's digits: 1 as i and as l. */\nconst unleet = (token: string): readonly string[] => {\n const mapped = token.replace(/[0345789]/g, (digit) => LEET[digit] ?? digit);\n return [mapped.replaceAll('1', 'i'), mapped.replaceAll('1', 'l')];\n};\n\n/** Splits at spaces, hyphens, underscores and camelCase humps (\"BigFoxFan\" → big, fox, fan). */\nconst tokenize = (name: string): readonly string[] =>\n name\n .replace(/([a-z0-9])([A-Z])/g, '$1 $2')\n .split(/[ _-]+/)\n .filter((token) => token !== '')\n .map((token) => token.toLowerCase());\n\n/**\n * The forms a word is compared in: as written, and without a plural \"s\". Stripping \"es\" too\n * would turn \"spices\" into a slur, so the few \"es\" plurals that matter are listed instead.\n */\nconst wordForms = (word: string): readonly string[] => [word, word.replace(/s$/, '')];\n\n/** True when the name contains a blocked word, stem or number in any of its readings. */\nexport const isBlocked = (name: string): boolean => {\n const tokens = tokenize(name);\n if (tokens.some((token) => NUMBERS.has(token))) return true;\n\n // Pure numbers are left out of the letter readings, so \"Fox 55\" doesn't read as \"foxss\".\n const lettered = tokens.filter((token) => /[a-z]/.test(token));\n const readings = lettered.map(unleet);\n const words = readings.flat().flatMap(wordForms);\n const joined = [0, 1].map((variant) => readings.map((forms) => forms[variant]).join(''));\n\n if (joined.some((text) => ANYWHERE.some((term) => term.test(text)))) return true;\n return [...words, ...joined.flatMap(wordForms)].some((word) => WORDS.some((term) => term.test(word)));\n};\n\n/**\n * Checks a display name and returns it normalized, or says why it was refused. Rooms and\n * every other place that shows a name to someone else should run the same check.\n */\nexport const checkDisplayName = (input: string): Result<string, NameRejection> => {\n const name = normalizeDisplayName(input);\n const length = [...name].length;\n if (length < NAME_LIMITS.minLength) return reject('TooShort');\n if (length > NAME_LIMITS.maxLength) return reject('TooLong');\n if (!SHAPE.test(name)) return reject('Characters');\n if (!/[A-Za-z]/.test(name)) return reject('NoLetter');\n if ((name.match(/[0-9]/g)?.length ?? 0) > NAME_LIMITS.maxDigits) return reject('TooManyDigits');\n if (isBlocked(name)) return reject('NotAllowed');\n return ok(name);\n};\n\n/** True when `checkDisplayName` accepts the name exactly as given (already normalized). */\nexport const isDisplayName = (value: unknown): value is string => {\n if (typeof value !== 'string') return false;\n const checked = checkDisplayName(value);\n return checked.ok && checked.value === value;\n};\n"],"mappings":";;;AAKA,IAAa,cAAc;;CAEzB,WAAW;CACX,WAAW;;CAEX,WAAW;AACb;AAQA,IAAM,WAAW;CACf,UAAU,uBAAuB,YAAY,UAAU;CACvD,SAAS,0BAA0B,YAAY,UAAU;CACzD,YAAY;CACZ,UAAU;CACV,eAAe,0BAA0B,YAAY,UAAU;CAC/D,YAAY;AACd;AAEA,IAAM,UAAU,SAA0D,IAAI;CAAE;CAAM,SAAS,SAAS;AAAM,CAAC;;;;;AAM/G,IAAM,QAAQ;;AAGd,IAAa,wBAAwB,UAA0B,MAAM,UAAU,MAAM,CAAC,CAAC,KAAK,CAAC,CAAC,QAAQ,QAAQ,GAAG;AAIjH,IAAM,WAAW,MAAc,aAA8B;CAE3D,MAAM,QADO,KAAK,MAAM,SAAS,KAAK,CAAC,EAAA,CACrB,KAAK,QAAQ,GAAG,IAAI,GAAG,GAAG,IAAI,OAAO,GAAG,CAAC,CAAC,KAAK,EAAE;CACnE,OAAO,IAAI,OAAO,WAAW,IAAI,KAAK,KAAK,IAAI;AACjD;AACA,IAAM,WAAW,iBAAiB,KAAK,SAAS,QAAQ,MAAM,KAAK,CAAC;AACpE,IAAM,QAAQ,cAAc,KAAK,SAAS,QAAQ,MAAM,IAAI,CAAC;AAC7D,IAAM,UAAU,IAAI,IAAI,eAAe;AAEvC,IAAM,OAAyC;CAAE,KAAK;CAAK,KAAK;CAAK,KAAK;CAAK,KAAK;CAAK,KAAK;CAAK,KAAK;CAAK,KAAK;AAAI;;AAGtH,IAAM,UAAU,UAAqC;CACnD,MAAM,SAAS,MAAM,QAAQ,eAAe,UAAU,KAAK,UAAU,KAAK;CAC1E,OAAO,CAAC,OAAO,WAAW,KAAK,GAAG,GAAG,OAAO,WAAW,KAAK,GAAG,CAAC;AAClE;;AAGA,IAAM,YAAY,SAChB,KACG,QAAQ,sBAAsB,OAAO,CAAC,CACtC,MAAM,QAAQ,CAAC,CACf,QAAQ,UAAU,UAAU,EAAE,CAAC,CAC/B,KAAK,UAAU,MAAM,YAAY,CAAC;;;;;AAMvC,IAAM,aAAa,SAAoC,CAAC,MAAM,KAAK,QAAQ,MAAM,EAAE,CAAC;;AAGpF,IAAa,aAAa,SAA0B;CAClD,MAAM,SAAS,SAAS,IAAI;CAC5B,IAAI,OAAO,MAAM,UAAU,QAAQ,IAAI,KAAK,CAAC,GAAG,OAAO;CAIvD,MAAM,WADW,OAAO,QAAQ,UAAU,QAAQ,KAAK,KAAK,CAC3C,CAAA,CAAS,IAAI,MAAM;CACpC,MAAM,QAAQ,SAAS,KAAK,CAAC,CAAC,QAAQ,SAAS;CAC/C,MAAM,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC,KAAK,YAAY,SAAS,KAAK,UAAU,MAAM,QAAQ,CAAC,CAAC,KAAK,EAAE,CAAC;CAEvF,IAAI,OAAO,MAAM,SAAS,SAAS,MAAM,SAAS,KAAK,KAAK,IAAI,CAAC,CAAC,GAAG,OAAO;CAC5E,OAAO,CAAC,GAAG,OAAO,GAAG,OAAO,QAAQ,SAAS,CAAC,CAAC,CAAC,MAAM,SAAS,MAAM,MAAM,SAAS,KAAK,KAAK,IAAI,CAAC,CAAC;AACtG;;;;;AAMA,IAAa,oBAAoB,UAAiD;CAChF,MAAM,OAAO,qBAAqB,KAAK;CACvC,MAAM,SAAS,CAAC,GAAG,IAAI,CAAC,CAAC;CACzB,IAAI,SAAS,YAAY,WAAW,OAAO,OAAO,UAAU;CAC5D,IAAI,SAAS,YAAY,WAAW,OAAO,OAAO,SAAS;CAC3D,IAAI,CAAC,MAAM,KAAK,IAAI,GAAG,OAAO,OAAO,YAAY;CACjD,IAAI,CAAC,WAAW,KAAK,IAAI,GAAG,OAAO,OAAO,UAAU;CACpD,KAAK,KAAK,MAAM,QAAQ,CAAC,EAAE,UAAU,KAAK,YAAY,WAAW,OAAO,OAAO,eAAe;CAC9F,IAAI,UAAU,IAAI,GAAG,OAAO,OAAO,YAAY;CAC/C,OAAO,GAAG,IAAI;AAChB;;AAGA,IAAa,iBAAiB,UAAoC;CAChE,IAAI,OAAO,UAAU,UAAU,OAAO;CACtC,MAAM,UAAU,iBAAiB,KAAK;CACtC,OAAO,QAAQ,MAAM,QAAQ,UAAU;AACzC"}
@@ -0,0 +1,10 @@
1
+ /** A uniform random number in [0, 1). Injected, so names are reproducible in tests. */
2
+ export type Random = () => number;
3
+ export declare const NAME_ADJECTIVES: readonly string[];
4
+ export declare const NAME_NOUNS: readonly string[];
5
+ /** Numbers 10–99, minus any the blocklist refuses. */
6
+ export declare const NAME_NUMBERS: readonly number[];
7
+ /** A uniformly random item of a non-empty list. */
8
+ export declare const pick: <T>(items: readonly T[], random: Random) => T;
9
+ /** A name like "Pixel Fox 42": adjective, noun, two-digit number. */
10
+ export declare const generateName: (random: Random) => string;
@@ -0,0 +1,104 @@
1
+ import { BLOCKED_NUMBERS } from "./blocklist.js";
2
+ //#region src/names/generator.ts
3
+ var NAME_ADJECTIVES = [
4
+ "Arcade",
5
+ "Atomic",
6
+ "Bonus",
7
+ "Bouncy",
8
+ "Brave",
9
+ "Chrome",
10
+ "Clever",
11
+ "Combo",
12
+ "Cosmic",
13
+ "Crystal",
14
+ "Dizzy",
15
+ "Golden",
16
+ "Hyper",
17
+ "Jolly",
18
+ "Laser",
19
+ "Lucky",
20
+ "Mega",
21
+ "Mighty",
22
+ "Neon",
23
+ "Nimble",
24
+ "Pixel",
25
+ "Plucky",
26
+ "Quantum",
27
+ "Rapid",
28
+ "Retro",
29
+ "Rocket",
30
+ "Royal",
31
+ "Silver",
32
+ "Snappy",
33
+ "Sparky",
34
+ "Speedy",
35
+ "Starry",
36
+ "Stellar",
37
+ "Sunny",
38
+ "Super",
39
+ "Swift",
40
+ "Turbo",
41
+ "Zippy"
42
+ ];
43
+ var NAME_NOUNS = [
44
+ "Badger",
45
+ "Beetle",
46
+ "Bunny",
47
+ "Cactus",
48
+ "Captain",
49
+ "Champ",
50
+ "Comet",
51
+ "Corgi",
52
+ "Dolphin",
53
+ "Dragon",
54
+ "Falcon",
55
+ "Fox",
56
+ "Gecko",
57
+ "Griffin",
58
+ "Hamster",
59
+ "Hero",
60
+ "Kitten",
61
+ "Koala",
62
+ "Llama",
63
+ "Lynx",
64
+ "Mango",
65
+ "Meteor",
66
+ "Muffin",
67
+ "Narwhal",
68
+ "Noodle",
69
+ "Nova",
70
+ "Otter",
71
+ "Owl",
72
+ "Panda",
73
+ "Pebble",
74
+ "Penguin",
75
+ "Phoenix",
76
+ "Pickle",
77
+ "Pilot",
78
+ "Puffin",
79
+ "Quasar",
80
+ "Ranger",
81
+ "Robot",
82
+ "Sprite",
83
+ "Taco",
84
+ "Tiger",
85
+ "Unicorn",
86
+ "Waffle",
87
+ "Whale",
88
+ "Wizard",
89
+ "Yeti"
90
+ ];
91
+ /** Numbers 10–99, minus any the blocklist refuses. */
92
+ var NAME_NUMBERS = Array.from({ length: 90 }, (_, i) => i + 10).filter((n) => !BLOCKED_NUMBERS.includes(String(n)));
93
+ /** A uniformly random item of a non-empty list. */
94
+ var pick = (items, random) => {
95
+ const item = items[Math.min(items.length - 1, Math.floor(random() * items.length))];
96
+ if (item === void 0) throw new RangeError("Cannot pick from an empty list");
97
+ return item;
98
+ };
99
+ /** A name like "Pixel Fox 42": adjective, noun, two-digit number. */
100
+ var generateName = (random) => `${pick(NAME_ADJECTIVES, random)} ${pick(NAME_NOUNS, random)} ${pick(NAME_NUMBERS, random)}`;
101
+ //#endregion
102
+ export { NAME_ADJECTIVES, NAME_NOUNS, NAME_NUMBERS, generateName, pick };
103
+
104
+ //# sourceMappingURL=generator.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"generator.js","names":[],"sources":["../../src/names/generator.ts"],"sourcesContent":["// The default display name: arcade-flavored, friendly, and safe by construction. Every\n// combination passes checkDisplayName (a test proves it), so a generated name never needs\n// a second roll.\nimport { BLOCKED_NUMBERS } from './blocklist.ts';\n\n/** A uniform random number in [0, 1). Injected, so names are reproducible in tests. */\nexport type Random = () => number;\n\nexport const NAME_ADJECTIVES: readonly string[] = [\n 'Arcade',\n 'Atomic',\n 'Bonus',\n 'Bouncy',\n 'Brave',\n 'Chrome',\n 'Clever',\n 'Combo',\n 'Cosmic',\n 'Crystal',\n 'Dizzy',\n 'Golden',\n 'Hyper',\n 'Jolly',\n 'Laser',\n 'Lucky',\n 'Mega',\n 'Mighty',\n 'Neon',\n 'Nimble',\n 'Pixel',\n 'Plucky',\n 'Quantum',\n 'Rapid',\n 'Retro',\n 'Rocket',\n 'Royal',\n 'Silver',\n 'Snappy',\n 'Sparky',\n 'Speedy',\n 'Starry',\n 'Stellar',\n 'Sunny',\n 'Super',\n 'Swift',\n 'Turbo',\n 'Zippy',\n];\n\nexport const NAME_NOUNS: readonly string[] = [\n 'Badger',\n 'Beetle',\n 'Bunny',\n 'Cactus',\n 'Captain',\n 'Champ',\n 'Comet',\n 'Corgi',\n 'Dolphin',\n 'Dragon',\n 'Falcon',\n 'Fox',\n 'Gecko',\n 'Griffin',\n 'Hamster',\n 'Hero',\n 'Kitten',\n 'Koala',\n 'Llama',\n 'Lynx',\n 'Mango',\n 'Meteor',\n 'Muffin',\n 'Narwhal',\n 'Noodle',\n 'Nova',\n 'Otter',\n 'Owl',\n 'Panda',\n 'Pebble',\n 'Penguin',\n 'Phoenix',\n 'Pickle',\n 'Pilot',\n 'Puffin',\n 'Quasar',\n 'Ranger',\n 'Robot',\n 'Sprite',\n 'Taco',\n 'Tiger',\n 'Unicorn',\n 'Waffle',\n 'Whale',\n 'Wizard',\n 'Yeti',\n];\n\n/** Numbers 10–99, minus any the blocklist refuses. */\nexport const NAME_NUMBERS: readonly number[] = Array.from({ length: 90 }, (_, i) => i + 10).filter(\n (n) => !BLOCKED_NUMBERS.includes(String(n)),\n);\n\n/** A uniformly random item of a non-empty list. */\nexport const pick = <T>(items: readonly T[], random: Random): T => {\n const item = items[Math.min(items.length - 1, Math.floor(random() * items.length))];\n if (item === undefined) throw new RangeError('Cannot pick from an empty list');\n return item;\n};\n\n/** A name like \"Pixel Fox 42\": adjective, noun, two-digit number. */\nexport const generateName = (random: Random): string =>\n `${pick(NAME_ADJECTIVES, random)} ${pick(NAME_NOUNS, random)} ${pick(NAME_NUMBERS, random)}`;\n"],"mappings":";;AAQA,IAAa,kBAAqC;CAChD;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF;AAEA,IAAa,aAAgC;CAC3C;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF;;AAGA,IAAa,eAAkC,MAAM,KAAK,EAAE,QAAQ,GAAG,IAAI,GAAG,MAAM,IAAI,EAAE,CAAC,CAAC,QACzF,MAAM,CAAC,gBAAgB,SAAS,OAAO,CAAC,CAAC,CAC5C;;AAGA,IAAa,QAAW,OAAqB,WAAsB;CACjE,MAAM,OAAO,MAAM,KAAK,IAAI,MAAM,SAAS,GAAG,KAAK,MAAM,OAAO,IAAI,MAAM,MAAM,CAAC;CACjF,IAAI,SAAS,KAAA,GAAW,MAAM,IAAI,WAAW,gCAAgC;CAC7E,OAAO;AACT;;AAGA,IAAa,gBAAgB,WAC3B,GAAG,KAAK,iBAAiB,MAAM,EAAE,GAAG,KAAK,YAAY,MAAM,EAAE,GAAG,KAAK,cAAc,MAAM"}
@@ -0,0 +1,3 @@
1
+ export { BLOCKED_ANYWHERE, BLOCKED_NUMBERS, BLOCKED_WORDS } from './blocklist.js';
2
+ export { checkDisplayName, isBlocked, isDisplayName, NAME_LIMITS, normalizeDisplayName, type NameRejection, type NameRejectionCode, } from './filter.js';
3
+ export { generateName, NAME_ADJECTIVES, NAME_NOUNS, NAME_NUMBERS, pick, type Random } from './generator.js';
@@ -0,0 +1,4 @@
1
+ import { BLOCKED_ANYWHERE, BLOCKED_NUMBERS, BLOCKED_WORDS } from "./blocklist.js";
2
+ import { NAME_ADJECTIVES, NAME_NOUNS, NAME_NUMBERS, generateName, pick } from "./generator.js";
3
+ import { NAME_LIMITS, checkDisplayName, isBlocked, isDisplayName, normalizeDisplayName } from "./filter.js";
4
+ export { BLOCKED_ANYWHERE, BLOCKED_NUMBERS, BLOCKED_WORDS, NAME_ADJECTIVES, NAME_LIMITS, NAME_NOUNS, NAME_NUMBERS, checkDisplayName, generateName, isBlocked, isDisplayName, normalizeDisplayName, pick };
package/package.json CHANGED
@@ -1,20 +1,59 @@
1
1
  {
2
2
  "name": "@joystyk/identity",
3
- "version": "0.0.0",
4
- "description": "Reserved for the first Joystyk library release.",
3
+ "version": "0.2.0",
4
+ "description": "Guest player identity: a stable on-device player id, a filtered display name and a preset avatar.",
5
+ "private": false,
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ },
13
+ "./browser": {
14
+ "types": "./dist/browser/index.d.ts",
15
+ "default": "./dist/browser/index.js"
16
+ },
17
+ "./names": {
18
+ "types": "./dist/names/index.d.ts",
19
+ "default": "./dist/names/index.js"
20
+ }
21
+ },
22
+ "dependencies": {
23
+ "zod": "4.6.5",
24
+ "@joystyk/contracts": "0.2.0",
25
+ "@joystyk/storage": "0.2.0"
26
+ },
27
+ "devDependencies": {
28
+ "typescript": "5.9.3",
29
+ "vite": "8.3.3",
30
+ "vitest": "4.1.11"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "files": [
36
+ "dist",
37
+ "README.md",
38
+ "LICENSE",
39
+ "NOTICE",
40
+ "CHANGELOG.md"
41
+ ],
42
+ "engines": {
43
+ "node": ">=24.15.0"
44
+ },
5
45
  "license": "MIT",
6
46
  "repository": {
7
47
  "type": "git",
8
48
  "url": "git+https://github.com/joystyk-games/game-libraries.git",
9
49
  "directory": "packages/identity"
10
50
  },
11
- "homepage": "https://github.com/joystyk-games/game-libraries",
12
- "files": [
13
- "README.md",
14
- "LICENSE",
15
- "NOTICE"
16
- ],
17
- "publishConfig": {
18
- "access": "public"
51
+ "homepage": "https://github.com/joystyk-games/game-libraries/tree/main/packages/identity#readme",
52
+ "bugs": {
53
+ "url": "https://github.com/joystyk-games/game-libraries/issues"
54
+ },
55
+ "scripts": {
56
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.core.json",
57
+ "build": "node ../../scripts/build-package.ts"
19
58
  }
20
- }
59
+ }