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 +21 -0
- package/README.md +79 -0
- package/package.json +44 -0
- package/src/index.js +3 -0
- package/src/resolver.js +168 -0
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
package/src/resolver.js
ADDED
|
@@ -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;
|