@common-agent/reputations 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/dist/score.js ADDED
@@ -0,0 +1,129 @@
1
+ /**
2
+ * fdbk-score-1 — the published scoring formula of the Agent Reputations
3
+ * specification (§5, §7), the arithmetic of the Agent Reputations core. Pure arithmetic over an event pool: no I/O, no
4
+ * randomness, no iteration. Anyone holding the events computes the same
5
+ * numbers, which is the whole point — a score that cannot be recomputed is
6
+ * an opinion with a haircut.
7
+ *
8
+ * The constants are formula constants, not tunables: changing any of them is
9
+ * a new formula version (fdbk-score-2), never a quiet edit to this one.
10
+ */
11
+ /** Quality value per outcome code (spec §5). DECLINED and UNREACHABLE carry
12
+ * no quality value: declining feeds candor, unreachability feeds facts.
13
+ *
14
+ * VALUE_OK and VALUE_POOR answer a DIFFERENT question — was the spend worth
15
+ * it, on engagements where nothing was named that either party could accept
16
+ * (§6.6) — and enter the pool at the reduced weight of §7.6. They carry the
17
+ * same 1.0/0.0 values as acceptance and rejection; what separates them is
18
+ * the weight, not the value. */
19
+ export const OUTCOME_VALUES = {
20
+ OK: 1.0,
21
+ OK_CORRECTED: 0.6,
22
+ FAILED: 0.15,
23
+ FAILED_SILENT: 0.0,
24
+ CONTRACT: 0.0,
25
+ VALUE_OK: 1.0,
26
+ VALUE_POOR: 0.0,
27
+ };
28
+ /** The two value-judgment codes (§6.6), by name. A value judgment MUST NOT be
29
+ * recorded through the acceptance path, and an acceptance MUST NOT be
30
+ * recorded through this one; the doors check against this set. */
31
+ export const VALUE_OUTCOMES = new Set(["VALUE_OK", "VALUE_POOR"]);
32
+ /** Outcomes excluded from the quality pool but still counted elsewhere. */
33
+ export const UNSCORED_OUTCOMES = new Set(["DECLINED", "UNREACHABLE"]);
34
+ export const FORMULA_VERSION = "fdbk-score-1";
35
+ export const HALF_LIFE_DAYS = 60;
36
+ /** Below this effective sample size a capability is UNPROVEN (§7.4). */
37
+ export const UNPROVEN_THRESHOLD = 20;
38
+ /**
39
+ * §7.6: a value judgment's decay weight is multiplied by this.
40
+ *
41
+ * A formula constant of fdbk-score-1, alongside H and the UNPROVEN threshold,
42
+ * not a tunable. With no artifact to point at, a judgment about whether money
43
+ * was well spent is more subjective and more disputable than a judgment about
44
+ * work that was named and delivered, and the honest way to price that is one
45
+ * number a reader can see and argue with. The spec forbids setting it above
46
+ * 1.0 and advises against above 0.5; 0.5 is the published default and what
47
+ * this bureau uses.
48
+ */
49
+ export const K_VALUE = 0.5;
50
+ /** z for the ~10th percentile lower confidence bound (§7.3). */
51
+ const Z = 1.2816;
52
+ /** Exponential decay weight (§7.2): w = 2^(-age_days / H). Future-dated
53
+ * events get weight 1 rather than >1 — clock skew is not extra credit. */
54
+ export function decayWeight(atIso, nowMs, halfLifeDays = HALF_LIFE_DAYS) {
55
+ const ageMs = nowMs - Date.parse(atIso);
56
+ if (!Number.isFinite(ageMs) || ageMs <= 0)
57
+ return 1;
58
+ return Math.pow(2, -(ageMs / 86_400_000) / halfLifeDays);
59
+ }
60
+ /** The weight one event contributes: the §7.2 decay, times §7.6's K_value
61
+ * when the event is a value judgment. Because the multiplier lands on w_i it
62
+ * reduces the event's effect on mean, sd, lcb and n_eff together — which is
63
+ * the correct reading of thinner evidence, and why §7.6 needs no other
64
+ * change to §7.3. */
65
+ export function eventWeight(e, nowMs, halfLifeDays = HALF_LIFE_DAYS) {
66
+ const w = decayWeight(e.at, nowMs, halfLifeDays);
67
+ return e.value_judgment ? K_VALUE * w : w;
68
+ }
69
+ export const UNIFORM_PRIOR = { a0: 1, b0: 1 };
70
+ /** §7.5: a portfolio prior's weight, a0 + b0 − 2, MUST NOT exceed this. A formula constant. */
71
+ export const MAX_PRIOR_WEIGHT = 10;
72
+ /** Throws when a prior is not one §7.5 allows: both terms positive and finite, its weight from 0 to 10. */
73
+ export function checkPrior(prior) {
74
+ const { a0, b0 } = prior;
75
+ if (!Number.isFinite(a0) || !Number.isFinite(b0) || a0 <= 0 || b0 <= 0)
76
+ throw new Error("a prior's a0 and b0 are positive numbers");
77
+ const weight = a0 + b0 - 2;
78
+ if (weight < 0 || weight > MAX_PRIOR_WEIGHT + 1e-12) {
79
+ throw new Error(`a prior's weight, a0 + b0 - 2, is from 0 to ${MAX_PRIOR_WEIGHT} (§7.5); this one is ${weight}`);
80
+ }
81
+ }
82
+ /**
83
+ * A portfolio prior from the anchor's other agents RATED for the same
84
+ * capability (§7.5), as their mean quality spread over `weight` effective
85
+ * events: a0 = 1 + weight · m, b0 = 1 + weight · (1 − m). The uniform prior
86
+ * when there are none. The caller chooses which agents count; §7.5 says only
87
+ * the anchor's own members RATED for this capability may.
88
+ */
89
+ export function portfolioPrior(ratedMeans, weight = MAX_PRIOR_WEIGHT) {
90
+ const means = ratedMeans.filter((m) => Number.isFinite(m) && m >= 0 && m <= 1);
91
+ if (!means.length)
92
+ return UNIFORM_PRIOR;
93
+ const w = Math.min(Math.max(weight, 0), MAX_PRIOR_WEIGHT);
94
+ const m = means.reduce((x, y) => x + y, 0) / means.length;
95
+ return { a0: 1 + w * m, b0: 1 + w * (1 - m) };
96
+ }
97
+ /**
98
+ * The Beta posterior summary of §7.3 over weighted values, with the prior of
99
+ * §7.5. n_eff counts direct evidence only: a + b less the two prior terms, so
100
+ * a portfolio prior moves the mean and the bound and never makes a capability
101
+ * RATED (§7.4).
102
+ */
103
+ export function scoreWeighted(entries, prior = UNIFORM_PRIOR) {
104
+ checkPrior(prior);
105
+ let sumWV = 0;
106
+ let sumW = 0;
107
+ let valueW = 0;
108
+ for (const e of entries) {
109
+ sumWV += e.w * e.v;
110
+ sumW += e.w;
111
+ if (e.value_judgment)
112
+ valueW += e.w;
113
+ }
114
+ const a = prior.a0 + sumWV;
115
+ const b = prior.b0 + (sumW - sumWV);
116
+ const mean = a / (a + b);
117
+ const sd = Math.sqrt((a * b) / ((a + b) * (a + b) * (a + b + 1)));
118
+ const lcb = Math.max(0, mean - Z * sd);
119
+ const n_eff = a + b - (prior.a0 + prior.b0);
120
+ return {
121
+ a, b, mean, sd, lcb, n_eff,
122
+ unproven: n_eff < UNPROVEN_THRESHOLD,
123
+ value_judgment_share: sumW > 0 ? valueW / sumW : 0,
124
+ };
125
+ }
126
+ /** The Beta posterior summary of §7.3, exactly as published, over scored events, with an optional prior (§7.5). */
127
+ export function scorePool(events, nowMs, prior = UNIFORM_PRIOR) {
128
+ return scoreWeighted(events.map((e) => ({ v: e.value, w: eventWeight(e, nowMs), value_judgment: e.value_judgment })), prior);
129
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Signing and verifying a report (Agent Reputations §8.1.1 to §8.1.3).
3
+ *
4
+ * The signed bytes are the tag (`fdbk-report-v1`, or `fdbk-org-report-v1`
5
+ * for an organization report), one line feed, and the JCS (RFC 8785)
6
+ * canonical JSON of the signed object. The signature is Ed25519 over those
7
+ * bytes, and it travels beside the object in an envelope with the key and the
8
+ * key's id, all in padded base64. A consumer checks the key against the key
9
+ * set it pinned for the bureau it asked, never against the envelope's word.
10
+ *
11
+ * Reports this bureau signed before 0.6.0 carry the same signed bytes (the
12
+ * same tag and line feed, over canonical JSON that matches JCS for every value
13
+ * a report holds) in an older envelope: the key as an nkey (`U…`), no key id,
14
+ * and the signature in base64url. verifyReport reads both, and both are held
15
+ * to the pinned key set, so the older envelope never brings its own trust.
16
+ *
17
+ * Pure: the shared crypto core (../crypto/), over node:crypto, and nothing else.
18
+ */
19
+ import { jcs } from "@common-agent/crypto";
20
+ export declare const REPORT_TAG = "fdbk-report-v1";
21
+ export declare const ORG_REPORT_TAG = "fdbk-org-report-v1";
22
+ /** The `use` every report signing key carries in a key set (§8.1.2 rule 4). */
23
+ export declare const REPORT_KEY_USE = "fdbk-report-signing";
24
+ /** The clock-skew grace §8.1.3 step 5 recommends at most. */
25
+ export declare const CLOCK_GRACE_SECONDS = 60;
26
+ /** One entry of a bureau's key set (§8.1.2). */
27
+ export interface KeySetEntry {
28
+ kid: string;
29
+ key: string;
30
+ status: "current" | "retired" | (string & {});
31
+ use: string;
32
+ }
33
+ /** A bureau's key set, as `GET <bureau>/api/bureau-key` serves it (§8.1.2). */
34
+ export interface KeySet {
35
+ bureau: string;
36
+ keys: string[];
37
+ key_set: KeySetEntry[];
38
+ }
39
+ /** What signs a report: the key it signs with, in padded base64, and Ed25519 over bytes. */
40
+ export interface ReportSigner {
41
+ key: string;
42
+ sign(bytes: Uint8Array): Uint8Array;
43
+ }
44
+ /**
45
+ * The JCS canonical JSON of a value, from the shared crypto core: members
46
+ * sorted by the UTF-16 code units of their names, and a value JCS has no form
47
+ * for refused with a CanonicalJSONError: a number that is not finite, a
48
+ * string or member name with a lone surrogate (JCS requires I-JSON), an
49
+ * undefined member, and anything that is not plain JSON.
50
+ */
51
+ export { jcs };
52
+ /** The bytes a report's signature covers: the tag, one line feed, and the JCS of the signed object (§8.1.1). */
53
+ export declare function signedBytes(tag: string, object: unknown): Uint8Array;
54
+ /** A key's id: the first 12 lowercase hex digits of the SHA-256 of its base64 text (§8.1.2 rule 1). */
55
+ export declare function kidOf(key: string): string;
56
+ /** A key set for a bureau, in the order given within each status, current keys first (§8.1.2 rule 2). */
57
+ export declare function keySetFor(bureau: string, keys: ReadonlyArray<{
58
+ key: string;
59
+ status: "current" | "retired";
60
+ }>): KeySet;
61
+ /**
62
+ * The 32-byte Ed25519 public key inside an nkey public user key (`U…`), the
63
+ * form the bureau's reports carried before 0.6.0. Throws on anything else,
64
+ * including a checksum that does not match.
65
+ */
66
+ export declare function rawKeyFromNkey(nkey: string): Uint8Array;
67
+ /** A raw 32-byte Ed25519 public key in the padded base64 a key set and an envelope carry. */
68
+ export declare function keyFromRaw(raw: Uint8Array): string;
69
+ /** A signer from a 32-byte Ed25519 seed (RFC 8032 §5.1.5). The test vectors use one; a bureau may use its own. */
70
+ export declare function seedSigner(seed: Uint8Array): ReportSigner;
71
+ /** A signed report, or a signed organization report, in the envelope of §8.1. */
72
+ export type ReportEnvelope<M extends string = "report"> = {
73
+ [K in M]: Record<string, unknown>;
74
+ } & {
75
+ bureau_key: string;
76
+ bureau_kid: string;
77
+ bureau_sig: string;
78
+ };
79
+ /**
80
+ * The first number in a value that a signed object may not hold (§8.1.1): an
81
+ * integer whose magnitude is above 2^53, which JCS writes but not exactly. A
82
+ * count that large is written as a string. Null when there is none.
83
+ */
84
+ export declare function inexactInteger(value: unknown, path?: string): string | null;
85
+ /** Sign a report object and put it in its envelope (§8.1.1). Refuses an object holding an integer above 2^53. */
86
+ export declare function signReport<M extends string = "report">(object: Record<string, unknown>, signer: ReportSigner, opts?: {
87
+ tag?: string;
88
+ member?: M;
89
+ }): ReportEnvelope<M>;
90
+ export type VerifyResult = {
91
+ ok: true;
92
+ format: "0.6.0" | "pre-0.6.0";
93
+ kid: string;
94
+ } | {
95
+ ok: false;
96
+ reason: string;
97
+ };
98
+ /**
99
+ * What the consumer asked about, which the report must answer (§8.1.3 step 6).
100
+ * A report answers about a subject and a capability; `capability: null` means
101
+ * the consumer asked about none in particular, which only a report from before
102
+ * 0.6.0 can answer, because those carry every capability in one list. An
103
+ * organization report answers about an anchor.
104
+ */
105
+ export type ExpectedAnswer = {
106
+ subject: string;
107
+ capability: string | null;
108
+ } | {
109
+ anchor: string;
110
+ };
111
+ export interface VerifyOptions {
112
+ /** The pinned key set for the bureau the consumer asked (§8.1.2). */
113
+ keySet: KeySet;
114
+ /** The URL of the bureau the consumer asked (§8.1.3 step 2). */
115
+ bureau: string;
116
+ /** The verification instant. Anything that is not a time fails verification. */
117
+ now: number | string | Date;
118
+ /** What the consumer asked about (§8.1.3 step 6). */
119
+ expect: ExpectedAnswer;
120
+ tag?: string;
121
+ member?: string;
122
+ graceSeconds?: number;
123
+ }
124
+ /**
125
+ * The steps of §8.1.3 that need no network, in order, stopping at the first
126
+ * that fails: the envelope, the bureau, the key against the pinned set, the
127
+ * signature, the time window, and the answer. The reasons are the conformance
128
+ * file's words where it has them.
129
+ *
130
+ * An envelope from before 0.6.0 (an nkey `bureau_key`, no `bureau_kid`, a
131
+ * base64url signature) is read too. Its key must still be in the pinned set,
132
+ * as the same 32 bytes, so it is trusted no further than a 0.6.0 envelope.
133
+ *
134
+ * This takes a parsed envelope, so it cannot see a member name repeated in
135
+ * the text; a consumer holding the text calls verifyReportText instead.
136
+ */
137
+ export declare function verifyReport(envelope: unknown, opts: VerifyOptions): VerifyResult;
138
+ /**
139
+ * §8.1.3 from the text a consumer received: the text must be one JSON value
140
+ * with no member name repeated in any object (§8.1.1), and then the envelope
141
+ * it holds must verify.
142
+ */
143
+ export declare function verifyReportText(text: string, opts: VerifyOptions): VerifyResult;
144
+ /**
145
+ * The first member name that repeats inside one object of a JSON text, or
146
+ * null. JSON.parse keeps the last of two members with one name, so a consumer
147
+ * that must reject a report with a repeated member (§8.1.1, §8.1.3 step 1)
148
+ * checks the text it received before parsing it.
149
+ */
150
+ export declare function repeatedMember(text: string): string | null;
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Signing and verifying a report (Agent Reputations §8.1.1 to §8.1.3).
3
+ *
4
+ * The signed bytes are the tag (`fdbk-report-v1`, or `fdbk-org-report-v1`
5
+ * for an organization report), one line feed, and the JCS (RFC 8785)
6
+ * canonical JSON of the signed object. The signature is Ed25519 over those
7
+ * bytes, and it travels beside the object in an envelope with the key and the
8
+ * key's id, all in padded base64. A consumer checks the key against the key
9
+ * set it pinned for the bureau it asked, never against the envelope's word.
10
+ *
11
+ * Reports this bureau signed before 0.6.0 carry the same signed bytes (the
12
+ * same tag and line feed, over canonical JSON that matches JCS for every value
13
+ * a report holds) in an older envelope: the key as an nkey (`U…`), no key id,
14
+ * and the signature in base64url. verifyReport reads both, and both are held
15
+ * to the pinned key set, so the older envelope never brings its own trust.
16
+ *
17
+ * Pure: the shared crypto core (../crypto/), over node:crypto, and nothing else.
18
+ */
19
+ import { decodeSignature, encodeSignature, jcs, rawFromBase64Key, rawUserKey, sha256Digest, signerFromRawSeed, toBase64, verifyRaw } from "@common-agent/crypto";
20
+ export const REPORT_TAG = "fdbk-report-v1";
21
+ export const ORG_REPORT_TAG = "fdbk-org-report-v1";
22
+ /** The `use` every report signing key carries in a key set (§8.1.2 rule 4). */
23
+ export const REPORT_KEY_USE = "fdbk-report-signing";
24
+ /** The clock-skew grace §8.1.3 step 5 recommends at most. */
25
+ export const CLOCK_GRACE_SECONDS = 60;
26
+ // ── JCS (RFC 8785) ───────────────────────────────────────────────────────────
27
+ const MAX_EXACT_INTEGER = 2 ** 53;
28
+ /**
29
+ * The JCS canonical JSON of a value, from the shared crypto core: members
30
+ * sorted by the UTF-16 code units of their names, and a value JCS has no form
31
+ * for refused with a CanonicalJSONError: a number that is not finite, a
32
+ * string or member name with a lone surrogate (JCS requires I-JSON), an
33
+ * undefined member, and anything that is not plain JSON.
34
+ */
35
+ export { jcs };
36
+ /** The bytes a report's signature covers: the tag, one line feed, and the JCS of the signed object (§8.1.1). */
37
+ export function signedBytes(tag, object) {
38
+ return new TextEncoder().encode(`${tag}\n${jcs(object)}`);
39
+ }
40
+ // ── keys ─────────────────────────────────────────────────────────────────────
41
+ /** A key's id: the first 12 lowercase hex digits of the SHA-256 of its base64 text (§8.1.2 rule 1). */
42
+ export function kidOf(key) {
43
+ return sha256Digest(key).slice("sha256:".length, "sha256:".length + 12);
44
+ }
45
+ /** A key set for a bureau, in the order given within each status, current keys first (§8.1.2 rule 2). */
46
+ export function keySetFor(bureau, keys) {
47
+ const ordered = [...keys.filter((k) => k.status === "current"), ...keys.filter((k) => k.status !== "current")];
48
+ return {
49
+ bureau,
50
+ keys: ordered.map((k) => k.key),
51
+ key_set: ordered.map((k) => ({ kid: kidOf(k.key), key: k.key, status: k.status, use: REPORT_KEY_USE })),
52
+ };
53
+ }
54
+ /**
55
+ * The 32-byte Ed25519 public key inside an nkey public user key (`U…`), the
56
+ * form the bureau's reports carried before 0.6.0. Throws on anything else,
57
+ * including a checksum that does not match.
58
+ */
59
+ export function rawKeyFromNkey(nkey) {
60
+ const raw = rawUserKey(nkey);
61
+ if (!raw)
62
+ throw new Error("not an nkey public user key, or its checksum does not match");
63
+ return raw;
64
+ }
65
+ /** A raw 32-byte Ed25519 public key in the padded base64 a key set and an envelope carry. */
66
+ export function keyFromRaw(raw) {
67
+ return toBase64(raw);
68
+ }
69
+ /** A signer from a 32-byte Ed25519 seed (RFC 8032 §5.1.5). The test vectors use one; a bureau may use its own. */
70
+ export function seedSigner(seed) {
71
+ const s = signerFromRawSeed(new Uint8Array(seed));
72
+ return { key: keyFromRaw(s.raw), sign: s.sign };
73
+ }
74
+ /**
75
+ * The first number in a value that a signed object may not hold (§8.1.1): an
76
+ * integer whose magnitude is above 2^53, which JCS writes but not exactly. A
77
+ * count that large is written as a string. Null when there is none.
78
+ */
79
+ export function inexactInteger(value, path = "$") {
80
+ if (typeof value === "number")
81
+ return Number.isInteger(value) && Math.abs(value) > MAX_EXACT_INTEGER ? path : null;
82
+ if (Array.isArray(value)) {
83
+ for (let i = 0; i < value.length; i++) {
84
+ const found = inexactInteger(value[i], `${path}[${i}]`);
85
+ if (found)
86
+ return found;
87
+ }
88
+ return null;
89
+ }
90
+ if (value !== null && typeof value === "object") {
91
+ for (const [k, v] of Object.entries(value)) {
92
+ const found = inexactInteger(v, `${path}.${k}`);
93
+ if (found)
94
+ return found;
95
+ }
96
+ }
97
+ return null;
98
+ }
99
+ /** Sign a report object and put it in its envelope (§8.1.1). Refuses an object holding an integer above 2^53. */
100
+ export function signReport(object, signer, opts = {}) {
101
+ const tag = opts.tag ?? REPORT_TAG;
102
+ const member = (opts.member ?? "report");
103
+ const inexact = inexactInteger(object);
104
+ if (inexact)
105
+ throw new Error(`the integer at ${inexact} is above 2^53; a signed object writes it as a string (§8.1.1)`);
106
+ const sig = signer.sign(signedBytes(tag, object));
107
+ return {
108
+ [member]: object,
109
+ bureau_key: signer.key,
110
+ bureau_kid: kidOf(signer.key),
111
+ bureau_sig: encodeSignature(sig),
112
+ };
113
+ }
114
+ const fail = (reason) => ({ ok: false, reason });
115
+ const trimUrl = (u) => u.replace(/\/+$/, "");
116
+ /**
117
+ * The steps of §8.1.3 that need no network, in order, stopping at the first
118
+ * that fails: the envelope, the bureau, the key against the pinned set, the
119
+ * signature, the time window, and the answer. The reasons are the conformance
120
+ * file's words where it has them.
121
+ *
122
+ * An envelope from before 0.6.0 (an nkey `bureau_key`, no `bureau_kid`, a
123
+ * base64url signature) is read too. Its key must still be in the pinned set,
124
+ * as the same 32 bytes, so it is trusted no further than a 0.6.0 envelope.
125
+ *
126
+ * This takes a parsed envelope, so it cannot see a member name repeated in
127
+ * the text; a consumer holding the text calls verifyReportText instead.
128
+ */
129
+ export function verifyReport(envelope, opts) {
130
+ const tag = opts.tag ?? REPORT_TAG;
131
+ const member = opts.member ?? "report";
132
+ // 1. The envelope.
133
+ if (envelope === null || typeof envelope !== "object" || Array.isArray(envelope))
134
+ return fail("the envelope is not an object");
135
+ const env = envelope;
136
+ const obj = env[member];
137
+ if (obj === null || typeof obj !== "object" || Array.isArray(obj))
138
+ return fail(`the envelope has no ${member}`);
139
+ for (const k of ["bureau_key", "bureau_sig"])
140
+ if (typeof env[k] !== "string" || !env[k])
141
+ return fail(`the envelope has no ${k}`);
142
+ const legacy = !("bureau_kid" in env) && /^U[A-Z2-7]{55}$/.test(env.bureau_key);
143
+ if (!legacy && (typeof env.bureau_kid !== "string" || !env.bureau_kid))
144
+ return fail("the envelope has no bureau_kid");
145
+ const allowed = new Set([member, "bureau_key", "bureau_sig", ...(legacy ? [] : ["bureau_kid"])]);
146
+ const extra = Object.keys(env).find((k) => !allowed.has(k));
147
+ if (extra !== undefined)
148
+ return fail(`the envelope holds a member it may not: ${extra}`);
149
+ const report = obj;
150
+ // 2. The bureau, and a key set pinned for that same bureau.
151
+ if (report.bureau !== opts.bureau)
152
+ return fail("the report names another bureau");
153
+ if (typeof opts.keySet?.bureau !== "string" || trimUrl(opts.keySet.bureau) !== trimUrl(opts.bureau)) {
154
+ return fail("the key set is for another bureau");
155
+ }
156
+ // 3. The key.
157
+ let entry;
158
+ if (legacy) {
159
+ let key;
160
+ try {
161
+ key = keyFromRaw(rawKeyFromNkey(env.bureau_key));
162
+ }
163
+ catch {
164
+ return fail("the key is not in the pinned key set");
165
+ }
166
+ entry = opts.keySet.key_set.find((k) => k.key === key);
167
+ }
168
+ else {
169
+ entry = opts.keySet.key_set.find((k) => k.kid === env.bureau_kid);
170
+ if (entry && entry.key !== env.bureau_key)
171
+ entry = undefined;
172
+ }
173
+ if (!entry || entry.use !== REPORT_KEY_USE || (entry.status !== "current" && entry.status !== "retired")) {
174
+ return fail("the key is not in the pinned key set");
175
+ }
176
+ // 4. The signature, over the JCS of the object as parsed.
177
+ let bytes;
178
+ try {
179
+ bytes = signedBytes(tag, report);
180
+ }
181
+ catch {
182
+ return fail("the report holds a value JCS cannot write");
183
+ }
184
+ const sig = decodeSignature(env.bureau_sig, legacy ? "base64url" : "base64");
185
+ if (!sig)
186
+ return fail("the signature is not encoded as the envelope requires");
187
+ if (!verifyRaw(rawFromBase64Key(entry.key), bytes, sig))
188
+ return fail("the signature does not verify");
189
+ // 5. The time.
190
+ const now = opts.now instanceof Date ? opts.now.getTime() : typeof opts.now === "number" ? opts.now : Date.parse(String(opts.now));
191
+ if (!Number.isFinite(now))
192
+ return fail("the verification instant is not a time");
193
+ const graceSeconds = opts.graceSeconds ?? CLOCK_GRACE_SECONDS;
194
+ if (typeof graceSeconds !== "number" || !Number.isFinite(graceSeconds) || graceSeconds < 0)
195
+ return fail("the clock grace is not a number of seconds");
196
+ const grace = graceSeconds * 1000;
197
+ const expires = Date.parse(String(report.expires_at));
198
+ const issued = Date.parse(String(report.issued_at));
199
+ if (!Number.isFinite(expires) || expires + grace < now)
200
+ return fail("the report has expired");
201
+ if (!Number.isFinite(issued) || issued - grace > now)
202
+ return fail("the report was issued in the future");
203
+ // 6. The answer.
204
+ const asked = opts.expect;
205
+ if (!asked || typeof asked !== "object")
206
+ return fail("the consumer did not say what it asked about");
207
+ if ("anchor" in asked) {
208
+ if (typeof asked.anchor !== "string" || report.anchor !== asked.anchor)
209
+ return fail("the report answers about another anchor");
210
+ }
211
+ else {
212
+ if (typeof asked.subject !== "string" || report.subject !== asked.subject)
213
+ return fail("the report answers about another subject");
214
+ if (!answersCapability(report, asked.capability))
215
+ return fail("the report answers about another capability");
216
+ }
217
+ return { ok: true, format: legacy ? "pre-0.6.0" : "0.6.0", kid: entry.kid };
218
+ }
219
+ /**
220
+ * Whether a report answers about the capability asked. A 0.6.0 report names
221
+ * one, at the top. A report from before 0.6.0 names none at the top and lists
222
+ * its capabilities instead: asked about one, every entry must be that one;
223
+ * asked about none, it answers for all of them, which only that kind can.
224
+ */
225
+ function answersCapability(report, asked) {
226
+ if (asked === undefined)
227
+ return false;
228
+ if ("capability" in report)
229
+ return asked !== null && report.capability === asked;
230
+ if (!Array.isArray(report.capabilities))
231
+ return false;
232
+ if (asked === null)
233
+ return true;
234
+ return report.capabilities.every((c) => c?.capability === asked);
235
+ }
236
+ /**
237
+ * §8.1.3 from the text a consumer received: the text must be one JSON value
238
+ * with no member name repeated in any object (§8.1.1), and then the envelope
239
+ * it holds must verify.
240
+ */
241
+ export function verifyReportText(text, opts) {
242
+ let parsed;
243
+ try {
244
+ parsed = JSON.parse(text);
245
+ }
246
+ catch {
247
+ return fail("the envelope is not JSON");
248
+ }
249
+ const repeated = repeatedMember(text);
250
+ if (repeated !== null)
251
+ return fail(`the envelope repeats the member ${repeated}`);
252
+ return verifyReport(parsed, opts);
253
+ }
254
+ /**
255
+ * The first member name that repeats inside one object of a JSON text, or
256
+ * null. JSON.parse keeps the last of two members with one name, so a consumer
257
+ * that must reject a report with a repeated member (§8.1.1, §8.1.3 step 1)
258
+ * checks the text it received before parsing it.
259
+ */
260
+ export function repeatedMember(text) {
261
+ const stack = [];
262
+ for (let i = 0; i < text.length; i++) {
263
+ const c = text[i];
264
+ if (c === "{")
265
+ stack.push({ keys: new Set(), expectKey: true });
266
+ else if (c === "[")
267
+ stack.push({ keys: null, expectKey: false });
268
+ else if (c === "}" || c === "]")
269
+ stack.pop();
270
+ else if (c === "," || c === ":") {
271
+ const top = stack[stack.length - 1];
272
+ if (top?.keys)
273
+ top.expectKey = c === ",";
274
+ }
275
+ else if (c === '"') {
276
+ let j = i + 1;
277
+ while (j < text.length && text[j] !== '"')
278
+ j += text[j] === "\\" ? 2 : 1;
279
+ const top = stack[stack.length - 1];
280
+ if (top?.keys && top.expectKey) {
281
+ // A name that is not valid JSON is compared as written; the caller's
282
+ // parse refuses the text anyway, and this never throws.
283
+ let name;
284
+ try {
285
+ name = JSON.parse(text.slice(i, j + 1));
286
+ }
287
+ catch {
288
+ name = text.slice(i, j + 1);
289
+ }
290
+ if (top.keys.has(name))
291
+ return name;
292
+ top.keys.add(name);
293
+ top.expectKey = false;
294
+ }
295
+ i = j;
296
+ }
297
+ }
298
+ return null;
299
+ }
package/package.json CHANGED
@@ -1,6 +1,55 @@
1
1
  {
2
2
  "name": "@common-agent/reputations",
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": "Reference implementation of Agent Reputations: the scoring formula, the pool, signed reports and their verification, the heads commitment, and NDA breach claims.",
5
+ "keywords": [
6
+ "common-agent",
7
+ "agent-reputations",
8
+ "reputation",
9
+ "scoring"
10
+ ],
11
+ "homepage": "https://agentreputations.com",
12
+ "license": "Apache-2.0",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/CommonAgent/common-agent-ts.git",
16
+ "directory": "packages/reputations"
17
+ },
18
+ "bugs": {
19
+ "url": "https://github.com/CommonAgent/common-agent-ts/issues"
20
+ },
21
+ "type": "module",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "default": "./dist/index.js"
26
+ },
27
+ "./ports": {
28
+ "types": "./dist/ports.d.ts",
29
+ "default": "./dist/ports.js"
30
+ },
31
+ "./package.json": "./package.json"
32
+ },
33
+ "main": "./dist/index.js",
34
+ "types": "./dist/index.d.ts",
35
+ "files": [
36
+ "dist",
37
+ "README.md",
38
+ "LICENSE",
39
+ "NOTICE",
40
+ "LICENSE-UNICODE"
41
+ ],
42
+ "sideEffects": false,
43
+ "engines": {
44
+ "node": ">=20.10"
45
+ },
46
+ "dependencies": {
47
+ "@common-agent/crypto": "0.1.0"
48
+ },
49
+ "scripts": {
50
+ "build": "tsc -p tsconfig.json"
51
+ },
52
+ "publishConfig": {
53
+ "access": "public"
54
+ }
55
+ }