@seasonkoh/webaz 0.1.26 → 0.1.27
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/LICENSE +2 -2
- package/NOTICE +24 -3
- package/README.md +74 -330
- package/README.zh-CN.md +419 -0
- package/dist/layer0-foundation/L0-2-state-machine/genuine-sale.js +21 -0
- package/dist/layer0-foundation/L0-5-manifest/manifest.js +8 -3
- package/dist/layer1-agent/L1-1-mcp-server/auth.js +13 -1
- package/dist/layer1-agent/L1-1-mcp-server/server.js +36 -28
- package/dist/layer2-business/L2-9-contribution/admin-coordination-ingestion-engine.js +181 -0
- package/dist/layer2-business/L2-9-contribution/admin-coordination-resolver.js +114 -0
- package/dist/layer2-business/L2-9-contribution/admin-coordination-store.js +251 -0
- package/dist/layer2-business/L2-9-contribution/admin-operator-claim-workflow.js +390 -0
- package/dist/layer2-business/L2-9-contribution/build-task-agent-metadata-store.js +24 -0
- package/dist/layer2-business/L2-9-contribution/build-task-participation.js +6 -2
- package/dist/layer2-business/L2-9-contribution/build-task-quota.js +337 -0
- package/dist/layer2-business/L2-9-contribution/build-task-read.js +25 -2
- package/dist/layer2-business/L2-9-contribution/build-tasks-engine.js +57 -7
- package/dist/layer2-business/L2-9-contribution/canonical-contribution-target.js +1 -1
- package/dist/layer2-business/L2-9-contribution/contribution-facts-read.js +66 -0
- package/dist/layer2-business/L2-9-contribution/task-proposal-draft.js +187 -18
- package/dist/layer2-business/L2-9-contribution/task-proposal-store.js +29 -4
- package/dist/ledger.js +1 -1
- package/dist/pwa/admin-audit.js +38 -0
- package/dist/pwa/anti-abuse-thresholds.js +135 -0
- package/dist/pwa/cf-origin-guard.js +33 -0
- package/dist/pwa/contract-fingerprint.js +1 -0
- package/dist/pwa/data/onboarding-cases.js +2 -2
- package/dist/pwa/data/onboarding-quiz.js +1 -1
- package/dist/pwa/economic-participation.js +2 -2
- package/dist/pwa/integration-contract.js +46 -4
- package/dist/pwa/internal/pv-settlement.js +12 -0
- package/dist/pwa/internal/wallet-signer.js +26 -0
- package/dist/pwa/public/app.js +679 -679
- package/dist/pwa/public/i18n.js +15 -28
- package/dist/pwa/public/index.html +1 -1
- package/dist/pwa/public/openapi.json +4760 -2769
- package/dist/pwa/pv-kill-switch.js +31 -0
- package/dist/pwa/routes/admin-admins.js +48 -1
- package/dist/pwa/routes/admin-analytics.js +1 -10
- package/dist/pwa/routes/admin-atomic.js +4 -17
- package/dist/pwa/routes/admin-operator-claims.js +280 -0
- package/dist/pwa/routes/admin-reports.js +4 -26
- package/dist/pwa/routes/admin-tokenomics.js +2 -76
- package/dist/pwa/routes/admin-users-lifecycle.js +1 -14
- package/dist/pwa/routes/admin-users-query.js +23 -1
- package/dist/pwa/routes/admin-wallet-ops.js +1 -1
- package/dist/pwa/routes/auth-read.js +1 -5
- package/dist/pwa/routes/auth-register.js +3 -13
- package/dist/pwa/routes/build-task-quota.js +113 -0
- package/dist/pwa/routes/claim-verify.js +15 -11
- package/dist/pwa/routes/contribution-facts.js +18 -0
- package/dist/pwa/routes/dispute-cases.js +5 -4
- package/dist/pwa/routes/growth.js +3 -3
- package/dist/pwa/routes/orders-action.js +27 -10
- package/dist/pwa/routes/orders-create.js +1 -1
- package/dist/pwa/routes/products-meta.js +19 -6
- package/dist/pwa/routes/profile-placement.js +1 -1
- package/dist/pwa/routes/promoter.js +10 -29
- package/dist/pwa/routes/public-build-tasks.js +5 -1
- package/dist/pwa/routes/public-utils.js +9 -12
- package/dist/pwa/routes/referral.js +5 -26
- package/dist/pwa/routes/rewards-apply.js +3 -2
- package/dist/pwa/routes/share-redirects.js +1 -1
- package/dist/pwa/routes/shareables-interactions.js +2 -1
- package/dist/pwa/routes/task-proposals.js +85 -9
- package/dist/pwa/routes/users-public.js +1 -4
- package/dist/pwa/routes/wallet-read.js +2 -14
- package/dist/pwa/routes/webauthn.js +1 -1
- package/dist/pwa/server.js +156 -469
- package/dist/settlement-math.js +3 -3
- package/dist/version.js +6 -4
- package/package.json +33 -7
- package/dist/index.js +0 -182
- package/dist/pwa/public/docs/ECONOMIC-MODEL.md +0 -287
- package/dist/pwa/public/docs/INTEGRATOR.md +0 -67
- package/dist/pwa/public/docs/META-RULES-FULL.md +0 -543
- package/dist/test-dispute.js +0 -153
- package/dist/test-manifest.js +0 -61
- package/dist/test-mcp-tools.js +0 -135
- package/dist/test-reputation.js +0 -116
- package/dist/test-skill-market.js +0 -101
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { generateId } from '../../layer0-foundation/L0-1-database/schema.js';
|
|
2
|
+
export const QUOTA_TYPE_BUILD_TASK_CREATE = 'build_task.create';
|
|
3
|
+
export const QUOTA_TYPES = new Set([QUOTA_TYPE_BUILD_TASK_CREATE]);
|
|
4
|
+
export const URGENCIES = new Set(['low', 'normal', 'high']);
|
|
5
|
+
// fallbacks when protocol_params is absent — kept in sync with DEFAULT_PARAMS in server.ts
|
|
6
|
+
const DEFAULT_MAX_EXTRA_COUNT = 50;
|
|
7
|
+
const DEFAULT_MAX_DURATION_HOURS = 72;
|
|
8
|
+
const REASON_MIN = 5;
|
|
9
|
+
const REASON_MAX = 2000;
|
|
10
|
+
const MAX_LINKED_REFS = 20;
|
|
11
|
+
const REF_MAX_LEN = 200;
|
|
12
|
+
const isErr = (x) => !!x && typeof x === 'object' && 'error' in x;
|
|
13
|
+
function paramNum(db, key, fallback) {
|
|
14
|
+
try {
|
|
15
|
+
const row = db.prepare('SELECT value FROM protocol_params WHERE key = ?').get(key);
|
|
16
|
+
if (!row || row.value == null)
|
|
17
|
+
return fallback;
|
|
18
|
+
const n = Number(row.value);
|
|
19
|
+
return Number.isFinite(n) ? n : fallback;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return fallback;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export const maxQuotaExtraCount = (db) => paramNum(db, 'max_quota_extra_count', DEFAULT_MAX_EXTRA_COUNT);
|
|
26
|
+
export const maxQuotaDurationHours = (db) => paramNum(db, 'max_quota_duration_hours', DEFAULT_MAX_DURATION_HOURS);
|
|
27
|
+
export function initBuildTaskQuotaSchema(db) {
|
|
28
|
+
// requests + grant (single row); status drives the lifecycle
|
|
29
|
+
db.exec(`
|
|
30
|
+
CREATE TABLE IF NOT EXISTS build_task_quota_requests (
|
|
31
|
+
id TEXT PRIMARY KEY,
|
|
32
|
+
quota_type TEXT NOT NULL DEFAULT 'build_task.create',
|
|
33
|
+
requester_user_id TEXT NOT NULL,
|
|
34
|
+
requested_extra_count INTEGER NOT NULL,
|
|
35
|
+
reason TEXT NOT NULL,
|
|
36
|
+
linked_refs TEXT, -- JSON array of strings
|
|
37
|
+
urgency TEXT NOT NULL DEFAULT 'normal', -- low | normal | high
|
|
38
|
+
requested_duration_hours INTEGER,
|
|
39
|
+
requested_expires_at TEXT,
|
|
40
|
+
status TEXT NOT NULL DEFAULT 'pending', -- pending|approved|rejected|expired|exhausted|revoked
|
|
41
|
+
decided_at TEXT,
|
|
42
|
+
decided_by TEXT,
|
|
43
|
+
decision_note TEXT,
|
|
44
|
+
granted_count INTEGER,
|
|
45
|
+
consumed_count INTEGER NOT NULL DEFAULT 0,
|
|
46
|
+
expires_at TEXT,
|
|
47
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
48
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
49
|
+
)`);
|
|
50
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_bqr_status ON build_task_quota_requests(status, updated_at DESC)`);
|
|
51
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_bqr_requester ON build_task_quota_requests(requester_user_id, status)`);
|
|
52
|
+
// at most one PENDING request per requester per quota type (a decided one frees the slot)
|
|
53
|
+
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_bqr_one_pending ON build_task_quota_requests(requester_user_id, quota_type) WHERE status = 'pending'`);
|
|
54
|
+
// immutable audit/event history (append-only)
|
|
55
|
+
db.exec(`
|
|
56
|
+
CREATE TABLE IF NOT EXISTS build_task_quota_events (
|
|
57
|
+
id TEXT PRIMARY KEY,
|
|
58
|
+
request_id TEXT NOT NULL,
|
|
59
|
+
actor_id TEXT,
|
|
60
|
+
action TEXT NOT NULL, -- request_created|request_approved|request_rejected|grant_consumed|grant_expired|request_revoked
|
|
61
|
+
detail TEXT, -- JSON
|
|
62
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
63
|
+
)`);
|
|
64
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_bqe_request ON build_task_quota_events(request_id, created_at)`);
|
|
65
|
+
}
|
|
66
|
+
export function logQuotaEvent(db, requestId, actorId, action, detail) {
|
|
67
|
+
db.prepare(`INSERT INTO build_task_quota_events (id, request_id, actor_id, action, detail) VALUES (?,?,?,?,?)`)
|
|
68
|
+
.run(generateId('bqev'), requestId, actorId, action, detail ? JSON.stringify(detail) : null);
|
|
69
|
+
}
|
|
70
|
+
function parseRefs(input) {
|
|
71
|
+
const arr = Array.isArray(input) ? input : [];
|
|
72
|
+
return arr.map(r => String(r ?? '').trim()).filter(Boolean).slice(0, MAX_LINKED_REFS).map(r => r.slice(0, REF_MAX_LEN));
|
|
73
|
+
}
|
|
74
|
+
/** Lazily flip approved grants whose window has passed to 'expired' (audited). Best-effort, fail-closed. */
|
|
75
|
+
export function expireStaleGrants(db) {
|
|
76
|
+
try {
|
|
77
|
+
const stale = db.prepare(`SELECT id FROM build_task_quota_requests
|
|
78
|
+
WHERE status = 'approved' AND expires_at IS NOT NULL AND datetime(expires_at) <= datetime('now')`).all();
|
|
79
|
+
for (const s of stale) {
|
|
80
|
+
db.prepare(`UPDATE build_task_quota_requests SET status='expired', updated_at=datetime('now') WHERE id = ? AND status='approved'`).run(s.id);
|
|
81
|
+
logQuotaEvent(db, s.id, null, 'grant_expired', null);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch { /* table missing / fresh DB — nothing to expire */ }
|
|
85
|
+
}
|
|
86
|
+
export function createQuotaRequest(db, a) {
|
|
87
|
+
const quotaType = a.quotaType && QUOTA_TYPES.has(a.quotaType) ? a.quotaType : QUOTA_TYPE_BUILD_TASK_CREATE;
|
|
88
|
+
if (a.quotaType && !QUOTA_TYPES.has(a.quotaType))
|
|
89
|
+
return { error: 'unknown quota_type', error_code: 'BAD_QUOTA_TYPE' };
|
|
90
|
+
if (!a.requesterId)
|
|
91
|
+
return { error: 'requester required', error_code: 'REQUESTER_REQUIRED' };
|
|
92
|
+
const count = Math.trunc(Number(a.requestedExtraCount));
|
|
93
|
+
if (!Number.isFinite(count) || count <= 0)
|
|
94
|
+
return { error: 'requested_extra_count must be a positive integer', error_code: 'BAD_COUNT' };
|
|
95
|
+
const maxCount = maxQuotaExtraCount(db);
|
|
96
|
+
if (count > maxCount)
|
|
97
|
+
return { error: `requested_extra_count exceeds the max (${maxCount})`, error_code: 'COUNT_TOO_LARGE' };
|
|
98
|
+
const reason = String(a.reason ?? '').trim();
|
|
99
|
+
if (reason.length < REASON_MIN)
|
|
100
|
+
return { error: `reason is required (>= ${REASON_MIN} chars)`, error_code: 'REASON_REQUIRED' };
|
|
101
|
+
const reasonClamped = reason.slice(0, REASON_MAX);
|
|
102
|
+
const urgency = a.urgency && URGENCIES.has(a.urgency) ? a.urgency : 'normal';
|
|
103
|
+
if (a.urgency && !URGENCIES.has(a.urgency))
|
|
104
|
+
return { error: 'bad urgency', error_code: 'BAD_URGENCY' };
|
|
105
|
+
const maxDur = maxQuotaDurationHours(db);
|
|
106
|
+
let durationHours = null;
|
|
107
|
+
if (a.requestedDurationHours != null) {
|
|
108
|
+
durationHours = Math.trunc(Number(a.requestedDurationHours));
|
|
109
|
+
if (!Number.isFinite(durationHours) || durationHours <= 0)
|
|
110
|
+
return { error: 'requested_duration_hours must be a positive integer', error_code: 'BAD_DURATION' };
|
|
111
|
+
if (durationHours > maxDur)
|
|
112
|
+
return { error: `requested_duration_hours exceeds the max (${maxDur})`, error_code: 'DURATION_TOO_LARGE' };
|
|
113
|
+
}
|
|
114
|
+
const refs = JSON.stringify(parseRefs(a.linkedRefs));
|
|
115
|
+
const id = generateId('bqr');
|
|
116
|
+
try {
|
|
117
|
+
const tx = db.transaction(() => {
|
|
118
|
+
db.prepare(`INSERT INTO build_task_quota_requests
|
|
119
|
+
(id, quota_type, requester_user_id, requested_extra_count, reason, linked_refs, urgency, requested_duration_hours, requested_expires_at, status)
|
|
120
|
+
VALUES (?,?,?,?,?,?,?,?, ${durationHours != null ? `datetime('now','+${durationHours} hours')` : 'NULL'}, 'pending')`).run(id, quotaType, a.requesterId, count, reasonClamped, refs, urgency, durationHours);
|
|
121
|
+
logQuotaEvent(db, id, a.requesterId, 'request_created', { quota_type: quotaType, requested_extra_count: count, urgency });
|
|
122
|
+
});
|
|
123
|
+
tx();
|
|
124
|
+
}
|
|
125
|
+
catch (e) {
|
|
126
|
+
// partial unique index → an open pending request already exists
|
|
127
|
+
if (String(e.message || '').includes('idx_bqr_one_pending') || String(e.message || '').toUpperCase().includes('UNIQUE'))
|
|
128
|
+
return { error: 'you already have a pending quota request of this type', error_code: 'ALREADY_PENDING' };
|
|
129
|
+
return { error: 'failed to create quota request', error_code: 'CREATE_FAILED' };
|
|
130
|
+
}
|
|
131
|
+
return { id, status: 'pending' };
|
|
132
|
+
}
|
|
133
|
+
export function getQuotaRequest(db, id) {
|
|
134
|
+
expireStaleGrants(db);
|
|
135
|
+
return db.prepare('SELECT * FROM build_task_quota_requests WHERE id = ?').get(id);
|
|
136
|
+
}
|
|
137
|
+
export function listMyQuotaRequests(db, userId) {
|
|
138
|
+
expireStaleGrants(db);
|
|
139
|
+
return db.prepare('SELECT * FROM build_task_quota_requests WHERE requester_user_id = ? ORDER BY created_at DESC LIMIT 200').all(userId);
|
|
140
|
+
}
|
|
141
|
+
export function listQuotaRequests(db, f = {}) {
|
|
142
|
+
expireStaleGrants(db);
|
|
143
|
+
if (f.status)
|
|
144
|
+
return db.prepare('SELECT * FROM build_task_quota_requests WHERE status = ? ORDER BY created_at DESC LIMIT 500').all(f.status);
|
|
145
|
+
return db.prepare('SELECT * FROM build_task_quota_requests ORDER BY created_at DESC LIMIT 500').all();
|
|
146
|
+
}
|
|
147
|
+
/** Requester's create count over the trailing 24h — context for the root reviewer. */
|
|
148
|
+
export function requesterUsage24h(db, userId) {
|
|
149
|
+
try {
|
|
150
|
+
return db.prepare(`SELECT COUNT(*) AS n FROM build_tasks WHERE created_by = ? AND created_at > datetime('now','-1 day')`).get(userId).n;
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return 0;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
export function approveQuotaRequest(db, id, approverId, a = {}) {
|
|
157
|
+
if (!approverId)
|
|
158
|
+
return { error: 'approver required', error_code: 'APPROVER_REQUIRED' };
|
|
159
|
+
expireStaleGrants(db);
|
|
160
|
+
let result = { error: 'unknown', error_code: 'UNKNOWN' };
|
|
161
|
+
try {
|
|
162
|
+
db.transaction(() => {
|
|
163
|
+
const r = db.prepare('SELECT * FROM build_task_quota_requests WHERE id = ?').get(id);
|
|
164
|
+
if (!r) {
|
|
165
|
+
result = { error: 'request not found', error_code: 'NOT_FOUND' };
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (r.status !== 'pending') {
|
|
169
|
+
result = { error: `request is ${String(r.status)}, not pending`, error_code: 'BAD_STATE' };
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (String(r.requester_user_id) === approverId) {
|
|
173
|
+
result = { error: 'cannot decide your own quota request', error_code: 'SELF_DECISION' };
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const maxCount = maxQuotaExtraCount(db);
|
|
177
|
+
let granted = a.grantedCount != null ? Math.trunc(Number(a.grantedCount)) : Number(r.requested_extra_count);
|
|
178
|
+
if (!Number.isFinite(granted) || granted <= 0) {
|
|
179
|
+
result = { error: 'granted count must be a positive integer', error_code: 'BAD_COUNT' };
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (granted > maxCount) {
|
|
183
|
+
result = { error: `granted count exceeds the max (${maxCount})`, error_code: 'COUNT_TOO_LARGE' };
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const maxDur = maxQuotaDurationHours(db);
|
|
187
|
+
// prefer an explicit duration; else fall back to the requested duration; else the max
|
|
188
|
+
let durationHours = a.durationHours != null ? Math.trunc(Number(a.durationHours))
|
|
189
|
+
: (r.requested_duration_hours != null ? Number(r.requested_duration_hours) : maxDur);
|
|
190
|
+
if (!Number.isFinite(durationHours) || durationHours <= 0) {
|
|
191
|
+
result = { error: 'duration must be a positive integer', error_code: 'BAD_DURATION' };
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (durationHours > maxDur) {
|
|
195
|
+
result = { error: `duration exceeds the max (${maxDur})`, error_code: 'DURATION_TOO_LARGE' };
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
// compute the grant window. An explicit ISO expiresAt is normalized + range-checked via SQL.
|
|
199
|
+
if (a.expiresAt) {
|
|
200
|
+
const okFuture = db.prepare(`SELECT datetime(?) AS e, datetime('now') AS now, datetime('now','+${maxDur} hours') AS maxe`).get(a.expiresAt);
|
|
201
|
+
if (!okFuture.e) {
|
|
202
|
+
result = { error: 'bad expires_at', error_code: 'BAD_EXPIRES_AT' };
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (okFuture.e <= okFuture.now) {
|
|
206
|
+
result = { error: 'expires_at must be in the future', error_code: 'EXPIRES_IN_PAST' };
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (okFuture.e > okFuture.maxe) {
|
|
210
|
+
result = { error: `expires_at exceeds the max window (${maxDur}h)`, error_code: 'EXPIRES_TOO_FAR' };
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
db.prepare(`UPDATE build_task_quota_requests
|
|
214
|
+
SET status='approved', decided_at=datetime('now'), decided_by=?, decision_note=?, granted_count=?, consumed_count=0, expires_at=datetime(?), updated_at=datetime('now')
|
|
215
|
+
WHERE id = ?`).run(approverId, a.decisionNote ?? null, granted, a.expiresAt, id);
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
db.prepare(`UPDATE build_task_quota_requests
|
|
219
|
+
SET status='approved', decided_at=datetime('now'), decided_by=?, decision_note=?, granted_count=?, consumed_count=0, expires_at=datetime('now','+${durationHours} hours'), updated_at=datetime('now')
|
|
220
|
+
WHERE id = ?`).run(approverId, a.decisionNote ?? null, granted, id);
|
|
221
|
+
}
|
|
222
|
+
const after = db.prepare('SELECT expires_at FROM build_task_quota_requests WHERE id = ?').get(id);
|
|
223
|
+
logQuotaEvent(db, id, approverId, 'request_approved', { granted_count: granted, expires_at: after.expires_at, decision_note: a.decisionNote ?? null });
|
|
224
|
+
result = { ok: true, id, granted_count: granted, expires_at: after.expires_at };
|
|
225
|
+
})();
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return { error: 'failed to approve quota request', error_code: 'APPROVE_FAILED' };
|
|
229
|
+
}
|
|
230
|
+
return result;
|
|
231
|
+
}
|
|
232
|
+
export function rejectQuotaRequest(db, id, approverId, a = {}) {
|
|
233
|
+
if (!approverId)
|
|
234
|
+
return { error: 'approver required', error_code: 'APPROVER_REQUIRED' };
|
|
235
|
+
let result = { error: 'unknown', error_code: 'UNKNOWN' };
|
|
236
|
+
try {
|
|
237
|
+
db.transaction(() => {
|
|
238
|
+
const r = db.prepare('SELECT * FROM build_task_quota_requests WHERE id = ?').get(id);
|
|
239
|
+
if (!r) {
|
|
240
|
+
result = { error: 'request not found', error_code: 'NOT_FOUND' };
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (r.status !== 'pending') {
|
|
244
|
+
result = { error: `request is ${String(r.status)}, not pending`, error_code: 'BAD_STATE' };
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (String(r.requester_user_id) === approverId) {
|
|
248
|
+
result = { error: 'cannot decide your own quota request', error_code: 'SELF_DECISION' };
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
db.prepare(`UPDATE build_task_quota_requests SET status='rejected', decided_at=datetime('now'), decided_by=?, decision_note=?, updated_at=datetime('now') WHERE id = ?`)
|
|
252
|
+
.run(approverId, a.decisionNote ?? null, id);
|
|
253
|
+
logQuotaEvent(db, id, approverId, 'request_rejected', { decision_note: a.decisionNote ?? null });
|
|
254
|
+
result = { ok: true, id };
|
|
255
|
+
})();
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
return { error: 'failed to reject quota request', error_code: 'REJECT_FAILED' };
|
|
259
|
+
}
|
|
260
|
+
return result;
|
|
261
|
+
}
|
|
262
|
+
export function revokeQuotaRequest(db, id, approverId, a = {}) {
|
|
263
|
+
if (!approverId)
|
|
264
|
+
return { error: 'approver required', error_code: 'APPROVER_REQUIRED' };
|
|
265
|
+
let result = { error: 'unknown', error_code: 'UNKNOWN' };
|
|
266
|
+
try {
|
|
267
|
+
db.transaction(() => {
|
|
268
|
+
const r = db.prepare('SELECT * FROM build_task_quota_requests WHERE id = ?').get(id);
|
|
269
|
+
if (!r) {
|
|
270
|
+
result = { error: 'request not found', error_code: 'NOT_FOUND' };
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (r.status !== 'approved') {
|
|
274
|
+
result = { error: `request is ${String(r.status)}, not an active grant`, error_code: 'BAD_STATE' };
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
db.prepare(`UPDATE build_task_quota_requests SET status='revoked', decision_note=COALESCE(?, decision_note), updated_at=datetime('now') WHERE id = ?`)
|
|
278
|
+
.run(a.decisionNote ?? null, id);
|
|
279
|
+
logQuotaEvent(db, id, approverId, 'request_revoked', { decision_note: a.decisionNote ?? null });
|
|
280
|
+
result = { ok: true, id };
|
|
281
|
+
})();
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
return { error: 'failed to revoke quota grant', error_code: 'REVOKE_FAILED' };
|
|
285
|
+
}
|
|
286
|
+
return result;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Consume ONE unit from the oldest still-valid grant for (user, quota_type). MUST be called inside the
|
|
290
|
+
* caller's db.transaction so the unit is spent atomically with the task INSERT. Returns the grant id +
|
|
291
|
+
* remaining units on success, or null when no valid grant exists (caller then stays rate-limited).
|
|
292
|
+
* Fail-closed: any error (e.g. missing table) returns null. Expired grants are flipped + skipped.
|
|
293
|
+
*/
|
|
294
|
+
export function consumeQuotaForCreate(db, userId, quotaType = QUOTA_TYPE_BUILD_TASK_CREATE) {
|
|
295
|
+
try {
|
|
296
|
+
// lazily expire stale grants for this user first (audited)
|
|
297
|
+
const stale = db.prepare(`SELECT id FROM build_task_quota_requests
|
|
298
|
+
WHERE requester_user_id = ? AND quota_type = ? AND status='approved'
|
|
299
|
+
AND expires_at IS NOT NULL AND datetime(expires_at) <= datetime('now')`).all(userId, quotaType);
|
|
300
|
+
for (const s of stale) {
|
|
301
|
+
db.prepare(`UPDATE build_task_quota_requests SET status='expired', updated_at=datetime('now') WHERE id = ? AND status='approved'`).run(s.id);
|
|
302
|
+
logQuotaEvent(db, s.id, null, 'grant_expired', null);
|
|
303
|
+
}
|
|
304
|
+
const g = db.prepare(`SELECT id, granted_count, consumed_count FROM build_task_quota_requests
|
|
305
|
+
WHERE requester_user_id = ? AND quota_type = ? AND status='approved'
|
|
306
|
+
AND (expires_at IS NULL OR datetime(expires_at) > datetime('now'))
|
|
307
|
+
AND consumed_count < granted_count
|
|
308
|
+
ORDER BY datetime(expires_at) ASC, created_at ASC
|
|
309
|
+
LIMIT 1`).get(userId, quotaType);
|
|
310
|
+
if (!g)
|
|
311
|
+
return null;
|
|
312
|
+
const newConsumed = Number(g.consumed_count) + 1;
|
|
313
|
+
const exhausted = newConsumed >= Number(g.granted_count);
|
|
314
|
+
db.prepare(`UPDATE build_task_quota_requests SET consumed_count = ?, status = ?, updated_at=datetime('now') WHERE id = ?`)
|
|
315
|
+
.run(newConsumed, exhausted ? 'exhausted' : 'approved', g.id);
|
|
316
|
+
logQuotaEvent(db, g.id, userId, 'grant_consumed', { consumed_count: newConsumed, granted_count: g.granted_count, exhausted });
|
|
317
|
+
return { requestId: g.id, remaining: Number(g.granted_count) - newConsumed };
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
/** Total remaining (unexpired, unexhausted) units across a user's active grants — for the UI affordance. */
|
|
324
|
+
export function remainingQuota(db, userId, quotaType = QUOTA_TYPE_BUILD_TASK_CREATE) {
|
|
325
|
+
try {
|
|
326
|
+
expireStaleGrants(db);
|
|
327
|
+
const row = db.prepare(`SELECT COALESCE(SUM(granted_count - consumed_count), 0) AS rem FROM build_task_quota_requests
|
|
328
|
+
WHERE requester_user_id = ? AND quota_type = ? AND status='approved'
|
|
329
|
+
AND (expires_at IS NULL OR datetime(expires_at) > datetime('now'))
|
|
330
|
+
AND consumed_count < granted_count`).get(userId, quotaType);
|
|
331
|
+
return Number(row.rem) || 0;
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
return 0;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
export { isErr as isQuotaError };
|
|
@@ -94,6 +94,21 @@ function shapeMetadata(row, shape) {
|
|
|
94
94
|
estimated_context_size: row.estimated_context_size, estimated_agent_budget: row.estimated_agent_budget,
|
|
95
95
|
value_state: row.value_state,
|
|
96
96
|
};
|
|
97
|
+
// Honest estimate signal (#5): a proposal→draft conversion (task-proposal-draft.ts) seeds duration 0–0 +
|
|
98
|
+
// budget 'minimal' as a "no real estimate yet" placeholder — NOT a claim the task is instant / zero-cost.
|
|
99
|
+
// Surface a typed status so an agent never reads the placeholder as a real estimate. The raw
|
|
100
|
+
// estimated_duration / estimated_agent_budget / auto_claimable fields above are left untouched (no storage
|
|
101
|
+
// change); these are derived, advisory fields. estimate_status keys on the DURATION sentinel (null or 0–0):
|
|
102
|
+
// the draft path sets duration 0–0 together with budget 'minimal', so a 0–0 duration marks the whole estimate
|
|
103
|
+
// (duration + budget) as a placeholder. Budget alone is NOT used as the signal — 'minimal' is also a valid
|
|
104
|
+
// value for a genuinely tiny task. A 0–0/placeholder task that is nominally auto_claimable is downgraded to
|
|
105
|
+
// manual_review so a missing estimate is reviewed before the task is treated as routine.
|
|
106
|
+
const durMin = row.estimated_duration_min_minutes, durMax = row.estimated_duration_max_minutes;
|
|
107
|
+
const estimateUnknown = durMin == null || durMax == null || (durMin === 0 && durMax === 0);
|
|
108
|
+
const autoClaimable = row.auto_claimable === 1;
|
|
109
|
+
m.estimate_status = estimateUnknown ? 'unknown' : 'provided';
|
|
110
|
+
m.claimability = !autoClaimable || estimateUnknown ? 'manual_review' : 'auto_claimable';
|
|
111
|
+
m.human_review_required = m.claimability === 'manual_review';
|
|
97
112
|
for (const k of LIST_ARRAY_FIELDS)
|
|
98
113
|
m[k] = parseJsonList(row[k]);
|
|
99
114
|
if (shape === 'detail') {
|
|
@@ -148,9 +163,17 @@ function buildWhere(scope, f) {
|
|
|
148
163
|
where.push('m.audience = ?');
|
|
149
164
|
params.push(f.audience);
|
|
150
165
|
}
|
|
166
|
+
// auto_claimable filter is CLAIMABILITY-EQUIVALENT (#5), not a raw-field match — both directions mirror the
|
|
167
|
+
// derived claimability so list/filter/guard agree:
|
|
168
|
+
// claimability='auto_claimable' ⟺ raw auto_claimable=1 AND a real (non 0–0/non-null) estimate
|
|
169
|
+
// claimability='manual_review' ⟺ raw auto_claimable=0 OR a 0–0/null placeholder estimate
|
|
170
|
+
// So `=true` excludes placeholder tasks, and `=false` INCLUDES a placeholder task even if its raw flag is 1.
|
|
151
171
|
if (f.auto_claimable !== undefined) {
|
|
152
|
-
|
|
153
|
-
|
|
172
|
+
const ESTIMATE_REAL = '(m.estimated_duration_min_minutes IS NOT NULL AND m.estimated_duration_max_minutes IS NOT NULL AND NOT (m.estimated_duration_min_minutes = 0 AND m.estimated_duration_max_minutes = 0))';
|
|
173
|
+
if (f.auto_claimable)
|
|
174
|
+
where.push(`m.auto_claimable = 1 AND ${ESTIMATE_REAL}`);
|
|
175
|
+
else
|
|
176
|
+
where.push(`(m.auto_claimable = 0 OR NOT ${ESTIMATE_REAL})`);
|
|
154
177
|
}
|
|
155
178
|
// required_capabilities (AND): required_capabilities is a JSON array of strings; match an exact element
|
|
156
179
|
// via a quoted LIKE (dialect-agnostic; no json_each). ESCAPE so %/_ in a capability stay literal. This
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { generateId } from '../../layer0-foundation/L0-1-database/schema.js';
|
|
2
2
|
import { creditBuildReputation, BUILD_POINTS } from './build-reputation-engine.js';
|
|
3
|
+
import { consumeQuotaForCreate, QUOTA_TYPE_BUILD_TASK_CREATE } from './build-task-quota.js';
|
|
3
4
|
export const TASK_STATUS = new Set(['open', 'claimed', 'in_review', 'done', 'abandoned']);
|
|
4
5
|
export const TASK_PROVENANCE = new Set(['human', 'ai_assisted', 'ai_authored']);
|
|
5
6
|
const CLAIM_TTL_DAYS = 7; // 认领后多久没进 in_review 自动回 open
|
|
@@ -60,6 +61,35 @@ export function releaseExpiredClaims(db) {
|
|
|
60
61
|
}
|
|
61
62
|
return expired.length;
|
|
62
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Root admin accounts are exempt from the per-user create rate limit. The anti-spam cap
|
|
66
|
+
* (CREATE_RATE_PER_DAY) targets untrusted / public contributors; root maintainer actions are already
|
|
67
|
+
* gated by auth and recorded in admin_audit_log, so the cap adds friction without protection.
|
|
68
|
+
* Root = users.role = 'admin' AND users.admin_type = 'root' (the boot migration in server.ts forces
|
|
69
|
+
* any legacy admin with NULL admin_type to 'root', so the live root admin always carries 'root';
|
|
70
|
+
* matches the existing isRootAdmin / requireRootAdmin distinction).
|
|
71
|
+
* Regional / non-root admins stay capped — they should request more headroom via the separate
|
|
72
|
+
* quota-increase request workflow (PR #18) rather than a blanket exemption. Fail-closed: any lookup
|
|
73
|
+
* failure (unknown creator, non-root role, or a missing users table / missing admin_type column)
|
|
74
|
+
* returns false → the cap still applies, so non-root and test behavior is unchanged.
|
|
75
|
+
*/
|
|
76
|
+
function isCreateRateLimitExempt(db, userId) {
|
|
77
|
+
try {
|
|
78
|
+
const u = db.prepare('SELECT role, admin_type FROM users WHERE id = ?').get(userId);
|
|
79
|
+
return !!u && u.role === 'admin' && u.admin_type === 'root';
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// sync INSERT + event log; callable directly or inside a db.transaction (e.g. atomic quota consume)
|
|
86
|
+
function insertBuildTaskRow(db, fields) {
|
|
87
|
+
const id = generateId('bt');
|
|
88
|
+
db.prepare(`INSERT INTO build_tasks (id, title, area, description, rfc_ref, status, created_by)
|
|
89
|
+
VALUES (?,?,?,?,?, 'open', ?)`).run(id, fields.title, fields.area, fields.description, fields.rfcRef, fields.creatorId);
|
|
90
|
+
logTaskEvent(db, id, fields.creatorId, null, 'open', 'created');
|
|
91
|
+
return { id, status: 'open' };
|
|
92
|
+
}
|
|
63
93
|
export function createBuildTask(db, input) {
|
|
64
94
|
const title = String(input.title || '').trim();
|
|
65
95
|
if (title.length < 3)
|
|
@@ -69,15 +99,35 @@ export function createBuildTask(db, input) {
|
|
|
69
99
|
const description = input.description ? String(input.description).slice(0, TEXT_MAX) : null;
|
|
70
100
|
const area = input.area ? String(input.area).slice(0, 64) : null;
|
|
71
101
|
const rfcRef = input.rfcRef ? String(input.rfcRef).slice(0, 64) : null;
|
|
102
|
+
const fields = { title, area, description, rfcRef, creatorId: input.creatorId };
|
|
103
|
+
// root admin accounts are exempt from the cap (accountable via auth + admin_audit_log)
|
|
104
|
+
if (isCreateRateLimitExempt(db, input.creatorId))
|
|
105
|
+
return insertBuildTaskRow(db, fields);
|
|
106
|
+
// anti-spam per-user daily cap
|
|
72
107
|
const todayCount = db.prepare(`SELECT COUNT(*) AS n FROM build_tasks WHERE created_by = ? AND created_at > datetime('now','-1 day')`).get(input.creatorId).n;
|
|
73
|
-
if (todayCount
|
|
74
|
-
return
|
|
108
|
+
if (todayCount < CREATE_RATE_PER_DAY)
|
|
109
|
+
return insertBuildTaskRow(db, fields);
|
|
110
|
+
// cap exhausted — spend one unit of an approved quota grant ATOMICALLY with the INSERT, so a failed
|
|
111
|
+
// create never consumes quota. No valid grant → RATE_LIMITED (structured, so the UI can offer the
|
|
112
|
+
// "request extra quota" affordance). consumeQuotaForCreate is fail-closed (missing table → null).
|
|
113
|
+
const RATE_LIMITED_SENTINEL = '__RATE_LIMITED__';
|
|
114
|
+
try {
|
|
115
|
+
let ok = null;
|
|
116
|
+
db.transaction(() => {
|
|
117
|
+
const grant = consumeQuotaForCreate(db, input.creatorId, QUOTA_TYPE_BUILD_TASK_CREATE);
|
|
118
|
+
if (!grant)
|
|
119
|
+
throw new Error(RATE_LIMITED_SENTINEL);
|
|
120
|
+
const r = insertBuildTaskRow(db, fields);
|
|
121
|
+
ok = { id: r.id, status: r.status, via_grant: true, remaining_quota: grant.remaining };
|
|
122
|
+
})();
|
|
123
|
+
return ok;
|
|
124
|
+
}
|
|
125
|
+
catch (e) {
|
|
126
|
+
if (e.message === RATE_LIMITED_SENTINEL) {
|
|
127
|
+
return { error: `今日建任务已达上限(${CREATE_RATE_PER_DAY}/天)`, error_code: 'RATE_LIMITED', limit: CREATE_RATE_PER_DAY, used: todayCount, can_request: true };
|
|
128
|
+
}
|
|
129
|
+
throw e;
|
|
75
130
|
}
|
|
76
|
-
const id = generateId('bt');
|
|
77
|
-
db.prepare(`INSERT INTO build_tasks (id, title, area, description, rfc_ref, status, created_by)
|
|
78
|
-
VALUES (?,?,?,?,?, 'open', ?)`).run(id, title, area, description, rfcRef, input.creatorId);
|
|
79
|
-
logTaskEvent(db, id, input.creatorId, null, 'open', 'created');
|
|
80
|
-
return { id, status: 'open' };
|
|
81
131
|
}
|
|
82
132
|
export function listBuildTasks(db, f = {}) {
|
|
83
133
|
releaseExpiredClaims(db); // 先回收过期占坑,列表才准
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const DEFAULT_FULL_NAME = '
|
|
1
|
+
const DEFAULT_FULL_NAME = 'webaz-protocol/webaz';
|
|
2
2
|
const DEFAULT_BASE_BRANCH = 'main';
|
|
3
3
|
/** The frozen canonical target from trusted config. Identical for every read response (public + member). */
|
|
4
4
|
export function getCanonicalContributionTarget() {
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { resolveOperatorClaimAsOf } from './admin-coordination-resolver.js';
|
|
2
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
3
|
+
const dateOf = (ts) => (ts ? String(ts).slice(0, 10) : '');
|
|
4
|
+
// GitHub: credential-backed facts whose executor is an actor the caller currently binds (same trust-root
|
|
5
|
+
// join as identity-claim-read.ts FACTS_SQL). The b.account_id = ? anchor means only MY facts return.
|
|
6
|
+
const GITHUB_FACTS_SQL = `
|
|
7
|
+
SELECT DISTINCT f.fact_id, f.source_event_key, f.source, f.type, f.artifact_ref, f.occurred_at,
|
|
8
|
+
f.executor_ref, f.provenance, f.status
|
|
9
|
+
FROM identity_bindings_active b
|
|
10
|
+
JOIN github_contribution_credentials c
|
|
11
|
+
ON c.github_actor_id = b.github_actor_id
|
|
12
|
+
JOIN github_fact_credentials l
|
|
13
|
+
ON l.credential_id = c.credential_id AND l.source_event_key = c.source_event_key
|
|
14
|
+
JOIN contribution_facts f
|
|
15
|
+
ON f.fact_id = l.fact_id AND f.source_event_key = l.source_event_key
|
|
16
|
+
WHERE b.account_id = ?
|
|
17
|
+
AND f.source = 'github'
|
|
18
|
+
AND f.executor_ref = 'github:' || b.github_actor_id
|
|
19
|
+
ORDER BY f.occurred_at DESC, f.fact_id`;
|
|
20
|
+
// Admin coordination: every evidence-linked fact (executor admin:<seat>). Attribution is decided per-row
|
|
21
|
+
// by the AS-OF resolver below — never by a stored accountable_ref. The link join also yields the
|
|
22
|
+
// evidence_ref (source_type + opaque audit id); admin_audit_log.detail is never selected.
|
|
23
|
+
const ADMIN_FACTS_SQL = `
|
|
24
|
+
SELECT f.fact_id, f.source_event_key, f.source, f.type, f.artifact_ref, f.occurred_at,
|
|
25
|
+
f.executor_ref, f.provenance, f.status, s.source_type, s.admin_audit_log_id
|
|
26
|
+
FROM admin_coordination_fact_sources s
|
|
27
|
+
JOIN contribution_facts f ON f.fact_id = s.fact_id
|
|
28
|
+
WHERE f.executor_ref LIKE 'admin:%'
|
|
29
|
+
ORDER BY f.occurred_at DESC, f.fact_id`;
|
|
30
|
+
/** The caller's OWN attributable contribution facts (GitHub + admin coordination). Read-only; writes nothing. */
|
|
31
|
+
export function getMyContributionFacts(db, accountId) {
|
|
32
|
+
const github = [];
|
|
33
|
+
const admin_coordination = [];
|
|
34
|
+
if (!accountId)
|
|
35
|
+
return { total: 0, groups: { github, admin_coordination, agent: [] } };
|
|
36
|
+
for (const r of db.prepare(GITHUB_FACTS_SQL).all(accountId)) {
|
|
37
|
+
github.push({
|
|
38
|
+
fact_id: r.fact_id, source_event_key: r.source_event_key, source: r.source, type: r.type ?? null,
|
|
39
|
+
occurred_at: r.occurred_at ?? null, executor_ref: r.executor_ref, attribution_via: 'github_binding',
|
|
40
|
+
contributor_account_id: accountId, artifact_ref: r.artifact_ref, status: r.status, provenance: r.provenance,
|
|
41
|
+
display_source_label: 'GitHub', display_source_label_en: 'GitHub',
|
|
42
|
+
display_summary: `GitHub · ${r.type || 'contribution'}${dateOf(r.occurred_at) ? ' · ' + dateOf(r.occurred_at) : ''}`,
|
|
43
|
+
evidence_ref: null, notice: 'evidence_only',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
for (const r of db.prepare(ADMIN_FACTS_SQL).all()) {
|
|
47
|
+
const adminAccountId = String(r.executor_ref).slice('admin:'.length);
|
|
48
|
+
if (!adminAccountId || !r.occurred_at)
|
|
49
|
+
continue;
|
|
50
|
+
// AS-OF attribution: the operator claim effective when the work occurred decides the contributor.
|
|
51
|
+
const resolved = resolveOperatorClaimAsOf(db, adminAccountId, r.occurred_at);
|
|
52
|
+
if (!resolved || resolved.contributor_account_id !== accountId)
|
|
53
|
+
continue; // not mine → never shown
|
|
54
|
+
admin_coordination.push({
|
|
55
|
+
fact_id: r.fact_id, source_event_key: r.source_event_key, source: r.source, type: r.type ?? null,
|
|
56
|
+
occurred_at: r.occurred_at ?? null, executor_ref: r.executor_ref, attribution_via: 'operator_claim',
|
|
57
|
+
contributor_account_id: accountId, artifact_ref: r.artifact_ref, status: r.status, provenance: r.provenance,
|
|
58
|
+
display_source_label: '管理协调', display_source_label_en: 'Admin coordination',
|
|
59
|
+
display_summary: `管理协调 · ${r.source_type}${dateOf(r.occurred_at) ? ' · ' + dateOf(r.occurred_at) : ''}`,
|
|
60
|
+
// evidence_ref: opaque audit id + the action only — NEVER admin_audit_log.detail.
|
|
61
|
+
evidence_ref: { source_type: r.source_type, admin_audit_log_id: r.admin_audit_log_id },
|
|
62
|
+
notice: 'evidence_only',
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return { total: github.length + admin_coordination.length, groups: { github, admin_coordination, agent: [] } };
|
|
66
|
+
}
|