@muretai/agent-entry 1.0.0 → 1.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/README.md CHANGED
@@ -144,9 +144,35 @@ your site's identity**), `AGENT_ENTRY_PORT` (8788), `AGENT_ENTRY_BASE_URL`,
144
144
  `AGENT_ENTRY_NAME`, `AGENT_ENTRY_ANON` (`1` also accepts unsigned inquiries, which create
145
145
  no account).
146
146
 
147
+ ## What `baseUrl` may be
148
+
147
149
  `baseUrl` must be the URL visitors actually dial: it is what your signed card claims, and
148
150
  a card naming a different origin proves nothing about yours.
149
151
 
152
+ Agent Entry does not copy it into the card — it canonicalises it, so the string it signs is
153
+ the one a visitor computes from the URL they dialled. Where the two could differ, **it
154
+ refuses to start**, naming the rule and the value to paste instead. That is deliberate: the
155
+ alternative is a card that fails on a stranger's machine, where the only diagnostic is
156
+ "signature verification failed" and nothing at all appears on yours.
157
+
158
+ Tidied up for you: surrounding spaces, the case of the scheme and host, a default port
159
+ (`:443`, `:80`), a trailing dot on the host, and any trailing slashes.
160
+ `https://studio.example/` and `https://Studio.Example:443` both publish as
161
+ `https://studio.example`.
162
+
163
+ Refused, with the fix in the message: a scheme other than `http`/`https`, a missing host,
164
+ `user@host`, a query string, a `#` fragment, non-ASCII characters, a stray tab or space, a
165
+ backslash, `.` or `..` in the path, a broken `%` escape, and a port outside 1–65535.
166
+
167
+ Two rules worth knowing before you pick a URL:
168
+
169
+ - **Paths are case-sensitive.** `https://studio.example/Alice` and `.../alice` are different
170
+ sites to a visitor. Choose one spelling and use it in every link, invite and QR code.
171
+ - **Write an international domain in its `xn--` form** — `https://xn--eckwd4c7c.example`,
172
+ not the Unicode spelling — and publish your links in that same form. JavaScript's URL
173
+ parser punycodes a host and Python's does not, so the two implementations would otherwise
174
+ sign different bytes for the same site.
175
+
150
176
  ## Pairs with WebMCP: the tab conversation becomes a customer
151
177
 
152
178
  If your page already exposes [WebMCP](https://github.com/MiguelsPizza/WebMCP) tools, you have
@@ -870,6 +870,234 @@ function isThenable(v) {
870
870
  return v !== null && typeof v === 'object' && typeof v.then === 'function';
871
871
  }
872
872
 
873
+ // ---------------------------------------------------------------- baseUrl canonicalisation
874
+
875
+ /** RFC 3986 `pchar` plus '/' — the only characters an accepted path may carry. Everything
876
+ * outside this set is percent-encoded by `new URL()` and left verbatim by Python's
877
+ * urlsplit, which is precisely the divergence canonicalBaseUrl exists to prevent. */
878
+ const PATH_OK = new Set(
879
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~!$&'()*+,;=:@/");
880
+ const HEX = new Set('0123456789abcdefABCDEF');
881
+ /** A host is lowercased before this is applied. '_' is not legal in a hostname but is real
882
+ * in the wild and both parsers keep it verbatim, so it is not a divergence. */
883
+ const HOST_OK = new Set('abcdefghijklmnopqrstuvwxyz0123456789.-_');
884
+ const DEFAULT_PORT = { http: 80, https: 443 };
885
+
886
+ /** Raise the one refusal shape, in the order an operator can act on at 2am: what they gave
887
+ * (JSON-quoted, so an invisible tab is VISIBLE), which rule in words, the fix as a string
888
+ * they can paste, and one clause of why. `base_url is not publishable:` is the greppable
889
+ * stem the Python twin shares. */
890
+ function refuseBaseUrl(given, rule, why, fix) {
891
+ const lines = [`base_url is not publishable: ${rule}`, ` given: ${JSON.stringify(given)}`];
892
+ if (fix) lines.push(` use: ${JSON.stringify(fix)}`);
893
+ lines.push(` ${why}`);
894
+ throw new TypeError(lines.join('\n'));
895
+ }
896
+
897
+ /**
898
+ * canonicalBaseUrl(baseUrl) -> the exact string this entry may publish as its card `url`.
899
+ *
900
+ * The contract, and the only reason this function exists: **the returned value equals what
901
+ * a VISITOR computes** from the url they dialled (`Outbox.card_scope` in agent/outbox.py)
902
+ * when deciding whether this card is about this site. An entry that publishes anything else
903
+ * fails verification on a stranger's machine, with "signature verification failed" as the
904
+ * only diagnostic and nothing at all on the operator's screen.
905
+ *
906
+ * So every input on which the operator's spelling and the visitor's arithmetic could
907
+ * disagree — or on which this file and its Python twin (examples/agent_entry_reference.py)
908
+ * could disagree — is REFUSED here, loudly, at construction. The two are held byte-identical
909
+ * by an acceptance suite; a value they canonicalise differently is a signed-bytes split for
910
+ * identical operator input, which is the bug this whole function is about.
911
+ *
912
+ * It HAND-PARSES rather than using `new URL()`, which is not a candidate: it keeps a trailing
913
+ * DNS dot (`https://x.`) that the verifier drops, punycodes hosts, percent-encodes non-ASCII,
914
+ * spaces and `|^{}`, removes dot segments, and rewrites `\` to `/` — five spellings Python
915
+ * does not produce. `new URL()` runs at the END instead, as a tripwire: if a future Node
916
+ * changes the parser out from under the hand-parse, that is a loud startup failure here
917
+ * rather than silent byte drift discovered by a customer whose card stopped verifying.
918
+ *
919
+ * The gate (steps 1-7) runs before any folding, which is what makes the folds safe: it
920
+ * removes exactly the inputs the two languages disagree about, so every remaining fold is
921
+ * one they already agree on.
922
+ */
923
+ export function canonicalBaseUrl(baseUrl, { warn = true } = {}) {
924
+ if (typeof baseUrl !== 'string' || !baseUrl.trim()) {
925
+ refuseBaseUrl(baseUrl, 'it is empty.', undefined,
926
+ 'Set it to the URL visitors actually dial — it is what your signed card claims, and '
927
+ + 'a card naming a different origin proves nothing about yours.');
928
+ }
929
+ const s = baseUrl.trim();
930
+
931
+ for (const ch of s) {
932
+ const c = ch.codePointAt(0);
933
+ if (c >= 0x21 && c <= 0x7e) continue;
934
+ if (c > 0x7e) {
935
+ const hostGuess = (s.split('://')[1] ?? s).split('/')[0];
936
+ let fix;
937
+ try { // best-effort A-label, for the message only
938
+ const asciiHost = new URL(`https://${hostGuess}`).hostname;
939
+ if (asciiHost && asciiHost !== hostGuess) fix = s.replace(hostGuess, asciiHost);
940
+ } catch { /* not a host we can suggest a spelling for */ }
941
+ refuseBaseUrl(s, 'it is not ASCII.', fix,
942
+ 'Python and JavaScript disagree on how to spell this (Python keeps it as typed, '
943
+ + 'JavaScript percent-encodes or punycodes it), so the two Agent Entry '
944
+ + 'implementations would sign different bytes for the same site. Supply the ASCII '
945
+ + 'form, and publish your links and QR codes in that same form.');
946
+ }
947
+ refuseBaseUrl(s, 'it contains whitespace or a control character.', undefined,
948
+ 'Both URL parsers DELETE a tab or newline silently and mid-string, so the value you '
949
+ + 'meant and the value that gets signed are not the same string and nothing tells you.');
950
+ }
951
+
952
+ if (s.includes('\\')) {
953
+ refuseBaseUrl(s, 'it contains a backslash.', s.replaceAll('\\', '/'),
954
+ "JavaScript reads '\\' as '/' and Python does not, so the two Agent Entry "
955
+ + 'implementations would disagree about where the host ends.');
956
+ }
957
+ for (const [bad, label] of [['?', 'a query string'], ['#', 'a fragment']]) {
958
+ if (s.includes(bad)) {
959
+ refuseBaseUrl(s, `it carries ${label}.`, s.split(bad)[0] || undefined,
960
+ 'This string is signed into your Agent Card, and every visitor compares it to the '
961
+ + 'URL they dialled — which never carries one.');
962
+ }
963
+ }
964
+
965
+ const sep = s.indexOf('://');
966
+ const scheme = sep < 0 ? '' : s.slice(0, sep).toLowerCase();
967
+ if (sep < 0 || !(scheme in DEFAULT_PORT)) {
968
+ refuseBaseUrl(s, 'it is not an http(s) URL.',
969
+ sep < 0 && !s.startsWith('/') ? `https://${s}` : undefined,
970
+ 'An Agent Card names an origin a visitor can dial over HTTP.');
971
+ }
972
+ const rest = s.slice(sep + 3);
973
+ const cut = rest.indexOf('/');
974
+ const authority = cut < 0 ? rest : rest.slice(0, cut);
975
+ let path = cut < 0 ? '' : rest.slice(cut);
976
+
977
+ if (authority.includes('@')) {
978
+ refuseBaseUrl(s, "it carries userinfo (a '@' before the host).",
979
+ `${scheme}://${authority.slice(authority.lastIndexOf('@') + 1)}${path}`,
980
+ "The part before '@' is not the host: a visitor dials the part AFTER it, so a card "
981
+ + 'built from this string would name a different site than the one being served.');
982
+ }
983
+ if (authority.includes('%')) {
984
+ refuseBaseUrl(s, 'the host contains a percent-escape.', undefined,
985
+ 'Python lowercases it into the host and JavaScript refuses the URL outright, so the '
986
+ + 'two Agent Entry implementations cannot agree.');
987
+ }
988
+
989
+ let host;
990
+ let portS;
991
+ if (authority.startsWith('[')) { // IPv6 literal — brackets are part of it
992
+ const close = authority.indexOf(']');
993
+ if (close < 0) {
994
+ refuseBaseUrl(s, "the IPv6 host is missing its closing ']'.", undefined,
995
+ 'An address literal must be bracketed, e.g. http://[::1]:9000');
996
+ }
997
+ host = authority.slice(0, close + 1).toLowerCase();
998
+ const tail = authority.slice(close + 1);
999
+ if (tail && !tail.startsWith(':')) {
1000
+ refuseBaseUrl(s, 'there is text after the IPv6 address.', undefined,
1001
+ "Only ':<port>' may follow a bracketed address.");
1002
+ }
1003
+ portS = tail ? tail.slice(1) : '';
1004
+ } else {
1005
+ const colon = authority.lastIndexOf(':');
1006
+ host = (colon < 0 ? authority : authority.slice(0, colon)).toLowerCase();
1007
+ portS = colon < 0 ? '' : authority.slice(colon + 1);
1008
+ }
1009
+
1010
+ let port = null;
1011
+ if (portS) {
1012
+ const n = Number(portS);
1013
+ if (!/^[0-9]+$/.test(portS) || !Number.isInteger(n) || n < 1 || n > 65535) {
1014
+ refuseBaseUrl(s, `the port ${JSON.stringify(portS)} is not a number in 1-65535.`,
1015
+ undefined, 'A visitor dials a real port; anything else cannot be reached.');
1016
+ }
1017
+ port = n;
1018
+ }
1019
+ host = host.replace(/\.+$/, ''); // trailing dot: DNS-equal, byte-different
1020
+ if (!host || (!host.startsWith('[') && [...host].some((c) => !HOST_OK.has(c)))) {
1021
+ refuseBaseUrl(s, 'the host is missing or contains characters that are not a hostname.',
1022
+ undefined, 'A card must name a host a visitor can resolve.');
1023
+ }
1024
+
1025
+ path = path.replace(/\/+$/, ''); // ALL of them: card_scope uses rstrip too
1026
+ if (path) {
1027
+ for (let i = 0; i < path.length;) {
1028
+ const ch = path[i];
1029
+ if (ch === '%') {
1030
+ if (path.length - i < 3 || !HEX.has(path[i + 1]) || !HEX.has(path[i + 2])) {
1031
+ refuseBaseUrl(s, 'the path has a malformed percent-escape.', undefined,
1032
+ "'%' must be followed by exactly two hex digits, or the two URL parsers "
1033
+ + 'disagree about what the path is.');
1034
+ }
1035
+ i += 3;
1036
+ continue;
1037
+ }
1038
+ if (!PATH_OK.has(ch)) {
1039
+ refuseBaseUrl(s, `the path contains ${JSON.stringify(ch)}, which is not allowed `
1040
+ + 'unencoded.', undefined,
1041
+ 'JavaScript percent-encodes this character and Python leaves it verbatim, so the '
1042
+ + 'two Agent Entry implementations would sign different bytes. Percent-encode it '
1043
+ + 'yourself.');
1044
+ }
1045
+ i += 1;
1046
+ }
1047
+ const parts = path.split('/');
1048
+ if (parts.some((seg) => seg === '.' || seg === '..')) {
1049
+ const segs = []; // RFC 3986 remove_dot_segments, for the fix
1050
+ for (const seg of parts) {
1051
+ if (seg === '.') continue;
1052
+ if (seg === '..') { if (segs.length > 1) segs.pop(); continue; }
1053
+ segs.push(seg);
1054
+ }
1055
+ refuseBaseUrl(s, "the path contains '.' or '..' segments.",
1056
+ `${scheme}://${authority}${segs.join('/').replace(/\/+$/, '')}`,
1057
+ 'JavaScript collapses these segments and Python does not, so the two Agent Entry '
1058
+ + 'implementations would sign different bytes.');
1059
+ }
1060
+ }
1061
+
1062
+ const originOut = `${scheme}://${host}${port !== null && port !== DEFAULT_PORT[scheme] ? `:${port}` : ''}`;
1063
+ const out = originOut + path;
1064
+
1065
+ // The tripwire. Not redundant with the hand-parse: it is what turns a future Node/WHATWG
1066
+ // change into a loud startup failure instead of silent byte drift. If this ever fires,
1067
+ // the hand-parse and the platform parser have diverged on an input the gate let through.
1068
+ const probe = new URL(out);
1069
+ if (probe.origin !== originOut || probe.pathname !== (path || '/')
1070
+ || probe.search || probe.hash) {
1071
+ throw new TypeError(
1072
+ `base_url canonicalisation is broken (a bug in this file, not in your input): `
1073
+ + `${JSON.stringify(baseUrl)} -> ${JSON.stringify(out)}, which this runtime's URL `
1074
+ + `parser reads as ${JSON.stringify(probe.origin + probe.pathname)}. `
1075
+ + `Please report this at https://github.com/muretai/agent-entry/issues`);
1076
+ }
1077
+
1078
+ if (warn) {
1079
+ if (/[A-Z]/.test(path)) {
1080
+ process.stderr.write(`warning: base_url path ${JSON.stringify(path)} contains `
1081
+ + `uppercase letters. Paths are case-SENSITIVE and are not folded by the visitor's `
1082
+ + `check, so a visitor who dials ${JSON.stringify(path.toLowerCase())} will fail to `
1083
+ + `verify your card. Make sure every link, invite and QR code you publish spells it `
1084
+ + `exactly ${JSON.stringify(path)}.\n`);
1085
+ }
1086
+ if (path.includes('%')) {
1087
+ process.stderr.write(`warning: base_url path ${JSON.stringify(path)} contains a `
1088
+ + `percent-escape. It is compared verbatim, case included, so a visitor who dials `
1089
+ + `another spelling of the same path will fail to verify your card.\n`);
1090
+ }
1091
+ if (scheme === 'http' && !['localhost', '127.0.0.1', '[::1]'].includes(host)) {
1092
+ process.stderr.write(`warning: base_url ${JSON.stringify(out)} is plain HTTP on a `
1093
+ + `public host. A visiting agent refuses a plain-http open door (the message text `
1094
+ + `would cross the wire in the clear), so this entry will be skipped by every `
1095
+ + `well-behaved visitor. Use https.\n`);
1096
+ }
1097
+ }
1098
+ return out;
1099
+ }
1100
+
873
1101
  /**
874
1102
  * createAgentEntry(opts) -> { did, card, ledger, handleRequest, handleRequestAsync, listen }
875
1103
  *
@@ -904,13 +1132,17 @@ export function createAgentEntry({
904
1132
  } = {}) {
905
1133
  if (!seedHex) throw new TypeError('createAgentEntry: seedHex is required');
906
1134
  if (!baseUrl) throw new TypeError('createAgentEntry: baseUrl is required (it is signed into the card)');
1135
+ // The ONE string this entry publishes as its card url. Canonicalised, not echoed: it must
1136
+ // equal what a visitor's Outbox.card_scope computes for the url they dialled, or the card
1137
+ // fails verification on THEIR machine with nothing on ours.
1138
+ const canonUrl = canonicalBaseUrl(baseUrl);
907
1139
  const did = didFromSeedHex(seedHex);
908
1140
 
909
1141
  const card = {
910
1142
  protocolVersion: PROTOCOL_VERSION,
911
1143
  name,
912
1144
  description,
913
- url: baseUrl,
1145
+ url: canonUrl,
914
1146
  did,
915
1147
  version,
916
1148
  capabilities: { streaming: false, pushNotifications: false },
@@ -1010,6 +1242,13 @@ export function createAgentEntry({
1010
1242
  return { ok: false, reason: 'device binding does not name the sender' };
1011
1243
  }
1012
1244
  const { ts, validUntil, rootDid } = binding;
1245
+ // `Number.isSafeInteger` is the SAME predicate the Python twin now applies after
1246
+ // normalising an integer-valued float: both accept `1` and `1.0`, both refuse a true
1247
+ // fraction, and both refuse a magnitude that cannot round-trip (Python because such a
1248
+ // float is not integer-valued once it loses precision, JS at the safe-integer bound).
1249
+ // They disagreed before: Python refused `1.0` outright while this accepted it, so the
1250
+ // same POST created a customer here and 401'd there
1251
+ // (ISSUE(agent-entry-binding-float-ts-divergence)).
1013
1252
  if (!Number.isSafeInteger(ts) || !Number.isSafeInteger(validUntil)) {
1014
1253
  return { ok: false, reason: 'device binding timestamps must be integers' };
1015
1254
  }
@@ -1252,7 +1491,7 @@ export function createAgentEntry({
1252
1491
  if (pathname === '/') {
1253
1492
  const body = Buffer.from(
1254
1493
  `${name}\n\nThis origin is agent-reachable (Muretai agent entry).\n`
1255
- + `DID: ${did}\nCard: ${baseUrl.replace(/\/+$/, '')}${AGENT_CARD_PATH}\n`
1494
+ + `DID: ${did}\nCard: ${canonUrl}${AGENT_CARD_PATH}\n`
1256
1495
  + 'POST a signed A2A message/send request to / for a signed reply.\n', 'utf8');
1257
1496
  return { status: 200,
1258
1497
  headers: { 'Content-Type': 'text/plain; charset=utf-8',
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@muretai/agent-entry",
3
- "version": "1.0.0",
4
- "description": "Your website can recognise AI agents, hold accounts for them, and answer them one dependency-free file.",
3
+ "version": "1.1.0",
4
+ "description": "Your website can recognise AI agents, hold accounts for them, and answer them \u2014 one dependency-free file.",
5
5
  "type": "module",
6
6
  "main": "muretai-agent-entry.mjs",
7
7
  "exports": {