@seasonkoh/webaz 0.1.25 → 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.
Files changed (97) hide show
  1. package/LICENSE +2 -2
  2. package/NOTICE +24 -3
  3. package/README.md +74 -328
  4. package/README.zh-CN.md +419 -0
  5. package/dist/layer0-foundation/L0-2-state-machine/genuine-sale.js +21 -0
  6. package/dist/layer0-foundation/L0-5-manifest/manifest.js +8 -3
  7. package/dist/layer1-agent/L1-1-mcp-server/auth.js +13 -1
  8. package/dist/layer1-agent/L1-1-mcp-server/server.js +164 -177
  9. package/dist/layer2-business/L2-9-contribution/admin-coordination-ingestion-engine.js +181 -0
  10. package/dist/layer2-business/L2-9-contribution/admin-coordination-resolver.js +114 -0
  11. package/dist/layer2-business/L2-9-contribution/admin-coordination-store.js +251 -0
  12. package/dist/layer2-business/L2-9-contribution/admin-operator-claim-workflow.js +390 -0
  13. package/dist/layer2-business/L2-9-contribution/build-task-agent-metadata-store.js +33 -0
  14. package/dist/layer2-business/L2-9-contribution/build-task-participation.js +6 -2
  15. package/dist/layer2-business/L2-9-contribution/build-task-quota.js +337 -0
  16. package/dist/layer2-business/L2-9-contribution/build-task-read.js +25 -2
  17. package/dist/layer2-business/L2-9-contribution/build-tasks-engine.js +58 -8
  18. package/dist/layer2-business/L2-9-contribution/canonical-contribution-target.js +1 -1
  19. package/dist/layer2-business/L2-9-contribution/contribution-facts-read.js +66 -0
  20. package/dist/layer2-business/L2-9-contribution/identity-claim-discovery.js +55 -0
  21. package/dist/layer2-business/L2-9-contribution/task-proposal-ai-store.js +99 -0
  22. package/dist/layer2-business/L2-9-contribution/task-proposal-draft.js +360 -0
  23. package/dist/layer2-business/L2-9-contribution/task-proposal-store.js +29 -4
  24. package/dist/ledger.js +1 -1
  25. package/dist/pwa/admin-audit.js +38 -0
  26. package/dist/pwa/admin-bearer-auth.js +21 -0
  27. package/dist/pwa/anti-abuse-thresholds.js +135 -0
  28. package/dist/pwa/cf-origin-guard.js +33 -0
  29. package/dist/pwa/contract-fingerprint.js +1 -0
  30. package/dist/pwa/data/onboarding-cases.js +2 -2
  31. package/dist/pwa/data/onboarding-quiz.js +1 -1
  32. package/dist/pwa/economic-participation.js +2 -2
  33. package/dist/pwa/email-delivery.js +127 -0
  34. package/dist/pwa/integration-contract.js +46 -4
  35. package/dist/pwa/internal/pv-settlement.js +12 -0
  36. package/dist/pwa/internal/wallet-signer.js +26 -0
  37. package/dist/pwa/public/app.js +1607 -912
  38. package/dist/pwa/public/i18n.js +284 -68
  39. package/dist/pwa/public/index.html +1 -1
  40. package/dist/pwa/public/openapi.json +4760 -2769
  41. package/dist/pwa/public/whitepaper/en/index.html +153 -0
  42. package/dist/pwa/public/whitepaper/zh-CN/index.html +153 -0
  43. package/dist/pwa/pv-kill-switch.js +31 -0
  44. package/dist/pwa/routes/admin-admins.js +48 -1
  45. package/dist/pwa/routes/admin-analytics.js +1 -10
  46. package/dist/pwa/routes/admin-atomic.js +7 -14
  47. package/dist/pwa/routes/admin-moderation.js +25 -1
  48. package/dist/pwa/routes/admin-operator-claims.js +280 -0
  49. package/dist/pwa/routes/admin-ops.js +13 -2
  50. package/dist/pwa/routes/admin-reports.js +4 -26
  51. package/dist/pwa/routes/admin-tokenomics.js +2 -76
  52. package/dist/pwa/routes/admin-users-lifecycle.js +1 -14
  53. package/dist/pwa/routes/admin-users-query.js +35 -2
  54. package/dist/pwa/routes/admin-wallet-ops.js +26 -3
  55. package/dist/pwa/routes/auction.js +4 -2
  56. package/dist/pwa/routes/auth-read.js +11 -6
  57. package/dist/pwa/routes/auth-register.js +84 -24
  58. package/dist/pwa/routes/build-task-quota.js +113 -0
  59. package/dist/pwa/routes/claim-verify.js +15 -11
  60. package/dist/pwa/routes/contribution-facts.js +18 -0
  61. package/dist/pwa/routes/contribution-identity.js +17 -0
  62. package/dist/pwa/routes/dispute-cases.js +5 -4
  63. package/dist/pwa/routes/growth.js +4 -4
  64. package/dist/pwa/routes/orders-action.js +46 -23
  65. package/dist/pwa/routes/orders-create.js +1 -1
  66. package/dist/pwa/routes/products-meta.js +19 -6
  67. package/dist/pwa/routes/profile-credentials.js +7 -4
  68. package/dist/pwa/routes/profile-placement.js +8 -9
  69. package/dist/pwa/routes/promoter.js +11 -44
  70. package/dist/pwa/routes/public-build-tasks.js +5 -1
  71. package/dist/pwa/routes/public-utils.js +9 -12
  72. package/dist/pwa/routes/ratings.js +64 -4
  73. package/dist/pwa/routes/recover-key.js +58 -19
  74. package/dist/pwa/routes/referral.js +9 -50
  75. package/dist/pwa/routes/rewards-apply.js +3 -2
  76. package/dist/pwa/routes/share-redirects.js +5 -4
  77. package/dist/pwa/routes/shareables-interactions.js +2 -1
  78. package/dist/pwa/routes/shop-referral.js +6 -5
  79. package/dist/pwa/routes/shops.js +5 -2
  80. package/dist/pwa/routes/task-proposals.js +159 -7
  81. package/dist/pwa/routes/trial.js +4 -2
  82. package/dist/pwa/routes/users-public.js +1 -14
  83. package/dist/pwa/routes/wallet-read.js +3 -15
  84. package/dist/pwa/routes/webauthn.js +1 -1
  85. package/dist/pwa/server.js +223 -478
  86. package/dist/settlement-math.js +3 -3
  87. package/dist/version.js +6 -4
  88. package/package.json +62 -8
  89. package/dist/index.js +0 -182
  90. package/dist/pwa/public/docs/ECONOMIC-MODEL.md +0 -287
  91. package/dist/pwa/public/docs/INTEGRATOR.md +0 -67
  92. package/dist/pwa/public/docs/META-RULES-FULL.md +0 -543
  93. package/dist/test-dispute.js +0 -153
  94. package/dist/test-manifest.js +0 -61
  95. package/dist/test-mcp-tools.js +0 -135
  96. package/dist/test-reputation.js +0 -116
  97. 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
- where.push('m.auto_claimable = ?');
153
- params.push(f.auto_claimable ? 1 : 0);
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
@@ -45,7 +46,7 @@ export function initBuildTasksSchema(db) {
45
46
  `);
46
47
  db.exec(`CREATE INDEX IF NOT EXISTS idx_build_task_events ON build_task_events(task_id, created_at)`);
47
48
  }
48
- function logTaskEvent(db, taskId, actorId, from, to, note) {
49
+ export function logTaskEvent(db, taskId, actorId, from, to, note) {
49
50
  db.prepare(`INSERT INTO build_task_events (id, task_id, actor_id, from_status, to_status, note) VALUES (?,?,?,?,?,?)`)
50
51
  .run(generateId('btev'), taskId, actorId, from, to, note);
51
52
  }
@@ -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 >= CREATE_RATE_PER_DAY) {
74
- return { error: `今日建任务已达上限(${CREATE_RATE_PER_DAY}/天)`, error_code: 'RATE_LIMITED' };
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 = 'seasonsagents-art/webaz';
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
+ }
@@ -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
+ }