clone

a browser grid puzzle game where you push words around to rewrite the rules of each level

feat(packs): encode data-only links and CommonJS exports

Dami committed bb17768
commit
bb177681777e08578e27434528a118d83a99c0f8
parent
4f54a7999382de08463c1eec5bff2a9420cc6237
author
Dami <dami.etoile@gmail.com>
2 files changed, +175 -0
Asrc/share.js107++++++++++++++++++++++++++++++
Atests/share.test.js68+++++++++++++++++++
A src/share.js +107 -0
@@ -0,0 +1,107 @@
1+/**2+ * Portable data-only sharing helpers.3+ *4+ * encodeSharedPack(pack) -> URL-safe checksummed payload5+ * decodeSharedPack(payload) -> validated, normalized mod data (never executes JS)6+ * exportCommonJS(pack) -> deterministic CommonJS source containing only data7+ */8+import { validateMod, MOD_SCHEMA_VERSION } from './mods.js';9+10+export const SHARE_SCHEMA_VERSION = 1;11+export const MAX_SHARED_JSON_BYTES = 48 * 1024;12+// Keep shared links practical for browser address bars and copy/paste surfaces.13+export const MAX_SHARED_PAYLOAD_LENGTH = 16 * 1024;14+const PREFIX = `miau${SHARE_SCHEMA_VERSION}.`;15+16+function isObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }17+18+function stableStringify(value) {19+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return JSON.stringify(value);20+ if (typeof value === 'number') {21+ if (!Number.isFinite(value)) throw new TypeError('shared pack contains a non-finite number');22+ return JSON.stringify(value);23+ }24+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;25+ if (isObject(value)) return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`;26+ throw new TypeError(`shared pack contains unsupported ${typeof value}`);27+}28+29+function byteLength(text) { return new TextEncoder().encode(text).byteLength; }30+31+function normalizePack(pack) {32+ let copy;33+ try { copy = JSON.parse(JSON.stringify(pack)); } catch (error) { throw new TypeError(`shared pack must contain JSON data: ${error.message}`); }34+ if (!isObject(copy)) throw new TypeError('shared pack must be an object');35+ copy.manifest = { ...copy.manifest, engine: copy.manifest?.engine ?? MOD_SCHEMA_VERSION };36+ copy.levels = Array.isArray(copy.levels) ? copy.levels.map(level => ({37+ ...level,38+ objects: Array.isArray(level.objects) ? level.objects.map(object => ({39+ ...object,40+ kind: object.kind || (object.text !== undefined ? 'word' : 'object'),41+ ...(object.text !== undefined ? { text: String(object.text).toUpperCase() } : {}),42+ ...(object.noun !== undefined ? { noun: String(object.noun).toUpperCase() } : {}),43+ })) : level.objects,44+ })) : copy.levels;45+ if (Array.isArray(copy.vocabulary)) copy.vocabulary = copy.vocabulary.map(word => String(word).toUpperCase());46+ if (isObject(copy.palette)) {47+ const palette = {};48+ for (const [noun, glyph] of Object.entries(copy.palette)) palette[noun.toUpperCase()] = glyph;49+ copy.palette = palette;50+ }51+ const errors = validateMod(copy);52+ if (errors.length) throw new TypeError(`Invalid shared pack:\n- ${errors.join('\n- ')}`);53+ return copy;54+}55+56+function fnv1a(text) {57+ let hash = 0x811c9dc5;58+ for (const byte of new TextEncoder().encode(text)) {59+ hash ^= byte;60+ hash = Math.imul(hash, 0x01000193) >>> 0;61+ }62+ return hash.toString(16).padStart(8, '0');63+}64+65+function encodeBase64Url(text) {66+ const bytes = new TextEncoder().encode(text);67+ let binary = '';68+ for (const byte of bytes) binary += String.fromCharCode(byte);69+ return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');70+}71+72+function decodeBase64Url(payload) {73+ const base64 = payload.replaceAll('-', '+').replaceAll('_', '/') + '='.repeat((4 - payload.length % 4) % 4);74+ const binary = atob(base64);75+ const bytes = Uint8Array.from(binary, character => character.charCodeAt(0));76+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes);77+}78+79+export function encodeSharedPack(pack) {80+ const normalized = normalizePack(pack);81+ const json = stableStringify({ pack: normalized, v: SHARE_SCHEMA_VERSION });82+ if (byteLength(json) > MAX_SHARED_JSON_BYTES) throw new RangeError(`shared pack exceeds ${MAX_SHARED_JSON_BYTES} bytes`);83+ const encoded = `${PREFIX}${encodeBase64Url(json)}.${fnv1a(json)}`;84+ if (encoded.length > MAX_SHARED_PAYLOAD_LENGTH) throw new RangeError(`shared payload exceeds ${MAX_SHARED_PAYLOAD_LENGTH} characters`);85+ return encoded;86+}87+88+export function decodeSharedPack(payload) {89+ if (typeof payload !== 'string' || payload.length === 0 || payload.length > MAX_SHARED_PAYLOAD_LENGTH) throw new TypeError('invalid shared payload length');90+ if (!payload.startsWith(PREFIX)) throw new TypeError('unsupported shared payload schema');91+ const body = payload.slice(PREFIX.length);92+ const split = body.lastIndexOf('.');93+ if (split < 1 || !/^[0-9a-f]{8}$/.test(body.slice(split + 1))) throw new TypeError('malformed shared payload');94+ if (!/^[A-Za-z0-9_-]+$/.test(body.slice(0, split))) throw new TypeError('malformed shared payload encoding');95+ let json;96+ try { json = decodeBase64Url(body.slice(0, split)); } catch { throw new TypeError('malformed shared payload encoding'); }97+ if (byteLength(json) > MAX_SHARED_JSON_BYTES || fnv1a(json) !== body.slice(split + 1)) throw new TypeError('shared payload failed integrity check');98+ let envelope;99+ try { envelope = JSON.parse(json); } catch { throw new TypeError('shared payload is not JSON'); }100+ if (!isObject(envelope) || envelope.v !== SHARE_SCHEMA_VERSION || !isObject(envelope.pack)) throw new TypeError('invalid shared payload envelope');101+ return normalizePack(envelope.pack);102+}103+104+export function exportCommonJS(pack) {105+ const normalized = normalizePack(pack);106+ return `'use strict';\n\nmodule.exports = ${stableStringify(normalized)};\n`;107+}
A tests/share.test.js +68 -0
@@ -0,0 +1,68 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import { decodeSharedPack, encodeSharedPack, exportCommonJS } from '../src/share.js';4+import { loadMod } from '../src/mods.js';5+6+const pack = {7+ manifest: { id: 'emoji-garden', name: 'Emoji Garden', version: '1.0.0' },8+ palette: { miau: '🐈', flag: '🚩' },9+ levels: [{ id: 'first-garden', title: 'First garden', width: 4, height: 2, objects: [10+ { kind: 'word', text: 'miau', x: 0, y: 0 },11+ { kind: 'word', text: 'IS', x: 1, y: 0 },12+ { kind: 'word', text: 'you', x: 2, y: 0 },13+ { kind: 'object', noun: 'miau', x: 0, y: 1 },14+ ] }],15+};16+17+test('shared packs round trip as normalized Unicode data', () => {18+ const payload = encodeSharedPack(pack);19+ assert.match(payload, /^miau1\.[A-Za-z0-9_-]+\.[0-9a-f]{8}$/);20+ const decoded = decodeSharedPack(payload);21+ assert.equal(decoded.manifest.engine, 1);22+ assert.equal(decoded.levels[0].objects[0].text, 'MIAU');23+ assert.equal(decoded.levels[0].objects[3].noun, 'MIAU');24+ assert.equal(decoded.palette.MIAU, '🐈');25+});26+27+test('encoding is stable and CommonJS export is deterministic data only', () => {28+ const first = encodeSharedPack(pack);29+ const second = encodeSharedPack({ levels: pack.levels, palette: pack.palette, manifest: pack.manifest });30+ assert.equal(first, second);31+ const source = exportCommonJS(pack);32+ assert.equal(source, exportCommonJS(pack));33+ assert.match(source, /^'use strict';\n\nmodule\.exports = /);34+ assert.doesNotMatch(source, /require\(|function\s*\(/);35+ assert.match(source, /🐈/u);36+});37+38+test('CommonJS export round trips through the trusted loader with authored IDs', () => {39+ const loaded = loadMod(exportCommonJS(pack));40+ assert.equal(loaded.manifest.id, 'emoji-garden');41+ assert.equal(loaded.levels[0].id, 'first-garden');42+ assert.equal(loaded.levels[0].objects[0].text, 'MIAU');43+ assert.equal('rules' in loaded.levels[0], false);44+ assert.equal('moves' in loaded.levels[0], false);45+});46+47+test('rejects tampered, malformed, wrong-schema, and oversized payloads', () => {48+ const payload = encodeSharedPack(pack);49+ const changed = `${payload.slice(0, -9)}${payload.endsWith('0') ? '1' : '0'}${payload.slice(-8)}`;50+ assert.throws(() => decodeSharedPack(changed), /integrity|malformed/);51+ assert.throws(() => decodeSharedPack('miau1.not-json.deadbeef'), /integrity|malformed/);52+ assert.throws(() => decodeSharedPack('miau1.a$bc.deadbeef'), /encoding|malformed/);53+ // _w decodes to the invalid UTF-8 byte 0xff; fatal decoding must reject it.54+ assert.throws(() => decodeSharedPack('miau1._w.deadbeef'), /encoding|UTF-8|malformed/);55+ assert.throws(() => decodeSharedPack('miau2.abc.deadbeef'), /schema/);56+ const vocabulary = Array.from({ length: 10000 }, (_, index) => `TOKEN${index}`);57+ assert.throws(() => encodeSharedPack({ ...pack, vocabulary }), /exceeds/);58+});59+60+test('decoder validates decoded data without executing source', () => {61+ const malicious = {62+ manifest: pack.manifest,63+ levels: [{ id: 'safe-level', width: 2, height: 1, objects: [{ kind: 'word', text: 'MIAU', x: 0, y: 0 }] }],64+ };65+ const decoded = decodeSharedPack(encodeSharedPack(malicious));66+ assert.equal(decoded.levels[0].objects[0].text, 'MIAU');67+ assert.equal(Object.prototype.hasOwnProperty.call(decoded, 'exports'), false);68+});