@nano133/ticket 0.0.0-stage → 0.1.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,8 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ First release (Pre-alpha): `readTicket`, `verifyTicket`, `fetchKeys`, `ticketFromHash`,
6
+ `hashWithoutTicket`. One ticket is pinned byte for byte in the tests, and nano133.com's own
7
+ test pins the same one.
8
+ The key list is read with a 5 second limit (`KEYS_TIMEOUT_MS`), so a slow nano133.com never holds a game's first screen. A ticket's `look` is a clean copy of its five colours.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 nano133.com
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,119 @@
1
- # Temporary Holding Version
1
+ # @nano133/ticket
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Stage: Pre-alpha. What this means: https://nano133.com/stages
4
+
5
+ Read and check a Nanoterra sign-in ticket, in the player's browser. It is for a
6
+ game on a **static site**: no server, no secret. A player who is signed in at
7
+ nano133.com presses Play in Nanoterra, agrees once on a card, and arrives at your
8
+ game with a signed ticket after the `#` of its address. This package checks the
9
+ ticket and gives you the player's **username**, **Nano address** and
10
+ **character** (the look and the cosmetics the player owns).
11
+
12
+ If your game has a server, use the code exchange instead (the
13
+ [game starter](https://github.com/nano133-com/nano-game-starter) does): a server that holds
14
+ a secret can know who is at the other end. A ticket cannot.
15
+
16
+ ```ts
17
+ import { readTicket } from "@nano133/ticket";
18
+
19
+ const result = await readTicket();
20
+ if (result.ok) {
21
+ const { username, address, look, items } = result.ticket;
22
+ // Ask first: "Sign in as {username}? Continue / Not me" (see "Rules" below).
23
+ }
24
+ // result.ok === false: a guest. result.reason says why ("none" is a plain visit).
25
+ ```
26
+
27
+ `readTicket()` does four things, in this order:
28
+
29
+ 1. Finds `#nanoterra=<ticket>` in the page's address. With none, it answers
30
+ `{ ok: false, reason: "none" }` and does nothing else (no network call).
31
+ 2. **Takes the ticket out of the address bar** (`history.replaceState`), so a
32
+ reload, a copied link, a screenshot or a bookmark never carries it.
33
+ 3. Reads the public keys from `https://nano133.com/api/nanoterra/keys` (once for
34
+ the session, and again if a ticket names a key it does not know; it waits 5 seconds at most, then the player is a guest).
35
+ 4. Checks the signature, the kind, that the ticket is **for this origin** and that
36
+ it has not run out. It never throws.
37
+
38
+ Call it once, when the page loads.
39
+
40
+ ## Rules
41
+
42
+ - **A ticket proves nothing to your server.** The ticket sits in an address bar:
43
+ anyone who sees it can use it for two minutes. Use it to show the player's name
44
+ and character. **Never give a sale, a prize or a score on a ticket alone.**
45
+ - **Ask before you treat the player as that name.** Anyone can send a link that holds
46
+ their *own* ticket (login CSRF). Show "Sign in as *name*? Continue / Not me" and
47
+ keep the player a guest until Continue.
48
+ - **Show the name** wherever the player is "signed in", and say whose account it is
49
+ before a payment.
50
+ - **The username comes from another site.** Put it in the page as text, never as HTML.
51
+ - A guest is a normal visit. Every refusal (`reason`) means "play as a guest".
52
+
53
+ ## What you get
54
+
55
+ ```ts
56
+ type Ticket = {
57
+ username: string;
58
+ address: string; // the whole Nano address; also the player's id
59
+ look: { skin: string; hair: string; shirt: string; pants: string; shoes: string }; // "#rrggbb"
60
+ items: string[]; // the Nanoterra cosmetics the player owns, in the character's slots
61
+ game: string; // your game's id at nano133.com
62
+ audience: string; // the origin the ticket is for
63
+ issuedAt: number; // ms since 1970
64
+ expiresAt: number;
65
+ id: string;
66
+ };
67
+ ```
68
+
69
+ The character is the same one `@nano133/character` draws in Three.js.
70
+
71
+ ## Refusals
72
+
73
+ | `reason` | Meaning |
74
+ |---|---|
75
+ | `none` | no ticket in the address: a plain visit |
76
+ | `unsupported` | this browser has no Ed25519 in Web Crypto (Chrome 137, Safari 17, Firefox 129 and later have it) |
77
+ | `malformed` | not a ticket |
78
+ | `keys` | the public keys could not be read (offline, or nano133.com did not answer) |
79
+ | `unknown-key`, `signature` | signed with a key that is not nano133.com's, or the signature does not hold |
80
+ | `wrong-kind` | signed right, but not a version 1 ticket from nano133.com |
81
+ | `audience` | a ticket for another origin |
82
+ | `expired`, `early`, `life` | the time does not hold. A player's clock may be a minute off, either way |
83
+
84
+ ## Options
85
+
86
+ ```ts
87
+ readTicket({
88
+ keys, // pin the public keys: no fetch at all
89
+ keysUrl, // another place for the keys (tests)
90
+ keysTimeoutMs, // how long to wait for the keys; default 5,000 (a slow nano133.com never holds your first screen)
91
+ audience, // the origin to expect; default: this page's own
92
+ });
93
+ ```
94
+
95
+ `verifyTicket(token, { audience, keys })` checks a ticket you already hold, and
96
+ `fetchKeys()`, `ticketFromHash(hash)` and `hashWithoutTicket(hash)` are the
97
+ parts.
98
+
99
+ ## The format
100
+
101
+ For any language. A ticket is `base64url(JSON claims) "." base64url(signature)`: an Ed25519 signature over the
102
+ ASCII of the first part, made with the key whose `kid` the claims name.
103
+
104
+ ```json
105
+ { "v": 1, "typ": "ticket", "iss": "nano133.com", "kid": "fe812c12f3ab4ce6", "aud": "https://game.example",
106
+ "game": "gem-island", "sub": "nano_3i7m…", "name": "Ann",
107
+ "look": { "skin": "#f2c29b", "hair": "#3a2a1e", "shirt": "#2a9df4", "pants": "#1e2a4a", "shoes": "#ffffff" },
108
+ "items": ["hub.sun-hat"], "iat": 1800000000, "exp": 1800000120, "jti": "AAAAAAAAAAAAAAAA" }
109
+ ```
110
+
111
+ `GET https://nano133.com/api/nanoterra/keys` answers `{ "keys": [{ "kid", "kty": "OKP", "crv": "Ed25519", "x", "alg": "EdDSA", "use": "sig" }] }`
112
+ and may be read from any page. A ticket lives two minutes (`exp - iat` is 120; a longer life is refused).
113
+
114
+ ## Turn it on
115
+
116
+ Your `nano-game.json` says `"signin": "ticket"`. Nanoterra's Play key then sends signed-in players to
117
+ your play address with a ticket. A player who has not agreed, or who plays as a guest, arrives with no ticket.
118
+
119
+ MIT license.
package/ai/PROMPT.md ADDED
@@ -0,0 +1,10 @@
1
+ # Nanoterra sign-in ticket: the prompt
2
+
3
+ ```
4
+ Take players who press Play in Nanoterra signed in on this static game (no server of its own).
5
+ Use the npm package @nano133/ticket and follow its SPEC step by step:
6
+ https://nano133.com/build/nanoterra
7
+ Set "signin": "ticket" in nano-game.json. Call readTicket() once when the page loads, ask
8
+ "Sign in as <name>? Continue / Not me" before treating the player as that name, and show the name.
9
+ Never give a sale, a prize or a score on a ticket alone. Put the name in the page as text.
10
+ ```
package/ai/SPEC.md ADDED
@@ -0,0 +1,42 @@
1
+ # Nanoterra sign-in ticket: implementation spec
2
+
3
+ Package: `@nano133/ticket` (0.1.0 or later). For a game on a **static site** (no server of its
4
+ own). A game with a server uses the code exchange instead (the game starter has it).
5
+
6
+ ## 0. Decide (ask)
7
+
8
+ 1. Does the game have a server? If yes, stop: use the code exchange. A ticket cannot prove who
9
+ is at the other end, so a server must not trust it.
10
+ 2. What the game does with the player's name and character: show them (fine), or give something
11
+ of value (a sale, a prize, a score). **Never give anything of value on a ticket alone.**
12
+
13
+ ## 1. Turn it on
14
+
15
+ In `nano-game.json` set `"signin": "ticket"`. Nothing else is made at nano133.com (no secret).
16
+
17
+ ## 2. Read the ticket when the page loads
18
+
19
+ ```ts
20
+ import { readTicket } from "@nano133/ticket";
21
+ const result = await readTicket();
22
+ ```
23
+
24
+ Call it once. It takes the ticket out of the address bar first. If `result.ok` is false the
25
+ player is a guest (`result.reason` is `"none"` for a plain visit).
26
+
27
+ ## 3. Ask before you sign the player in
28
+
29
+ Show "Sign in as *name*? Continue / Not me". Anyone can send a link that holds their own ticket.
30
+ Until Continue, the player is a guest. Put the name in the page as text, never as HTML.
31
+
32
+ ## 4. Use the details
33
+
34
+ `result.ticket`: `username`, `address`, `look` (five `#rrggbb` colours), `items` (catalogue ids).
35
+ Keep them in memory or `sessionStorage`. Show the name wherever the player is signed in.
36
+
37
+ ## Checks
38
+
39
+ - A plain visit (no `#nanoterra=`) makes no network call and shows no card.
40
+ - A ticket for another origin, a changed ticket and an old ticket each give a guest.
41
+ - The address bar has no ticket after the page loaded.
42
+ - No sale, prize or score depends on the ticket.
@@ -0,0 +1,95 @@
1
+ export declare const TICKET_PARAM = "nanoterra";
2
+ export declare const DEFAULT_KEYS_URL = "https://nano133.com/api/nanoterra/keys";
3
+ export declare const ISSUER = "nano133.com";
4
+ /** A ticket longer than this is not one. */
5
+ export declare const MAX_LENGTH = 4096;
6
+ /** A ticket lives this long (seconds). A longer life is refused. */
7
+ export declare const MAX_LIFE_SECONDS = 120;
8
+ /** A player's clock may be this far off (seconds), either way. */
9
+ export declare const SLACK_SECONDS = 60;
10
+ export type Look = {
11
+ skin: string;
12
+ hair: string;
13
+ shirt: string;
14
+ pants: string;
15
+ shoes: string;
16
+ };
17
+ /** What a ticket says about the player. Times are in milliseconds since 1970. */
18
+ export type Ticket = {
19
+ username: string;
20
+ /** The player's whole Nano address. It is also the player's id. */
21
+ address: string;
22
+ look: Look;
23
+ /** The Nanoterra cosmetics the player owns, in the island character's slots (catalogue ids). */
24
+ items: string[];
25
+ /** The game's id at nano133.com, and the origin the ticket is for. */
26
+ game: string;
27
+ audience: string;
28
+ issuedAt: number;
29
+ expiresAt: number;
30
+ /** A random id of this ticket. */
31
+ id: string;
32
+ };
33
+ /** One public key of GET https://nano133.com/api/nanoterra/keys (a JWK, RFC 8037: Ed25519). */
34
+ export type PublicKey = {
35
+ kid: string;
36
+ x: string;
37
+ kty?: string;
38
+ crv?: string;
39
+ alg?: string;
40
+ use?: string;
41
+ };
42
+ export type Keys = {
43
+ keys: PublicKey[];
44
+ };
45
+ export type Refusal = "none" | "unsupported" | "malformed" | "keys" | "unknown-key" | "signature" | "wrong-kind" | "audience" | "expired" | "early" | "life";
46
+ export type Result = {
47
+ ok: true;
48
+ ticket: Ticket;
49
+ } | {
50
+ ok: false;
51
+ reason: Refusal;
52
+ };
53
+ /** The ticket in an address's fragment (`location.hash`), or null. Other fragment parts are left alone. */
54
+ export declare function ticketFromHash(hash: string): string | null;
55
+ /** The fragment without the ticket ("" when nothing else is in it, else "#" and the rest). */
56
+ export declare function hashWithoutTicket(hash: string): string;
57
+ /** How long the page waits for the keys (milliseconds). A slow nano133.com must not hold a game's first screen. */
58
+ export declare const KEYS_TIMEOUT_MS = 5000;
59
+ /** The public keys, as nano133.com publishes them (any origin may read them). `cache: "reload"` skips the browser's copy. Rejects after KEYS_TIMEOUT_MS. */
60
+ export declare function fetchKeys(url?: string, fetcher?: typeof fetch, cache?: RequestCache, timeoutMs?: number): Promise<Keys>;
61
+ export type VerifyOptions = {
62
+ /** The origin this page is on, e.g. "https://game.example". A ticket for another origin is refused. */
63
+ audience: string;
64
+ keys: Keys;
65
+ /** Milliseconds since 1970 (for tests). */
66
+ now?: number;
67
+ /** Web Crypto's `subtle` (for tests). */
68
+ subtle?: SubtleCrypto;
69
+ };
70
+ /** Checks a ticket: the key, the signature, the kind, the audience and the time. Never throws. */
71
+ export declare function verifyTicket(token: string, o: VerifyOptions): Promise<Result>;
72
+ export type ReadOptions = {
73
+ /** Pin the keys (no fetch). Else they are read from `keysUrl` once, and again when a ticket names a key that is not in the list. */
74
+ keys?: Keys;
75
+ keysUrl?: string;
76
+ /** How long to wait for the keys (milliseconds): the default is KEYS_TIMEOUT_MS. */
77
+ keysTimeoutMs?: number;
78
+ /** The origin to expect. Default: this page's own origin. */
79
+ audience?: string;
80
+ location?: Pick<Location, "hash" | "pathname" | "search" | "origin">;
81
+ history?: Pick<History, "replaceState" | "state">;
82
+ fetch?: typeof fetch;
83
+ now?: number;
84
+ subtle?: SubtleCrypto;
85
+ };
86
+ /**
87
+ * Reads the ticket from this page's address, if there is one. FIRST it takes the ticket out of the address bar (the page's history
88
+ * entry is replaced), so a reload, a copied link, a screenshot or a bookmark never carries it; then it checks the ticket. Call it once, when
89
+ * the page loads. `{ ok: false, reason: "none" }` is a plain visit (no network call). Anything else that is not ok is a guest.
90
+ *
91
+ * Show "Sign in as <name>? Continue / Not me" before you treat the player as that name: anyone can send a link that holds their own ticket.
92
+ */
93
+ export declare function readTicket(o?: ReadOptions): Promise<Result>;
94
+ /** Forgets the keys read so far (for tests). */
95
+ export declare function forgetKeys(): void;
package/dist/index.js ADDED
@@ -0,0 +1,173 @@
1
+ // Read and check a Nanoterra sign-in ticket, in the player's browser. For a game on a STATIC site: no server to trade a one-time code.
2
+ //
3
+ // Nanoterra (nano133.com) opens the game's own address with the ticket after a `#`: https://game.example/#nanoterra=<ticket>.
4
+ // A fragment is never sent to a server. `readTicket()` takes the ticket out of the address bar at once, checks its signature with
5
+ // nano133.com's public key and answers with the player's username, Nano address and character.
6
+ //
7
+ // WHAT A TICKET PROVES: that nano133.com said "this player, for this game, a moment ago". Anyone who sees the address can use the
8
+ // ticket until it runs out (two minutes), so it proves nothing to YOUR SERVER. Use it to show the player's name and character. Never
9
+ // give a sale, a prize or a score on a ticket alone: for that, use the code exchange (a server that holds a secret).
10
+ //
11
+ // Format (the same as nano133.com's receipts): base64url(JSON claims) "." base64url(Ed25519 signature over the first part).
12
+ // Web Crypto only (Ed25519: Chrome 137, Safari 17, Firefox 129, Node 20 and later); no other import.
13
+ export const TICKET_PARAM = "nanoterra";
14
+ export const DEFAULT_KEYS_URL = "https://nano133.com/api/nanoterra/keys";
15
+ export const ISSUER = "nano133.com";
16
+ /** A ticket longer than this is not one. */
17
+ export const MAX_LENGTH = 4096;
18
+ /** A ticket lives this long (seconds). A longer life is refused. */
19
+ export const MAX_LIFE_SECONDS = 120;
20
+ /** A player's clock may be this far off (seconds), either way. */
21
+ export const SLACK_SECONDS = 60;
22
+ const TOKEN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
23
+ const COLOUR = /^#[0-9a-fA-F]{6}$/;
24
+ const ADDRESS = /^nano_[13][13-9a-km-uw-z]{59}$/;
25
+ const dec = new TextDecoder();
26
+ const enc = new TextEncoder();
27
+ function unb64url(text) {
28
+ const s = atob(text.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((text.length + 3) % 4));
29
+ return Uint8Array.from(s, (c) => c.charCodeAt(0));
30
+ }
31
+ /** The ticket in an address's fragment (`location.hash`), or null. Other fragment parts are left alone. */
32
+ export function ticketFromHash(hash) {
33
+ for (const part of hash.replace(/^#/, "").split("&")) {
34
+ if (part.startsWith(`${TICKET_PARAM}=`)) {
35
+ const value = part.slice(TICKET_PARAM.length + 1);
36
+ return value.length <= MAX_LENGTH && TOKEN.test(value) ? value : null;
37
+ }
38
+ }
39
+ return null;
40
+ }
41
+ /** The fragment without the ticket ("" when nothing else is in it, else "#" and the rest). */
42
+ export function hashWithoutTicket(hash) {
43
+ const rest = hash.replace(/^#/, "").split("&").filter((part) => !part.startsWith(`${TICKET_PARAM}=`) && part !== "");
44
+ return rest.length ? `#${rest.join("&")}` : "";
45
+ }
46
+ /** How long the page waits for the keys (milliseconds). A slow nano133.com must not hold a game's first screen. */
47
+ export const KEYS_TIMEOUT_MS = 5000;
48
+ /** The public keys, as nano133.com publishes them (any origin may read them). `cache: "reload"` skips the browser's copy. Rejects after KEYS_TIMEOUT_MS. */
49
+ export async function fetchKeys(url = DEFAULT_KEYS_URL, fetcher = fetch, cache = "default", timeoutMs = KEYS_TIMEOUT_MS) {
50
+ const stop = new AbortController();
51
+ const timer = setTimeout(() => stop.abort(), timeoutMs);
52
+ try {
53
+ const r = await fetcher(url, { credentials: "omit", cache, signal: stop.signal });
54
+ if (!r.ok)
55
+ throw new Error(`keys answered ${r.status}`);
56
+ return readKeys(await r.json());
57
+ }
58
+ finally {
59
+ clearTimeout(timer);
60
+ }
61
+ }
62
+ function readKeys(j) {
63
+ if (!j || !Array.isArray(j.keys))
64
+ throw new Error("keys: not a list");
65
+ return { keys: j.keys.filter((k) => !!k && typeof k.kid === "string" && typeof k.x === "string") };
66
+ }
67
+ /** Checks a ticket: the key, the signature, the kind, the audience and the time. Never throws. */
68
+ export async function verifyTicket(token, o) {
69
+ const no = (reason) => ({ ok: false, reason });
70
+ if (typeof token !== "string" || token.length > MAX_LENGTH || !TOKEN.test(token))
71
+ return no("malformed");
72
+ const subtle = o.subtle ?? globalThis.crypto?.subtle;
73
+ if (!subtle)
74
+ return no("unsupported");
75
+ const [body, sig] = token.split(".");
76
+ let claims;
77
+ try {
78
+ claims = JSON.parse(dec.decode(unb64url(body)));
79
+ }
80
+ catch {
81
+ return no("malformed");
82
+ }
83
+ if (!claims || typeof claims !== "object" || typeof claims.kid !== "string")
84
+ return no("malformed");
85
+ const key = o.keys.keys.find((k) => k.kid === claims.kid);
86
+ if (!key)
87
+ return no("unknown-key");
88
+ try {
89
+ const publicKey = await subtle.importKey("jwk", { kty: "OKP", crv: "Ed25519", x: key.x }, { name: "Ed25519" }, false, ["verify"]);
90
+ if (!(await subtle.verify("Ed25519", publicKey, unb64url(sig), enc.encode(body))))
91
+ return no("signature");
92
+ }
93
+ catch (e) {
94
+ return no(e?.name === "NotSupportedError" ? "unsupported" : "signature");
95
+ }
96
+ // The signature holds: the claims are nano133.com's. They still must be what a ticket is, for this page, now.
97
+ if (claims.v !== 1 || claims.typ !== "ticket" || claims.iss !== ISSUER)
98
+ return no("wrong-kind");
99
+ if (claims.aud !== o.audience)
100
+ return no("audience");
101
+ const { iat, exp } = claims;
102
+ if (typeof iat !== "number" || typeof exp !== "number")
103
+ return no("malformed");
104
+ const now = (o.now ?? Date.now()) / 1000;
105
+ if (!(exp > now - SLACK_SECONDS))
106
+ return no("expired");
107
+ if (!(iat < now + SLACK_SECONDS))
108
+ return no("early");
109
+ if (!(exp - iat <= MAX_LIFE_SECONDS))
110
+ return no("life");
111
+ const { sub, name, look, items, game, jti } = claims;
112
+ const l = look;
113
+ if (typeof sub !== "string" || !ADDRESS.test(sub) || typeof name !== "string" || !name || name.length > 60 || typeof game !== "string" || typeof jti !== "string")
114
+ return no("malformed");
115
+ if (!l || typeof l !== "object" || !["skin", "hair", "shirt", "pants", "shoes"].every((p) => typeof l[p] === "string" && COLOUR.test(l[p])))
116
+ return no("malformed");
117
+ if (!Array.isArray(items) || items.length > 64 || !items.every((i) => typeof i === "string" && i.length <= 48))
118
+ return no("malformed");
119
+ const clean = { skin: l.skin, hair: l.hair, shirt: l.shirt, pants: l.pants, shoes: l.shoes };
120
+ return { ok: true, ticket: { username: name, address: sub, look: clean, items: [...items], game, audience: claims.aud, issuedAt: iat * 1000, expiresAt: exp * 1000, id: jti } };
121
+ }
122
+ let cached = null;
123
+ /**
124
+ * Reads the ticket from this page's address, if there is one. FIRST it takes the ticket out of the address bar (the page's history
125
+ * entry is replaced), so a reload, a copied link, a screenshot or a bookmark never carries it; then it checks the ticket. Call it once, when
126
+ * the page loads. `{ ok: false, reason: "none" }` is a plain visit (no network call). Anything else that is not ok is a guest.
127
+ *
128
+ * Show "Sign in as <name>? Continue / Not me" before you treat the player as that name: anyone can send a link that holds their own ticket.
129
+ */
130
+ export async function readTicket(o = {}) {
131
+ const loc = o.location ?? globalThis.location;
132
+ const hist = o.history ?? globalThis.history;
133
+ if (!loc)
134
+ return { ok: false, reason: "none" };
135
+ // A ticket is taken out of the address bar FIRST, also one that is not a good ticket.
136
+ if (!loc.hash.replace(/^#/, "").split("&").some((part) => part.startsWith(`${TICKET_PARAM}=`)))
137
+ return { ok: false, reason: "none" };
138
+ const token = ticketFromHash(loc.hash);
139
+ if (hist)
140
+ hist.replaceState(hist.state, "", loc.pathname + loc.search + hashWithoutTicket(loc.hash));
141
+ if (!token)
142
+ return { ok: false, reason: "malformed" };
143
+ const audience = o.audience ?? loc.origin;
144
+ const load = async (fresh) => {
145
+ if (o.keys)
146
+ return o.keys;
147
+ if (cached && !fresh)
148
+ return cached;
149
+ try {
150
+ cached = await fetchKeys(o.keysUrl, o.fetch, fresh ? "reload" : "default", o.keysTimeoutMs);
151
+ return cached;
152
+ }
153
+ catch {
154
+ return null;
155
+ }
156
+ };
157
+ let keys = await load(false);
158
+ if (!keys)
159
+ return { ok: false, reason: "keys" };
160
+ let result = await verifyTicket(token, { audience, keys, now: o.now, subtle: o.subtle });
161
+ if (!result.ok && result.reason === "unknown-key" && !o.keys) {
162
+ // nano133.com may have a new key since this page last read the list.
163
+ keys = await load(true);
164
+ if (!keys)
165
+ return { ok: false, reason: "keys" };
166
+ result = await verifyTicket(token, { audience, keys, now: o.now, subtle: o.subtle });
167
+ }
168
+ return result;
169
+ }
170
+ /** Forgets the keys read so far (for tests). */
171
+ export function forgetKeys() {
172
+ cached = null;
173
+ }
package/package.json CHANGED
@@ -1,6 +1,33 @@
1
1
  {
2
2
  "name": "@nano133/ticket",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Read and check a Nanoterra sign-in ticket in the player's browser: for a game on a static site. The player's name, address and character; no server, no secret.",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" }
10
+ },
11
+ "sideEffects": false,
12
+ "files": ["dist", "src", "README.md", "CHANGELOG.md", "LICENSE", "ai"],
13
+ "scripts": {
14
+ "build": "tsc -p tsconfig.json",
15
+ "typecheck": "tsc -p tsconfig.json --noEmit",
16
+ "test": "node --import tsx --test test/*.test.ts",
17
+ "prepublishOnly": "npm run build"
18
+ },
19
+ "devDependencies": {
20
+ "@types/node": "^26.6.3",
21
+ "tsx": "^4.23.15",
22
+ "typescript": "5.9"
23
+ },
24
+ "publishConfig": { "access": "public" },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/nano133-com/kit.git",
28
+ "directory": "packages/ticket"
29
+ },
30
+ "homepage": "https://nano133.com/build/nanoterra",
31
+ "license": "MIT",
32
+ "keywords": ["nano", "xno", "nanoterra", "sign in", "ticket", "ed25519", "static site"]
33
+ }
package/src/index.ts ADDED
@@ -0,0 +1,221 @@
1
+ // Read and check a Nanoterra sign-in ticket, in the player's browser. For a game on a STATIC site: no server to trade a one-time code.
2
+ //
3
+ // Nanoterra (nano133.com) opens the game's own address with the ticket after a `#`: https://game.example/#nanoterra=<ticket>.
4
+ // A fragment is never sent to a server. `readTicket()` takes the ticket out of the address bar at once, checks its signature with
5
+ // nano133.com's public key and answers with the player's username, Nano address and character.
6
+ //
7
+ // WHAT A TICKET PROVES: that nano133.com said "this player, for this game, a moment ago". Anyone who sees the address can use the
8
+ // ticket until it runs out (two minutes), so it proves nothing to YOUR SERVER. Use it to show the player's name and character. Never
9
+ // give a sale, a prize or a score on a ticket alone: for that, use the code exchange (a server that holds a secret).
10
+ //
11
+ // Format (the same as nano133.com's receipts): base64url(JSON claims) "." base64url(Ed25519 signature over the first part).
12
+ // Web Crypto only (Ed25519: Chrome 137, Safari 17, Firefox 129, Node 20 and later); no other import.
13
+
14
+ export const TICKET_PARAM = "nanoterra";
15
+ export const DEFAULT_KEYS_URL = "https://nano133.com/api/nanoterra/keys";
16
+ export const ISSUER = "nano133.com";
17
+ /** A ticket longer than this is not one. */
18
+ export const MAX_LENGTH = 4096;
19
+ /** A ticket lives this long (seconds). A longer life is refused. */
20
+ export const MAX_LIFE_SECONDS = 120;
21
+ /** A player's clock may be this far off (seconds), either way. */
22
+ export const SLACK_SECONDS = 60;
23
+
24
+ export type Look = { skin: string; hair: string; shirt: string; pants: string; shoes: string };
25
+
26
+ /** What a ticket says about the player. Times are in milliseconds since 1970. */
27
+ export type Ticket = {
28
+ username: string;
29
+ /** The player's whole Nano address. It is also the player's id. */
30
+ address: string;
31
+ look: Look;
32
+ /** The Nanoterra cosmetics the player owns, in the island character's slots (catalogue ids). */
33
+ items: string[];
34
+ /** The game's id at nano133.com, and the origin the ticket is for. */
35
+ game: string;
36
+ audience: string;
37
+ issuedAt: number;
38
+ expiresAt: number;
39
+ /** A random id of this ticket. */
40
+ id: string;
41
+ };
42
+
43
+ /** One public key of GET https://nano133.com/api/nanoterra/keys (a JWK, RFC 8037: Ed25519). */
44
+ export type PublicKey = { kid: string; x: string; kty?: string; crv?: string; alg?: string; use?: string };
45
+ export type Keys = { keys: PublicKey[] };
46
+
47
+ export type Refusal =
48
+ | "none" // no ticket in the address: a plain visit
49
+ | "unsupported" // this browser has no Ed25519 in Web Crypto
50
+ | "malformed" // not a ticket
51
+ | "keys" // the public keys could not be read
52
+ | "unknown-key" // signed with a key that is not in the list
53
+ | "signature" // the signature does not hold
54
+ | "wrong-kind" // signed right, but not a version 1 ticket from nano133.com
55
+ | "audience" // a ticket for another origin
56
+ | "expired"
57
+ | "early" // issued in the future (a clock that is far off)
58
+ | "life"; // asks for a life longer than a ticket may have
59
+ export type Result = { ok: true; ticket: Ticket } | { ok: false; reason: Refusal };
60
+
61
+ const TOKEN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
62
+ const COLOUR = /^#[0-9a-fA-F]{6}$/;
63
+ const ADDRESS = /^nano_[13][13-9a-km-uw-z]{59}$/;
64
+ const dec = new TextDecoder();
65
+ const enc = new TextEncoder();
66
+
67
+ function unb64url(text: string): Uint8Array<ArrayBuffer> {
68
+ const s = atob(text.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((text.length + 3) % 4));
69
+ return Uint8Array.from(s, (c) => c.charCodeAt(0));
70
+ }
71
+
72
+ /** The ticket in an address's fragment (`location.hash`), or null. Other fragment parts are left alone. */
73
+ export function ticketFromHash(hash: string): string | null {
74
+ for (const part of hash.replace(/^#/, "").split("&")) {
75
+ if (part.startsWith(`${TICKET_PARAM}=`)) {
76
+ const value = part.slice(TICKET_PARAM.length + 1);
77
+ return value.length <= MAX_LENGTH && TOKEN.test(value) ? value : null;
78
+ }
79
+ }
80
+ return null;
81
+ }
82
+
83
+ /** The fragment without the ticket ("" when nothing else is in it, else "#" and the rest). */
84
+ export function hashWithoutTicket(hash: string): string {
85
+ const rest = hash.replace(/^#/, "").split("&").filter((part) => !part.startsWith(`${TICKET_PARAM}=`) && part !== "");
86
+ return rest.length ? `#${rest.join("&")}` : "";
87
+ }
88
+
89
+ /** How long the page waits for the keys (milliseconds). A slow nano133.com must not hold a game's first screen. */
90
+ export const KEYS_TIMEOUT_MS = 5000;
91
+
92
+ /** The public keys, as nano133.com publishes them (any origin may read them). `cache: "reload"` skips the browser's copy. Rejects after KEYS_TIMEOUT_MS. */
93
+ export async function fetchKeys(url: string = DEFAULT_KEYS_URL, fetcher: typeof fetch = fetch, cache: RequestCache = "default", timeoutMs: number = KEYS_TIMEOUT_MS): Promise<Keys> {
94
+ const stop = new AbortController();
95
+ const timer = setTimeout(() => stop.abort(), timeoutMs);
96
+ try {
97
+ const r = await fetcher(url, { credentials: "omit", cache, signal: stop.signal });
98
+ if (!r.ok) throw new Error(`keys answered ${r.status}`);
99
+ return readKeys(await r.json());
100
+ } finally {
101
+ clearTimeout(timer);
102
+ }
103
+ }
104
+
105
+ function readKeys(j: Partial<Keys>): Keys {
106
+ if (!j || !Array.isArray(j.keys)) throw new Error("keys: not a list");
107
+ return { keys: j.keys.filter((k): k is PublicKey => !!k && typeof k.kid === "string" && typeof k.x === "string") };
108
+ }
109
+
110
+ export type VerifyOptions = {
111
+ /** The origin this page is on, e.g. "https://game.example". A ticket for another origin is refused. */
112
+ audience: string;
113
+ keys: Keys;
114
+ /** Milliseconds since 1970 (for tests). */
115
+ now?: number;
116
+ /** Web Crypto's `subtle` (for tests). */
117
+ subtle?: SubtleCrypto;
118
+ };
119
+
120
+ type Claims = { v?: unknown; typ?: unknown; iss?: unknown; kid?: unknown; aud?: unknown; game?: unknown; sub?: unknown; name?: unknown; look?: unknown; items?: unknown; iat?: unknown; exp?: unknown; jti?: unknown };
121
+
122
+ /** Checks a ticket: the key, the signature, the kind, the audience and the time. Never throws. */
123
+ export async function verifyTicket(token: string, o: VerifyOptions): Promise<Result> {
124
+ const no = (reason: Refusal): Result => ({ ok: false, reason });
125
+ if (typeof token !== "string" || token.length > MAX_LENGTH || !TOKEN.test(token)) return no("malformed");
126
+ const subtle = o.subtle ?? globalThis.crypto?.subtle;
127
+ if (!subtle) return no("unsupported");
128
+ const [body, sig] = token.split(".") as [string, string];
129
+ let claims: Claims;
130
+ try {
131
+ claims = JSON.parse(dec.decode(unb64url(body))) as Claims;
132
+ } catch {
133
+ return no("malformed");
134
+ }
135
+ if (!claims || typeof claims !== "object" || typeof claims.kid !== "string") return no("malformed");
136
+ const key = o.keys.keys.find((k) => k.kid === claims.kid);
137
+ if (!key) return no("unknown-key");
138
+ try {
139
+ const publicKey = await subtle.importKey("jwk", { kty: "OKP", crv: "Ed25519", x: key.x }, { name: "Ed25519" }, false, ["verify"]);
140
+ if (!(await subtle.verify("Ed25519", publicKey, unb64url(sig), enc.encode(body)))) return no("signature");
141
+ } catch (e) {
142
+ return no((e as { name?: string })?.name === "NotSupportedError" ? "unsupported" : "signature");
143
+ }
144
+ // The signature holds: the claims are nano133.com's. They still must be what a ticket is, for this page, now.
145
+ if (claims.v !== 1 || claims.typ !== "ticket" || claims.iss !== ISSUER) return no("wrong-kind");
146
+ if (claims.aud !== o.audience) return no("audience");
147
+ const { iat, exp } = claims;
148
+ if (typeof iat !== "number" || typeof exp !== "number") return no("malformed");
149
+ const now = (o.now ?? Date.now()) / 1000;
150
+ if (!(exp > now - SLACK_SECONDS)) return no("expired");
151
+ if (!(iat < now + SLACK_SECONDS)) return no("early");
152
+ if (!(exp - iat <= MAX_LIFE_SECONDS)) return no("life");
153
+ const { sub, name, look, items, game, jti } = claims;
154
+ const l = look as Record<string, unknown> | null;
155
+ if (typeof sub !== "string" || !ADDRESS.test(sub) || typeof name !== "string" || !name || name.length > 60 || typeof game !== "string" || typeof jti !== "string") return no("malformed");
156
+ if (!l || typeof l !== "object" || !(["skin", "hair", "shirt", "pants", "shoes"] as const).every((p) => typeof l[p] === "string" && COLOUR.test(l[p] as string))) return no("malformed");
157
+ if (!Array.isArray(items) || items.length > 64 || !items.every((i) => typeof i === "string" && i.length <= 48)) return no("malformed");
158
+ const clean: Look = { skin: l.skin as string, hair: l.hair as string, shirt: l.shirt as string, pants: l.pants as string, shoes: l.shoes as string };
159
+ return { ok: true, ticket: { username: name, address: sub, look: clean, items: [...(items as string[])], game, audience: claims.aud as string, issuedAt: iat * 1000, expiresAt: exp * 1000, id: jti } };
160
+ }
161
+
162
+ export type ReadOptions = {
163
+ /** Pin the keys (no fetch). Else they are read from `keysUrl` once, and again when a ticket names a key that is not in the list. */
164
+ keys?: Keys;
165
+ keysUrl?: string;
166
+ /** How long to wait for the keys (milliseconds): the default is KEYS_TIMEOUT_MS. */
167
+ keysTimeoutMs?: number;
168
+ /** The origin to expect. Default: this page's own origin. */
169
+ audience?: string;
170
+ location?: Pick<Location, "hash" | "pathname" | "search" | "origin">;
171
+ history?: Pick<History, "replaceState" | "state">;
172
+ fetch?: typeof fetch;
173
+ now?: number;
174
+ subtle?: SubtleCrypto;
175
+ };
176
+
177
+ let cached: Keys | null = null;
178
+
179
+ /**
180
+ * Reads the ticket from this page's address, if there is one. FIRST it takes the ticket out of the address bar (the page's history
181
+ * entry is replaced), so a reload, a copied link, a screenshot or a bookmark never carries it; then it checks the ticket. Call it once, when
182
+ * the page loads. `{ ok: false, reason: "none" }` is a plain visit (no network call). Anything else that is not ok is a guest.
183
+ *
184
+ * Show "Sign in as <name>? Continue / Not me" before you treat the player as that name: anyone can send a link that holds their own ticket.
185
+ */
186
+ export async function readTicket(o: ReadOptions = {}): Promise<Result> {
187
+ const loc = o.location ?? globalThis.location;
188
+ const hist = o.history ?? globalThis.history;
189
+ if (!loc) return { ok: false, reason: "none" };
190
+ // A ticket is taken out of the address bar FIRST, also one that is not a good ticket.
191
+ if (!loc.hash.replace(/^#/, "").split("&").some((part) => part.startsWith(`${TICKET_PARAM}=`))) return { ok: false, reason: "none" };
192
+ const token = ticketFromHash(loc.hash);
193
+ if (hist) hist.replaceState(hist.state, "", loc.pathname + loc.search + hashWithoutTicket(loc.hash));
194
+ if (!token) return { ok: false, reason: "malformed" };
195
+ const audience = o.audience ?? loc.origin;
196
+ const load = async (fresh: boolean): Promise<Keys | null> => {
197
+ if (o.keys) return o.keys;
198
+ if (cached && !fresh) return cached;
199
+ try {
200
+ cached = await fetchKeys(o.keysUrl, o.fetch, fresh ? "reload" : "default", o.keysTimeoutMs);
201
+ return cached;
202
+ } catch {
203
+ return null;
204
+ }
205
+ };
206
+ let keys = await load(false);
207
+ if (!keys) return { ok: false, reason: "keys" };
208
+ let result = await verifyTicket(token, { audience, keys, now: o.now, subtle: o.subtle });
209
+ if (!result.ok && result.reason === "unknown-key" && !o.keys) {
210
+ // nano133.com may have a new key since this page last read the list.
211
+ keys = await load(true);
212
+ if (!keys) return { ok: false, reason: "keys" };
213
+ result = await verifyTicket(token, { audience, keys, now: o.now, subtle: o.subtle });
214
+ }
215
+ return result;
216
+ }
217
+
218
+ /** Forgets the keys read so far (for tests). */
219
+ export function forgetKeys(): void {
220
+ cached = null;
221
+ }