mememage-resolver 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Catmemes
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 ADDED
@@ -0,0 +1,79 @@
1
+ # mememage-resolver
2
+
3
+ Resolve a [Mememage](https://mememage.art) identifier to a record over configured mirrors,
4
+ then check its integrity — framework-agnostic, dependency-free.
5
+
6
+ A Mememage bar carries an **identifier** and a **content hash**. The identifier is a lookup
7
+ key, not an authority. This module walks an ordered list of record **sources** (mirrors),
8
+ takes the first that has the record, and checks it against the hash in the bar. The source
9
+ is never trusted: the content hash is the authority, so a mirror can only fail to verify,
10
+ never forge.
11
+
12
+ It owns the mirror walk, the per-source timeout, and the fallback. It is **I/O-injected** —
13
+ you provide `fetch` and the verify math from the [`mememage`](https://www.npmjs.com/package/mememage)
14
+ SDK.
15
+
16
+ ## Install
17
+
18
+ ```
19
+ npm install mememage-resolver mememage
20
+ ```
21
+
22
+ ## Use
23
+
24
+ ```js
25
+ import { createResolver } from "mememage-resolver";
26
+ import { computeContentHash, isSupportedHashVersion } from "mememage";
27
+
28
+ const resolver = createResolver({
29
+ fetch, // your HTTP fetch
30
+ verifyMath: { computeContentHash, isSupportedHashVersion },
31
+ sources: [ // ordered mirrors; first hit wins
32
+ "https://souls.mememage.art",
33
+ "https://archive.org/download/{id}", // {id} substitutes the identifier
34
+ ],
35
+ timeout: 5000, // per-source ms
36
+ });
37
+
38
+ // bar comes from the detector / a decode: { identifier, contentHash }
39
+ const verdict = await resolver.verify(bar);
40
+ // -> { state: "verified" | "altered" | "norecord" | "error" | "nosource" | "unsupported", ... }
41
+
42
+ const found = await resolver.fetchRecord(bar.identifier);
43
+ // -> { ok, url, source } | { noSource } | { notFound, detail }
44
+ ```
45
+
46
+ `sources` and `timeout` also accept a **function** (sync or async), so you can back them
47
+ with live config that changes at runtime:
48
+
49
+ ```js
50
+ createResolver({ fetch, verifyMath, sources: () => readMyMirrors(), timeout: () => readMyTimeout() });
51
+ ```
52
+
53
+ Per call, `opts` overrides them: `resolver.verify(bar, { sources, timeout })`.
54
+
55
+ ## Mirror format
56
+
57
+ A source is a URL base. `{id}` is substituted with the identifier; the resolver then tries
58
+ `<base>/<id>.json` and `<base>/<id>.soul`.
59
+
60
+ - `https://souls.mememage.art` → `https://souls.mememage.art/<id>.json`
61
+ - `https://archive.org/download/{id}` → `https://archive.org/download/<id>/<id>.json`
62
+
63
+ ## Verdicts
64
+
65
+ | `state` | Meaning |
66
+ |---|---|
67
+ | `verified` | The record's content hash matches the bar. Integrity confirmed. |
68
+ | `altered` | A record was found, but it recomputes to a different hash. |
69
+ | `norecord` | Every source answered, none had the record. |
70
+ | `error` | One or more sources timed out or were unreachable. |
71
+ | `nosource` | No sources configured. |
72
+ | `unsupported` | The record uses a hash model this check does not cover. Not tampering. |
73
+
74
+ `verify` returns the `source` and `recordUrl` that answered, plus a human `detail`/`reason`
75
+ for the non-verified states.
76
+
77
+ ## License
78
+
79
+ MIT © Catmemes
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "mememage-resolver",
3
+ "version": "0.1.0",
4
+ "description": "Resolve a Mememage identifier to a record over configured mirrors, then check its integrity by hash. The network layer — mirror walk, per-source timeout, fallback. Fetch and the verify math are injected.",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.js",
8
+ "./package.json": "./package.json"
9
+ },
10
+ "main": "./src/index.js",
11
+ "files": [
12
+ "src",
13
+ "README.md"
14
+ ],
15
+ "scripts": {
16
+ "test": "node test/resolver.test.mjs"
17
+ },
18
+ "keywords": [
19
+ "mememage",
20
+ "provenance",
21
+ "resolver",
22
+ "verify",
23
+ "mirror",
24
+ "record"
25
+ ],
26
+ "license": "MIT",
27
+ "author": "Catmemes",
28
+ "homepage": "https://mememage.art",
29
+ "engines": {
30
+ "node": ">=18"
31
+ },
32
+ "peerDependencies": {
33
+ "mememage": ">=0.1.0"
34
+ },
35
+ "peerDependenciesMeta": {
36
+ "mememage": {
37
+ "optional": true
38
+ }
39
+ },
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/sememtac/mememage-resolver.git"
43
+ }
44
+ }
package/src/index.js ADDED
@@ -0,0 +1,3 @@
1
+ // mememage-resolver — public ESM entry.
2
+ export { createResolver } from "./resolver.js";
3
+ export { default } from "./resolver.js";
@@ -0,0 +1,168 @@
1
+ // mememage-resolver — resolve a Mememage identifier to a record over configured
2
+ // mirrors, then check its integrity. The network layer, framework-agnostic.
3
+ //
4
+ // A Mememage bar carries an identifier + a content hash. The identifier is a lookup key,
5
+ // NOT an authority: the resolver walks an ordered list of record sources (mirrors), and
6
+ // the FIRST source that has the record wins. The source is never trusted — the content
7
+ // hash in the bar's pixels is the authority, so a mirror can only fail to verify, never
8
+ // forge. This module owns the mirror walk, the per-source timeout, and the fallback.
9
+ //
10
+ // It is I/O-injected. You provide:
11
+ // • fetch(url, init) — the HTTP fetch (privileged in an extension; window.fetch on a page).
12
+ // • verifyMath — { computeContentHash, isSupportedHashVersion } from the `mememage` SDK.
13
+ // • sources — the ordered mirror list (array, or a function returning one — dynamic).
14
+ // • timeout — per-source timeout ms (number, or a function returning one).
15
+ //
16
+ // createResolver({ fetch, verifyMath, sources, timeout }) ->
17
+ // { resolveRecord(id, opts), fetchRecord(id, opts), verify(bar, opts) }
18
+ //
19
+ // A mirror base is a URL template. `{id}` is substituted with the identifier; otherwise
20
+ // the identifier is appended. The resolver tries `<base>/<id>.json` then `<id>.soul`.
21
+
22
+ const DEFAULT_TIMEOUT_MS = 5000;
23
+
24
+ export function createResolver(deps) {
25
+ "use strict";
26
+ deps = deps || {};
27
+ const _fetch = deps.fetch;
28
+ if (typeof _fetch !== "function") {
29
+ throw new TypeError("createResolver: options.fetch (url, init) => Promise<Response> is required");
30
+ }
31
+ const vm = deps.verifyMath || {};
32
+ const _computeContentHash = vm.computeContentHash;
33
+ const _isSupportedHashVersion = vm.isSupportedHashVersion;
34
+ const _sources = deps.sources != null ? deps.sources : [];
35
+ const _timeout = deps.timeout != null ? deps.timeout : DEFAULT_TIMEOUT_MS;
36
+
37
+ // sources / timeout accept a value OR a (possibly async) function, so a consumer can
38
+ // back them with live config (the extension reads chrome.storage fresh each call).
39
+ async function settle(v, fallback) {
40
+ try { return (typeof v === "function") ? await v() : await v; }
41
+ catch (e) { return fallback; }
42
+ }
43
+ async function getSources(opts) {
44
+ const raw = (opts && opts.sources != null) ? opts.sources : _sources;
45
+ const s = await settle(raw, []);
46
+ return Array.isArray(s) ? s.filter(Boolean) : [];
47
+ }
48
+ async function getTimeout(opts) {
49
+ const raw = (opts && opts.timeout != null) ? opts.timeout : _timeout;
50
+ const n = Number(await settle(raw, DEFAULT_TIMEOUT_MS));
51
+ return isFinite(n) && n > 0 ? n : DEFAULT_TIMEOUT_MS;
52
+ }
53
+
54
+ function expandBase(base, identifier) {
55
+ return String(base).replace(/\{id\}/g, identifier).replace(/\/+$/, "");
56
+ }
57
+
58
+ // Probe ONE source. Returns { record, url } on a hit, { notFound: true } when the
59
+ // host answered but has neither <id>.json nor <id>.soul, or { blocked, error } when
60
+ // the host timed out, did not respond, or returned a non-404 status. On a blocked
61
+ // host the .soul retry is skipped — the same host is down, so move to the next mirror.
62
+ async function fetchFromSource(base, identifier, ms) {
63
+ const root = expandBase(base, identifier);
64
+ for (const ext of ["json", "soul"]) {
65
+ const url = root + "/" + identifier + "." + ext;
66
+ let resp;
67
+ try {
68
+ resp = await _fetch(url, { credentials: "omit", signal: AbortSignal.timeout(ms) });
69
+ } catch (e) {
70
+ const timedOut = e && (e.name === "TimeoutError" || e.name === "AbortError");
71
+ return { blocked: true, error: timedOut
72
+ ? "timed out after " + (ms / 1000) + "s"
73
+ : "no response (unreachable, or blocked)" };
74
+ }
75
+ if (resp.ok) {
76
+ try { return { record: await resp.json(), url: url }; }
77
+ catch (e) { return { blocked: true, error: "returned data that is not JSON" }; }
78
+ }
79
+ if (resp.status !== 404) return { blocked: true, error: "status " + resp.status };
80
+ // 404 -> this host answered but lacks this extension; try .soul, then next source
81
+ }
82
+ return { notFound: true };
83
+ }
84
+
85
+ // Walk the mirror list; the first source with the record wins. On total failure,
86
+ // report enough to tell "none had it" (every host answered 404) apart from "none
87
+ // answered" (timeout / unreachable / bad status on one or more).
88
+ async function resolveRecord(identifier, opts) {
89
+ const sources = await getSources(opts);
90
+ if (!sources.length) return { noSource: true };
91
+ const ms = await getTimeout(opts);
92
+ let anyNotFound = false, anyBlocked = false, lastError = null;
93
+ for (const base of sources) {
94
+ const r = await fetchFromSource(base, identifier, ms);
95
+ if (r.record) return { record: r.record, url: r.url, source: base };
96
+ if (r.notFound) anyNotFound = true;
97
+ else if (r.blocked) { anyBlocked = true; lastError = r.error; }
98
+ }
99
+ return { notFound: true, tried: sources.length,
100
+ anyNotFound: anyNotFound, anyBlocked: anyBlocked, lastError: lastError };
101
+ }
102
+
103
+ // One-line summary of a total-failure resolveRecord result, for a card / log.
104
+ function triedSummary(res) {
105
+ const n = res.tried || 0;
106
+ const many = n === 1 ? "1 source" : n + " sources";
107
+ if (res.anyBlocked && !res.anyNotFound) {
108
+ return "Tried " + many + ". No source responded (" + (res.lastError || "blocked") + ").";
109
+ }
110
+ if (res.anyBlocked && res.anyNotFound) {
111
+ return "Tried " + many + ". No source had the record, and one or more did not respond (" +
112
+ (res.lastError || "blocked") + ").";
113
+ }
114
+ return "Tried " + many + ". No source has a record for this identifier.";
115
+ }
116
+
117
+ // Resolve a record by identifier — the "fetch record" command. Returns the URL that
118
+ // answered so a UI can link to it. { ok, url, source } | { noSource } | { notFound, detail }.
119
+ async function fetchRecord(identifier, opts) {
120
+ const res = await resolveRecord(identifier, opts);
121
+ if (res.noSource) return { noSource: true };
122
+ if (res.record) return { ok: true, url: res.url, source: res.source };
123
+ return { notFound: true, detail: triedSummary(res) };
124
+ }
125
+
126
+ // Verify a SPECIFIC bar (identifier + the hash stamped in its pixels). Works for any
127
+ // bar, not just the bottom one: fetch the record, recompute its content hash, compare
128
+ // to the bar's. No pixel re-decode — the bar's hash is authoritative and already in
129
+ // hand. Returns a verdict:
130
+ // { state: "verified" | "altered" | "norecord" | "error" | "nosource" | "unsupported", ... }
131
+ async function verify(bar, opts) {
132
+ const identifier = bar && bar.identifier, contentHash = bar && bar.contentHash;
133
+ const base = { identifier: identifier, contentHash: contentHash };
134
+ const res = await resolveRecord(identifier, opts);
135
+ if (res.noSource) return { state: "nosource", ...base };
136
+ if (!res.record) {
137
+ // A blocked source is a fetch failure (error), not an absence (norecord).
138
+ const summary = triedSummary(res);
139
+ return res.anyBlocked ? { state: "error", ...base, reason: summary }
140
+ : { state: "norecord", ...base, detail: summary };
141
+ }
142
+
143
+ const record = res.record, recordUrl = res.url, srcBase = res.source;
144
+ if (_isSupportedHashVersion && !_isSupportedHashVersion(record)) {
145
+ return { state: "unsupported", ...base, source: srcBase, recordUrl: recordUrl,
146
+ detail: "The record uses hash model " + JSON.stringify(record && record.hash_version) +
147
+ ". This check covers the open model only, so it cannot confirm this record's " +
148
+ "integrity here. This is not tampering." };
149
+ }
150
+ if (typeof _computeContentHash !== "function") {
151
+ throw new Error("createResolver: verifyMath.computeContentHash is required to verify()");
152
+ }
153
+ const recomputed = await _computeContentHash(record);
154
+ if (recomputed !== contentHash) {
155
+ return { state: "altered", ...base, source: srcBase, recordUrl: recordUrl,
156
+ detail: "Hash mismatch. The bar has " + contentHash + ". The record computes to " + recomputed + "." };
157
+ }
158
+ return { state: "verified", ...base, source: srcBase, recordUrl: recordUrl };
159
+ }
160
+
161
+ return {
162
+ resolveRecord: resolveRecord, // (id, opts) -> { record, url, source } | { noSource } | { notFound, ... }
163
+ fetchRecord: fetchRecord, // (id, opts) -> { ok, url, source } | { noSource } | { notFound, detail }
164
+ verify: verify, // (bar, opts) -> verdict
165
+ };
166
+ }
167
+
168
+ export default createResolver;