@seasonkoh/webaz 0.1.23 → 0.1.25
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 +2 -0
- package/dist/layer0-foundation/L0-1-database/db-backends/pg-backend.js +51 -0
- package/dist/layer0-foundation/L0-1-database/db-backends/sql-dialect-datetime.js +437 -0
- package/dist/layer0-foundation/L0-1-database/db-backends/sql-placeholders.js +98 -0
- package/dist/layer0-foundation/L0-1-database/db.js +65 -0
- package/dist/layer0-foundation/L0-2-state-machine/order-chain.js +13 -11
- package/dist/layer0-foundation/L0-2-state-machine/transitions.js +1 -1
- package/dist/layer0-foundation/L0-5-manifest/manifest.js +13 -11
- package/dist/layer1-agent/L1-1-mcp-server/server.js +198 -83
- package/dist/layer1-agent/L1-2-external-anchor/anchor-engine.js +14 -12
- package/dist/layer2-business/L2-6-notifications/notification-engine.js +8 -5
- package/dist/layer2-business/L2-7-snf/snf-engine.js +16 -14
- package/dist/layer2-business/L2-8-feedback/build-feedback-engine.js +18 -10
- package/dist/layer2-business/L2-9-contribution/build-reputation-engine.js +37 -23
- package/dist/layer2-business/L2-9-contribution/build-task-agent-metadata-store.js +173 -0
- package/dist/layer2-business/L2-9-contribution/build-task-participation.js +47 -0
- package/dist/layer2-business/L2-9-contribution/build-task-read.js +222 -0
- package/dist/layer2-business/L2-9-contribution/build-tasks-engine.js +10 -2
- package/dist/layer2-business/L2-9-contribution/canonical-contribution-target.js +16 -0
- package/dist/layer2-business/L2-9-contribution/contribution-display-envelope.js +40 -0
- package/dist/layer2-business/L2-9-contribution/contribution-score-contract.js +36 -0
- package/dist/layer2-business/L2-9-contribution/contribution-score-evidence.js +61 -0
- package/dist/layer2-business/L2-9-contribution/github-credential/canonical.js +60 -0
- package/dist/layer2-business/L2-9-contribution/github-credential/github-credential.schema.js +140 -0
- package/dist/layer2-business/L2-9-contribution/github-credential/github-fetch-adapter.js +437 -0
- package/dist/layer2-business/L2-9-contribution/github-credential/self-consistency.js +38 -0
- package/dist/layer2-business/L2-9-contribution/github-credential/verifier.js +231 -0
- package/dist/layer2-business/L2-9-contribution/github-credential-ingestion-engine.js +145 -0
- package/dist/layer2-business/L2-9-contribution/github-credential-store.js +115 -0
- package/dist/layer2-business/L2-9-contribution/identity-binding-engine.js +134 -0
- package/dist/layer2-business/L2-9-contribution/identity-binding-store.js +101 -0
- package/dist/layer2-business/L2-9-contribution/identity-claim-challenge-engine.js +126 -0
- package/dist/layer2-business/L2-9-contribution/identity-claim-challenge-store.js +30 -0
- package/dist/layer2-business/L2-9-contribution/identity-claim-engine.js +109 -0
- package/dist/layer2-business/L2-9-contribution/identity-claim-fact-precondition.js +22 -0
- package/dist/layer2-business/L2-9-contribution/identity-claim-proof-verifier.js +97 -0
- package/dist/layer2-business/L2-9-contribution/identity-claim-read.js +59 -0
- package/dist/layer2-business/L2-9-contribution/task-proposal-store.js +129 -0
- package/dist/layer2-business/L2-notes/note-photo-storage.js +4 -2
- package/dist/layer3-trust/L3-1-dispute-engine/dispute-engine.js +17 -15
- package/dist/layer3-trust/L3-1-dispute-engine/evidence-storage.js +11 -8
- package/dist/layer4-economics/L4-3-reputation/reputation-engine.js +9 -8
- package/dist/layer4-economics/L4-4-skill-market/skill-engine.js +11 -8
- package/dist/layer4-economics/L4-4-skill-market/skill-listing-engine.js +22 -16
- package/dist/pwa/acp-feed.js +13 -1
- package/dist/pwa/contract-fingerprint.js +2 -0
- package/dist/pwa/endpoint-actions.js +5 -1
- package/dist/pwa/goal-index.js +8 -8
- package/dist/pwa/human-presence.js +62 -0
- package/dist/pwa/public/app.js +575 -68
- package/dist/pwa/public/i18n.js +29 -20
- package/dist/pwa/public/index.html +1 -0
- package/dist/pwa/public/openapi.json +2 -2
- package/dist/pwa/rate-limit.js +22 -0
- package/dist/pwa/routes/account-deletion.js +15 -13
- package/dist/pwa/routes/addresses.js +10 -9
- package/dist/pwa/routes/admin-admins.js +13 -14
- package/dist/pwa/routes/admin-analytics.js +109 -69
- package/dist/pwa/routes/admin-catalog.js +13 -11
- package/dist/pwa/routes/admin-editor-picks.js +15 -10
- package/dist/pwa/routes/admin-events.js +5 -3
- package/dist/pwa/routes/admin-health.js +2 -1
- package/dist/pwa/routes/admin-moderation.js +26 -29
- package/dist/pwa/routes/admin-ops.js +22 -21
- package/dist/pwa/routes/admin-protocol-params.js +16 -19
- package/dist/pwa/routes/admin-reports.js +23 -21
- package/dist/pwa/routes/admin-tokenomics.js +26 -25
- package/dist/pwa/routes/admin-users-lifecycle.js +37 -40
- package/dist/pwa/routes/admin-users-query.js +54 -53
- package/dist/pwa/routes/admin-verifier-flow.js +82 -41
- package/dist/pwa/routes/admin-verifier-whitelist.js +55 -27
- package/dist/pwa/routes/admin-wallet-ops.js +7 -5
- package/dist/pwa/routes/agent-buy.js +46 -22
- package/dist/pwa/routes/agent-governance.js +52 -56
- package/dist/pwa/routes/ai.js +7 -5
- package/dist/pwa/routes/analytics.js +43 -41
- package/dist/pwa/routes/anchors.js +19 -20
- package/dist/pwa/routes/announcements.js +13 -13
- package/dist/pwa/routes/arbitrator.js +97 -31
- package/dist/pwa/routes/auction.js +153 -114
- package/dist/pwa/routes/auth-login.js +6 -4
- package/dist/pwa/routes/auth-read.js +11 -9
- package/dist/pwa/routes/auth-register.js +35 -20
- package/dist/pwa/routes/auth-sessions.js +12 -11
- package/dist/pwa/routes/blocklist.js +16 -15
- package/dist/pwa/routes/build-feedback.js +10 -9
- package/dist/pwa/routes/build-reputation.js +6 -2
- package/dist/pwa/routes/build-tasks.js +45 -13
- package/dist/pwa/routes/buyer-feeds.js +27 -25
- package/dist/pwa/routes/cart.js +16 -15
- package/dist/pwa/routes/charity.js +212 -150
- package/dist/pwa/routes/chat.js +42 -43
- package/dist/pwa/routes/checkin-tasks.js +10 -9
- package/dist/pwa/routes/checkout-helpers.js +12 -10
- package/dist/pwa/routes/claim-initiators.js +34 -14
- package/dist/pwa/routes/claim-verify.js +86 -53
- package/dist/pwa/routes/claim-voting.js +43 -18
- package/dist/pwa/routes/contribution-identity.js +147 -0
- package/dist/pwa/routes/contribution-score.js +19 -0
- package/dist/pwa/routes/coupons.js +19 -16
- package/dist/pwa/routes/dashboards.js +18 -16
- package/dist/pwa/routes/dispute-cases.js +25 -24
- package/dist/pwa/routes/disputes-read.js +45 -51
- package/dist/pwa/routes/disputes-write.js +124 -61
- package/dist/pwa/routes/evidence.js +9 -9
- package/dist/pwa/routes/external-anchors.js +13 -12
- package/dist/pwa/routes/feedback.js +29 -33
- package/dist/pwa/routes/flash-sales.js +18 -16
- package/dist/pwa/routes/follows.js +25 -24
- package/dist/pwa/routes/governance-auto-deactivate.js +21 -9
- package/dist/pwa/routes/governance-onboarding.js +70 -59
- package/dist/pwa/routes/group-buys.js +22 -22
- package/dist/pwa/routes/growth.js +33 -30
- package/dist/pwa/routes/import-product.js +12 -10
- package/dist/pwa/routes/kyc.js +9 -8
- package/dist/pwa/routes/leaderboard.js +20 -18
- package/dist/pwa/routes/listings.js +23 -22
- package/dist/pwa/routes/logistics.js +10 -8
- package/dist/pwa/routes/manifests.js +27 -27
- package/dist/pwa/routes/me-data.js +23 -21
- package/dist/pwa/routes/notifications.js +7 -6
- package/dist/pwa/routes/offers.js +30 -12
- package/dist/pwa/routes/orders-action.js +33 -17
- package/dist/pwa/routes/orders-create.js +75 -20
- package/dist/pwa/routes/orders-read.js +21 -20
- package/dist/pwa/routes/p2p-products.js +30 -18
- package/dist/pwa/routes/payments-governance.js +61 -56
- package/dist/pwa/routes/peers.js +9 -8
- package/dist/pwa/routes/pin-receipts.js +13 -13
- package/dist/pwa/routes/products-aliases.js +12 -10
- package/dist/pwa/routes/products-claims.js +36 -17
- package/dist/pwa/routes/products-create.js +53 -38
- package/dist/pwa/routes/products-crud.js +17 -16
- package/dist/pwa/routes/products-links.js +49 -26
- package/dist/pwa/routes/products-list.js +6 -4
- package/dist/pwa/routes/products-meta.js +40 -39
- package/dist/pwa/routes/products-update.js +19 -5
- package/dist/pwa/routes/profile-credentials.js +14 -16
- package/dist/pwa/routes/profile-identity.js +14 -13
- package/dist/pwa/routes/profile-location.js +7 -6
- package/dist/pwa/routes/profile-placement.js +19 -17
- package/dist/pwa/routes/profile-prefs.js +11 -11
- package/dist/pwa/routes/promoter.js +55 -49
- package/dist/pwa/routes/public-build-tasks.js +19 -0
- package/dist/pwa/routes/public-utils.js +108 -46
- package/dist/pwa/routes/push.js +16 -15
- package/dist/pwa/routes/ratings.js +30 -30
- package/dist/pwa/routes/recover-key.js +13 -12
- package/dist/pwa/routes/referral.js +37 -32
- package/dist/pwa/routes/reputation.js +3 -2
- package/dist/pwa/routes/returns.js +76 -73
- package/dist/pwa/routes/reviews.js +41 -18
- package/dist/pwa/routes/rewards-apply.js +16 -15
- package/dist/pwa/routes/rewards-auto-downgrade.js +9 -7
- package/dist/pwa/routes/rewards-escrow-expire.js +7 -5
- package/dist/pwa/routes/rfqs.js +163 -85
- package/dist/pwa/routes/search.js +16 -14
- package/dist/pwa/routes/secondhand.js +25 -22
- package/dist/pwa/routes/seller-quota.js +24 -26
- package/dist/pwa/routes/share-redirects.js +59 -55
- package/dist/pwa/routes/shareables-interactions.js +34 -35
- package/dist/pwa/routes/shareables.js +55 -51
- package/dist/pwa/routes/shop-referral.js +57 -0
- package/dist/pwa/routes/shops.js +20 -18
- package/dist/pwa/routes/signaling.js +10 -9
- package/dist/pwa/routes/skill-market.js +16 -16
- package/dist/pwa/routes/skills.js +15 -14
- package/dist/pwa/routes/snf.js +14 -13
- package/dist/pwa/routes/tags.js +10 -9
- package/dist/pwa/routes/task-proposals.js +45 -0
- package/dist/pwa/routes/trial.js +69 -51
- package/dist/pwa/routes/trusted-kpi.js +20 -18
- package/dist/pwa/routes/url-claim.js +67 -28
- package/dist/pwa/routes/users-public.js +62 -60
- package/dist/pwa/routes/variants.js +12 -13
- package/dist/pwa/routes/verifier-user.js +61 -21
- package/dist/pwa/routes/verify-tasks.js +49 -25
- package/dist/pwa/routes/waitlist.js +16 -15
- package/dist/pwa/routes/wallet-read.js +74 -36
- package/dist/pwa/routes/wallet-write.js +12 -9
- package/dist/pwa/routes/webauthn.js +25 -26
- package/dist/pwa/routes/webhooks.js +26 -26
- package/dist/pwa/routes/welcome.js +45 -50
- package/dist/pwa/routes/wishlist-qa.js +29 -32
- package/dist/pwa/server.js +237 -81
- package/dist/version.js +1 -1
- package/package.json +47 -2
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authenticated GitHub Fetch Adapter (PR 3B-1) — establishes SOURCE AUTHENTICITY for a
|
|
3
|
+
* GitHub Contribution Credential by performing WebAZ's OWN authenticated, read-only fetch.
|
|
4
|
+
*
|
|
5
|
+
* ─────────────────────────────────────────────────────────────────────────────────────────
|
|
6
|
+
* THREAT MODEL (written first, per the project's security-artifact discipline)
|
|
7
|
+
*
|
|
8
|
+
* Who controls each input (NOT all "trusted config" — be precise):
|
|
9
|
+
* - owner / repo / prNumber : the CALLER chooses which PR to ask about (untrusted-ish:
|
|
10
|
+
* only selects a target; cannot weaken the trust path).
|
|
11
|
+
* - expectedRepositoryId / token /
|
|
12
|
+
* the runtime environment : the TRUSTED SERVICE configuration (operator-controlled).
|
|
13
|
+
* - the transport (fetch) + the clock : the WebAZ runtime ITSELF — NOT injectable. The public
|
|
14
|
+
* entry uses only `globalThis.fetch` and the system clock.
|
|
15
|
+
*
|
|
16
|
+
* Trust root established here = an authenticated HTTPS GET to the FIXED origin
|
|
17
|
+
* https://api.github.com with the trusted token, anchored to a repository id from TRUSTED
|
|
18
|
+
* CONFIG. The response is therefore trusted *within this execution* (modulo TLS + GitHub).
|
|
19
|
+
*
|
|
20
|
+
* ⚠️ NO transport/time injection on the production entry. Accepting a caller-supplied `fetchImpl`
|
|
21
|
+
* would let a caller return forged repo/PR bytes WITHOUT touching GitHub and still mint a
|
|
22
|
+
* credential; a caller-supplied `now` would forge `fetched_at`. Both are therefore REJECTED by
|
|
23
|
+
* the strict args schema (outcome `invalid_request`). Tests swap `globalThis.fetch` instead.
|
|
24
|
+
*
|
|
25
|
+
* What the output promises (and does NOT):
|
|
26
|
+
* - A returned credential was produced INSIDE this trusted execution at `fetched_at`.
|
|
27
|
+
* - `fetch_metadata` is AUDIT info for THIS execution only — NOT an independently verifiable
|
|
28
|
+
* signature. A serialized credential is NOT a portable proof: a later replay cannot
|
|
29
|
+
* re-establish authenticity without re-fetching (PR 3B-* / signing).
|
|
30
|
+
*
|
|
31
|
+
* Hardening: fixed origin; path segments encodeURIComponent'd + origin re-asserted; method GET;
|
|
32
|
+
* Authorization sent ONLY to api.github.com; redirects NOT followed; AbortSignal timeout;
|
|
33
|
+
* token never logged/returned/interpolated into errors/URLs; repository anchored on a STABLE id
|
|
34
|
+
* from trusted config (fork PRs anchored on BASE repo); PR.number must equal the requested
|
|
35
|
+
* prNumber; missing info never guessed; all predictable failures are TYPED returns, never thrown.
|
|
36
|
+
*
|
|
37
|
+
* Lifecycle: `merged` only (merged-only profile; no title/body/branch/commit inference). Boundaries: NO DB,
|
|
38
|
+
* persistence, Contribution Fact write, ingestion endpoint, webhook, write API, Passkey/KYC,
|
|
39
|
+
* scoring/reward, or Assurance Surface. No new dependency (native fetch).
|
|
40
|
+
*/
|
|
41
|
+
import { z } from 'zod';
|
|
42
|
+
import { verifyGithubContribution } from './verifier.js';
|
|
43
|
+
// Audited authenticated-read primitives (PR 3B-1). Exported so other GitHub-facing readers (e.g. the
|
|
44
|
+
// PR 4b identity-claim gist verifier) REUSE this single audited fetch — fixed origin, GET-only,
|
|
45
|
+
// manual-redirect, AbortSignal timeout, typed outcomes — instead of building a second authenticated
|
|
46
|
+
// read that would need its own origin/redirect/timeout audit.
|
|
47
|
+
export const ORIGIN = 'https://api.github.com';
|
|
48
|
+
export const API_VERSION = '2022-11-28';
|
|
49
|
+
export const DEFAULT_TIMEOUT_MS = 10_000;
|
|
50
|
+
const MAX_TIMEOUT_MS = 60_000;
|
|
51
|
+
// Loose schemas for GitHub responses — accept GitHub's extra/new fields; only require what we map.
|
|
52
|
+
const RepoResponse = z.object({
|
|
53
|
+
node_id: z.string(),
|
|
54
|
+
name: z.string(),
|
|
55
|
+
owner: z.object({ login: z.string() }),
|
|
56
|
+
visibility: z.string().optional(),
|
|
57
|
+
});
|
|
58
|
+
const PrResponse = z.object({
|
|
59
|
+
number: z.number(),
|
|
60
|
+
node_id: z.string(),
|
|
61
|
+
merged: z.boolean().optional(),
|
|
62
|
+
state: z.string().optional(),
|
|
63
|
+
merged_at: z.string().nullable().optional(),
|
|
64
|
+
merge_commit_sha: z.string().nullable().optional(),
|
|
65
|
+
commits: z.number().optional(), // total commit count — used to detect the 250-cap truncation
|
|
66
|
+
base: z.object({ ref: z.string(), repo: z.object({ node_id: z.string() }).nullable().optional() }),
|
|
67
|
+
head: z.object({ ref: z.string(), sha: z.string() }),
|
|
68
|
+
user: z.object({ id: z.union([z.string(), z.number()]), login: z.string() }),
|
|
69
|
+
merged_by: z.object({ id: z.union([z.string(), z.number()]) }).nullable().optional(),
|
|
70
|
+
});
|
|
71
|
+
// Loose item schemas — supplementary list items are STRUCTURALLY validated and malformed ones
|
|
72
|
+
// are dropped (a malformed commit must NOT become an all-null author).
|
|
73
|
+
const CheckRunItem = z.object({ name: z.string().optional(), conclusion: z.string().nullable().optional() });
|
|
74
|
+
const ReviewItem = z.object({ state: z.string().optional(), user: z.object({ id: z.union([z.string(), z.number()]).optional() }).nullable().optional() });
|
|
75
|
+
const CommitItem = z.object({ author: z.object({ id: z.union([z.string(), z.number()]).nullable().optional(), login: z.string().nullable().optional() }).nullable().optional(), commit: z.object({ author: z.object({ name: z.string().nullable().optional() }).nullable().optional(), message: z.string().nullable().optional() }).nullable().optional() });
|
|
76
|
+
// STRICT args schema — rejects unknown keys (incl. fetchImpl / now), bounds timeoutMs, etc.
|
|
77
|
+
const nonBlank = z.string().min(1).refine(v => v.trim().length > 0, { message: 'blank' });
|
|
78
|
+
const ArgsSchema = z.strictObject({
|
|
79
|
+
owner: nonBlank,
|
|
80
|
+
repo: nonBlank,
|
|
81
|
+
prNumber: z.number().int().positive(),
|
|
82
|
+
expectedRepositoryId: nonBlank, // stable repo id from TRUSTED CONFIG
|
|
83
|
+
token: z.string().optional(), // presence/blank → auth_required (post-parse)
|
|
84
|
+
timeoutMs: z.number().int().positive().max(MAX_TIMEOUT_MS).optional(),
|
|
85
|
+
// NOTE: evidence_scope is NOT a caller argument. 3B-1 does not verify repo-collaborator access,
|
|
86
|
+
// so it MUST NOT let a caller claim a higher scope. It is fixed to 'public_metadata' (below);
|
|
87
|
+
// elevating it requires a future PR that actually proves collaborator/admin access.
|
|
88
|
+
});
|
|
89
|
+
export function pathFromOrigin(...segments) {
|
|
90
|
+
const url = `${ORIGIN}/${segments.map(encodeURIComponent).join('/')}`;
|
|
91
|
+
const parsed = new URL(url);
|
|
92
|
+
if (parsed.origin !== ORIGIN || parsed.protocol !== 'https:')
|
|
93
|
+
throw new Error('origin assertion failed');
|
|
94
|
+
return url;
|
|
95
|
+
}
|
|
96
|
+
const isAbort = (err) => err?.name === 'AbortError';
|
|
97
|
+
export async function getJson(fetchImpl, url, token, timeoutMs) {
|
|
98
|
+
// Defense-in-depth at the EXPORTED boundary (Codex P1): this function attaches Authorization, so it
|
|
99
|
+
// MUST refuse any URL that is not exactly https://api.github.com BEFORE calling fetch — otherwise a
|
|
100
|
+
// caller-supplied off-origin URL would leak the token. Callers should still build URLs via
|
|
101
|
+
// pathFromOrigin; this is the backstop. Never echo the URL / token in the failure (no leak).
|
|
102
|
+
let parsed;
|
|
103
|
+
try {
|
|
104
|
+
parsed = new URL(url);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return { kind: 'fail', outcome: 'invalid_request', reasons: ['malformed url'], status: 0 };
|
|
108
|
+
}
|
|
109
|
+
if (parsed.protocol !== 'https:' || parsed.origin !== ORIGIN) {
|
|
110
|
+
return { kind: 'fail', outcome: 'invalid_request', reasons: ['url origin not allowed'], status: 0 };
|
|
111
|
+
}
|
|
112
|
+
const controller = new AbortController();
|
|
113
|
+
// ONE timer covers the whole operation — fetch + status handling + body read (res.json()) —
|
|
114
|
+
// and is cleared in finally on EVERY return/throw path. A hung body read still aborts → timeout.
|
|
115
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
116
|
+
try {
|
|
117
|
+
let res;
|
|
118
|
+
try {
|
|
119
|
+
const headers = {
|
|
120
|
+
Accept: 'application/vnd.github+json',
|
|
121
|
+
'X-GitHub-Api-Version': API_VERSION,
|
|
122
|
+
'User-Agent': 'webaz-github-fetch-adapter',
|
|
123
|
+
};
|
|
124
|
+
// Authorization is sent ONLY to api.github.com (fixed origin) and ONLY when a token is supplied;
|
|
125
|
+
// omitted for unauthenticated PUBLIC reads (e.g. a public gist) so no bogus `Bearer undefined`.
|
|
126
|
+
if (token)
|
|
127
|
+
headers.Authorization = `Bearer ${token}`;
|
|
128
|
+
res = await fetchImpl(url, {
|
|
129
|
+
method: 'GET',
|
|
130
|
+
redirect: 'manual', // never follow redirects (would risk leaking Authorization)
|
|
131
|
+
signal: controller.signal,
|
|
132
|
+
headers,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
if (isAbort(err))
|
|
137
|
+
return { kind: 'fail', outcome: 'timeout', reasons: ['request aborted (timeout)'], status: 0 };
|
|
138
|
+
return { kind: 'fail', outcome: 'upstream_unavailable', reasons: ['network error'], status: 0 }; // no token / no raw message
|
|
139
|
+
}
|
|
140
|
+
const status = res.status;
|
|
141
|
+
if ((status >= 300 && status < 400) || res.type === 'opaqueredirect' || res.redirected) {
|
|
142
|
+
return { kind: 'fail', outcome: 'upstream_unavailable', reasons: ['unexpected redirect (not followed)'], status };
|
|
143
|
+
}
|
|
144
|
+
if (status === 401)
|
|
145
|
+
return { kind: 'fail', outcome: 'authentication_failed', reasons: ['401 unauthorized'], status };
|
|
146
|
+
if (status === 403) {
|
|
147
|
+
const remaining = res.headers?.get?.('x-ratelimit-remaining');
|
|
148
|
+
const retryAfter = res.headers?.get?.('retry-after');
|
|
149
|
+
if (remaining === '0' || retryAfter)
|
|
150
|
+
return { kind: 'fail', outcome: 'rate_limited', reasons: ['403 rate limited'], status };
|
|
151
|
+
return { kind: 'fail', outcome: 'authentication_failed', reasons: ['403 forbidden'], status };
|
|
152
|
+
}
|
|
153
|
+
if (status === 429)
|
|
154
|
+
return { kind: 'fail', outcome: 'rate_limited', reasons: ['429 too many requests'], status };
|
|
155
|
+
if (status === 404)
|
|
156
|
+
return { kind: 'fail', outcome: 'not_found', reasons: ['404 not found'], status };
|
|
157
|
+
if (status >= 500)
|
|
158
|
+
return { kind: 'fail', outcome: 'upstream_unavailable', reasons: [`${status} upstream error`], status };
|
|
159
|
+
if (status < 200 || status >= 300)
|
|
160
|
+
return { kind: 'fail', outcome: 'upstream_unavailable', reasons: [`unexpected status ${status}`], status };
|
|
161
|
+
// body read is ALSO under the timeout: an abort here → timeout; any other failure → malformed.
|
|
162
|
+
try {
|
|
163
|
+
const body = await res.json();
|
|
164
|
+
return { kind: 'ok', status, body };
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
if (isAbort(err))
|
|
168
|
+
return { kind: 'fail', outcome: 'timeout', reasons: ['body read aborted (timeout)'], status };
|
|
169
|
+
return { kind: 'fail', outcome: 'malformed_response', reasons: ['response was not valid JSON'], status };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
clearTimeout(timer);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const MAX_PAGES = 10;
|
|
177
|
+
const PER_PAGE = 100;
|
|
178
|
+
const MAX_SUPPLEMENTARY_BUDGET_MS = 20_000; // hard wall-clock cap for ALL supplementary evidence
|
|
179
|
+
const downgrade = (cov, rawLen, keptLen) => cov === 'observed' && keptLen < rawLen ? 'partial' : cov; // dropped an unrepresentable item ⇒ not complete
|
|
180
|
+
// Best-effort paged GET of a GitHub list endpoint, bounded by a SHARED deadline. ANY page failure /
|
|
181
|
+
// non-list → 'unobserved'. Page cap reached with full pages, or the shared deadline hit mid-stream →
|
|
182
|
+
// 'partial' (never silently complete). Per-page timeout is capped by the remaining shared budget.
|
|
183
|
+
async function getListPaged(doFetch, baseUrl, token, perPageTimeoutMs, deadline, extract) {
|
|
184
|
+
const items = [];
|
|
185
|
+
for (let page = 1; page <= MAX_PAGES; page++) {
|
|
186
|
+
const remaining = deadline - Date.now();
|
|
187
|
+
if (remaining <= 0)
|
|
188
|
+
return { items, coverage: items.length ? 'partial' : 'unobserved' }; // shared budget exhausted
|
|
189
|
+
const url = `${baseUrl}?per_page=${PER_PAGE}&page=${page}`;
|
|
190
|
+
const res = await getJson(doFetch, url, token, Math.min(perPageTimeoutMs, remaining));
|
|
191
|
+
// a mid-stream failure (incl. a deadline-capped page timeout) keeps what we already have as
|
|
192
|
+
// 'partial'; a first-page failure with nothing accumulated → 'unobserved'.
|
|
193
|
+
if (res.kind === 'fail')
|
|
194
|
+
return { items, coverage: items.length ? 'partial' : 'unobserved' };
|
|
195
|
+
const arr = extract(res.body);
|
|
196
|
+
if (!arr)
|
|
197
|
+
return { items, coverage: items.length ? 'partial' : 'unobserved' };
|
|
198
|
+
for (const it of arr)
|
|
199
|
+
items.push(it);
|
|
200
|
+
if (arr.length < PER_PAGE)
|
|
201
|
+
return { items, coverage: 'observed' };
|
|
202
|
+
}
|
|
203
|
+
return { items, coverage: 'partial' }; // page cap reached with full pages → may be truncated
|
|
204
|
+
}
|
|
205
|
+
// Co-authors come ONLY from a valid `Co-authored-by:` trailer in the commit message's TRAILING
|
|
206
|
+
// trailer block (a distinct last paragraph whose every line is a `Token: value` trailer) — NOT from
|
|
207
|
+
// arbitrary body lines. Both name and a syntactically-valid email are required, but the EMAIL IS
|
|
208
|
+
// NEVER stored (rule 10) — only the name. These are COMMIT-DECLARED and IDENTITY-UNVERIFIED (no
|
|
209
|
+
// GitHub id) → never usable for identity claim or reward (recorded as is_coauthor, author_id=null).
|
|
210
|
+
const TRAILER_LINE = /^[A-Za-z][A-Za-z0-9-]*:\s.+$/;
|
|
211
|
+
const COAUTHOR_TRAILER = /^co-authored-by:\s*(.+?)\s*<([^<>@\s]+@[^<>\s]+)>\s*$/i;
|
|
212
|
+
function parseCoAuthorNames(message) {
|
|
213
|
+
if (typeof message !== 'string')
|
|
214
|
+
return [];
|
|
215
|
+
const lines = message.replace(/\r\n/g, '\n').split('\n');
|
|
216
|
+
while (lines.length && lines[lines.length - 1].trim() === '')
|
|
217
|
+
lines.pop(); // drop trailing blank lines
|
|
218
|
+
if (!lines.length)
|
|
219
|
+
return [];
|
|
220
|
+
let start = lines.length;
|
|
221
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
222
|
+
if (lines[i].trim() === '')
|
|
223
|
+
break;
|
|
224
|
+
start = i;
|
|
225
|
+
}
|
|
226
|
+
if (start === 0 || lines[start - 1].trim() !== '')
|
|
227
|
+
return []; // must be a distinct trailing paragraph
|
|
228
|
+
const block = lines.slice(start);
|
|
229
|
+
if (!block.every(l => TRAILER_LINE.test(l)))
|
|
230
|
+
return []; // not a clean all-trailers block
|
|
231
|
+
const names = [];
|
|
232
|
+
for (const l of block) {
|
|
233
|
+
const m = COAUTHOR_TRAILER.exec(l);
|
|
234
|
+
if (m && m[1].trim() && m[2].trim())
|
|
235
|
+
names.push(m[1].trim()); // name + valid email required; email discarded
|
|
236
|
+
}
|
|
237
|
+
return names;
|
|
238
|
+
}
|
|
239
|
+
// returns the deduped authors AND the count of raw commits that could NOT be represented
|
|
240
|
+
// (malformed, or no identifying author info) — used to downgrade coverage to 'partial'.
|
|
241
|
+
function buildCommitAuthors(commits) {
|
|
242
|
+
const map = new Map();
|
|
243
|
+
const add = (author_id, login, name, is_coauthor) => {
|
|
244
|
+
if (!author_id && !login && !name)
|
|
245
|
+
return false; // malformed/empty → NO all-null author entry
|
|
246
|
+
const key = author_id ? `id:${author_id}` : login ? `login:${login}` : `name:${name ?? ''}`;
|
|
247
|
+
if (!map.has(key))
|
|
248
|
+
map.set(key, { author_id, login, name, is_coauthor });
|
|
249
|
+
return true;
|
|
250
|
+
};
|
|
251
|
+
let dropped = 0;
|
|
252
|
+
for (const raw of commits) {
|
|
253
|
+
const parsed = CommitItem.safeParse(raw); // structurally validate
|
|
254
|
+
if (!parsed.success) {
|
|
255
|
+
dropped++;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
const c = parsed.data;
|
|
259
|
+
const ghId = c.author?.id != null ? String(c.author.id) : null;
|
|
260
|
+
// primary-author completeness is INDEPENDENT of co-authors: if the primary author can't be
|
|
261
|
+
// represented, the commit is incompletely observed (→ partial) EVEN IF co-authors are kept.
|
|
262
|
+
const primaryRepresented = add(ghId, c.author?.login ?? null, c.commit?.author?.name ?? null, false);
|
|
263
|
+
for (const coName of parseCoAuthorNames(c.commit?.message ?? undefined))
|
|
264
|
+
add(null, null, coName, true);
|
|
265
|
+
if (!primaryRepresented)
|
|
266
|
+
dropped++;
|
|
267
|
+
}
|
|
268
|
+
return { authors: [...map.values()], dropped };
|
|
269
|
+
}
|
|
270
|
+
const KNOWN_REVIEW_STATES = new Set(['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED']);
|
|
271
|
+
// a review is representable only if it parses, is attributable (has a user id), AND has a RECOGNIZED
|
|
272
|
+
// state. A missing/unknown state is unrepresentable → dropped → downgrades coverage to 'partial'
|
|
273
|
+
// (we must not claim 'observed' while silently losing a review's state).
|
|
274
|
+
function representableReviews(items) {
|
|
275
|
+
const out = [];
|
|
276
|
+
for (const raw of items) {
|
|
277
|
+
const p = ReviewItem.safeParse(raw);
|
|
278
|
+
if (p.success && p.data.user?.id != null && typeof p.data.state === 'string' && KNOWN_REVIEW_STATES.has(p.data.state.toUpperCase())) {
|
|
279
|
+
out.push({ state: p.data.state, user_id: p.data.user.id });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return out;
|
|
283
|
+
}
|
|
284
|
+
export async function fetchGithubContributionCredential(args) {
|
|
285
|
+
// Parse the WHOLE arg object with a strict schema BEFORE any destructuring / Date / setTimeout.
|
|
286
|
+
// Rejects unknown keys (incl. fetchImpl / now), bad types, bad timeoutMs — all as invalid_request.
|
|
287
|
+
const parsed = ArgsSchema.safeParse(args);
|
|
288
|
+
if (!parsed.success) {
|
|
289
|
+
const reasons = parsed.error.issues.map(i => i.code === 'unrecognized_keys'
|
|
290
|
+
? `unrecognized argument(s): ${i.keys?.join(', ')}`
|
|
291
|
+
: `${i.path.join('.') || '(args)'}: ${i.code}`); // codes only — never echoes a value (no token leak)
|
|
292
|
+
return { ok: false, outcome: 'invalid_request', reasons };
|
|
293
|
+
}
|
|
294
|
+
const a = parsed.data;
|
|
295
|
+
if (!a.token || a.token.trim().length === 0) {
|
|
296
|
+
return { ok: false, outcome: 'auth_required', reasons: ['missing or blank token'] }; // never includes the token
|
|
297
|
+
}
|
|
298
|
+
const token = a.token;
|
|
299
|
+
const timeoutMs = a.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
300
|
+
const observedAt = new Date().toISOString(); // system clock — NOT caller-injectable
|
|
301
|
+
const doFetch = globalThis.fetch; // runtime transport — NOT caller-injectable
|
|
302
|
+
try {
|
|
303
|
+
let repoUrl, prUrl;
|
|
304
|
+
try {
|
|
305
|
+
repoUrl = pathFromOrigin('repos', a.owner, a.repo);
|
|
306
|
+
prUrl = pathFromOrigin('repos', a.owner, a.repo, 'pulls', String(a.prNumber));
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
return { ok: false, outcome: 'invalid_request', reasons: ['could not build a safe api.github.com URL'] };
|
|
310
|
+
}
|
|
311
|
+
const repoEndpoint = new URL(repoUrl).pathname;
|
|
312
|
+
const prEndpoint = new URL(prUrl).pathname;
|
|
313
|
+
// 1) authenticated GET /repos/{owner}/{repo}
|
|
314
|
+
const repoRes = await getJson(doFetch, repoUrl, token, timeoutMs);
|
|
315
|
+
if (repoRes.kind === 'fail')
|
|
316
|
+
return { ok: false, outcome: repoRes.outcome, reasons: repoRes.reasons };
|
|
317
|
+
const repoParsed = RepoResponse.safeParse(repoRes.body);
|
|
318
|
+
if (!repoParsed.success)
|
|
319
|
+
return { ok: false, outcome: 'malformed_response', reasons: ['repository response missing/typed-wrong fields'] };
|
|
320
|
+
const repoData = repoParsed.data;
|
|
321
|
+
// 2) repository anchoring — stable id from trusted config vs the API's stable id (not self-derived)
|
|
322
|
+
if (repoData.node_id !== a.expectedRepositoryId) {
|
|
323
|
+
return { ok: false, outcome: 'wrong_repository', reasons: ['repository node_id != expectedRepositoryId (trusted config)'] };
|
|
324
|
+
}
|
|
325
|
+
// 3) authenticated GET /repos/{owner}/{repo}/pulls/{prNumber}
|
|
326
|
+
const prRes = await getJson(doFetch, prUrl, token, timeoutMs);
|
|
327
|
+
if (prRes.kind === 'fail')
|
|
328
|
+
return { ok: false, outcome: prRes.outcome, reasons: prRes.reasons };
|
|
329
|
+
const prParsed = PrResponse.safeParse(prRes.body);
|
|
330
|
+
if (!prParsed.success)
|
|
331
|
+
return { ok: false, outcome: 'malformed_response', reasons: ['pull request response missing/typed-wrong fields'] };
|
|
332
|
+
const pr = prParsed.data;
|
|
333
|
+
// 3a) the response must be for the PR we asked about (no substituted PR)
|
|
334
|
+
if (pr.number !== a.prNumber) {
|
|
335
|
+
return { ok: false, outcome: 'malformed_response', reasons: [`pull_request.number ${pr.number} != requested ${a.prNumber}`] };
|
|
336
|
+
}
|
|
337
|
+
// 4) fork PRs allowed, but base/target repo must still be the anchored one
|
|
338
|
+
const baseRepoNodeId = pr.base.repo?.node_id;
|
|
339
|
+
if (baseRepoNodeId && baseRepoNodeId !== a.expectedRepositoryId) {
|
|
340
|
+
return { ok: false, outcome: 'wrong_repository', reasons: ['PR base repository != expectedRepositoryId'] };
|
|
341
|
+
}
|
|
342
|
+
// 5) best-effort SUPPLEMENTARY evidence (checks / reviews / commits → authors + DCO).
|
|
343
|
+
// The core merged fact is already authenticated above; a supplementary failure degrades
|
|
344
|
+
// that stream to 'unobserved' (never half-claimed), it does NOT refuse the credential.
|
|
345
|
+
// fetch the 3 supplementary streams IN PARALLEL, under a SHARED wall-clock deadline (bounds the
|
|
346
|
+
// whole supplementary phase, not just one stream).
|
|
347
|
+
const supplementaryDeadline = Date.now() + Math.min(timeoutMs * 4, MAX_SUPPLEMENTARY_BUDGET_MS);
|
|
348
|
+
const [checksPaged, reviewsPaged, commitsPaged] = await Promise.all([
|
|
349
|
+
getListPaged(doFetch, pathFromOrigin('repos', a.owner, a.repo, 'commits', pr.head.sha, 'check-runs'), token, timeoutMs, supplementaryDeadline, b => Array.isArray(b?.check_runs) ? b.check_runs : null),
|
|
350
|
+
getListPaged(doFetch, pathFromOrigin('repos', a.owner, a.repo, 'pulls', String(a.prNumber), 'reviews'), token, timeoutMs, supplementaryDeadline, b => Array.isArray(b) ? b : null),
|
|
351
|
+
getListPaged(doFetch, pathFromOrigin('repos', a.owner, a.repo, 'pulls', String(a.prNumber), 'commits'), token, timeoutMs, supplementaryDeadline, b => Array.isArray(b) ? b : null),
|
|
352
|
+
]);
|
|
353
|
+
// structurally validate each item; an UNREPRESENTABLE item (malformed / no usable identity)
|
|
354
|
+
// downgrades the stream to 'partial' — we must not claim a complete result after dropping data.
|
|
355
|
+
const checkRuns = checksPaged.items.map(i => CheckRunItem.safeParse(i)).flatMap(r => r.success ? [r.data] : []);
|
|
356
|
+
const reviewItems = representableReviews(reviewsPaged.items);
|
|
357
|
+
const checksCoverage = downgrade(checksPaged.coverage, checksPaged.items.length, checkRuns.length);
|
|
358
|
+
const reviewsCoverage = downgrade(reviewsPaged.coverage, reviewsPaged.items.length, reviewItems.length);
|
|
359
|
+
const checkConclusions = checksCoverage === 'unobserved' ? undefined
|
|
360
|
+
: checkRuns.map(c => typeof c.conclusion === 'string' ? c.conclusion : 'other');
|
|
361
|
+
const reviews = reviewsCoverage === 'unobserved' ? undefined
|
|
362
|
+
: reviewItems.map(rv => ({ state: rv.state ?? '', user_id: rv.user_id }));
|
|
363
|
+
const { authors, dropped: commitsDropped } = buildCommitAuthors(commitsPaged.items);
|
|
364
|
+
let commitsCoverage = downgrade(commitsPaged.coverage, commitsPaged.items.length, commitsPaged.items.length - commitsDropped);
|
|
365
|
+
// GitHub's PR-commits API caps at 250: if the PR total exceeds what we fetched → 'partial'.
|
|
366
|
+
if (commitsCoverage === 'observed') {
|
|
367
|
+
if (typeof pr.commits === 'number') {
|
|
368
|
+
if (commitsPaged.items.length < pr.commits)
|
|
369
|
+
commitsCoverage = 'partial';
|
|
370
|
+
}
|
|
371
|
+
else if (commitsPaged.items.length >= 250)
|
|
372
|
+
commitsCoverage = 'partial';
|
|
373
|
+
}
|
|
374
|
+
const commitAuthors = commitsCoverage === 'unobserved' ? undefined : authors;
|
|
375
|
+
// DCO is DEFERRED (3B-2): a DCO check-run 'success' does NOT reliably prove a real-human
|
|
376
|
+
// Signed-off-by (the DCO legal statement) — e.g. a lenient check may pass on Co-authored-by.
|
|
377
|
+
// We do NOT derive present/absent from a check whose semantics we cannot verify. Reliable DCO
|
|
378
|
+
// (verifying Signed-off-by per commit against its author) is a later PR. → always unknown/unobserved.
|
|
379
|
+
const dcoState = 'unknown';
|
|
380
|
+
const evidenceCoverage = {
|
|
381
|
+
checks: checksCoverage,
|
|
382
|
+
reviews: reviewsCoverage,
|
|
383
|
+
commit_authors: commitsCoverage,
|
|
384
|
+
dco: 'unobserved',
|
|
385
|
+
};
|
|
386
|
+
// 6) build the PR #294 verifier input — only what GitHub returned; nothing guessed.
|
|
387
|
+
const verifierInput = {
|
|
388
|
+
repository: { id: repoData.node_id, owner: { login: repoData.owner.login }, name: repoData.name, visibility: repoData.visibility },
|
|
389
|
+
pull_request: {
|
|
390
|
+
number: pr.number,
|
|
391
|
+
node_id: pr.node_id,
|
|
392
|
+
merged: pr.merged,
|
|
393
|
+
state: pr.state,
|
|
394
|
+
merged_at: pr.merged_at,
|
|
395
|
+
merge_commit_sha: pr.merge_commit_sha,
|
|
396
|
+
base: { ref: pr.base.ref },
|
|
397
|
+
head: { ref: pr.head.ref, sha: pr.head.sha },
|
|
398
|
+
user: { id: pr.user.id, login: pr.user.login },
|
|
399
|
+
merged_by: pr.merged_by ? { id: pr.merged_by.id } : null,
|
|
400
|
+
},
|
|
401
|
+
observed_at: observedAt,
|
|
402
|
+
check_conclusions: checkConclusions,
|
|
403
|
+
reviews,
|
|
404
|
+
commit_authors: commitAuthors,
|
|
405
|
+
dco_state: dcoState,
|
|
406
|
+
evidence_coverage: evidenceCoverage,
|
|
407
|
+
};
|
|
408
|
+
// 7) mint inside the trusted path (merged-only profile; verifier self-checks schema + self-consistency)
|
|
409
|
+
const verified = verifyGithubContribution(verifierInput, {
|
|
410
|
+
expectedRepositoryId: a.expectedRepositoryId,
|
|
411
|
+
lifecycle_event: 'merged',
|
|
412
|
+
evidence_scope: 'public_metadata', // FIXED in 3B-1 — never caller-claimed (no collaborator proof yet)
|
|
413
|
+
});
|
|
414
|
+
if (!verified.ok) {
|
|
415
|
+
if (verified.outcome === 'wrong_repository')
|
|
416
|
+
return { ok: false, outcome: 'wrong_repository', reasons: verified.reasons };
|
|
417
|
+
return { ok: false, outcome: 'credential_refused', reasons: [verified.outcome, ...verified.reasons] };
|
|
418
|
+
}
|
|
419
|
+
const fetch_metadata = {
|
|
420
|
+
fetched_at: observedAt,
|
|
421
|
+
origin: ORIGIN,
|
|
422
|
+
repo_endpoint: repoEndpoint,
|
|
423
|
+
pr_endpoint: prEndpoint,
|
|
424
|
+
repo_status: repoRes.status,
|
|
425
|
+
pr_status: prRes.status,
|
|
426
|
+
api_version: API_VERSION,
|
|
427
|
+
// authoritative per-stream coverage lives in credential.observation.evidence_coverage;
|
|
428
|
+
// mirrored here = streams NOT fully observed (+ self_report, which is never fetched).
|
|
429
|
+
unobserved: [...Object.entries(evidenceCoverage).filter(([, v]) => v !== 'observed').map(([k]) => k), 'self_report'],
|
|
430
|
+
note: 'Audit info for THIS trusted execution only. NOT an independently verifiable signature; a serialized credential cannot be re-verified for source authenticity outside this fetch path (see PR 3B-* / signing).',
|
|
431
|
+
};
|
|
432
|
+
return { ok: true, credential: verified.credential, fetch_metadata };
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
return { ok: false, outcome: 'upstream_unavailable', reasons: ['unexpected adapter error'] }; // never leak the token
|
|
436
|
+
}
|
|
437
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-consistency check for GitHub Contribution Credentials (PR 3A).
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ THIS IS NOT TAMPER-PROOF / ANTI-FORGERY. A plain SHA-256 recomputation only proves the
|
|
5
|
+
* credential is **internally self-consistent** — that its stored `core_digest`, `credential_id`,
|
|
6
|
+
* and `observation_digest` match its own content. An attacker who controls the whole payload can
|
|
7
|
+
* edit `core` and simply RE-COMPUTE the digests/id, and this check will pass. It detects accidental
|
|
8
|
+
* corruption / wrong-id wiring, NOT malicious tampering.
|
|
9
|
+
*
|
|
10
|
+
* Real anti-tamper / authenticity is NOT possible from a self-describing payload. It requires an
|
|
11
|
+
* external root of trust (PR 3B): re-fetch + re-derive the credential via an AUTHENTICATED GitHub
|
|
12
|
+
* API, OR verify a trusted-service SIGNATURE / anchored record. PR 3B MUST do that IN ADDITION to
|
|
13
|
+
* schema validation and this self-consistency check — never accept caller-supplied digests as proof.
|
|
14
|
+
*
|
|
15
|
+
* Pure function; returns a typed result (never throws).
|
|
16
|
+
*/
|
|
17
|
+
import { digestCore, digestObject, credentialIdFromDigest } from './canonical.js';
|
|
18
|
+
export function verifyCredentialSelfConsistency(credential) {
|
|
19
|
+
const reasons = [];
|
|
20
|
+
try {
|
|
21
|
+
const expectedCoreDigest = digestCore(credential.core);
|
|
22
|
+
if (credential.core_digest !== expectedCoreDigest) {
|
|
23
|
+
reasons.push(`core_digest mismatch: stored ${credential.core_digest} != recomputed ${expectedCoreDigest}`);
|
|
24
|
+
}
|
|
25
|
+
const expectedId = credentialIdFromDigest(expectedCoreDigest);
|
|
26
|
+
if (credential.credential_id !== expectedId) {
|
|
27
|
+
reasons.push(`credential_id mismatch: stored ${credential.credential_id} != derived ${expectedId}`);
|
|
28
|
+
}
|
|
29
|
+
const expectedObsDigest = digestObject(credential.observation);
|
|
30
|
+
if (credential.observation_digest !== expectedObsDigest) {
|
|
31
|
+
reasons.push(`observation_digest mismatch: stored ${credential.observation_digest} != recomputed ${expectedObsDigest}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
reasons.push(`self-consistency check failed to evaluate: ${err.message}`);
|
|
36
|
+
}
|
|
37
|
+
return reasons.length ? { ok: false, reasons } : { ok: true };
|
|
38
|
+
}
|