@seasonkoh/webaz 0.1.24 → 0.1.26
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 +5 -1
- 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 +288 -208
- 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 +182 -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 +11 -3
- 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-discovery.js +55 -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-ai-store.js +99 -0
- package/dist/layer2-business/L2-9-contribution/task-proposal-draft.js +191 -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/admin-bearer-auth.js +21 -0
- package/dist/pwa/contract-fingerprint.js +2 -0
- package/dist/pwa/email-delivery.js +127 -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 +1485 -283
- package/dist/pwa/public/i18n.js +297 -59
- package/dist/pwa/public/index.html +1 -0
- package/dist/pwa/public/openapi.json +5 -5
- package/dist/pwa/public/whitepaper/en/index.html +153 -0
- package/dist/pwa/public/whitepaper/zh-CN/index.html +153 -0
- 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-atomic.js +10 -4
- 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 +50 -29
- package/dist/pwa/routes/admin-ops.js +35 -23
- 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 +65 -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 +32 -7
- 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 +157 -116
- package/dist/pwa/routes/auth-login.js +6 -4
- package/dist/pwa/routes/auth-read.js +21 -10
- package/dist/pwa/routes/auth-register.js +111 -26
- 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 +164 -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 +34 -31
- 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 +51 -29
- 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 +20 -19
- 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 +20 -19
- package/dist/pwa/routes/profile-prefs.js +11 -11
- package/dist/pwa/routes/promoter.js +58 -66
- 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 +92 -32
- package/dist/pwa/routes/recover-key.js +66 -26
- package/dist/pwa/routes/referral.js +37 -52
- 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 +60 -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 +58 -0
- package/dist/pwa/routes/shops.js +25 -20
- 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 +121 -0
- package/dist/pwa/routes/trial.js +72 -52
- 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 -70
- 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 +75 -37
- 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 +304 -90
- package/dist/version.js +1 -1
- package/package.json +76 -3
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
const IDENTITY_TABLES = ['identity_bindings_active', 'identity_binding_events']; // child-first for DROP
|
|
2
|
+
const CREATE_EVENTS = `
|
|
3
|
+
CREATE TABLE IF NOT EXISTS identity_binding_events (
|
|
4
|
+
event_id TEXT PRIMARY KEY,
|
|
5
|
+
event_type TEXT NOT NULL CHECK (event_type IN ('bound','revoked')),
|
|
6
|
+
github_actor_id TEXT NOT NULL,
|
|
7
|
+
account_id TEXT NOT NULL REFERENCES users(id),
|
|
8
|
+
visibility TEXT NOT NULL DEFAULT 'private' CHECK (visibility IN ('private','public')),
|
|
9
|
+
proof_method TEXT NOT NULL CHECK (proof_method IN ('github_publication_challenge','admin_manual')),
|
|
10
|
+
proof_ref TEXT,
|
|
11
|
+
supersedes_event_id TEXT REFERENCES identity_binding_events(event_id),
|
|
12
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
13
|
+
immutable INTEGER NOT NULL DEFAULT 1 CHECK (immutable = 1),
|
|
14
|
+
UNIQUE (event_id, event_type, github_actor_id, account_id, visibility)
|
|
15
|
+
)
|
|
16
|
+
`;
|
|
17
|
+
const CREATE_ACTIVE = `
|
|
18
|
+
CREATE TABLE IF NOT EXISTS identity_bindings_active (
|
|
19
|
+
github_actor_id TEXT PRIMARY KEY,
|
|
20
|
+
account_id TEXT NOT NULL REFERENCES users(id),
|
|
21
|
+
visibility TEXT NOT NULL DEFAULT 'private' CHECK (visibility IN ('private','public')),
|
|
22
|
+
bound_event_id TEXT NOT NULL,
|
|
23
|
+
ref_event_type TEXT NOT NULL DEFAULT 'bound' CHECK (ref_event_type = 'bound'),
|
|
24
|
+
bound_at TEXT NOT NULL,
|
|
25
|
+
FOREIGN KEY (bound_event_id, ref_event_type, github_actor_id, account_id, visibility)
|
|
26
|
+
REFERENCES identity_binding_events(event_id, event_type, github_actor_id, account_id, visibility)
|
|
27
|
+
)
|
|
28
|
+
`;
|
|
29
|
+
const CREATE_INDEX = `CREATE INDEX IF NOT EXISTS idx_ibe_github_actor_id ON identity_binding_events(github_actor_id)`;
|
|
30
|
+
// req1: DB-level immutability of the event log (SQLite). The PG generator emits the equivalent guard.
|
|
31
|
+
const TRIGGER_NO_UPDATE = `CREATE TRIGGER IF NOT EXISTS trg_ibe_no_update BEFORE UPDATE ON identity_binding_events BEGIN SELECT RAISE(ABORT, 'identity_binding_events is append-only (UPDATE forbidden)'); END`;
|
|
32
|
+
const TRIGGER_NO_DELETE = `CREATE TRIGGER IF NOT EXISTS trg_ibe_no_delete BEFORE DELETE ON identity_binding_events BEGIN SELECT RAISE(ABORT, 'identity_binding_events is append-only (DELETE forbidden)'); END`;
|
|
33
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
34
|
+
const tableExists = (db, name) => db.prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name=?`).get(name) !== undefined;
|
|
35
|
+
const triggerExists = (db, name) => db.prepare(`SELECT 1 FROM sqlite_master WHERE type='trigger' AND name=?`).get(name) !== undefined;
|
|
36
|
+
const hasColumn = (db, table, col) => db.prepare(`SELECT 1 FROM pragma_table_info('${table}') WHERE name=?`).get(col) !== undefined;
|
|
37
|
+
function hasUniqueOnCols(db, table, cols) {
|
|
38
|
+
for (const idx of db.prepare(`SELECT * FROM pragma_index_list('${table}')`).all()) {
|
|
39
|
+
if (idx.unique !== 1)
|
|
40
|
+
continue;
|
|
41
|
+
const onCols = db.prepare(`SELECT name FROM pragma_index_info('${idx.name}') ORDER BY seqno`).all().map(r => r.name);
|
|
42
|
+
if (onCols.length === cols.length && onCols.every((c, i) => c === cols[i]))
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The req2 composite FK must REALLY exist (not just the ref_event_type column): a half-migrated
|
|
49
|
+
* `active` with the column but no FK would otherwise be misjudged as current and let mismatched
|
|
50
|
+
* projections through (Codex). Verify a FK on identity_bindings_active maps EXACTLY
|
|
51
|
+
* (bound_event_id, ref_event_type, github_actor_id, account_id, visibility) →
|
|
52
|
+
* identity_binding_events(event_id, event_type, github_actor_id, account_id, visibility).
|
|
53
|
+
*/
|
|
54
|
+
function activeHasCompositeFk(db) {
|
|
55
|
+
const wantFrom = ['bound_event_id', 'ref_event_type', 'github_actor_id', 'account_id', 'visibility'];
|
|
56
|
+
const wantTo = ['event_id', 'event_type', 'github_actor_id', 'account_id', 'visibility'];
|
|
57
|
+
const byId = new Map();
|
|
58
|
+
for (const r of db.prepare(`SELECT * FROM pragma_foreign_key_list('identity_bindings_active')`).all()) {
|
|
59
|
+
const g = byId.get(r.id);
|
|
60
|
+
if (g)
|
|
61
|
+
g.push(r);
|
|
62
|
+
else
|
|
63
|
+
byId.set(r.id, [r]);
|
|
64
|
+
}
|
|
65
|
+
for (const rows of byId.values()) {
|
|
66
|
+
if (rows[0].table !== 'identity_binding_events')
|
|
67
|
+
continue;
|
|
68
|
+
const sorted = [...rows].sort((a, b) => a.seq - b.seq);
|
|
69
|
+
const from = sorted.map(r => r.from), to = sorted.map(r => r.to);
|
|
70
|
+
if (from.length === wantFrom.length && from.every((c, i) => c === wantFrom[i]) && to.every((c, i) => c === wantTo[i]))
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
/** Full-structure check: both tables + req2 composite-UNIQUE + projection column + composite FK + req1 triggers. */
|
|
76
|
+
function isCurrentShape(db) {
|
|
77
|
+
return IDENTITY_TABLES.every(t => tableExists(db, t))
|
|
78
|
+
&& hasColumn(db, 'identity_bindings_active', 'ref_event_type')
|
|
79
|
+
&& hasUniqueOnCols(db, 'identity_binding_events', ['event_id', 'event_type', 'github_actor_id', 'account_id', 'visibility'])
|
|
80
|
+
&& activeHasCompositeFk(db)
|
|
81
|
+
&& triggerExists(db, 'trg_ibe_no_update')
|
|
82
|
+
&& triggerExists(db, 'trg_ibe_no_delete');
|
|
83
|
+
}
|
|
84
|
+
export function initIdentityBindingSchema(db) {
|
|
85
|
+
const apply = db.transaction(() => {
|
|
86
|
+
const present = IDENTITY_TABLES.filter(t => tableExists(db, t));
|
|
87
|
+
if (present.length > 0 && !isCurrentShape(db)) {
|
|
88
|
+
const total = present.reduce((n, t) => n + db.prepare(`SELECT COUNT(*) AS c FROM ${t}`).get().c, 0);
|
|
89
|
+
if (total !== 0)
|
|
90
|
+
throw new Error('identity binding store is old/partial shape but NOT empty — manual migration required (refusing to drop data)');
|
|
91
|
+
for (const t of IDENTITY_TABLES)
|
|
92
|
+
db.exec(`DROP TABLE IF EXISTS ${t}`); // child-first; events' triggers drop with it
|
|
93
|
+
}
|
|
94
|
+
db.exec(CREATE_EVENTS);
|
|
95
|
+
db.exec(CREATE_ACTIVE);
|
|
96
|
+
db.exec(CREATE_INDEX);
|
|
97
|
+
db.exec(TRIGGER_NO_UPDATE);
|
|
98
|
+
db.exec(TRIGGER_NO_DELETE);
|
|
99
|
+
});
|
|
100
|
+
apply.immediate();
|
|
101
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PR-F3b — GitHub identity-claim challenge ISSUANCE engine (internal). Issues an identity_claim_challenges
|
|
3
|
+
* row for an ACTIVE GitHub credential-backed contribution fact, and returns the proof marker the user
|
|
4
|
+
* copies into a GitHub Gist. NO API/MCP/UI; does NOT verify the gist (F3a) or bind (F2/4a).
|
|
5
|
+
*
|
|
6
|
+
* Security:
|
|
7
|
+
* - Same trust root as F2: the fact must be GitHub credential-BACKED (assertGithubCredentialBackedFact)
|
|
8
|
+
* — not governance/in_protocol/transaction, not a github fact without a credential link, not a
|
|
9
|
+
* credential naming another actor.
|
|
10
|
+
* - If the actor is already actively bound: same account → already_bound_self (no new challenge);
|
|
11
|
+
* other account → refused already_bound_other (no challenge).
|
|
12
|
+
* - nonce / challenge_id / expires_at are ENGINE-generated (crypto random; never caller-supplied —
|
|
13
|
+
* the strict input rejects them). Only sha256(nonce) is stored; the plaintext nonce is returned ONLY
|
|
14
|
+
* inside the proof_marker (never persisted).
|
|
15
|
+
* - One synchronous db.transaction().immediate(); non-sqlite backend → fail-closed backend_unsupported.
|
|
16
|
+
* - Predictable failures are typed results; unexpected errors throw loud.
|
|
17
|
+
*/
|
|
18
|
+
import { randomBytes } from 'node:crypto';
|
|
19
|
+
import { z } from 'zod';
|
|
20
|
+
import { seamBackendKind, seamSqliteHandle } from '../../layer0-foundation/L0-1-database/db.js';
|
|
21
|
+
import { sha256hex } from './github-credential/canonical.js';
|
|
22
|
+
import { assertGithubCredentialBackedFact } from './identity-claim-fact-precondition.js';
|
|
23
|
+
import { CLAIM_MARKER_PREFIX } from './identity-claim-proof-verifier.js';
|
|
24
|
+
const CHALLENGE_TTL = '+30 minutes'; // SQLite datetime modifier
|
|
25
|
+
// Strict input — accountId/githubActorId/sourceEventKey only; caller-supplied nonce/challengeId/
|
|
26
|
+
// expiresAt/unknown keys are rejected (engine generates those) → invalid_request.
|
|
27
|
+
const IssueArgs = z.strictObject({
|
|
28
|
+
accountId: z.string().min(1),
|
|
29
|
+
githubActorId: z.string().min(1),
|
|
30
|
+
sourceEventKey: z.string().min(1),
|
|
31
|
+
});
|
|
32
|
+
const MAX_BUSY_RETRIES = 5;
|
|
33
|
+
const BUSY_BACKOFF_MS = 25;
|
|
34
|
+
const isSqliteBusy = (e) => { const c = e?.code; return c === 'SQLITE_BUSY' || c === 'SQLITE_BUSY_SNAPSHOT'; };
|
|
35
|
+
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
36
|
+
const refused = (reason, detail) => ({ ok: false, status: 'refused', reason, detail });
|
|
37
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
38
|
+
export async function issueGithubIdentityClaimChallenge(args) {
|
|
39
|
+
const parsed = IssueArgs.safeParse(args);
|
|
40
|
+
if (!parsed.success) {
|
|
41
|
+
const reasons = parsed.error.issues.map(i => i.code === 'unrecognized_keys' ? `unrecognized argument(s): ${i.keys?.join(', ')}` : `${i.path.join('.') || '(args)'}: ${i.code}`);
|
|
42
|
+
return refused('invalid_request', reasons.join('; '));
|
|
43
|
+
}
|
|
44
|
+
const { accountId, githubActorId, sourceEventKey } = parsed.data;
|
|
45
|
+
const kind = seamBackendKind();
|
|
46
|
+
const db = seamSqliteHandle();
|
|
47
|
+
if (kind !== 'sqlite' || db === null)
|
|
48
|
+
return refused('backend_unsupported', `backend=${kind ?? 'uninitialized'}`);
|
|
49
|
+
const txn = db.transaction(() => {
|
|
50
|
+
// 1) credential-backed fact precondition (shared with F2). No write if it fails.
|
|
51
|
+
const pre = assertGithubCredentialBackedFact(db, sourceEventKey, githubActorId);
|
|
52
|
+
if (!pre.ok)
|
|
53
|
+
return refused(pre.reason, pre.detail);
|
|
54
|
+
// 2) already-bound state — never issue if the identity is already claimed.
|
|
55
|
+
const active = db.prepare('SELECT account_id FROM identity_bindings_active WHERE github_actor_id = ?')
|
|
56
|
+
.get(githubActorId);
|
|
57
|
+
if (active) {
|
|
58
|
+
if (active.account_id === accountId)
|
|
59
|
+
return { ok: true, status: 'already_bound_self', github_actor_id: githubActorId, account_id: accountId };
|
|
60
|
+
return refused('already_bound_other', 'github id is actively bound to a different account');
|
|
61
|
+
}
|
|
62
|
+
// 3) engine-generated, crypto-random nonce + id + expiry; store ONLY sha256(nonce).
|
|
63
|
+
const nonce = randomBytes(32).toString('hex'); // 64 lowercase hex
|
|
64
|
+
const challengeId = `icc_${randomBytes(20).toString('hex')}`; // icc_ + 40 hex
|
|
65
|
+
const nonceHash = sha256hex(nonce); // 64 lowercase hex (matches the table CHECK)
|
|
66
|
+
const expiresAt = db.prepare(`SELECT datetime('now', ?) AS t`).get(CHALLENGE_TTL).t;
|
|
67
|
+
db.prepare(`INSERT INTO identity_claim_challenges
|
|
68
|
+
(challenge_id, account_id, github_actor_id, source_event_key, nonce_hash, status, expires_at)
|
|
69
|
+
VALUES (?, ?, ?, ?, ?, 'issued', ?)`)
|
|
70
|
+
.run(challengeId, accountId, githubActorId, sourceEventKey, nonceHash, expiresAt);
|
|
71
|
+
// proof_marker carries the PLAINTEXT nonce (returned to caller, never persisted); prefix imported from
|
|
72
|
+
// the F3a verifier so the two never drift.
|
|
73
|
+
const proofMarker = `${CLAIM_MARKER_PREFIX}${challengeId}:${nonce}`;
|
|
74
|
+
return { ok: true, status: 'issued', challenge_id: challengeId, expires_at: expiresAt, proof_marker: proofMarker };
|
|
75
|
+
});
|
|
76
|
+
for (let attempt = 0;; attempt++) {
|
|
77
|
+
try {
|
|
78
|
+
return txn.immediate();
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
if (isSqliteBusy(err) && attempt < MAX_BUSY_RETRIES) {
|
|
82
|
+
await sleep(BUSY_BACKOFF_MS * (attempt + 1));
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (isSqliteBusy(err))
|
|
86
|
+
return refused('db_busy', `busy after ${MAX_BUSY_RETRIES} retries`);
|
|
87
|
+
throw err;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// ── READ-ONLY: fetch the verification inputs the F3c API needs before it calls the F3a verifier ──
|
|
92
|
+
// The API never embeds SQL against a core table (iron-rule rule4) and never holds the nonce plaintext:
|
|
93
|
+
// it only needs the stored `nonce_hash` (the F3a `expectedNonceHash`) for a challenge that is ISSUED,
|
|
94
|
+
// not expired, and owned by THIS (account, actor, source). Seam-based + read-only like the engines, so a
|
|
95
|
+
// non-sqlite backend fails closed. This is ADVISORY (lets the API reject early / avoid a pointless GitHub
|
|
96
|
+
// fetch); the AUTHORITATIVE single-use consume is still the CAS inside the F2 claim engine, so a
|
|
97
|
+
// race here can never double-spend a challenge.
|
|
98
|
+
const LookupArgs = z.strictObject({
|
|
99
|
+
challengeId: z.string().min(1),
|
|
100
|
+
accountId: z.string().min(1),
|
|
101
|
+
githubActorId: z.string().min(1),
|
|
102
|
+
sourceEventKey: z.string().min(1),
|
|
103
|
+
});
|
|
104
|
+
export function getIssuedChallengeForVerification(args) {
|
|
105
|
+
const parsed = LookupArgs.safeParse(args);
|
|
106
|
+
if (!parsed.success)
|
|
107
|
+
return { ok: false, reason: 'invalid_request' };
|
|
108
|
+
const { challengeId, accountId, githubActorId, sourceEventKey } = parsed.data;
|
|
109
|
+
const kind = seamBackendKind();
|
|
110
|
+
const db = seamSqliteHandle();
|
|
111
|
+
if (kind !== 'sqlite' || db === null)
|
|
112
|
+
return { ok: false, reason: 'backend_unsupported' };
|
|
113
|
+
// Bind ownership in the WHERE clause: a row for another account/actor/source is reported as not-found
|
|
114
|
+
// (no information about challenges the caller doesn't own).
|
|
115
|
+
const row = db.prepare(`SELECT status, nonce_hash, (expires_at > datetime('now')) AS not_expired
|
|
116
|
+
FROM identity_claim_challenges
|
|
117
|
+
WHERE challenge_id = ? AND account_id = ? AND github_actor_id = ? AND source_event_key = ?`)
|
|
118
|
+
.get(challengeId, accountId, githubActorId, sourceEventKey);
|
|
119
|
+
if (!row)
|
|
120
|
+
return { ok: false, reason: 'challenge_not_found' };
|
|
121
|
+
if (row.status === 'consumed')
|
|
122
|
+
return { ok: false, reason: 'challenge_already_used' };
|
|
123
|
+
if (row.status !== 'issued' || row.not_expired !== 1)
|
|
124
|
+
return { ok: false, reason: 'challenge_expired' };
|
|
125
|
+
return { ok: true, nonceHash: row.nonce_hash };
|
|
126
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export function initIdentityClaimChallengeSchema(db) {
|
|
2
|
+
// DB-enforced state machine (Codex F1 P1) — illegal states must be rejected by the DB, not merely by
|
|
3
|
+
// a future engine:
|
|
4
|
+
// - nonce_hash CHECK: a 64-char LOWERCASE sha256 hex digest, never a short/plaintext/upper value (P2).
|
|
5
|
+
// `length=64 AND NOT GLOB '*[^0-9a-f]*'` (no char outside lowercase hex). gen-pg-schema translates
|
|
6
|
+
// the GLOB to the PG regex `!~ '[^0-9a-f]'`.
|
|
7
|
+
// - consumed_at NOT NULL IFF status='consumed' (the row-level consistency CHECK below).
|
|
8
|
+
// - INSERT must be status='issued' (the BEFORE INSERT trigger below; a CHECK can't be INSERT-scoped).
|
|
9
|
+
// consumed/expired/revoked are only reachable FROM 'issued' via the sanctioned status CAS (UPDATE).
|
|
10
|
+
db.exec(`
|
|
11
|
+
CREATE TABLE IF NOT EXISTS identity_claim_challenges (
|
|
12
|
+
challenge_id TEXT PRIMARY KEY,
|
|
13
|
+
account_id TEXT NOT NULL REFERENCES users(id),
|
|
14
|
+
github_actor_id TEXT NOT NULL,
|
|
15
|
+
source_event_key TEXT NOT NULL,
|
|
16
|
+
nonce_hash TEXT NOT NULL UNIQUE CHECK (length(nonce_hash) = 64 AND nonce_hash NOT GLOB '*[^0-9a-f]*'),
|
|
17
|
+
status TEXT NOT NULL CHECK (status IN ('issued','consumed','expired','revoked')),
|
|
18
|
+
expires_at TEXT NOT NULL,
|
|
19
|
+
consumed_at TEXT,
|
|
20
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
21
|
+
immutable INTEGER NOT NULL DEFAULT 1 CHECK (immutable = 1),
|
|
22
|
+
CHECK ((status = 'consumed' AND consumed_at IS NOT NULL) OR (status <> 'consumed' AND consumed_at IS NULL))
|
|
23
|
+
)
|
|
24
|
+
`);
|
|
25
|
+
// Supports the future CAS lookup "find the issued challenge for this account+github+source event".
|
|
26
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_icc_lookup ON identity_claim_challenges(account_id, github_actor_id, source_event_key)`);
|
|
27
|
+
// INSERT-status guard: a row may only be created in 'issued' (the gen-pg-schema PG generator emits the
|
|
28
|
+
// equivalent BEFORE INSERT trigger). Status then migrates issued→{consumed,expired,revoked} via UPDATE.
|
|
29
|
+
db.exec(`CREATE TRIGGER IF NOT EXISTS trg_icc_insert_issued BEFORE INSERT ON identity_claim_challenges WHEN NEW.status <> 'issued' BEGIN SELECT RAISE(ABORT, 'identity_claim_challenges must be inserted with status=issued'); END`);
|
|
30
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F10 — claimable GitHub contribution DISCOVERY (read-only). Lets a logged-in account see which
|
|
3
|
+
* credential-backed GitHub contribution facts are currently CLAIMABLE — i.e. their GitHub actor is not
|
|
4
|
+
* yet bound by ANY account — so the F9 claim UI no longer depends on a maintainer hand-delivering
|
|
5
|
+
* `source_event_key` / `github_actor_id` (dogfood R3 finding F10, proposal tp_ce110fed).
|
|
6
|
+
*
|
|
7
|
+
* Trust / safety boundaries (mirrors identity-claim-read.ts, PR-F4):
|
|
8
|
+
* - READ-ONLY: this module issues SELECT only — it never writes identity_bindings_active /
|
|
9
|
+
* identity_binding_events / contribution_facts / github_fact_credentials /
|
|
10
|
+
* identity_claim_challenges, never issues a challenge, never touches accountable_ref.
|
|
11
|
+
* - Same credential-backed trust root as F2/F3b/F4: a fact is surfaced only when it is
|
|
12
|
+
* `source='github'` + `status='active'` + linked to a credential whose actor matches the fact's
|
|
13
|
+
* `executor_ref` ('github:' || actor) — a forged executor_ref without a credential never appears.
|
|
14
|
+
* - CLAIMABLE = the actor has NO active binding (LEFT JOIN … IS NULL): an actor bound by another
|
|
15
|
+
* account is excluded (it is theirs), and an actor bound by the CALLER is also excluded here —
|
|
16
|
+
* those facts already appear in /github/me's attributable_facts (the F4 surface).
|
|
17
|
+
* - No secret in the output: no account_id, credential_id, core_json/digest, token, nonce,
|
|
18
|
+
* nonce_hash, proof material. Only minimal display fields + what the claim form needs
|
|
19
|
+
* (source_event_key + github_actor_id — both already disclosed-by-design at claim-challenge).
|
|
20
|
+
* - `accountId` is accepted for interface parity with the other read engines (the route always passes
|
|
21
|
+
* the SESSION user) and reserved for future per-account filtering; discovery output is currently
|
|
22
|
+
* account-independent by construction (unbound actors only).
|
|
23
|
+
*
|
|
24
|
+
* Visibility posture: same as claim-challenge (#311, by design) — an unclaimed, credential-backed
|
|
25
|
+
* contribution is discoverable and claimable; that is the point of the GitHub-first promise.
|
|
26
|
+
* No reward / score / valuation anywhere; the route wraps the response in the uncommitted-value boundary.
|
|
27
|
+
*/
|
|
28
|
+
import { dbAll } from '../../layer0-foundation/L0-1-database/db.js'; // RFC-016 async read seam
|
|
29
|
+
// Active, credential-backed, executor-matching GitHub facts whose actor is NOT bound by any account.
|
|
30
|
+
// DISTINCT collapses credential-version upgrade chains (multiple credentials → the same fact carry the
|
|
31
|
+
// same PR identity fields). Newest merged work first; bounded.
|
|
32
|
+
const CLAIMABLE_SQL = `
|
|
33
|
+
SELECT DISTINCT f.fact_id, f.source_event_key, f.source, f.type, f.artifact_ref, f.occurred_at,
|
|
34
|
+
f.created_at, c.github_actor_id, c.repository_id, c.pr_number, c.merge_commit_sha,
|
|
35
|
+
c.merged_at, c.lifecycle_event
|
|
36
|
+
FROM contribution_facts f
|
|
37
|
+
JOIN github_fact_credentials l
|
|
38
|
+
ON l.fact_id = f.fact_id AND l.source_event_key = f.source_event_key
|
|
39
|
+
JOIN github_contribution_credentials c
|
|
40
|
+
ON c.credential_id = l.credential_id AND c.source_event_key = l.source_event_key
|
|
41
|
+
LEFT JOIN identity_bindings_active b
|
|
42
|
+
ON b.github_actor_id = c.github_actor_id
|
|
43
|
+
WHERE f.source = 'github'
|
|
44
|
+
AND f.status = 'active'
|
|
45
|
+
AND f.executor_ref = 'github:' || c.github_actor_id
|
|
46
|
+
AND b.github_actor_id IS NULL
|
|
47
|
+
ORDER BY COALESCE(c.merged_at, f.created_at) DESC, f.fact_id
|
|
48
|
+
LIMIT 50`;
|
|
49
|
+
/** List the currently claimable (unbound-actor) credential-backed GitHub facts. Read-only. */
|
|
50
|
+
export async function listClaimableGithubIdentityFacts(accountId) {
|
|
51
|
+
if (!accountId || typeof accountId !== 'string')
|
|
52
|
+
return { claimable_facts: [] };
|
|
53
|
+
const rows = await dbAll(CLAIMABLE_SQL, []);
|
|
54
|
+
return { claimable_facts: rows };
|
|
55
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PR-F2 — GitHub identity claim engine (NO API). Consumes an issued publication challenge and binds the
|
|
3
|
+
* GitHub actor → WebAZ account, in ONE synchronous transaction. Design: docs/IDENTITY-CLAIM-DESIGN.md.
|
|
4
|
+
*
|
|
5
|
+
* Trust boundary (F2 vs F3): F2 takes `proofVerified: true` — the publication proof (gist via the #295
|
|
6
|
+
* authenticated read) is F3's job. F2 REFUSES (proof_not_verified) if the flag is not explicitly true,
|
|
7
|
+
* so an un-verified proof can never complete a binding. No API/MCP/UI, no GitHub fetch here.
|
|
8
|
+
*
|
|
9
|
+
* Binding granularity is IDENTITY-level: `github_actor_id → account_id` (the stable actor id, NEVER the
|
|
10
|
+
* renameable login). The fact/source_event_key is only a PRECONDITION GUARD ("this GitHub actor has a
|
|
11
|
+
* claimable contribution"): the fact must exist and its executor must be this github_actor_id.
|
|
12
|
+
*
|
|
13
|
+
* Atomicity (Codex F2): challenge consume (CAS) + bind run in ONE synchronous `db.transaction().immediate()`.
|
|
14
|
+
* The fact/actor precondition is checked BEFORE the CAS, so a doomed claim never consumes the challenge.
|
|
15
|
+
* The CAS requires status='issued' AND not expired AND account/github/source all match → changes=1. If the
|
|
16
|
+
* bind then refuses (already_bound_to_other) or anything throws, the whole tx ROLLS BACK — the challenge is
|
|
17
|
+
* NOT left consumed. No async/await inside the transaction. proof_method is always
|
|
18
|
+
* 'github_publication_challenge' (never the governance/manual override path). visibility defaults 'private'.
|
|
19
|
+
*/
|
|
20
|
+
import { seamBackendKind, seamSqliteHandle } from '../../layer0-foundation/L0-1-database/db.js';
|
|
21
|
+
import { bindGithubIdentityCore } from './identity-binding-engine.js';
|
|
22
|
+
import { assertGithubCredentialBackedFact } from './identity-claim-fact-precondition.js';
|
|
23
|
+
const MAX_BUSY_RETRIES = 5;
|
|
24
|
+
const BUSY_BACKOFF_MS = 25;
|
|
25
|
+
const isSqliteBusy = (e) => {
|
|
26
|
+
const c = e?.code;
|
|
27
|
+
return c === 'SQLITE_BUSY' || c === 'SQLITE_BUSY_SNAPSHOT';
|
|
28
|
+
};
|
|
29
|
+
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
30
|
+
const refused = (reason, detail) => ({ ok: false, status: 'refused', reason, detail });
|
|
31
|
+
// Sentinel thrown inside the tx to ROLL BACK a consumed challenge (e.g. bind refused) — caught outside.
|
|
32
|
+
class ClaimRollback extends Error {
|
|
33
|
+
outcome;
|
|
34
|
+
constructor(outcome) {
|
|
35
|
+
super('claim rollback');
|
|
36
|
+
this.outcome = outcome;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
40
|
+
export async function claimGithubIdentity(input) {
|
|
41
|
+
const { accountId, githubActorId, sourceEventKey, challengeId } = input;
|
|
42
|
+
if (!accountId || !githubActorId || !sourceEventKey || !challengeId) {
|
|
43
|
+
return refused('invariant_violation', 'accountId, githubActorId, sourceEventKey, challengeId are required');
|
|
44
|
+
}
|
|
45
|
+
// Proof gate — F2 only completes a binding for a PRE-VERIFIED proof (F3 verifies the gist).
|
|
46
|
+
if (input.proofVerified !== true)
|
|
47
|
+
return refused('proof_not_verified', 'publication proof is not verified (F3 must verify before F2 binds)');
|
|
48
|
+
const kind = seamBackendKind();
|
|
49
|
+
const db = seamSqliteHandle();
|
|
50
|
+
if (kind !== 'sqlite' || db === null)
|
|
51
|
+
return refused('backend_unsupported', `backend=${kind ?? 'uninitialized'}`);
|
|
52
|
+
const txn = db.transaction(() => {
|
|
53
|
+
// 1) precondition (BEFORE the CAS, so a doomed claim leaves the challenge ISSUED): the fact must be a
|
|
54
|
+
// GitHub credential-BACKED active fact whose AUTHENTICATED credential names this actor (Codex F2 P1).
|
|
55
|
+
// Shared with the F3b issuance engine via assertGithubCredentialBackedFact (behavior-zero).
|
|
56
|
+
const pre = assertGithubCredentialBackedFact(db, sourceEventKey, githubActorId);
|
|
57
|
+
if (!pre.ok)
|
|
58
|
+
return refused(pre.reason, pre.detail);
|
|
59
|
+
// 2) CAS consume the issued challenge (single-use; all of account/github/source must match).
|
|
60
|
+
const cas = db.prepare(`UPDATE identity_claim_challenges
|
|
61
|
+
SET status = 'consumed', consumed_at = datetime('now')
|
|
62
|
+
WHERE challenge_id = ? AND status = 'issued' AND expires_at > datetime('now')
|
|
63
|
+
AND account_id = ? AND github_actor_id = ? AND source_event_key = ?`)
|
|
64
|
+
.run(challengeId, accountId, githubActorId, sourceEventKey);
|
|
65
|
+
if (cas.changes !== 1) {
|
|
66
|
+
// 0 changes → no write happened; determine WHY (commit is a no-op, challenge state untouched).
|
|
67
|
+
const row = db.prepare(`SELECT status, (expires_at > datetime('now')) AS not_expired, account_id, github_actor_id, source_event_key
|
|
68
|
+
FROM identity_claim_challenges WHERE challenge_id = ?`).get(challengeId);
|
|
69
|
+
if (!row)
|
|
70
|
+
return refused('challenge_not_found');
|
|
71
|
+
if (row.status === 'consumed')
|
|
72
|
+
return refused('challenge_already_used');
|
|
73
|
+
if (row.status === 'expired' || row.status === 'revoked')
|
|
74
|
+
return refused('challenge_expired');
|
|
75
|
+
if (row.status === 'issued' && row.not_expired !== 1)
|
|
76
|
+
return refused('challenge_expired');
|
|
77
|
+
// issued + not expired but account/github/source didn't match this claim → no matching challenge.
|
|
78
|
+
if (row.account_id !== accountId || row.github_actor_id !== githubActorId || row.source_event_key !== sourceEventKey)
|
|
79
|
+
return refused('challenge_not_found');
|
|
80
|
+
return refused('invariant_violation', 'challenge CAS matched 0 rows for an otherwise-valid challenge');
|
|
81
|
+
}
|
|
82
|
+
// 3) bind (challenge now consumed in THIS tx). bound→claimed; already_bound(self)→commit idempotently;
|
|
83
|
+
// already_bound_to_other / unexpected → THROW to roll back the consumed challenge.
|
|
84
|
+
const b = bindGithubIdentityCore(db, { githubActorId, accountId, proofMethod: 'github_publication_challenge', proofRef: challengeId, visibility: 'private' });
|
|
85
|
+
if (b.ok && b.status === 'bound')
|
|
86
|
+
return { ok: true, status: 'claimed', github_actor_id: githubActorId, account_id: accountId, challenge_id: challengeId };
|
|
87
|
+
if (b.ok && b.status === 'already_bound')
|
|
88
|
+
return { ok: true, status: 'already_bound_self', github_actor_id: githubActorId, account_id: accountId, challenge_id: challengeId };
|
|
89
|
+
if (!b.ok && b.reason === 'already_bound_to_other')
|
|
90
|
+
throw new ClaimRollback(refused('already_bound_other', b.detail));
|
|
91
|
+
throw new ClaimRollback(refused('invariant_violation', `unexpected bind result: ${JSON.stringify(b)}`));
|
|
92
|
+
});
|
|
93
|
+
for (let attempt = 0;; attempt++) {
|
|
94
|
+
try {
|
|
95
|
+
return txn.immediate();
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
if (err instanceof ClaimRollback)
|
|
99
|
+
return err.outcome;
|
|
100
|
+
if (isSqliteBusy(err) && attempt < MAX_BUSY_RETRIES) {
|
|
101
|
+
await sleep(BUSY_BACKOFF_MS * (attempt + 1));
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (isSqliteBusy(err))
|
|
105
|
+
return refused('db_busy', `busy after ${MAX_BUSY_RETRIES} retries`);
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function assertGithubCredentialBackedFact(db, sourceEventKey, githubActorId) {
|
|
2
|
+
const backed = db.prepare(`
|
|
3
|
+
SELECT 1 AS ok
|
|
4
|
+
FROM contribution_facts f
|
|
5
|
+
JOIN github_fact_credentials l ON l.fact_id = f.fact_id AND l.source_event_key = f.source_event_key
|
|
6
|
+
JOIN github_contribution_credentials c ON c.credential_id = l.credential_id AND c.source_event_key = l.source_event_key
|
|
7
|
+
WHERE f.source_event_key = ? AND f.source = 'github' AND f.status = 'active'
|
|
8
|
+
AND f.executor_ref = ? AND c.github_actor_id = ?
|
|
9
|
+
LIMIT 1`).get(sourceEventKey, `github:${githubActorId}`, githubActorId);
|
|
10
|
+
if (backed)
|
|
11
|
+
return { ok: true };
|
|
12
|
+
// Not credential-backed. Distinguish actor_mismatch (a github fact exists but its generic executor
|
|
13
|
+
// names another actor) from fact_not_found (everything else: no fact / not active / wrong source /
|
|
14
|
+
// no link / credential names another actor).
|
|
15
|
+
const f = db.prepare('SELECT executor_ref FROM contribution_facts WHERE source_event_key = ?')
|
|
16
|
+
.get(sourceEventKey);
|
|
17
|
+
if (!f)
|
|
18
|
+
return { ok: false, reason: 'fact_not_found', detail: sourceEventKey };
|
|
19
|
+
if (f.executor_ref !== `github:${githubActorId}`)
|
|
20
|
+
return { ok: false, reason: 'actor_mismatch', detail: `fact executor ${f.executor_ref} != github:${githubActorId}` };
|
|
21
|
+
return { ok: false, reason: 'fact_not_found', detail: 'no active GitHub credential-backed fact for this actor/source' };
|
|
22
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PR-F3a — GitHub identity-claim publication-proof verifier (GitHub Gist proof v1). INTERNAL.
|
|
3
|
+
*
|
|
4
|
+
* Trust root (lesson from #308): identity-claim authenticity must come from WebAZ's OWN authenticated
|
|
5
|
+
* re-fetch, never from caller-supplied JSON / owner / digest. This verifier RE-FETCHES the gist via the
|
|
6
|
+
* #295/#301 audited primitives (`pathFromOrigin` + `getJson`, fixed origin https://api.github.com,
|
|
7
|
+
* GET-only, manual-redirect, AbortSignal timeout) and verifies:
|
|
8
|
+
* - the gist's `owner.id` STRICTLY equals `githubActorId` (the stable id — NEVER login);
|
|
9
|
+
* - a gist file contains the marker `webaz-identity-claim:v1:<challengeId>:<nonce>`;
|
|
10
|
+
* - `sha256(nonce)` equals `expectedNonceHash` (the value stored in identity_claim_challenges).
|
|
11
|
+
*
|
|
12
|
+
* Scope: ONLY GitHub Gist proof v1. No API/DB write/claim-commit here. The production entry takes NO
|
|
13
|
+
* fetchImpl/now/caller-owner injection (uses globalThis.fetch; tests swap the global). `token` is a
|
|
14
|
+
* trusted-config dep (optional — public gists need none); it is sent ONLY to api.github.com and NEVER
|
|
15
|
+
* appears in any result/reasons. Truncated content → refused `proof_truncated` (raw_url is NEVER
|
|
16
|
+
* followed). Every failure is a TYPED outcome; the function never throws a predictable error.
|
|
17
|
+
*/
|
|
18
|
+
import { z } from 'zod';
|
|
19
|
+
import { getJson, pathFromOrigin, DEFAULT_TIMEOUT_MS } from './github-credential/github-fetch-adapter.js';
|
|
20
|
+
import { sha256hex } from './github-credential/canonical.js';
|
|
21
|
+
export const CLAIM_MARKER_PREFIX = 'webaz-identity-claim:v1:'; // full marker: <prefix><challengeId>:<nonce>
|
|
22
|
+
const NONCE_RE_BODY = '([A-Za-z0-9_-]+)';
|
|
23
|
+
// Strict args — rejects unknown keys (fetchImpl / now / caller-supplied owner all refused as invalid_request).
|
|
24
|
+
const ArgsSchema = z.strictObject({
|
|
25
|
+
gistId: z.string().min(1),
|
|
26
|
+
githubActorId: z.string().min(1),
|
|
27
|
+
challengeId: z.string().min(1),
|
|
28
|
+
expectedNonceHash: z.string().regex(/^[0-9a-f]{64}$/), // sha256 hex
|
|
29
|
+
token: z.string().min(1).optional(), // trusted config; public gists need none
|
|
30
|
+
timeoutMs: z.number().int().positive().max(30_000).optional(),
|
|
31
|
+
});
|
|
32
|
+
// Tolerant view of the GitHub Gist response (extra fields ignored; missing/typed-wrong → malformed_response).
|
|
33
|
+
const GistResponse = z.object({
|
|
34
|
+
owner: z.object({ id: z.union([z.number(), z.string()]) }).passthrough().nullable().optional(),
|
|
35
|
+
truncated: z.boolean().optional(),
|
|
36
|
+
files: z.record(z.string(), z.object({ content: z.string().optional(), truncated: z.boolean().optional() }).passthrough()),
|
|
37
|
+
}).passthrough();
|
|
38
|
+
const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
39
|
+
export async function verifyGithubGistProof(args) {
|
|
40
|
+
const parsed = ArgsSchema.safeParse(args);
|
|
41
|
+
if (!parsed.success) {
|
|
42
|
+
// codes only — never echo a value (no token / nonce / url leak)
|
|
43
|
+
const reasons = parsed.error.issues.map(i => i.code === 'unrecognized_keys'
|
|
44
|
+
? `unrecognized argument(s): ${i.keys?.join(', ')}`
|
|
45
|
+
: `${i.path.join('.') || '(args)'}: ${i.code}`);
|
|
46
|
+
return { ok: false, outcome: 'invalid_request', reasons };
|
|
47
|
+
}
|
|
48
|
+
const a = parsed.data;
|
|
49
|
+
const timeoutMs = a.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
50
|
+
const doFetch = globalThis.fetch; // runtime transport — NOT caller-injectable
|
|
51
|
+
try {
|
|
52
|
+
let url;
|
|
53
|
+
try {
|
|
54
|
+
url = pathFromOrigin('gists', a.gistId);
|
|
55
|
+
} // fixed origin api.github.com; encodes the id
|
|
56
|
+
catch {
|
|
57
|
+
return { ok: false, outcome: 'invalid_request', reasons: ['could not build a safe api.github.com gist URL'] };
|
|
58
|
+
}
|
|
59
|
+
// WebAZ's own authenticated read (getJson rejects any non-https://api.github.com URL — #301 P1).
|
|
60
|
+
const res = await getJson(doFetch, url, a.token, timeoutMs);
|
|
61
|
+
if (res.kind === 'fail')
|
|
62
|
+
return { ok: false, outcome: res.outcome, reasons: res.reasons }; // getJson reasons are token-free
|
|
63
|
+
const parsedGist = GistResponse.safeParse(res.body);
|
|
64
|
+
if (!parsedGist.success)
|
|
65
|
+
return { ok: false, outcome: 'malformed_response', reasons: ['gist response missing/typed-wrong fields'] };
|
|
66
|
+
const gist = parsedGist.data;
|
|
67
|
+
// owner.id must STRICTLY equal the claimed stable actor id (never login; anonymous gist → no owner).
|
|
68
|
+
if (!gist.owner || String(gist.owner.id) !== a.githubActorId) {
|
|
69
|
+
return { ok: false, outcome: 'owner_mismatch', reasons: ['gist owner.id != githubActorId'] };
|
|
70
|
+
}
|
|
71
|
+
// search every NON-truncated file for the challenge marker; raw_url is NEVER followed.
|
|
72
|
+
const re = new RegExp(escapeRegex(`${CLAIM_MARKER_PREFIX}${a.challengeId}:`) + NONCE_RE_BODY);
|
|
73
|
+
let anyTruncated = gist.truncated === true;
|
|
74
|
+
for (const fname of Object.keys(gist.files)) {
|
|
75
|
+
const f = gist.files[fname];
|
|
76
|
+
if (f?.truncated === true) {
|
|
77
|
+
anyTruncated = true;
|
|
78
|
+
continue;
|
|
79
|
+
} // incomplete → don't trust; don't fetch raw_url
|
|
80
|
+
const content = f?.content;
|
|
81
|
+
if (typeof content !== 'string')
|
|
82
|
+
continue;
|
|
83
|
+
const m = re.exec(content);
|
|
84
|
+
if (m) {
|
|
85
|
+
if (sha256hex(m[1]) === a.expectedNonceHash)
|
|
86
|
+
return { ok: true, github_actor_id: a.githubActorId, challenge_id: a.challengeId };
|
|
87
|
+
return { ok: false, outcome: 'nonce_mismatch', reasons: ['sha256(nonce) != expectedNonceHash'] };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (anyTruncated)
|
|
91
|
+
return { ok: false, outcome: 'proof_truncated', reasons: ['gist content truncated; raw_url NOT followed — re-post the marker in a small file'] };
|
|
92
|
+
return { ok: false, outcome: 'proof_not_found', reasons: ['claim marker not found in gist files'] };
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return { ok: false, outcome: 'upstream_unavailable', reasons: ['unexpected verifier error'] }; // never leak the token
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PR-F4 — GitHub identity-claim READ surface (contribution attribution visibility). INTERNAL read-only.
|
|
3
|
+
*
|
|
4
|
+
* Lets a logged-in account see (a) its OWN current GitHub identity bindings and (b) the contribution
|
|
5
|
+
* facts that are currently attributable to it via those bindings — the accountable READ-OVERLAY. This
|
|
6
|
+
* does NOT change `contribution_facts.accountable_ref` (which stays NULL — facts are immutable; the
|
|
7
|
+
* accountable party is resolved at read time from `identity_bindings_active`, per RFC-017 I-3 and the
|
|
8
|
+
* 4a engine's `resolveAccountable`).
|
|
9
|
+
*
|
|
10
|
+
* Scope is the WHOLE security argument here: every query is anchored on `account_id = <the caller>`, so
|
|
11
|
+
* a row for any OTHER account is never selected. No other account's id is returned; no token / email /
|
|
12
|
+
* nonce / nonce_hash / gist content is read or returned. No reward / score / KYC — visibility only.
|
|
13
|
+
*
|
|
14
|
+
* A fact is "mine" iff it is an ACTIVE GitHub credential-BACKED fact (same trust root as the F2/F3b
|
|
15
|
+
* precondition: contribution_facts ⋈ github_fact_credentials ⋈ github_contribution_credentials) AND its
|
|
16
|
+
* `executor_ref` is `github:<actor>` for an `<actor>` CURRENTLY bound to me. The credential join means a
|
|
17
|
+
* fact with a merely-matching generic `executor_ref` but no authenticated credential is NOT shown (the
|
|
18
|
+
* #308 lesson), and the executor-match means a credential for my actor on a fact executed by someone else
|
|
19
|
+
* is NOT shown either.
|
|
20
|
+
*
|
|
21
|
+
* Read path: the async seam (dbAll) — no transaction, backend-agnostic. spec: docs/IDENTITY-CLAIM-DESIGN.md §8.7.
|
|
22
|
+
*/
|
|
23
|
+
import { dbAll } from '../../layer0-foundation/L0-1-database/db.js';
|
|
24
|
+
// SELECT only the caller's OWN active bindings — never another account's. account_id is NOT returned
|
|
25
|
+
// (the surface is the caller's own); visibility is shown to its OWNER only (this endpoint never serves
|
|
26
|
+
// another account), so a `private` binding is never disclosed to anyone else.
|
|
27
|
+
const BINDINGS_SQL = `
|
|
28
|
+
SELECT github_actor_id, visibility, bound_at
|
|
29
|
+
FROM identity_bindings_active
|
|
30
|
+
WHERE account_id = ?
|
|
31
|
+
ORDER BY bound_at DESC, github_actor_id`;
|
|
32
|
+
// Accountable overlay, anchored on the caller's bindings: a fact is attributable to me iff it is an
|
|
33
|
+
// active GitHub credential-BACKED fact whose executor is an actor I currently hold a binding for. The
|
|
34
|
+
// b.account_id = ? anchor + identity_bindings_active's actor PK mean only MY facts can be returned.
|
|
35
|
+
const FACTS_SQL = `
|
|
36
|
+
SELECT DISTINCT f.fact_id, f.source_event_key, f.source, f.type, f.artifact_ref, f.occurred_at,
|
|
37
|
+
f.executor_ref, f.provenance, f.status, f.created_at, b.github_actor_id
|
|
38
|
+
FROM identity_bindings_active b
|
|
39
|
+
JOIN github_contribution_credentials c
|
|
40
|
+
ON c.github_actor_id = b.github_actor_id
|
|
41
|
+
JOIN github_fact_credentials l
|
|
42
|
+
ON l.credential_id = c.credential_id AND l.source_event_key = c.source_event_key
|
|
43
|
+
JOIN contribution_facts f
|
|
44
|
+
ON f.fact_id = l.fact_id AND f.source_event_key = l.source_event_key
|
|
45
|
+
WHERE b.account_id = ?
|
|
46
|
+
AND f.source = 'github'
|
|
47
|
+
AND f.status = 'active'
|
|
48
|
+
AND f.executor_ref = 'github:' || b.github_actor_id
|
|
49
|
+
ORDER BY f.created_at DESC, f.fact_id`;
|
|
50
|
+
/** The caller's OWN GitHub identity bindings + the contribution facts currently attributable to them. */
|
|
51
|
+
export async function getMyGithubIdentitySurface(accountId) {
|
|
52
|
+
if (!accountId)
|
|
53
|
+
return { bindings: [], attributable_facts: [] }; // defensive — route always passes the session user
|
|
54
|
+
const [bindings, attributable_facts] = await Promise.all([
|
|
55
|
+
dbAll(BINDINGS_SQL, [accountId]),
|
|
56
|
+
dbAll(FACTS_SQL, [accountId]),
|
|
57
|
+
]);
|
|
58
|
+
return { bindings, attributable_facts };
|
|
59
|
+
}
|