@seasonkoh/webaz 0.1.25 → 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.
Files changed (40) hide show
  1. package/README.md +3 -1
  2. package/dist/layer1-agent/L1-1-mcp-server/server.js +129 -150
  3. package/dist/layer2-business/L2-9-contribution/build-task-agent-metadata-store.js +9 -0
  4. package/dist/layer2-business/L2-9-contribution/build-tasks-engine.js +1 -1
  5. package/dist/layer2-business/L2-9-contribution/identity-claim-discovery.js +55 -0
  6. package/dist/layer2-business/L2-9-contribution/task-proposal-ai-store.js +99 -0
  7. package/dist/layer2-business/L2-9-contribution/task-proposal-draft.js +191 -0
  8. package/dist/pwa/admin-bearer-auth.js +21 -0
  9. package/dist/pwa/email-delivery.js +127 -0
  10. package/dist/pwa/public/app.js +940 -245
  11. package/dist/pwa/public/i18n.js +269 -40
  12. package/dist/pwa/public/openapi.json +4 -4
  13. package/dist/pwa/public/whitepaper/en/index.html +153 -0
  14. package/dist/pwa/public/whitepaper/zh-CN/index.html +153 -0
  15. package/dist/pwa/routes/admin-atomic.js +10 -4
  16. package/dist/pwa/routes/admin-moderation.js +25 -1
  17. package/dist/pwa/routes/admin-ops.js +13 -2
  18. package/dist/pwa/routes/admin-users-query.js +12 -1
  19. package/dist/pwa/routes/admin-wallet-ops.js +26 -3
  20. package/dist/pwa/routes/auction.js +4 -2
  21. package/dist/pwa/routes/auth-read.js +10 -1
  22. package/dist/pwa/routes/auth-register.js +82 -12
  23. package/dist/pwa/routes/contribution-identity.js +17 -0
  24. package/dist/pwa/routes/growth.js +1 -1
  25. package/dist/pwa/routes/orders-action.js +19 -13
  26. package/dist/pwa/routes/profile-credentials.js +7 -4
  27. package/dist/pwa/routes/profile-placement.js +7 -8
  28. package/dist/pwa/routes/promoter.js +3 -17
  29. package/dist/pwa/routes/ratings.js +64 -4
  30. package/dist/pwa/routes/recover-key.js +58 -19
  31. package/dist/pwa/routes/referral.js +4 -24
  32. package/dist/pwa/routes/share-redirects.js +4 -3
  33. package/dist/pwa/routes/shop-referral.js +6 -5
  34. package/dist/pwa/routes/shops.js +5 -2
  35. package/dist/pwa/routes/task-proposals.js +76 -0
  36. package/dist/pwa/routes/trial.js +4 -2
  37. package/dist/pwa/routes/users-public.js +2 -12
  38. package/dist/pwa/routes/wallet-read.js +1 -1
  39. package/dist/pwa/server.js +67 -9
  40. package/package.json +31 -3
@@ -171,3 +171,12 @@ export function getBuildTaskAgentMetadata(db, taskId) {
171
171
  row.accountable_party_required = row.accountable_party_required === 1;
172
172
  return row;
173
173
  }
174
+ /**
175
+ * Flip a task's audience (used to PUBLISH an internal draft → 'public'). Validated against AUDIENCES.
176
+ * Returns the number of rows changed (0 = no metadata row for that task).
177
+ */
178
+ export function setBuildTaskAudience(db, taskId, audience) {
179
+ const a = assertEnum('audience', audience, AUDIENCES);
180
+ const r = db.prepare(`UPDATE build_task_agent_metadata SET audience = ? WHERE task_id = ?`).run(a, taskId);
181
+ return r.changes;
182
+ }
@@ -45,7 +45,7 @@ export function initBuildTasksSchema(db) {
45
45
  `);
46
46
  db.exec(`CREATE INDEX IF NOT EXISTS idx_build_task_events ON build_task_events(task_id, created_at)`);
47
47
  }
48
- function logTaskEvent(db, taskId, actorId, from, to, note) {
48
+ export function logTaskEvent(db, taskId, actorId, from, to, note) {
49
49
  db.prepare(`INSERT INTO build_task_events (id, task_id, actor_id, from_status, to_status, note) VALUES (?,?,?,?,?,?)`)
50
50
  .run(generateId('btev'), taskId, actorId, from, to, note);
51
51
  }
@@ -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,99 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { generateId } from '../../layer0-foundation/L0-1-database/schema.js';
3
+ export function initTaskProposalAiSchema(db) {
4
+ // CREATE only (no ALTER) — additive, never blocks an existing fresh-DB boot.
5
+ db.exec(`CREATE TABLE IF NOT EXISTS task_proposal_ai_suggestions (
6
+ id TEXT PRIMARY KEY,
7
+ proposal_id TEXT NOT NULL,
8
+ reviewer_type TEXT NOT NULL DEFAULT 'ai',
9
+ model TEXT,
10
+ provider TEXT,
11
+ input_hash TEXT,
12
+ input_summary TEXT,
13
+ output_json TEXT NOT NULL,
14
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
15
+ )`);
16
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_tpai_proposal ON task_proposal_ai_suggestions(proposal_id, created_at DESC)`);
17
+ }
18
+ /** Read the fields the recommender needs (keeps the route free of direct SQL). */
19
+ export function getProposalLite(db, id) {
20
+ return db.prepare('SELECT id, title, summary, suggested_area, source_ref, expected_outcome FROM task_proposals WHERE id = ?').get(id) ?? null;
21
+ }
22
+ const CATEGORY_KEYWORDS = {
23
+ docs: ['doc', 'readme', '文档', 'guide'], i18n: ['i18n', 'translat', '翻译', 'locale'],
24
+ tests: ['test', '测试', 'spec'], ui: ['ui', 'page', 'button', '界面', 'pwa', 'css'],
25
+ api: ['api', 'endpoint', 'route'], schema: ['schema', 'migration', 'table', '字段'],
26
+ infra: ['ci', 'deploy', 'infra', 'pipeline', 'docker'], governance: ['governance', 'charter', '治理', 'rfc'],
27
+ audit: ['audit', 'security', '审计', '安全'], code: ['fix', 'bug', 'refactor', 'implement', '实现', '修复'],
28
+ };
29
+ const HIGH_RISK_KEYWORDS = ['wallet', 'withdraw', 'fund', 'money', 'settle', 'escrow', 'payment', 'reward', 'schema', 'migration', 'auth', 'admin', 'key', '资金', '钱包', '提现', '结算', '权限', '密钥'];
30
+ const norm = (s) => s.toLowerCase().replace(/[^a-z0-9一-鿿]+/g, ' ').trim();
31
+ /**
32
+ * Deterministic local heuristic recommendation (stub for a future model). Read-only; never decides.
33
+ */
34
+ export function recommendForProposal(db, p) {
35
+ const text = norm(`${p.title} ${p.summary} ${p.suggested_area ?? ''}`);
36
+ // category
37
+ let category = 'other';
38
+ let best = 0;
39
+ for (const [cat, kws] of Object.entries(CATEGORY_KEYWORDS)) {
40
+ const hits = kws.filter(k => text.includes(k)).length;
41
+ if (hits > best) {
42
+ best = hits;
43
+ category = cat;
44
+ }
45
+ }
46
+ // risk
47
+ const risk = HIGH_RISK_KEYWORDS.some(k => text.includes(k)) ? 'high' : (p.summary.length > 400 ? 'medium' : 'low');
48
+ // effort by summary size
49
+ const effort = p.summary.length > 600 ? 'large' : p.summary.length > 200 ? 'medium' : 'small';
50
+ // missing info flags
51
+ const missing_info = [];
52
+ if (p.summary.trim().length < 40)
53
+ missing_info.push('summary too short — needs concrete scope / acceptance');
54
+ if (!p.source_ref)
55
+ missing_info.push('no source_ref (file / RFC / issue reference)');
56
+ if (!p.expected_outcome)
57
+ missing_info.push('no expected_outcome (definition of done)');
58
+ // duplicate likelihood: normalized-title overlap vs existing proposals + build_tasks
59
+ const titleNorm = norm(p.title);
60
+ const titleWords = new Set(titleNorm.split(' ').filter(w => w.length >= 3));
61
+ let dupScore = 0;
62
+ if (titleWords.size > 0) {
63
+ const others = db.prepare(`SELECT title FROM task_proposals WHERE id != ? UNION ALL SELECT title FROM build_tasks`).all(p.id);
64
+ for (const o of others) {
65
+ const ow = new Set(norm(o.title).split(' ').filter(w => w.length >= 3));
66
+ let inter = 0;
67
+ for (const w of titleWords)
68
+ if (ow.has(w))
69
+ inter++;
70
+ const jac = inter / (titleWords.size + ow.size - inter || 1);
71
+ if (jac > dupScore)
72
+ dupScore = jac;
73
+ }
74
+ }
75
+ const duplicate_likelihood = dupScore >= 0.6 ? 'high' : dupScore >= 0.3 ? 'medium' : 'low';
76
+ const recommendation = {
77
+ category, risk, effort, missing_info, duplicate_likelihood,
78
+ suggested: {
79
+ title: p.title,
80
+ area: p.suggested_area ?? category,
81
+ description: p.summary,
82
+ acceptance_criteria: p.expected_outcome ? [String(p.expected_outcome).slice(0, 500)] : [],
83
+ verification_commands: [],
84
+ },
85
+ };
86
+ return { recommendation, model: 'heuristic-v1', provider: 'local' };
87
+ }
88
+ /** Persist an AI suggestion as evidence (accountability metadata). Returns the row id. */
89
+ export function insertAiSuggestion(db, args) {
90
+ const id = generateId('tpai');
91
+ const inputHash = createHash('sha256').update(args.inputSummary).digest('hex');
92
+ db.prepare(`INSERT INTO task_proposal_ai_suggestions (id, proposal_id, reviewer_type, model, provider, input_hash, input_summary, output_json)
93
+ VALUES (?,?,?,?,?,?,?,?)`).run(id, args.proposalId, args.reviewerType ?? 'ai', args.model ?? null, args.provider ?? null, inputHash, args.inputSummary.slice(0, 1000), args.outputJson);
94
+ return { id };
95
+ }
96
+ export function listAiSuggestions(db, proposalId) {
97
+ return db.prepare(`SELECT id, proposal_id, reviewer_type, model, provider, input_summary, output_json, created_at
98
+ FROM task_proposal_ai_suggestions WHERE proposal_id = ? ORDER BY created_at DESC LIMIT 20`).all(proposalId);
99
+ }
@@ -0,0 +1,191 @@
1
+ import { createBuildTask, logTaskEvent } from './build-tasks-engine.js';
2
+ import { insertBuildTaskAgentMetadata, getBuildTaskAgentMetadata, setBuildTaskAudience, RISK_LEVELS, TASK_TYPES } from './build-task-agent-metadata-store.js';
3
+ import { reviewTaskProposal } from './task-proposal-store.js';
4
+ /** Source-proposal ↔ draft-task link (lets a draft preserve its origin WITHOUT marking the proposal terminal). */
5
+ export function initTaskProposalDraftLinkSchema(db) {
6
+ db.exec(`CREATE TABLE IF NOT EXISTS task_proposal_draft_links (
7
+ task_id TEXT PRIMARY KEY,
8
+ proposal_id TEXT NOT NULL,
9
+ created_by TEXT NOT NULL,
10
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
11
+ )`);
12
+ // UNIQUE: one draft per source proposal (aligns the DB with the createDraftFromProposal business rule).
13
+ db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_tpdl_proposal ON task_proposal_draft_links(proposal_id)`);
14
+ }
15
+ const strList = (v) => Array.isArray(v) ? v.slice(0, 50).map(s => String(s).slice(0, 500)).filter(s => s.trim()) : [];
16
+ const txt = (v) => (v ?? '').trim();
17
+ /** Handoff fields an executable agent task must carry. Returns the list of missing ones (empty = complete). */
18
+ function missingHandoff(f) {
19
+ const missing = [];
20
+ if (!txt(f.title))
21
+ missing.push('title');
22
+ if (!txt(f.description))
23
+ missing.push('summary/description (reason)');
24
+ if (f.allowed_paths.length === 0)
25
+ missing.push('allowed_paths (execution boundary)');
26
+ if (f.prohibited_actions.length === 0)
27
+ missing.push('forbidden_actions');
28
+ if (f.acceptance_criteria.length === 0)
29
+ missing.push('acceptance_criteria');
30
+ if (f.verification_commands.length === 0)
31
+ missing.push('verification_commands');
32
+ if (f.deliverables.length === 0)
33
+ missing.push('deliverables');
34
+ if (!txt(f.expected_results))
35
+ missing.push('expected_results');
36
+ if (!txt(f.definition_of_done))
37
+ missing.push('definition_of_done');
38
+ return missing;
39
+ }
40
+ /**
41
+ * Create an UNPUBLISHED draft (internal build_task) from a non-terminal proposal and record the
42
+ * source-proposal ↔ draft link. Does NOT mark the proposal 'converted' (acceptance happens at publish).
43
+ * Returns the new draft task id.
44
+ */
45
+ export function createDraftFromProposal(db, a) {
46
+ // 1) proposal must exist + be non-terminal (only new / needs_info → draft). Pre-check before creating anything.
47
+ const prop = db.prepare('SELECT id, status FROM task_proposals WHERE id = ?').get(a.proposalId);
48
+ if (!prop)
49
+ return { error: 'proposal not found', error_code: 'PROPOSAL_NOT_FOUND' };
50
+ if (prop.status === 'rejected' || prop.status === 'converted')
51
+ return { error: `proposal already ${prop.status}`, error_code: 'PROPOSAL_TERMINAL' };
52
+ // one draft per proposal (without using a terminal proposal status as the guard)
53
+ const existingLink = db.prepare('SELECT task_id FROM task_proposal_draft_links WHERE proposal_id = ?').get(a.proposalId);
54
+ if (existingLink)
55
+ return { error: `a task draft already exists for this proposal (${existingLink.task_id})`, error_code: 'PROPOSAL_HAS_DRAFT' };
56
+ // draft risk stays low/medium (high/critical metadata rules are out of scope for this PR)
57
+ const riskLevel = (a.riskLevel === 'medium') ? 'medium' : 'low';
58
+ if (a.riskLevel && !RISK_LEVELS.includes(a.riskLevel))
59
+ return { error: 'invalid risk_level', error_code: 'BAD_RISK_LEVEL' };
60
+ if (a.riskLevel === 'high' || a.riskLevel === 'critical')
61
+ return { error: 'draft risk_level must be low or medium (high/critical deferred)', error_code: 'RISK_TOO_HIGH_FOR_DRAFT' };
62
+ const taskType = (a.taskType && TASK_TYPES.includes(a.taskType)) ? a.taskType : 'other';
63
+ // 2) assemble the agent-handoff fields and VALIDATE completeness BEFORE creating anything — the existing
64
+ // task model requires these to be executable, so we never create an incomplete/orphan task.
65
+ const allowed = strList(a.allowedPaths);
66
+ const prohibited = strList(a.forbiddenActions);
67
+ const accept = strList(a.acceptanceCriteria);
68
+ const verify = strList(a.verificationCommands);
69
+ const deliver = strList(a.deliverables);
70
+ const description = txt(a.description);
71
+ const dod = txt(a.definitionOfDone).slice(0, 1000);
72
+ const expected = (txt(a.expectedResults) || description).slice(0, 1000);
73
+ const missing = missingHandoff({ title: txt(a.title), description, allowed_paths: allowed, prohibited_actions: prohibited, acceptance_criteria: accept, verification_commands: verify, deliverables: deliver, expected_results: expected, definition_of_done: dod });
74
+ if (missing.length > 0)
75
+ return { error: `draft incomplete — provide: ${missing.join(', ')}`, error_code: 'DRAFT_INCOMPLETE', missing };
76
+ // 3) create the formal task via the trusted path (creator = admin → accountability)
77
+ const created = createBuildTask(db, { creatorId: a.adminId, title: a.title, area: a.area ?? undefined, description: a.description ?? undefined, rfcRef: a.sourceRef ?? undefined });
78
+ if ('error' in created)
79
+ return created;
80
+ const taskId = created.id;
81
+ // 4) attach agent_metadata as an INTERNAL draft (hidden + unclaimable via the existing read/participation
82
+ // guards). auto_claimable defaults TRUE so once published the task enters the normal agent claim flow
83
+ // (maintainer may opt human-only with autoClaimable:false). While audience='internal' it is unclaimable.
84
+ const caps = strList(a.requiredCapabilities);
85
+ if (caps.length === 0)
86
+ caps.push('general');
87
+ const meta = {
88
+ task_type: taskType,
89
+ source_ref: a.sourceRef ?? null,
90
+ allowed_paths: allowed,
91
+ forbidden_paths: strList(a.forbiddenPaths),
92
+ prohibited_actions: prohibited,
93
+ risk_level: riskLevel,
94
+ audience: 'internal',
95
+ agent_autonomy: 'human_in_the_loop',
96
+ auto_claimable: a.autoClaimable !== false,
97
+ required_capabilities: caps,
98
+ acceptance_criteria: accept,
99
+ verification_commands: verify,
100
+ expected_results: expected,
101
+ deliverables: deliver,
102
+ definition_of_done: dod,
103
+ estimated_duration_min_minutes: 0,
104
+ estimated_duration_max_minutes: 0,
105
+ estimated_context_size: 'small',
106
+ estimated_agent_budget: 'minimal',
107
+ value_state: 'uncommitted',
108
+ contribution_type: 'task',
109
+ accountable_party_required: true,
110
+ };
111
+ insertBuildTaskAgentMetadata(db, taskId, meta);
112
+ // 5) record the source-proposal ↔ draft link (accountability) — WITHOUT marking the proposal converted;
113
+ // the proposal stays non-terminal until an explicit human publish.
114
+ db.prepare('INSERT INTO task_proposal_draft_links (task_id, proposal_id, created_by) VALUES (?,?,?)').run(taskId, a.proposalId, a.adminId);
115
+ return { draft_task_id: taskId };
116
+ }
117
+ /** Admin list of UNPUBLISHED drafts (internal, open) + their source proposal id (via the draft-link table). */
118
+ export function listDraftBuildTasks(db) {
119
+ return db.prepare(`
120
+ SELECT t.id, t.title, t.area, t.description, t.rfc_ref, t.status, t.created_by, t.created_at,
121
+ m.risk_level, m.audience, m.auto_claimable,
122
+ l.proposal_id AS source_proposal_id
123
+ FROM build_tasks t
124
+ JOIN build_task_agent_metadata m ON m.task_id = t.id
125
+ LEFT JOIN task_proposal_draft_links l ON l.task_id = t.id
126
+ WHERE m.audience = 'internal' AND t.status = 'open'
127
+ ORDER BY t.created_at DESC LIMIT 200
128
+ `).all();
129
+ }
130
+ /**
131
+ * Validate that a draft carries enough agent-handoff info to be executed by another participant's agent.
132
+ * Returns the list of missing fields (empty = complete). Gate for publish — never publish an incomplete task.
133
+ */
134
+ export function validateDraftForPublish(task, meta) {
135
+ return missingHandoff({
136
+ title: txt(task.title), description: txt(task.description),
137
+ allowed_paths: meta.allowed_paths ?? [], prohibited_actions: meta.prohibited_actions ?? [],
138
+ acceptance_criteria: meta.acceptance_criteria ?? [], verification_commands: meta.verification_commands ?? [],
139
+ deliverables: meta.deliverables ?? [], expected_results: txt(meta.expected_results), definition_of_done: txt(meta.definition_of_done),
140
+ });
141
+ }
142
+ /**
143
+ * PUBLISH a draft → audience 'public' (now on the board / claimable via the existing flow). Explicit
144
+ * human/admin action only; validates agent-handoff completeness first (never publishes an incomplete task).
145
+ * This is also where the source proposal is marked 'converted' (converted_ref=task id, reviewer=actor) —
146
+ * acceptance happens at publish, not at draft creation. The canonical repo / PR target is the protocol-wide
147
+ * canonical contribution target enforced by the existing submit path — it is not stored per-task.
148
+ *
149
+ * Evidence-chain guard: the linked proposal is validated BEFORE anything is published. If it was rejected (or
150
+ * converted to a different task) since draft creation, publish is refused (409) and the task stays internal —
151
+ * so a public, claimable task can never coexist with a rejected source proposal. The audience flip + proposal
152
+ * conversion run in one transaction (both or neither).
153
+ */
154
+ export function publishDraftBuildTask(db, taskId, adminId) {
155
+ const meta = getBuildTaskAgentMetadata(db, taskId);
156
+ if (!meta)
157
+ return { error: 'task has no draft metadata', error_code: 'NOT_A_DRAFT' };
158
+ if (meta.audience !== 'internal')
159
+ return { error: 'task is not an internal draft', error_code: 'NOT_DRAFT_AUDIENCE' };
160
+ const t = db.prepare('SELECT status, title, description FROM build_tasks WHERE id = ?').get(taskId);
161
+ if (!t)
162
+ return { error: 'task not found', error_code: 'NOT_FOUND' };
163
+ const missing = validateDraftForPublish(t, meta);
164
+ if (missing.length > 0)
165
+ return { error: `draft incomplete — fill before publish: ${missing.join(', ')}`, error_code: 'DRAFT_INCOMPLETE', missing };
166
+ // validate the linked proposal FIRST — do not publish on top of a rejected / elsewhere-converted proposal.
167
+ const link = db.prepare('SELECT proposal_id FROM task_proposal_draft_links WHERE task_id = ?').get(taskId);
168
+ let proposalToConvert = null;
169
+ if (link) {
170
+ const prop = db.prepare('SELECT status, converted_ref FROM task_proposals WHERE id = ?').get(link.proposal_id);
171
+ if (prop) {
172
+ if (prop.status === 'rejected')
173
+ return { error: 'source proposal was rejected — cannot publish', error_code: 'PROPOSAL_REJECTED' };
174
+ if (prop.status === 'converted' && prop.converted_ref !== taskId)
175
+ return { error: 'source proposal already converted to a different task', error_code: 'PROPOSAL_CONVERTED_ELSEWHERE' };
176
+ if (prop.status === 'new' || prop.status === 'needs_info')
177
+ proposalToConvert = link.proposal_id;
178
+ }
179
+ }
180
+ // all validation passed → publish atomically: flip audience + (if applicable) mark the proposal converted.
181
+ db.transaction(() => {
182
+ setBuildTaskAudience(db, taskId, 'public');
183
+ logTaskEvent(db, taskId, adminId, t.status, t.status, 'published from draft (audience → public)');
184
+ if (proposalToConvert) {
185
+ const rv = reviewTaskProposal(db, proposalToConvert, adminId, 'converted', `published as formal task ${taskId}`, taskId);
186
+ if ('error' in rv)
187
+ throw new Error(`proposal conversion failed: ${rv.code}`); // rollback the audience flip
188
+ }
189
+ })();
190
+ return { ok: true, task_id: taskId };
191
+ }
@@ -0,0 +1,21 @@
1
+ export function resolveBearerProtocolAdmin(db, req, isProtocolAdmin) {
2
+ const authz = req?.headers?.authorization;
3
+ if (typeof authz !== 'string' || !authz.startsWith('Bearer '))
4
+ return null; // Bearer only — NOT req.body.api_key
5
+ const key = authz.slice('Bearer '.length).trim();
6
+ if (!key)
7
+ return null;
8
+ const u = db.prepare('SELECT * FROM users WHERE api_key = ?').get(key);
9
+ if (!u)
10
+ return null;
11
+ const mod = db.prepare('SELECT suspended FROM user_moderation WHERE user_id = ?').get(u.id);
12
+ if (mod?.suspended)
13
+ return null; // suspended admin cannot approve
14
+ const session = db.prepare('SELECT revoked_at FROM user_sessions WHERE api_key = ? ORDER BY created_at DESC LIMIT 1')
15
+ .get(key);
16
+ if (session?.revoked_at)
17
+ return null; // remotely logged-out session cannot approve
18
+ if (!isProtocolAdmin(u))
19
+ return null; // admin role + protocol permission (caller-supplied, central logic)
20
+ return u;
21
+ }
@@ -0,0 +1,127 @@
1
+ const DEFAULT_FROM = 'WebAZ <noreply@webaz.xyz>';
2
+ const DEFAULT_BASE_URL = 'https://webaz.xyz';
3
+ export function emailDeliveryNotConfigured() {
4
+ return {
5
+ ok: false,
6
+ status: 503,
7
+ error_code: 'EMAIL_DELIVERY_NOT_CONFIGURED',
8
+ error: '邮箱发送服务未配置,请稍后再试',
9
+ };
10
+ }
11
+ export function emailDeliveryFailed() {
12
+ return {
13
+ ok: false,
14
+ status: 502,
15
+ error_code: 'EMAIL_DELIVERY_FAILED',
16
+ error: '验证码邮件发送失败,请稍后再试',
17
+ };
18
+ }
19
+ function isProtectedEmailEnv(env) {
20
+ return env.NODE_ENV === 'production'
21
+ || !!env.RAILWAY_ENVIRONMENT
22
+ || !!env.RAILWAY_PROJECT_ID
23
+ || !!env.RAILWAY_SERVICE_ID;
24
+ }
25
+ export function isVerificationEmailReady(env = process.env) {
26
+ if (!isProtectedEmailEnv(env))
27
+ return true;
28
+ return !!env.RESEND_API_KEY?.trim();
29
+ }
30
+ function purposeText(purpose) {
31
+ if (purpose === 'register')
32
+ return { zh: '注册账户(验证邮箱)', en: 'register your account (verify email)' };
33
+ if (purpose === 'bind_email')
34
+ return { zh: '绑定邮箱', en: 'bind your email address' };
35
+ if (purpose === 'recover_key')
36
+ return { zh: '找回密钥', en: 'recover your account key' };
37
+ if (purpose.startsWith('withdraw_confirm'))
38
+ return { zh: '确认提现', en: 'confirm a withdrawal' };
39
+ return { zh: '验证身份', en: 'verify your identity' };
40
+ }
41
+ function escapeHtml(s) {
42
+ return s.replace(/[&<>"']/g, c => ({
43
+ '&': '&amp;',
44
+ '<': '&lt;',
45
+ '>': '&gt;',
46
+ '"': '&quot;',
47
+ "'": '&#39;',
48
+ }[c] || c));
49
+ }
50
+ export function buildVerificationEmail(input) {
51
+ const purpose = purposeText(input.purpose);
52
+ const baseUrl = input.baseUrl?.trim() || DEFAULT_BASE_URL;
53
+ const subject = 'WebAZ 验证码 / Verification code';
54
+ const text = [
55
+ `WebAZ 验证码: ${input.code}`,
56
+ '',
57
+ `用途: ${purpose.zh}`,
58
+ `有效期: ${input.ttlMin} 分钟`,
59
+ '',
60
+ `Your WebAZ verification code is ${input.code}.`,
61
+ `Use it to ${purpose.en}. It expires in ${input.ttlMin} minutes.`,
62
+ '',
63
+ 'If you did not request this code, you can ignore this email.',
64
+ baseUrl,
65
+ ].join('\n');
66
+ const html = [
67
+ '<div style="font-family:system-ui,-apple-system,Segoe UI,sans-serif;line-height:1.5;color:#111827">',
68
+ '<h2 style="margin:0 0 12px">WebAZ verification code</h2>',
69
+ `<p style="margin:0 0 8px">用途: ${escapeHtml(purpose.zh)} / ${escapeHtml(purpose.en)}</p>`,
70
+ `<p style="font-size:28px;font-weight:700;letter-spacing:4px;margin:16px 0">${escapeHtml(input.code)}</p>`,
71
+ `<p style="margin:0 0 8px">有效期 ${input.ttlMin} 分钟 / Expires in ${input.ttlMin} minutes.</p>`,
72
+ '<p style="margin:16px 0 0;color:#6b7280">If you did not request this code, you can ignore this email.</p>',
73
+ `<p style="margin:16px 0 0"><a href="${escapeHtml(baseUrl)}">${escapeHtml(baseUrl)}</a></p>`,
74
+ '</div>',
75
+ ].join('');
76
+ return { subject, text, html };
77
+ }
78
+ export async function deliverVerificationCode(input) {
79
+ const env = input.env || process.env;
80
+ const logger = input.logger || console;
81
+ if (!isProtectedEmailEnv(env)) {
82
+ logger.log(`[verify] ${input.purpose} -> ${input.target} code=${input.code} (expires ${input.ttlMin}min)`);
83
+ return { ok: true, provider: 'dev_console' };
84
+ }
85
+ const apiKey = env.RESEND_API_KEY?.trim();
86
+ if (!apiKey)
87
+ return emailDeliveryNotConfigured();
88
+ const fetchImpl = input.fetchImpl || globalThis.fetch;
89
+ if (!fetchImpl)
90
+ return emailDeliveryNotConfigured();
91
+ const baseUrl = env.WEBAZ_PUBLIC_URL?.trim() || env.PUBLIC_BASE_URL?.trim() || DEFAULT_BASE_URL;
92
+ const email = buildVerificationEmail({
93
+ code: input.code,
94
+ purpose: input.purpose,
95
+ ttlMin: input.ttlMin,
96
+ baseUrl,
97
+ });
98
+ const body = {
99
+ from: env.EMAIL_FROM?.trim() || DEFAULT_FROM,
100
+ to: [input.target],
101
+ subject: email.subject,
102
+ text: email.text,
103
+ html: email.html,
104
+ };
105
+ const replyTo = env.EMAIL_REPLY_TO?.trim();
106
+ if (replyTo)
107
+ body.reply_to = replyTo;
108
+ try {
109
+ const response = await fetchImpl('https://api.resend.com/emails', {
110
+ method: 'POST',
111
+ headers: {
112
+ Authorization: `Bearer ${apiKey}`,
113
+ 'Content-Type': 'application/json',
114
+ },
115
+ body: JSON.stringify(body),
116
+ });
117
+ if (!response.ok) {
118
+ logger.warn(`[verify] resend delivery failed: status=${response.status} purpose=${input.purpose}`);
119
+ return emailDeliveryFailed();
120
+ }
121
+ return { ok: true, provider: 'resend' };
122
+ }
123
+ catch {
124
+ logger.warn(`[verify] resend delivery failed: network purpose=${input.purpose}`);
125
+ return emailDeliveryFailed();
126
+ }
127
+ }