@seasonkoh/webaz 0.1.24 → 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 +165 -64
- 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,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Contribution Credential verifier/adapter — PURE function (PR 3A).
|
|
3
|
+
*
|
|
4
|
+
* Maps an ALREADY-FETCHED GitHub API response → an immutable credential (or a typed refusal).
|
|
5
|
+
* **No network I/O, no token.** PR body / self-reported JSON are NON-authoritative.
|
|
6
|
+
*
|
|
7
|
+
* TRUST BOUNDARY: this verifies STRUCTURE + repository anchoring of a CALLER-SUPPLIED object. It
|
|
8
|
+
* does NOT prove the object authentically came from GitHub, and the self-consistency check it runs
|
|
9
|
+
* is NOT tamper-proof (an attacker who controls the payload can recompute digests). Real
|
|
10
|
+
* authenticity requires an authenticated fetch / trusted signature — deferred to PR 3B.
|
|
11
|
+
* `verification_state='verified'` = structural + repo-anchor verification only.
|
|
12
|
+
*
|
|
13
|
+
* Lifecycle (merged-only profile): mints ONLY `merged`. reverted/superseded/void are deferred to PR 3B.
|
|
14
|
+
*/
|
|
15
|
+
import { z } from 'zod';
|
|
16
|
+
import { digestCore, digestObject, credentialIdFromDigest } from './canonical.js';
|
|
17
|
+
import { verifyCredentialSelfConsistency } from './self-consistency.js';
|
|
18
|
+
import { GithubCredentialSchema, PROVENANCE, CONTRIBUTION_TYPES, CREDENTIAL_TYPE, CREDENTIAL_VERSION, } from './github-credential.schema.js';
|
|
19
|
+
const idField = z.union([z.string(), z.number()]);
|
|
20
|
+
// Full Zod schema for the external GitHub response — parsing it up-front means malformed input
|
|
21
|
+
// (missing fields, wrong types, non-array commit_authors/reviews/check_conclusions, [null], {}…)
|
|
22
|
+
// becomes a typed refusal instead of a thrown TypeError deep in the mapping code.
|
|
23
|
+
const InputSchema = z.object({
|
|
24
|
+
repository: z.object({
|
|
25
|
+
id: idField,
|
|
26
|
+
owner: z.object({ login: z.string() }),
|
|
27
|
+
name: z.string(),
|
|
28
|
+
visibility: z.string().optional(),
|
|
29
|
+
}),
|
|
30
|
+
pull_request: z.object({
|
|
31
|
+
number: z.number(),
|
|
32
|
+
node_id: z.string(),
|
|
33
|
+
merged: z.boolean().optional(),
|
|
34
|
+
state: z.string().optional(),
|
|
35
|
+
merged_at: z.string().nullable().optional(),
|
|
36
|
+
merge_commit_sha: z.string().nullable().optional(),
|
|
37
|
+
base: z.object({ ref: z.string() }),
|
|
38
|
+
head: z.object({ ref: z.string(), sha: z.string() }),
|
|
39
|
+
user: z.object({ id: idField, login: z.string() }),
|
|
40
|
+
merged_by: z.object({ id: idField }).nullable().optional(),
|
|
41
|
+
}),
|
|
42
|
+
commit_authors: z.array(z.object({
|
|
43
|
+
author_id: idField.nullable().optional(),
|
|
44
|
+
login: z.string().nullable().optional(),
|
|
45
|
+
name: z.string().nullable().optional(),
|
|
46
|
+
is_coauthor: z.boolean().optional(),
|
|
47
|
+
})).optional(),
|
|
48
|
+
check_conclusions: z.array(z.string()).optional(),
|
|
49
|
+
reviews: z.array(z.object({ state: z.string(), user_id: idField.optional() })).optional(),
|
|
50
|
+
dco_state: z.enum(['present', 'absent', 'unknown']).optional(),
|
|
51
|
+
evidence_coverage: z.object({
|
|
52
|
+
checks: z.enum(['observed', 'unobserved', 'partial']).optional(),
|
|
53
|
+
reviews: z.enum(['observed', 'unobserved', 'partial']).optional(),
|
|
54
|
+
commit_authors: z.enum(['observed', 'unobserved', 'partial']).optional(),
|
|
55
|
+
dco: z.enum(['observed', 'unobserved']).optional(),
|
|
56
|
+
}).optional(),
|
|
57
|
+
observed_at: z.string().min(1),
|
|
58
|
+
self_reported: z.object({
|
|
59
|
+
task_id: z.string().nullable().optional(),
|
|
60
|
+
source_ref: z.string().nullable().optional(),
|
|
61
|
+
agent_provenance: z.string().optional(),
|
|
62
|
+
contribution_type: z.string().optional(),
|
|
63
|
+
}).optional(),
|
|
64
|
+
});
|
|
65
|
+
const s = (v) => v === null || v === undefined || v === '' ? null : String(v);
|
|
66
|
+
function summarizeChecks(conclusions = []) {
|
|
67
|
+
const sum = { total: conclusions.length, success: 0, failure: 0, neutral: 0, other: 0 };
|
|
68
|
+
for (const c of conclusions) {
|
|
69
|
+
if (c === 'success')
|
|
70
|
+
sum.success++;
|
|
71
|
+
else if (c === 'failure' || c === 'timed_out' || c === 'cancelled')
|
|
72
|
+
sum.failure++;
|
|
73
|
+
else if (c === 'neutral' || c === 'skipped')
|
|
74
|
+
sum.neutral++;
|
|
75
|
+
else
|
|
76
|
+
sum.other++;
|
|
77
|
+
}
|
|
78
|
+
return sum;
|
|
79
|
+
}
|
|
80
|
+
// Reviews dedup + final-state rule: GitHub returns reviews chronologically; a reviewer can review
|
|
81
|
+
// many times. A reviewer's FINAL state = the last DECISIVE review (APPROVED | CHANGES_REQUESTED |
|
|
82
|
+
// DISMISSED); COMMENTED never changes the final state. We count one vote per reviewer.
|
|
83
|
+
function summarizeReviews(reviews = []) {
|
|
84
|
+
const finalDecisive = new Map(); // reviewer id → last decisive state
|
|
85
|
+
const everCommented = new Set();
|
|
86
|
+
const allReviewers = [];
|
|
87
|
+
for (const r of reviews) {
|
|
88
|
+
const id = s(r.user_id);
|
|
89
|
+
if (!id)
|
|
90
|
+
continue;
|
|
91
|
+
if (!allReviewers.includes(id))
|
|
92
|
+
allReviewers.push(id);
|
|
93
|
+
const st = (r.state || '').toUpperCase();
|
|
94
|
+
if (st === 'APPROVED' || st === 'CHANGES_REQUESTED' || st === 'DISMISSED')
|
|
95
|
+
finalDecisive.set(id, st);
|
|
96
|
+
else if (st === 'COMMENTED')
|
|
97
|
+
everCommented.add(id);
|
|
98
|
+
}
|
|
99
|
+
let approved = 0, changes_requested = 0;
|
|
100
|
+
for (const st of finalDecisive.values()) {
|
|
101
|
+
if (st === 'APPROVED')
|
|
102
|
+
approved++;
|
|
103
|
+
else if (st === 'CHANGES_REQUESTED')
|
|
104
|
+
changes_requested++; // DISMISSED → counted in neither
|
|
105
|
+
}
|
|
106
|
+
let commented = 0;
|
|
107
|
+
for (const id of everCommented)
|
|
108
|
+
if (!finalDecisive.has(id))
|
|
109
|
+
commented++; // commented-only reviewers
|
|
110
|
+
return { approved, changes_requested, commented, reviewer_ids: allReviewers };
|
|
111
|
+
}
|
|
112
|
+
export function verifyGithubContribution(resp, opts) {
|
|
113
|
+
// merged-only profile: supports ONLY `merged` (a pure PR response cannot prove reverted/superseded/void).
|
|
114
|
+
const lifecycle = opts.lifecycle_event ?? 'merged';
|
|
115
|
+
if (lifecycle !== 'merged') {
|
|
116
|
+
return { ok: false, outcome: 'unsupported_lifecycle', reasons: [`lifecycle '${lifecycle}' not supported by the pure-PR verifier (merged-only profile; only 'merged'); reverted/superseded/void are deferred to PR 3B's lifecycle-event verifier`] };
|
|
117
|
+
}
|
|
118
|
+
// P2: parse the whole external response up-front — malformed input ⇒ typed refusal, never throws.
|
|
119
|
+
const parsed = InputSchema.safeParse(resp);
|
|
120
|
+
if (!parsed.success) {
|
|
121
|
+
return { ok: false, outcome: 'insufficient_evidence', reasons: parsed.error.issues.map(i => `${i.path.join('.') || '(root)'}: ${i.message}`) };
|
|
122
|
+
}
|
|
123
|
+
const r = parsed.data;
|
|
124
|
+
const repoId = s(r.repository.id);
|
|
125
|
+
const prNodeId = s(r.pull_request.node_id);
|
|
126
|
+
const baseRef = s(r.pull_request.base.ref);
|
|
127
|
+
const headSha = s(r.pull_request.head.sha);
|
|
128
|
+
const actorId = s(r.pull_request.user.id);
|
|
129
|
+
if (!repoId || !prNodeId || !baseRef || !headSha || !actorId) {
|
|
130
|
+
const reasons = [];
|
|
131
|
+
if (!repoId)
|
|
132
|
+
reasons.push('empty repository_id');
|
|
133
|
+
if (!prNodeId)
|
|
134
|
+
reasons.push('empty pr_node_id');
|
|
135
|
+
if (!baseRef)
|
|
136
|
+
reasons.push('empty base_ref');
|
|
137
|
+
if (!headSha)
|
|
138
|
+
reasons.push('empty head_sha');
|
|
139
|
+
if (!actorId)
|
|
140
|
+
reasons.push('empty github_actor_id');
|
|
141
|
+
return { ok: false, outcome: 'insufficient_evidence', reasons };
|
|
142
|
+
}
|
|
143
|
+
if (repoId !== opts.expectedRepositoryId) {
|
|
144
|
+
return { ok: false, outcome: 'wrong_repository', reasons: [`repository_id ${repoId} != expected ${opts.expectedRepositoryId}`] };
|
|
145
|
+
}
|
|
146
|
+
const mergeSha = s(r.pull_request.merge_commit_sha);
|
|
147
|
+
const mergedAt = s(r.pull_request.merged_at);
|
|
148
|
+
if (r.pull_request.merged !== true) {
|
|
149
|
+
return { ok: false, outcome: 'not_merged', reasons: [`pull_request.merged=${r.pull_request.merged}, state=${r.pull_request.state ?? '?'}`] };
|
|
150
|
+
}
|
|
151
|
+
if (!mergeSha)
|
|
152
|
+
return { ok: false, outcome: 'insufficient_evidence', reasons: ['merged=true but missing merge_commit_sha'] };
|
|
153
|
+
if (!mergedAt)
|
|
154
|
+
return { ok: false, outcome: 'insufficient_evidence', reasons: ['merged=true but missing merged_at'] };
|
|
155
|
+
// "cannot verify → never guess": no valid self-report ⇒ unknown / null, NOT human / code.
|
|
156
|
+
const prov = PROVENANCE.includes(r.self_reported?.agent_provenance) ? r.self_reported.agent_provenance : 'unknown';
|
|
157
|
+
const ctype = CONTRIBUTION_TYPES.includes(r.self_reported?.contribution_type) ? r.self_reported.contribution_type : null;
|
|
158
|
+
// immutable GitHub fact core (digest domain = credential_type + credential_version)
|
|
159
|
+
const core = {
|
|
160
|
+
credential_type: CREDENTIAL_TYPE,
|
|
161
|
+
credential_version: CREDENTIAL_VERSION,
|
|
162
|
+
repository_id: repoId,
|
|
163
|
+
pr_node_id: prNodeId,
|
|
164
|
+
pr_number: r.pull_request.number,
|
|
165
|
+
base_ref: baseRef,
|
|
166
|
+
head_sha: headSha,
|
|
167
|
+
merge_commit_sha: mergeSha,
|
|
168
|
+
merged_at: mergedAt,
|
|
169
|
+
github_actor_id: actorId,
|
|
170
|
+
lifecycle_event: lifecycle,
|
|
171
|
+
supersedes_credential_id: null, // merged forces a null parent link
|
|
172
|
+
};
|
|
173
|
+
const coreDigest = digestCore(core);
|
|
174
|
+
const credentialId = credentialIdFromDigest(coreDigest);
|
|
175
|
+
const observation = {
|
|
176
|
+
observed_at: r.observed_at,
|
|
177
|
+
repository_owner: r.repository.owner.login,
|
|
178
|
+
repository_name: r.repository.name,
|
|
179
|
+
repository_visibility_at_observation: (['public', 'private', 'internal'].includes(r.repository.visibility) ? r.repository.visibility : 'unknown'), // missing ⇒ unknown, never guessed public
|
|
180
|
+
head_ref: s(r.pull_request.head.ref) ?? baseRef,
|
|
181
|
+
github_login: r.pull_request.user.login,
|
|
182
|
+
commit_authors: (r.commit_authors ?? []).map(a => ({ author_id: s(a.author_id), login: a.login ?? null, name: a.name ?? null, is_coauthor: a.is_coauthor === true })),
|
|
183
|
+
agent_provenance: prov,
|
|
184
|
+
claimed_task_id: r.self_reported?.task_id ?? null,
|
|
185
|
+
source_ref: r.self_reported?.source_ref ?? null,
|
|
186
|
+
contribution_type: ctype,
|
|
187
|
+
verification_state: 'verified',
|
|
188
|
+
evidence_scope: opts.evidence_scope ?? 'public_metadata',
|
|
189
|
+
checks_summary: summarizeChecks(r.check_conclusions),
|
|
190
|
+
reviews_summary: summarizeReviews(r.reviews),
|
|
191
|
+
dco_state: r.dco_state ?? 'unknown',
|
|
192
|
+
evidence_coverage: {
|
|
193
|
+
// default 'unobserved' — a pure verifier (no fetch) observed no evidence. The adapter
|
|
194
|
+
// (3B-1/3B-2) supplies real coverage. Zeros/unknown only mean something when coverage='observed'.
|
|
195
|
+
checks: r.evidence_coverage?.checks ?? 'unobserved',
|
|
196
|
+
reviews: r.evidence_coverage?.reviews ?? 'unobserved',
|
|
197
|
+
commit_authors: r.evidence_coverage?.commit_authors ?? 'unobserved',
|
|
198
|
+
dco: r.evidence_coverage?.dco ?? 'unobserved',
|
|
199
|
+
},
|
|
200
|
+
merged_by_actor_id: s(r.pull_request.merged_by?.id),
|
|
201
|
+
evidence_refs: [`pr:${repoId}#${r.pull_request.number}`, `merge_sha:${mergeSha}`],
|
|
202
|
+
known_limitations: [
|
|
203
|
+
'PR 3A is a PURE verifier over a caller-supplied response; it does NOT prove the response authentically came from GitHub. verification_state=verified = structural + repository-anchor verification only.',
|
|
204
|
+
'The self-consistency check is NOT tamper-proof: an attacker who controls the payload can recompute digests. Real authenticity needs an authenticated fetch / trusted signature (PR 3B).',
|
|
205
|
+
'credential_id + core_digest authenticate ONLY the immutable GitHub fact core, NOT this observation envelope.',
|
|
206
|
+
'GitHub identity is contribution attribution + a future-claim candidate, NOT a Passkey owner (RFC-017 I-7).',
|
|
207
|
+
'self-reported fields (claimed_task_id/source_ref/provenance/contribution_type) are NON-authoritative and excluded from core_digest.',
|
|
208
|
+
'commit_authors marked is_coauthor=true come from commit-message Co-authored-by trailers: COMMIT-DECLARED and IDENTITY-UNVERIFIED (no GitHub id) — must NOT be used for identity claim or reward.',
|
|
209
|
+
],
|
|
210
|
+
};
|
|
211
|
+
const credential = {
|
|
212
|
+
credential_id: credentialId,
|
|
213
|
+
event_source: 'github_api',
|
|
214
|
+
accountable_party_ref: null,
|
|
215
|
+
core,
|
|
216
|
+
core_digest: coreDigest,
|
|
217
|
+
observation,
|
|
218
|
+
observation_digest: digestObject(observation),
|
|
219
|
+
};
|
|
220
|
+
// schema validation (structure + cross-field) ...
|
|
221
|
+
const check = GithubCredentialSchema.safeParse(credential);
|
|
222
|
+
if (!check.success) {
|
|
223
|
+
return { ok: false, outcome: 'insufficient_evidence', reasons: check.error.issues.map(i => `${i.path.join('.')}: ${i.message}`) };
|
|
224
|
+
}
|
|
225
|
+
// ... and self-consistency (digests match content) — NOT a tamper-proof guarantee (see PR 3B).
|
|
226
|
+
const consistency = verifyCredentialSelfConsistency(credential);
|
|
227
|
+
if (!consistency.ok) {
|
|
228
|
+
return { ok: false, outcome: 'insufficient_evidence', reasons: consistency.reasons };
|
|
229
|
+
}
|
|
230
|
+
return { ok: true, credential };
|
|
231
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { seamBackendKind, seamSqliteHandle } from '../../layer0-foundation/L0-1-database/db.js';
|
|
2
|
+
import { fetchGithubContributionCredential } from './github-credential/github-fetch-adapter.js';
|
|
3
|
+
import { GithubCredentialSchema } from './github-credential/github-credential.schema.js';
|
|
4
|
+
import { verifyCredentialSelfConsistency } from './github-credential/self-consistency.js';
|
|
5
|
+
import { canonicalSerialize, sha256hex } from './github-credential/canonical.js';
|
|
6
|
+
const MAX_BUSY_RETRIES = 5;
|
|
7
|
+
const BUSY_BACKOFF_MS = 25;
|
|
8
|
+
function refused(reason, detail) {
|
|
9
|
+
return { ok: false, status: 'refused', reason, detail };
|
|
10
|
+
}
|
|
11
|
+
function isSqliteBusy(err) {
|
|
12
|
+
const code = err?.code;
|
|
13
|
+
return code === 'SQLITE_BUSY' || code === 'SQLITE_BUSY_SNAPSHOT';
|
|
14
|
+
}
|
|
15
|
+
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
16
|
+
export async function ingestGithubContribution(request, deps) {
|
|
17
|
+
// 1) trusted mapping — expectedRepositoryId is NEVER caller-reported (design §2)
|
|
18
|
+
const mapKey = `${request.owner}/${request.repo}`;
|
|
19
|
+
const expectedRepositoryId = deps.repositoryMapping.get(mapKey);
|
|
20
|
+
if (expectedRepositoryId === undefined)
|
|
21
|
+
return refused('repository_not_allowed', mapKey);
|
|
22
|
+
// 2) backend fail-closed BEFORE any network — no synchronous transaction on PG yet (design §5)
|
|
23
|
+
const kind = seamBackendKind();
|
|
24
|
+
const db = seamSqliteHandle();
|
|
25
|
+
if (kind !== 'sqlite' || db === null)
|
|
26
|
+
return refused('backend_unsupported', `backend=${kind ?? 'uninitialized'}`);
|
|
27
|
+
// 3) re-fetch + mint inside the trusted path (adapter uses globalThis.fetch) — OUTSIDE the transaction
|
|
28
|
+
const fetched = await fetchGithubContributionCredential({
|
|
29
|
+
owner: request.owner,
|
|
30
|
+
repo: request.repo,
|
|
31
|
+
prNumber: request.prNumber,
|
|
32
|
+
expectedRepositoryId,
|
|
33
|
+
token: deps.token,
|
|
34
|
+
timeoutMs: deps.timeoutMs,
|
|
35
|
+
});
|
|
36
|
+
if (!fetched.ok)
|
|
37
|
+
return refused(fetched.outcome, fetched.reasons.join('; '));
|
|
38
|
+
const credential = fetched.credential;
|
|
39
|
+
// 4) re-validate the minted credential (the trusted path must be self-consistent) — outside the tx
|
|
40
|
+
if (!GithubCredentialSchema.safeParse(credential).success) {
|
|
41
|
+
return refused('invariant_violation', 'minted credential failed schema re-validation');
|
|
42
|
+
}
|
|
43
|
+
const sc = verifyCredentialSelfConsistency(credential);
|
|
44
|
+
if (!sc.ok)
|
|
45
|
+
return refused('invariant_violation', `self-consistency: ${sc.reasons.join('; ')}`);
|
|
46
|
+
// 5) derive identity keys. source_event_key is VERSION-INDEPENDENT (credential_id includes the
|
|
47
|
+
// version; a v2 and a future v3 of the same merge are ONE fact) — design §3.
|
|
48
|
+
const core = credential.core;
|
|
49
|
+
const mergeCommitSha = core.merge_commit_sha;
|
|
50
|
+
const mergedAt = core.merged_at;
|
|
51
|
+
if (mergeCommitSha === null || mergedAt === null) {
|
|
52
|
+
return refused('invariant_violation', 'merged core missing merge_commit_sha/merged_at');
|
|
53
|
+
}
|
|
54
|
+
const sourceEventKey = `github:${core.repository_id}:${core.pr_node_id}:merged`;
|
|
55
|
+
const factId = `cfact_${sha256hex(sourceEventKey).slice(0, 40)}`;
|
|
56
|
+
const observationId = `gco_${sha256hex(`${credential.credential_id}:${credential.observation_digest}`).slice(0, 40)}`;
|
|
57
|
+
const executorRef = `github:${core.github_actor_id}`;
|
|
58
|
+
// 6) ONE synchronous transaction — all four lookups + decision + INSERTs + result (design §5).
|
|
59
|
+
// No async seam inside; raw prepared statements only; .immediate() = write lock up front.
|
|
60
|
+
const txn = db.transaction(() => {
|
|
61
|
+
const coreRow = db.prepare('SELECT core_digest FROM github_contribution_credentials WHERE credential_id = ?')
|
|
62
|
+
.get(credential.credential_id);
|
|
63
|
+
const obsRow = db.prepare('SELECT id FROM github_credential_observations WHERE credential_id = ? AND observation_digest = ?')
|
|
64
|
+
.get(credential.credential_id, credential.observation_digest);
|
|
65
|
+
const factRow = db.prepare('SELECT fact_id FROM contribution_facts WHERE source_event_key = ?')
|
|
66
|
+
.get(sourceEventKey);
|
|
67
|
+
const linkRow = db.prepare('SELECT fact_id FROM github_fact_credentials WHERE credential_id = ?')
|
|
68
|
+
.get(credential.credential_id);
|
|
69
|
+
const coreExists = coreRow !== undefined;
|
|
70
|
+
const obsExists = obsRow !== undefined;
|
|
71
|
+
const factExists = factRow !== undefined;
|
|
72
|
+
const linkExists = linkRow !== undefined;
|
|
73
|
+
// Defensive consistency: stored rows must agree with the deterministic derivations; any drift is a
|
|
74
|
+
// corrupted invariant → fail-closed, never silently repair (design §4 catch-all).
|
|
75
|
+
if (coreExists && coreRow.core_digest !== credential.core_digest) {
|
|
76
|
+
return refused('invariant_violation', 'stored core_digest mismatch for credential_id');
|
|
77
|
+
}
|
|
78
|
+
if (factExists && factRow.fact_id !== factId) {
|
|
79
|
+
return refused('invariant_violation', 'existing fact_id mismatch for source_event_key');
|
|
80
|
+
}
|
|
81
|
+
if (linkExists && linkRow.fact_id !== factId) {
|
|
82
|
+
return refused('invariant_violation', 'existing link points to a different fact');
|
|
83
|
+
}
|
|
84
|
+
const insertCore = () => {
|
|
85
|
+
db.prepare(`INSERT INTO github_contribution_credentials
|
|
86
|
+
(credential_id, core_digest, credential_version, source_event_key, repository_id, pr_node_id,
|
|
87
|
+
pr_number, merge_commit_sha, merged_at, github_actor_id, lifecycle_event, core_json)
|
|
88
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`).run(credential.credential_id, credential.core_digest, core.credential_version, sourceEventKey, core.repository_id, core.pr_node_id, core.pr_number, mergeCommitSha, mergedAt, core.github_actor_id, core.lifecycle_event, canonicalSerialize(core));
|
|
89
|
+
};
|
|
90
|
+
const insertObs = () => {
|
|
91
|
+
db.prepare(`INSERT INTO github_credential_observations
|
|
92
|
+
(id, credential_id, observation_digest, observation_json, observed_at) VALUES (?,?,?,?,?)`).run(observationId, credential.credential_id, credential.observation_digest, canonicalSerialize(credential.observation), credential.observation.observed_at);
|
|
93
|
+
};
|
|
94
|
+
const insertFact = () => {
|
|
95
|
+
db.prepare(`INSERT INTO contribution_facts
|
|
96
|
+
(fact_id, source_event_key, source, type, artifact_ref, occurred_at, executor_ref,
|
|
97
|
+
accountable_ref, provenance, status) VALUES (?,?,?,?,?,?,?,?,?,?)`).run(factId, sourceEventKey, 'github', null, mergeCommitSha, mergedAt, executorRef, null, 'unknown', 'active');
|
|
98
|
+
};
|
|
99
|
+
const insertLink = () => {
|
|
100
|
+
// source_event_key on the link + the composite FKs (Codex #297 P2-1) force the linked credential
|
|
101
|
+
// and fact to share THIS source event — a cross-event mislink is rejected by the DB.
|
|
102
|
+
db.prepare('INSERT INTO github_fact_credentials (fact_id, credential_id, source_event_key) VALUES (?,?,?)')
|
|
103
|
+
.run(factId, credential.credential_id, sourceEventKey);
|
|
104
|
+
};
|
|
105
|
+
const result = (status) => ({ ok: true, status, fact_id: factId, credential_id: credential.credential_id, source_event_key: sourceEventKey });
|
|
106
|
+
// Precise state machine — ONLY the four valid tuples write; anything else fail-closed (design §4).
|
|
107
|
+
if (!coreExists && !obsExists && !factExists && !linkExists) {
|
|
108
|
+
insertCore();
|
|
109
|
+
insertObs();
|
|
110
|
+
insertFact();
|
|
111
|
+
insertLink();
|
|
112
|
+
return result('ingested');
|
|
113
|
+
}
|
|
114
|
+
if (!coreExists && !obsExists && factExists && !linkExists) {
|
|
115
|
+
// v2→v3 of the SAME merge: new immutable core + observation, LINKED to the existing fact. No 2nd fact.
|
|
116
|
+
insertCore();
|
|
117
|
+
insertObs();
|
|
118
|
+
insertLink();
|
|
119
|
+
return result('credential_upgraded');
|
|
120
|
+
}
|
|
121
|
+
if (coreExists && !obsExists && factExists && linkExists) {
|
|
122
|
+
insertObs();
|
|
123
|
+
return result('re_observed');
|
|
124
|
+
}
|
|
125
|
+
if (coreExists && obsExists && factExists && linkExists) {
|
|
126
|
+
return result('already_present');
|
|
127
|
+
}
|
|
128
|
+
return refused('invariant_violation', `uncovered state core=${coreExists} obs=${obsExists} fact=${factExists} link=${linkExists}`);
|
|
129
|
+
});
|
|
130
|
+
// 7) bounded SQLITE_BUSY retry → typed db_busy; genuinely unexpected errors fail LOUD (never a fake success)
|
|
131
|
+
for (let attempt = 0;; attempt++) {
|
|
132
|
+
try {
|
|
133
|
+
return txn.immediate();
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
if (isSqliteBusy(err) && attempt < MAX_BUSY_RETRIES) {
|
|
137
|
+
await sleep(BUSY_BACKOFF_MS * (attempt + 1));
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (isSqliteBusy(err))
|
|
141
|
+
return refused('db_busy', `busy after ${MAX_BUSY_RETRIES} retries`);
|
|
142
|
+
throw err;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// child-first order (safe for DROP under foreign_keys=ON).
|
|
2
|
+
const CONTRIB_TABLES = ['github_fact_credentials', 'github_credential_observations', 'contribution_facts', 'github_contribution_credentials'];
|
|
3
|
+
const CREATE_CREDENTIALS = `
|
|
4
|
+
CREATE TABLE IF NOT EXISTS github_contribution_credentials (
|
|
5
|
+
credential_id TEXT PRIMARY KEY,
|
|
6
|
+
core_digest TEXT NOT NULL UNIQUE,
|
|
7
|
+
credential_version TEXT NOT NULL,
|
|
8
|
+
source_event_key TEXT NOT NULL,
|
|
9
|
+
repository_id TEXT NOT NULL,
|
|
10
|
+
pr_node_id TEXT NOT NULL,
|
|
11
|
+
pr_number INTEGER NOT NULL,
|
|
12
|
+
merge_commit_sha TEXT NOT NULL,
|
|
13
|
+
merged_at TEXT NOT NULL,
|
|
14
|
+
github_actor_id TEXT NOT NULL,
|
|
15
|
+
lifecycle_event TEXT NOT NULL CHECK (lifecycle_event = 'merged'),
|
|
16
|
+
core_json TEXT NOT NULL,
|
|
17
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
18
|
+
UNIQUE (credential_id, source_event_key)
|
|
19
|
+
)
|
|
20
|
+
`;
|
|
21
|
+
const CREATE_OBSERVATIONS = `
|
|
22
|
+
CREATE TABLE IF NOT EXISTS github_credential_observations (
|
|
23
|
+
id TEXT PRIMARY KEY,
|
|
24
|
+
credential_id TEXT NOT NULL REFERENCES github_contribution_credentials(credential_id),
|
|
25
|
+
observation_digest TEXT NOT NULL,
|
|
26
|
+
observation_json TEXT NOT NULL,
|
|
27
|
+
observed_at TEXT NOT NULL,
|
|
28
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
29
|
+
UNIQUE (credential_id, observation_digest)
|
|
30
|
+
)
|
|
31
|
+
`;
|
|
32
|
+
const CREATE_FACTS = `
|
|
33
|
+
CREATE TABLE IF NOT EXISTS contribution_facts (
|
|
34
|
+
fact_id TEXT PRIMARY KEY,
|
|
35
|
+
source_event_key TEXT NOT NULL UNIQUE,
|
|
36
|
+
source TEXT NOT NULL CHECK (source IN ('github','in_protocol','governance','transaction')),
|
|
37
|
+
type TEXT CHECK (type IS NULL OR type IN ('code','tests','audit','maintenance','governance','usage','transaction','referral')),
|
|
38
|
+
artifact_ref TEXT NOT NULL,
|
|
39
|
+
occurred_at TEXT,
|
|
40
|
+
executor_ref TEXT NOT NULL,
|
|
41
|
+
accountable_ref TEXT,
|
|
42
|
+
provenance TEXT NOT NULL DEFAULT 'unknown' CHECK (provenance IN ('human','ai_assisted','ai_authored','unknown')),
|
|
43
|
+
status TEXT NOT NULL CHECK (status IN ('active','superseded','reverted','void','forfeited')),
|
|
44
|
+
immutable INTEGER NOT NULL DEFAULT 1 CHECK (immutable = 1),
|
|
45
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
46
|
+
UNIQUE (fact_id, source_event_key)
|
|
47
|
+
)
|
|
48
|
+
`;
|
|
49
|
+
const CREATE_LINK = `
|
|
50
|
+
CREATE TABLE IF NOT EXISTS github_fact_credentials (
|
|
51
|
+
fact_id TEXT NOT NULL,
|
|
52
|
+
credential_id TEXT NOT NULL,
|
|
53
|
+
source_event_key TEXT NOT NULL,
|
|
54
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
55
|
+
PRIMARY KEY (fact_id, credential_id),
|
|
56
|
+
UNIQUE (credential_id),
|
|
57
|
+
FOREIGN KEY (credential_id, source_event_key) REFERENCES github_contribution_credentials(credential_id, source_event_key),
|
|
58
|
+
FOREIGN KEY (fact_id, source_event_key) REFERENCES contribution_facts(fact_id, source_event_key)
|
|
59
|
+
)
|
|
60
|
+
`;
|
|
61
|
+
const CREATE_INDEX = `CREATE INDEX IF NOT EXISTS idx_ghc_source_event_key ON github_contribution_credentials(source_event_key)`;
|
|
62
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
63
|
+
function tableExists(db, name) {
|
|
64
|
+
return db.prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name=?`).get(name) !== undefined;
|
|
65
|
+
}
|
|
66
|
+
function hasColumn(db, table, col) {
|
|
67
|
+
return db.prepare(`SELECT 1 FROM pragma_table_info('${table}') WHERE name=?`).get(col) !== undefined;
|
|
68
|
+
}
|
|
69
|
+
function hasUniqueOnCols(db, table, cols) {
|
|
70
|
+
const idxs = db.prepare(`SELECT * FROM pragma_index_list('${table}')`).all();
|
|
71
|
+
for (const idx of idxs) {
|
|
72
|
+
if (idx.unique !== 1)
|
|
73
|
+
continue;
|
|
74
|
+
const onCols = db.prepare(`SELECT name FROM pragma_index_info('${idx.name}') ORDER BY seqno`).all().map(r => r.name);
|
|
75
|
+
if (onCols.length === cols.length && onCols.every((c, i) => c === cols[i]))
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
function linkHasCompositeFk(db) {
|
|
81
|
+
const fks = db.prepare(`SELECT * FROM pragma_foreign_key_list('github_fact_credentials')`).all();
|
|
82
|
+
return fks.some(r => r.from === 'source_event_key');
|
|
83
|
+
}
|
|
84
|
+
/** Full-structure check (not just link existence): all 4 tables + the P2-1 composite UNIQUE/FK. */
|
|
85
|
+
function isCurrentShape(db) {
|
|
86
|
+
return CONTRIB_TABLES.every(t => tableExists(db, t))
|
|
87
|
+
&& hasColumn(db, 'github_fact_credentials', 'source_event_key')
|
|
88
|
+
&& hasUniqueOnCols(db, 'github_contribution_credentials', ['credential_id', 'source_event_key'])
|
|
89
|
+
&& hasUniqueOnCols(db, 'contribution_facts', ['fact_id', 'source_event_key'])
|
|
90
|
+
&& linkHasCompositeFk(db);
|
|
91
|
+
}
|
|
92
|
+
export function initGithubCredentialStoreSchema(db) {
|
|
93
|
+
// One synchronous .immediate() transaction wraps detect + empty-check + DROP + rebuild, so the
|
|
94
|
+
// schema is never left half-migrated: any error (incl. mid-rebuild) rolls the whole step back.
|
|
95
|
+
const apply = db.transaction(() => {
|
|
96
|
+
const present = CONTRIB_TABLES.filter(t => tableExists(db, t));
|
|
97
|
+
if (present.length > 0 && !isCurrentShape(db)) {
|
|
98
|
+
// Old / partial / half-migrated shape. SQLite can't ALTER-ADD the composite UNIQUE/FK, so we
|
|
99
|
+
// recreate — but ONLY if every present contribution table is empty (fail loud otherwise; never
|
|
100
|
+
// drop data). This also auto-repairs a half-migrated state (e.g. link dropped, parents left).
|
|
101
|
+
const total = present.reduce((n, t) => n + db.prepare(`SELECT COUNT(*) AS c FROM ${t}`).get().c, 0);
|
|
102
|
+
if (total !== 0)
|
|
103
|
+
throw new Error('github credential store is old/partial shape but NOT empty — manual migration required (refusing to drop data)');
|
|
104
|
+
for (const t of CONTRIB_TABLES)
|
|
105
|
+
db.exec(`DROP TABLE IF EXISTS ${t}`);
|
|
106
|
+
}
|
|
107
|
+
// fresh / post-drop rebuild / idempotent no-op (IF NOT EXISTS) when already current.
|
|
108
|
+
db.exec(CREATE_CREDENTIALS);
|
|
109
|
+
db.exec(CREATE_OBSERVATIONS);
|
|
110
|
+
db.exec(CREATE_FACTS);
|
|
111
|
+
db.exec(CREATE_LINK);
|
|
112
|
+
db.exec(CREATE_INDEX);
|
|
113
|
+
});
|
|
114
|
+
apply.immediate();
|
|
115
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PR 4a — GitHub identity → WebAZ account binding engine + accountable read-overlay.
|
|
3
|
+
*
|
|
4
|
+
* Records an identity binding (append-only event log + a current-state projection) and resolves a
|
|
5
|
+
* contribution fact's CURRENT accountable party at read time. Design + threat model:
|
|
6
|
+
* docs/IDENTITY-CLAIM-DESIGN.md. Schema: identity-binding-store.ts.
|
|
7
|
+
*
|
|
8
|
+
* Trust boundary (4a vs 4b): this engine takes an **already-verified `githubActorId`** as trusted
|
|
9
|
+
* input — proving control of that GitHub identity (publication challenge) and the human Passkey gate
|
|
10
|
+
* are 4b's job (the same schema/trigger split as 3B-3a/3B-3b). The engine itself is internal: it
|
|
11
|
+
* exposes NO agent/MCP/API surface.
|
|
12
|
+
*
|
|
13
|
+
* Atomicity: one synchronous better-sqlite3 `db.transaction(...).immediate()` (BEGIN IMMEDIATE takes
|
|
14
|
+
* the write lock before the lookup → no double-bind race); the active projection's PK is the second
|
|
15
|
+
* line. Non-sqlite backend → fail-closed (`backend_unsupported`). SQLITE_BUSY → bounded retry → typed
|
|
16
|
+
* `db_busy`; genuinely unexpected errors are re-thrown loud.
|
|
17
|
+
*
|
|
18
|
+
* Append-only: the EVENT LOG (`identity_binding_events`) is INSERT-only (immutable). The current-state
|
|
19
|
+
* projection (`identity_bindings_active`) is mutable BY DESIGN (bound→INSERT, revoked→DELETE) — it is a
|
|
20
|
+
* cache rebuildable from the log, never the audit truth.
|
|
21
|
+
*/
|
|
22
|
+
import { dbOne, seamBackendKind, seamSqliteHandle } from '../../layer0-foundation/L0-1-database/db.js';
|
|
23
|
+
import { sha256hex } from './github-credential/canonical.js';
|
|
24
|
+
const MAX_BUSY_RETRIES = 5;
|
|
25
|
+
const BUSY_BACKOFF_MS = 25;
|
|
26
|
+
const isSqliteBusy = (e) => {
|
|
27
|
+
const c = e?.code;
|
|
28
|
+
return c === 'SQLITE_BUSY' || c === 'SQLITE_BUSY_SNAPSHOT';
|
|
29
|
+
};
|
|
30
|
+
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
31
|
+
export const accountableRef = (accountId) => `webaz:${accountId}`;
|
|
32
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
33
|
+
// Runs `body` in one synchronous .immediate() transaction; maps the two infra refusals
|
|
34
|
+
// (non-sqlite backend / SQLITE_BUSY exhaustion) into the caller's own result type T.
|
|
35
|
+
async function runTx(buildRefused, body) {
|
|
36
|
+
const kind = seamBackendKind();
|
|
37
|
+
const db = seamSqliteHandle();
|
|
38
|
+
if (kind !== 'sqlite' || db === null)
|
|
39
|
+
return buildRefused('backend_unsupported', `backend=${kind ?? 'uninitialized'}`);
|
|
40
|
+
const txn = db.transaction(body(db));
|
|
41
|
+
for (let attempt = 0;; attempt++) {
|
|
42
|
+
try {
|
|
43
|
+
return txn.immediate();
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
if (isSqliteBusy(err) && attempt < MAX_BUSY_RETRIES) {
|
|
47
|
+
await sleep(BUSY_BACKOFF_MS * (attempt + 1));
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (isSqliteBusy(err))
|
|
51
|
+
return buildRefused('db_busy', `busy after ${MAX_BUSY_RETRIES} retries`);
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Synchronous bind core — the bind state machine WITHOUT its own transaction or backend check, so it
|
|
58
|
+
* can run INSIDE a caller-supplied `db.transaction` (e.g. the PR-F2 claim engine consuming a challenge
|
|
59
|
+
* + binding in ONE tx, avoiding the nested-transaction conflict). `bindGithubIdentity` wraps this in its
|
|
60
|
+
* own `.immediate()` tx; behavior is identical (proven by the existing identity-binding tests + a
|
|
61
|
+
* dedicated equivalence test). `db` is a better-sqlite3 handle already inside a transaction.
|
|
62
|
+
*/
|
|
63
|
+
export function bindGithubIdentityCore(db, input) {
|
|
64
|
+
const { githubActorId, accountId, proofMethod, proofRef = null } = input;
|
|
65
|
+
const visibility = input.visibility ?? 'private';
|
|
66
|
+
if (!githubActorId || !accountId) {
|
|
67
|
+
return { ok: false, status: 'refused', reason: 'invalid_input', detail: 'githubActorId and accountId are required' };
|
|
68
|
+
}
|
|
69
|
+
const refused = (reason, detail) => ({ ok: false, status: 'refused', reason, detail });
|
|
70
|
+
const active = db.prepare('SELECT account_id FROM identity_bindings_active WHERE github_actor_id = ?')
|
|
71
|
+
.get(githubActorId);
|
|
72
|
+
if (active) {
|
|
73
|
+
if (active.account_id === accountId) {
|
|
74
|
+
return { ok: true, status: 'already_bound', github_actor_id: githubActorId, account_id: accountId, event_id: '' };
|
|
75
|
+
}
|
|
76
|
+
return refused('already_bound_to_other', 'github id is actively bound to a different account — revoke first');
|
|
77
|
+
}
|
|
78
|
+
const eventId = `ibe_${sha256hex(`bound:${githubActorId}:${accountId}:${Date.now()}:${Math.random()}`).slice(0, 40)}`;
|
|
79
|
+
const boundAt = new Date().toISOString();
|
|
80
|
+
db.prepare(`INSERT INTO identity_binding_events
|
|
81
|
+
(event_id, event_type, github_actor_id, account_id, visibility, proof_method, proof_ref, supersedes_event_id)
|
|
82
|
+
VALUES (?, 'bound', ?, ?, ?, ?, ?, NULL)`).run(eventId, githubActorId, accountId, visibility, proofMethod, proofRef);
|
|
83
|
+
db.prepare(`INSERT INTO identity_bindings_active
|
|
84
|
+
(github_actor_id, account_id, visibility, bound_event_id, bound_at) VALUES (?, ?, ?, ?, ?)`)
|
|
85
|
+
.run(githubActorId, accountId, visibility, eventId, boundAt);
|
|
86
|
+
return { ok: true, status: 'bound', github_actor_id: githubActorId, account_id: accountId, event_id: eventId };
|
|
87
|
+
}
|
|
88
|
+
export async function bindGithubIdentity(input) {
|
|
89
|
+
// Input validation BEFORE runTx (so bad input → invalid_input regardless of backend) — unchanged.
|
|
90
|
+
if (!input.githubActorId || !input.accountId) {
|
|
91
|
+
return { ok: false, status: 'refused', reason: 'invalid_input', detail: 'githubActorId and accountId are required' };
|
|
92
|
+
}
|
|
93
|
+
return runTx((reason, detail) => ({ ok: false, status: 'refused', reason, detail }), (db) => () => bindGithubIdentityCore(db, input));
|
|
94
|
+
}
|
|
95
|
+
export async function revokeGithubIdentityBinding(input) {
|
|
96
|
+
const { githubActorId, accountId, proofMethod, proofRef = null } = input;
|
|
97
|
+
if (!githubActorId || !accountId) {
|
|
98
|
+
return { ok: false, status: 'refused', reason: 'invalid_input', detail: 'githubActorId and accountId are required' };
|
|
99
|
+
}
|
|
100
|
+
const refused = (reason, detail) => ({ ok: false, status: 'refused', reason, detail });
|
|
101
|
+
return runTx((reason, detail) => refused(reason, detail), (db) => () => {
|
|
102
|
+
const active = db.prepare('SELECT account_id, visibility, bound_event_id FROM identity_bindings_active WHERE github_actor_id = ?')
|
|
103
|
+
.get(githubActorId);
|
|
104
|
+
if (!active)
|
|
105
|
+
return refused('not_bound', 'no active binding for this github id');
|
|
106
|
+
// Only the current account may self-revoke; admin_manual lets governance override (audited).
|
|
107
|
+
if (proofMethod !== 'admin_manual' && active.account_id !== accountId) {
|
|
108
|
+
return refused('not_owner', 'only the currently-bound account may revoke (or use admin_manual)');
|
|
109
|
+
}
|
|
110
|
+
const eventId = `ibe_${sha256hex(`revoked:${githubActorId}:${active.account_id}:${Date.now()}:${Math.random()}`).slice(0, 40)}`;
|
|
111
|
+
db.prepare(`INSERT INTO identity_binding_events
|
|
112
|
+
(event_id, event_type, github_actor_id, account_id, visibility, proof_method, proof_ref, supersedes_event_id)
|
|
113
|
+
VALUES (?, 'revoked', ?, ?, ?, ?, ?, ?)`)
|
|
114
|
+
.run(eventId, githubActorId, active.account_id, active.visibility, proofMethod, proofRef, active.bound_event_id);
|
|
115
|
+
db.prepare('DELETE FROM identity_bindings_active WHERE github_actor_id = ?').run(githubActorId);
|
|
116
|
+
return { ok: true, status: 'revoked', github_actor_id: githubActorId, account_id: active.account_id, event_id: eventId };
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Read-overlay: the CURRENT accountable party for a contribution fact's `executor_ref`.
|
|
121
|
+
* Only `github:<id>` executors are bindable in v1; anything else (or no active binding) → null.
|
|
122
|
+
* Uses the async seam (a plain read, no transaction) so it composes with the rest of the read path.
|
|
123
|
+
*/
|
|
124
|
+
export async function resolveAccountable(executorRef) {
|
|
125
|
+
if (!executorRef.startsWith('github:'))
|
|
126
|
+
return null;
|
|
127
|
+
const githubActorId = executorRef.slice('github:'.length);
|
|
128
|
+
if (!githubActorId)
|
|
129
|
+
return null;
|
|
130
|
+
const row = await dbOne('SELECT account_id, visibility, bound_at, bound_event_id FROM identity_bindings_active WHERE github_actor_id = ?', [githubActorId]);
|
|
131
|
+
if (!row)
|
|
132
|
+
return null;
|
|
133
|
+
return { accountable_ref: accountableRef(row.account_id), account_id: row.account_id, visibility: row.visibility, bound_at: row.bound_at, bound_event_id: row.bound_event_id };
|
|
134
|
+
}
|