@bongos/core 1.24.9 → 1.24.11
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/.bongos-core.json +146 -86
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +14 -0
- package/clients/bongos-client/index.cjs +14 -0
- package/clients/bongos-client/index.d.ts +20 -1
- package/clients/bongos-client/index.mjs +14 -0
- package/docs/api/openapi.json +308 -5
- package/docs/api-reference.md +15 -4
- package/docs/architecture.md +4 -0
- package/docs/copy-inventory.md +109 -75
- package/docs/copy-registry.json +425 -89
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +34 -4
- package/docs/page-readings.json +301 -244
- package/modules/government/decision-rules.js +1 -0
- package/modules/hall-ui/public/ai-studio.html +2 -2
- package/modules/hall-ui/public/atlas.html +2 -2
- package/modules/hall-ui/public/blockers.html +2 -2
- package/modules/hall-ui/public/board-room.html +2 -2
- package/modules/hall-ui/public/city.html +2 -2
- package/modules/hall-ui/public/collab.html +2 -2
- package/modules/hall-ui/public/commands.html +2 -2
- package/modules/hall-ui/public/copy-desk.html +2 -2
- package/modules/hall-ui/public/craft-mode.js +1 -1
- package/modules/hall-ui/public/deploy.html +2 -2
- package/modules/hall-ui/public/diagrams.html +2 -2
- package/modules/hall-ui/public/drachmae.html +2 -2
- package/modules/hall-ui/public/fleet.html +2 -2
- package/modules/hall-ui/public/gate.html +2 -2
- package/modules/hall-ui/public/goal-map.html +2 -2
- package/modules/hall-ui/public/goals.html +2 -2
- package/modules/hall-ui/public/government.html +2 -2
- package/modules/hall-ui/public/idea.html +2 -2
- package/modules/hall-ui/public/ideas.html +2 -2
- package/modules/hall-ui/public/index.html +2 -2
- package/modules/hall-ui/public/market-watch-rules.html +2 -2
- package/modules/hall-ui/public/market-watch-settings.css +217 -0
- package/modules/hall-ui/public/market-watch-settings.html +113 -0
- package/modules/hall-ui/public/market-watch-settings.js +230 -0
- package/modules/hall-ui/public/market-watch-settings.states.json +14 -0
- package/modules/hall-ui/public/modules.html +2 -2
- package/modules/hall-ui/public/my-agents.html +2 -2
- package/modules/hall-ui/public/primer.html +2 -2
- package/modules/hall-ui/public/profile.html +2 -2
- package/modules/hall-ui/public/project-settings.css +12 -0
- package/modules/hall-ui/public/project-settings.html +2 -2
- package/modules/hall-ui/public/project-settings.js +63 -1
- package/modules/hall-ui/public/ranks.html +2 -2
- package/modules/hall-ui/public/roadmap.html +2 -2
- package/modules/hall-ui/public/roster.html +2 -2
- package/modules/hall-ui/public/sessions.html +2 -2
- package/modules/hall-ui/public/settings.html +2 -2
- package/modules/hall-ui/public/shell.js +3 -0
- package/modules/hall-ui/public/street.html +2 -2
- package/modules/hall-ui/public/studio.html +2 -2
- package/modules/hall-ui/public/task.html +2 -2
- package/modules/hall-ui/public/task.js +34 -1
- package/modules/hall-ui/public/thinking.html +2 -2
- package/modules/hall-ui/public/tweak-editor.html +2 -2
- package/modules/hall-ui/public/watch.html +2 -2
- package/modules/hall-ui/public/work.html +2 -2
- package/modules/hall-ui/records/market-watch-settings.md +11 -0
- package/modules/lifecycle/migrations/lifecycle_018_pay_terms.sql +64 -0
- package/modules/lifecycle/pay-terms.js +171 -0
- package/modules/lifecycle/routes/project-settings.js +94 -0
- package/modules/lifecycle/routes/task-write-routes.js +35 -4
- package/modules/market-watch/module.json +3 -2
- package/modules/market-watch/routes/settings.js +128 -0
- package/modules/market-watch/settings.js +207 -0
- package/modules/provisioning/starter-bundles.js +11 -6
- package/modules/ui-design/kit/serve.js +1 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/bongos/government-walk.js +1 -0
- package/scripts/bongos/run-unit-tests.js +4 -0
- package/scripts/bongos/start.js +42 -2
- package/src/bongos/module-scope-map.js +1 -0
- package/src/bongos/serve-internal.js +2 -2
- package/src/module-api.js +1 -1
- package/tests/government_act_alone_buildings.mjs +1 -0
- package/tests/hall_nav.mjs +4 -2
- package/tests/hall_page_gate_map.mjs +4 -0
- package/tests/market_watch_data_db.mjs +27 -0
- package/tests/market_watch_module.mjs +8 -4
- package/tests/market_watch_settings.mjs +392 -0
- package/tests/nav_permission_atoms.mjs +3 -2
- package/tests/patch_gate_ordering.mjs +3 -0
- package/tests/pay_terms.mjs +254 -0
- package/tests/pay_terms_db.mjs +86 -0
- package/tests/project_settings_page.mjs +25 -6
|
@@ -165,8 +165,8 @@
|
|
|
165
165
|
chrome exists before the page script runs. -->
|
|
166
166
|
<script src="/builders/dom-utils.js?v=2026-10-25-escape"></script>
|
|
167
167
|
<script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
|
|
168
|
-
<script src="/builders/craft-mode.js?v=2026-10-
|
|
169
|
-
<script src="/builders/shell.js?v=2026-10-
|
|
168
|
+
<script src="/builders/craft-mode.js?v=2026-10-30-market-watch"></script>
|
|
169
|
+
<script src="/builders/shell.js?v=2026-10-30-market-watch"></script>
|
|
170
170
|
<!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
|
|
171
171
|
<script src="/builders/palette.js" defer></script>
|
|
172
172
|
<!-- shared component kit (window.OTBKit) — tabs, ledger, pager (task 2166). -->
|
|
@@ -173,8 +173,8 @@
|
|
|
173
173
|
time so the chrome exists before the deferred page scripts execute. -->
|
|
174
174
|
<script src="/builders/dom-utils.js?v=2026-10-25-escape"></script>
|
|
175
175
|
<script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
|
|
176
|
-
<script src="/builders/craft-mode.js?v=2026-10-
|
|
177
|
-
<script src="/builders/shell.js?v=2026-10-
|
|
176
|
+
<script src="/builders/craft-mode.js?v=2026-10-30-market-watch"></script>
|
|
177
|
+
<script src="/builders/shell.js?v=2026-10-30-market-watch"></script>
|
|
178
178
|
<!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
|
|
179
179
|
<script src="/builders/palette.js" defer></script>
|
|
180
180
|
<!-- task 1004727: the ONE karma vote control every task/idea list renders -->
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Market Watch settings, at /market-watch-settings (task 1004938)
|
|
2
|
+
|
|
3
|
+
**The owner's fuel gauge for Market Watch (goal 1000137), built to `docs/design/mocks/market-watch/settings.html`, option B.** One panel: the gauge (spend this month against the monthly AI cap, with a faint arc for what the next run will use), the on/off pill, the cap slider, the cadence chips (weekly, every 2 weeks, monthly), the strip of recent runs, and the project's Anthropic key. Five things it settled:
|
|
4
|
+
|
|
5
|
+
- **The routes are the wall, not the page.** `GET`/`PATCH /market-watch/settings` and `PUT`/`DELETE /market-watch/settings/key` are owner-only: the `project.settings.manage` atom, then an in-route owner-seat (Archon) check (modules/market-watch/routes/settings.js). The shell joins `SIGNEDIN_BUILDERS_PAGE_RE` and holds no data; a 403 draws the owner-only panel and a 404 (module off, so its routes are not mounted) the "turn the module on" panel. The nav item sits in Government, `archon`, `module: 'market-watch'`.
|
|
6
|
+
- **The key is write-only.** The page learns `key_set` and nothing else, not the last four. The field is emptied before the request goes out; Clear asks twice by turning into its own question.
|
|
7
|
+
- **The page redraws from the server's answer.** Every write returns the whole view, so the gauge never shows a value the server did not store.
|
|
8
|
+
- **The strip is the run ledger, oldest left.** A ran run shows the names it watched; a skipped one is a hatched dash, with its reason in the cell's label and the legend (`cap reached`, `switched off`, `no key`, …); the dashed tile is the next run and the two after it are empty. When the watch is off the future tiles go. The seed run never shows. At 620px and below the dates stack day over month, or nine tiles overflow at 390px.
|
|
9
|
+
- **Mockup colours were not kept.** The ring, chips and tiles use the hall's `--accent`, `--accent-tint`, `--accent-text`, `--rule*` and `--ok-weak`; the mock's own fonts were dropped for the pack's.
|
|
10
|
+
|
|
11
|
+
Fixture: `scripts/hall-preview/fixtures/market-watch__settings.json` (the mockup's month). Tests: `tests/market_watch_settings.mjs` (403s for a Metic and a Xenos on all four routes, switching off stops the next run against `run.js`'s own gate, the key never in a response).
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
-- lifecycle_018_pay_terms.sql — task 1004990 (goal 1000092; ADR 0385 decision 3
|
|
2
|
+
-- and its 2026-10-29 amendment).
|
|
3
|
+
--
|
|
4
|
+
-- The PROJECT sets the pay, not Bongos. A task can carry zero or more pay terms,
|
|
5
|
+
-- each in the project's own words: a type, an amount and a unit as the project
|
|
6
|
+
-- states them, and a note. A project can also keep standing pay terms that apply
|
|
7
|
+
-- to every task without terms of its own — that is the project's pay policy,
|
|
8
|
+
-- and per the owner's ruling it lives here, in the database, changed on the
|
|
9
|
+
-- project's settings page, never in the branding pack.
|
|
10
|
+
--
|
|
11
|
+
-- A LIFECYCLE MIGRATION, NOT A CORE ONE: these are brand-new tables the lifecycle
|
|
12
|
+
-- module owns (modules/lifecycle/pay-terms.js is their only reader and writer), so
|
|
13
|
+
-- they are lifecycle_-namespaced here, the lifecycle_017_notes precedent — a core
|
|
14
|
+
-- migration is only for a change to a core table.
|
|
15
|
+
--
|
|
16
|
+
-- RECORDS ONLY. Nothing here moves, holds, owes or guarantees money; the amount
|
|
17
|
+
-- is TEXT on purpose, so no code can sum it into a balance (ADR 0387: money stays
|
|
18
|
+
-- with the processor). Bongos suggests no amount: no column has a default.
|
|
19
|
+
--
|
|
20
|
+
-- THE SHAPE IS A HISTORY OF WHOLE SETS. Each change appends one set row (who,
|
|
21
|
+
-- when, which task — NULL for the project's standing terms) and the terms that
|
|
22
|
+
-- set holds. The newest set is current; a set with no terms means "no pay terms".
|
|
23
|
+
-- So the audit trail of who changed what and when is the table itself, and
|
|
24
|
+
-- nothing is ever edited or deleted: a row trigger refuses UPDATE and DELETE on
|
|
25
|
+
-- both tables. (A whole-table wipe is not guarded here: the module-migration gate
|
|
26
|
+
-- keeps module migrations additive and refuses that statement's keyword outright,
|
|
27
|
+
-- and no code path in the tree issues one.)
|
|
28
|
+
|
|
29
|
+
CREATE TABLE IF NOT EXISTS lifecycle_pay_term_sets (
|
|
30
|
+
id bigserial PRIMARY KEY,
|
|
31
|
+
task_id bigint REFERENCES tasks(id), -- NULL = the project's standing terms
|
|
32
|
+
set_by bigint NOT NULL REFERENCES builders(id),
|
|
33
|
+
set_at timestamptz NOT NULL DEFAULT now()
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
-- The current set for a task (or for the project, task_id NULL): newest first.
|
|
37
|
+
CREATE INDEX IF NOT EXISTS lifecycle_pay_term_sets_current
|
|
38
|
+
ON lifecycle_pay_term_sets (task_id, set_at DESC, id DESC);
|
|
39
|
+
|
|
40
|
+
CREATE TABLE IF NOT EXISTS lifecycle_pay_terms (
|
|
41
|
+
id bigserial PRIMARY KEY,
|
|
42
|
+
set_id bigint NOT NULL REFERENCES lifecycle_pay_term_sets(id),
|
|
43
|
+
position smallint NOT NULL CHECK (position BETWEEN 0 AND 9),
|
|
44
|
+
pay_type text NOT NULL CHECK (pay_type IN
|
|
45
|
+
('cash', 'equity', 'module_share', 'credit_pool', 'non_monetary', 'self_managed')),
|
|
46
|
+
amount text CHECK (amount IS NULL OR char_length(amount) BETWEEN 1 AND 64),
|
|
47
|
+
unit text CHECK (unit IS NULL OR char_length(unit) BETWEEN 1 AND 32),
|
|
48
|
+
note text CHECK (note IS NULL OR char_length(note) BETWEEN 1 AND 500),
|
|
49
|
+
UNIQUE (set_id, position)
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
CREATE OR REPLACE FUNCTION lifecycle_pay_terms_refuse_change() RETURNS trigger AS $$
|
|
53
|
+
BEGIN
|
|
54
|
+
RAISE EXCEPTION 'pay terms are a history: % on % is refused; set a new set of terms instead', TG_OP, TG_TABLE_NAME
|
|
55
|
+
USING ERRCODE = 'check_violation';
|
|
56
|
+
END;
|
|
57
|
+
$$ LANGUAGE plpgsql;
|
|
58
|
+
|
|
59
|
+
DROP TRIGGER IF EXISTS lifecycle_pay_term_sets_append_only ON lifecycle_pay_term_sets;
|
|
60
|
+
CREATE TRIGGER lifecycle_pay_term_sets_append_only BEFORE UPDATE OR DELETE ON lifecycle_pay_term_sets
|
|
61
|
+
FOR EACH ROW EXECUTE FUNCTION lifecycle_pay_terms_refuse_change();
|
|
62
|
+
DROP TRIGGER IF EXISTS lifecycle_pay_terms_append_only ON lifecycle_pay_terms;
|
|
63
|
+
CREATE TRIGGER lifecycle_pay_terms_append_only BEFORE UPDATE OR DELETE ON lifecycle_pay_terms
|
|
64
|
+
FOR EACH ROW EXECUTE FUNCTION lifecycle_pay_terms_refuse_change();
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// modules/lifecycle/pay-terms.js — what a task pays, in the PROJECT's words
|
|
2
|
+
// (task 1004990, goal 1000092; ADR 0385 decision 3 and its 2026-10-29 amendment).
|
|
3
|
+
//
|
|
4
|
+
// The project sets the pay, not Bongos. A task carries zero or more pay terms:
|
|
5
|
+
// a type, and an amount, unit and note exactly as the project states them. A
|
|
6
|
+
// project may also keep STANDING terms (task_id NULL) — its pay policy — which a
|
|
7
|
+
// builder sees on any task that has none of its own. Per the owner's ruling that
|
|
8
|
+
// policy lives in the database (modules/lifecycle/migrations/lifecycle_018_pay_terms.sql) and is changed
|
|
9
|
+
// on the project's settings page, with the table itself as the audit trail; it
|
|
10
|
+
// never lives in the branding pack.
|
|
11
|
+
//
|
|
12
|
+
// THREE RULES THIS FILE HOLDS:
|
|
13
|
+
// 1. Bongos suggests no amount. There is no default and nothing is filled in:
|
|
14
|
+
// a term the project gave no amount has amount null, and every reader shows
|
|
15
|
+
// it as "not stated" — never a number Bongos chose.
|
|
16
|
+
// 2. Records only. The amount is text as stated; nothing here parses, sums or
|
|
17
|
+
// converts it, so no balance or amount owed can come out of it (ADR 0387).
|
|
18
|
+
// 3. A self-managed term always carries SELF_MANAGED_LABEL, so wherever it is
|
|
19
|
+
// shown it says Bongos moves and guarantees none of that money (plan P2.4).
|
|
20
|
+
//
|
|
21
|
+
// Who may write is decided by the ROUTE (task.edit for a task's terms,
|
|
22
|
+
// project.settings.manage for the project's), on the server (ADR 0016).
|
|
23
|
+
|
|
24
|
+
const api = require('../../src/module-api');
|
|
25
|
+
|
|
26
|
+
const PAY_TYPES = ['cash', 'equity', 'module_share', 'credit_pool', 'non_monetary', 'self_managed'];
|
|
27
|
+
const PAY_TYPE_LABELS = {
|
|
28
|
+
cash: 'Cash',
|
|
29
|
+
equity: 'Equity',
|
|
30
|
+
module_share: 'A share of a module',
|
|
31
|
+
credit_pool: 'A share of a credit pool',
|
|
32
|
+
non_monetary: 'Not money',
|
|
33
|
+
self_managed: 'Paid by the project itself',
|
|
34
|
+
};
|
|
35
|
+
const SELF_MANAGED_LABEL = 'Paid by the project on its own systems. Bongos does not move or guarantee this money.';
|
|
36
|
+
const MAX_TERMS = 10;
|
|
37
|
+
const LIMITS = { amount: 64, unit: 32, note: 500 };
|
|
38
|
+
|
|
39
|
+
// PURE. Checks a list of terms as sent by a client. Returns { terms } (each
|
|
40
|
+
// { pay_type, amount, unit, note }, blanks as null) or { error }. It never fills
|
|
41
|
+
// a field in: what the project did not state stays null.
|
|
42
|
+
function normalizePayTerms(input) {
|
|
43
|
+
if (!Array.isArray(input)) return { error: 'pay_terms is a list (empty for none).' };
|
|
44
|
+
if (input.length > MAX_TERMS) return { error: `A task carries at most ${MAX_TERMS} pay terms.` };
|
|
45
|
+
const terms = [];
|
|
46
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
47
|
+
const t = input[i];
|
|
48
|
+
if (!t || typeof t !== 'object' || Array.isArray(t)) return { error: `pay term ${i + 1} is not an object.` };
|
|
49
|
+
const extra = Object.keys(t).filter((k) => !['pay_type', 'amount', 'unit', 'note'].includes(k));
|
|
50
|
+
if (extra.length) return { error: `pay term ${i + 1} has an unknown field: ${extra.join(', ')}.` };
|
|
51
|
+
if (!PAY_TYPES.includes(t.pay_type)) {
|
|
52
|
+
return { error: `pay term ${i + 1}: pay_type must be one of ${PAY_TYPES.join(', ')}.` };
|
|
53
|
+
}
|
|
54
|
+
const out = { pay_type: t.pay_type };
|
|
55
|
+
for (const field of ['amount', 'unit', 'note']) {
|
|
56
|
+
const v = t[field];
|
|
57
|
+
if (v == null) { out[field] = null; continue; }
|
|
58
|
+
if (typeof v !== 'string') return { error: `pay term ${i + 1}: ${field} is text, as the project states it.` };
|
|
59
|
+
const s = v.trim();
|
|
60
|
+
if (s.length > LIMITS[field]) return { error: `pay term ${i + 1}: ${field} is at most ${LIMITS[field]} characters.` };
|
|
61
|
+
out[field] = s || null;
|
|
62
|
+
}
|
|
63
|
+
terms.push(out);
|
|
64
|
+
}
|
|
65
|
+
return { terms };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// PURE. One stored term as every reader shows it. The self-managed label rides on
|
|
69
|
+
// the term itself, so no surface can show the term without it.
|
|
70
|
+
function displayTerm(row) {
|
|
71
|
+
return {
|
|
72
|
+
pay_type: row.pay_type,
|
|
73
|
+
type_label: PAY_TYPE_LABELS[row.pay_type] || row.pay_type,
|
|
74
|
+
amount: row.amount ?? null,
|
|
75
|
+
unit: row.unit ?? null,
|
|
76
|
+
note: row.note ?? null,
|
|
77
|
+
label: row.pay_type === 'self_managed' ? SELF_MANAGED_LABEL : null,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Append one set: the whole list of terms for a task (taskId) or for the project
|
|
82
|
+
// (taskId null). One transaction, so a set is never half-written. Returns the set.
|
|
83
|
+
async function setPayTerms({ taskId = null, terms, builderId }, { db } = {}) {
|
|
84
|
+
const run = async (client) => {
|
|
85
|
+
const { rows: [set] } = await client.query(
|
|
86
|
+
'INSERT INTO lifecycle_pay_term_sets (task_id, set_by) VALUES ($1, $2) RETURNING id, task_id, set_by, set_at',
|
|
87
|
+
[taskId, builderId]);
|
|
88
|
+
// Every term in ONE statement, positions from unnest's ordinality, so a set of
|
|
89
|
+
// ten is one round trip, not ten.
|
|
90
|
+
if (terms.length) {
|
|
91
|
+
await client.query(
|
|
92
|
+
`INSERT INTO lifecycle_pay_terms (set_id, position, pay_type, amount, unit, note)
|
|
93
|
+
SELECT $1, (t.n - 1)::smallint, t.pay_type, t.amount, t.unit, t.note
|
|
94
|
+
FROM unnest($2::text[], $3::text[], $4::text[], $5::text[])
|
|
95
|
+
WITH ORDINALITY AS t(pay_type, amount, unit, note, n)`,
|
|
96
|
+
[set.id, terms.map((t) => t.pay_type), terms.map((t) => t.amount), terms.map((t) => t.unit), terms.map((t) => t.note)]);
|
|
97
|
+
}
|
|
98
|
+
return { set_id: set.id, task_id: set.task_id, set_by: set.set_by, set_at: set.set_at, terms: terms.map(displayTerm) };
|
|
99
|
+
};
|
|
100
|
+
if (db) return run(db);
|
|
101
|
+
return api.withTx(run);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The current sets for these tasks, plus the project's standing set, in two reads
|
|
105
|
+
// (lifecycle_pay_term_sets_current). Returns Map<taskIdString, view>, where a view is
|
|
106
|
+
// { source: 'task' | 'project' | 'none', terms, set_by, set_at }. A task whose
|
|
107
|
+
// newest set is EMPTY says so ('task', no terms): the project chose "no pay terms"
|
|
108
|
+
// for it, which the standing terms must not paper over.
|
|
109
|
+
async function readPayTerms(taskIds, { db } = {}) {
|
|
110
|
+
const exec = db || api.pool;
|
|
111
|
+
const ids = [...new Set((taskIds || []).map((x) => String(x)))];
|
|
112
|
+
const { rows: sets } = await exec.query(
|
|
113
|
+
`SELECT DISTINCT ON (task_id) id, task_id, set_by, set_at
|
|
114
|
+
FROM lifecycle_pay_term_sets
|
|
115
|
+
WHERE task_id = ANY($1::bigint[]) OR task_id IS NULL
|
|
116
|
+
ORDER BY task_id, set_at DESC, id DESC`, [ids]);
|
|
117
|
+
const setIds = sets.map((s) => s.id);
|
|
118
|
+
const termsBySet = new Map();
|
|
119
|
+
if (setIds.length) {
|
|
120
|
+
const { rows } = await exec.query(
|
|
121
|
+
`SELECT set_id, pay_type, amount, unit, note FROM lifecycle_pay_terms
|
|
122
|
+
WHERE set_id = ANY($1::bigint[]) ORDER BY set_id, position`, [setIds]);
|
|
123
|
+
for (const r of rows) {
|
|
124
|
+
const k = String(r.set_id);
|
|
125
|
+
if (!termsBySet.has(k)) termsBySet.set(k, []);
|
|
126
|
+
termsBySet.get(k).push(displayTerm(r));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const view = (s, source) => ({ source, terms: termsBySet.get(String(s.id)) || [], set_by: s.set_by, set_at: s.set_at });
|
|
130
|
+
const project = sets.find((s) => s.task_id == null);
|
|
131
|
+
const out = new Map();
|
|
132
|
+
for (const id of ids) {
|
|
133
|
+
const own = sets.find((s) => s.task_id != null && String(s.task_id) === id);
|
|
134
|
+
if (own) out.set(id, view(own, 'task'));
|
|
135
|
+
else if (project && (termsBySet.get(String(project.id)) || []).length) out.set(id, view(project, 'project'));
|
|
136
|
+
else out.set(id, { source: 'none', terms: [], set_by: null, set_at: null });
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The project's standing terms and their history (newest first, at most `limit`
|
|
142
|
+
// sets), for the settings page: who changed what, and when.
|
|
143
|
+
async function readProjectPayTerms({ db, limit = 10 } = {}) {
|
|
144
|
+
const exec = db || api.pool;
|
|
145
|
+
const { rows: sets } = await exec.query(
|
|
146
|
+
`SELECT s.id, s.set_by, s.set_at, b.github_login AS set_by_login
|
|
147
|
+
FROM lifecycle_pay_term_sets s LEFT JOIN builders b ON b.id = s.set_by
|
|
148
|
+
WHERE s.task_id IS NULL
|
|
149
|
+
ORDER BY s.set_at DESC, s.id DESC LIMIT $1`, [limit]);
|
|
150
|
+
const termsBySet = new Map();
|
|
151
|
+
if (sets.length) {
|
|
152
|
+
const { rows } = await exec.query(
|
|
153
|
+
`SELECT set_id, pay_type, amount, unit, note FROM lifecycle_pay_terms
|
|
154
|
+
WHERE set_id = ANY($1::bigint[]) ORDER BY set_id, position`, [sets.map((s) => s.id)]);
|
|
155
|
+
for (const r of rows) {
|
|
156
|
+
const k = String(r.set_id);
|
|
157
|
+
if (!termsBySet.has(k)) termsBySet.set(k, []);
|
|
158
|
+
termsBySet.get(k).push(displayTerm(r));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const history = sets.map((s) => ({
|
|
162
|
+
set_by: s.set_by, set_by_login: s.set_by_login || null, set_at: s.set_at,
|
|
163
|
+
terms: termsBySet.get(String(s.id)) || [],
|
|
164
|
+
}));
|
|
165
|
+
return { terms: history.length ? history[0].terms : [], history };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
module.exports = {
|
|
169
|
+
PAY_TYPES, PAY_TYPE_LABELS, SELF_MANAGED_LABEL, MAX_TERMS,
|
|
170
|
+
normalizePayTerms, displayTerm, setPayTerms, readPayTerms, readProjectPayTerms,
|
|
171
|
+
};
|
|
@@ -34,9 +34,49 @@ const express = require('express');
|
|
|
34
34
|
const api = require('../../../src/module-api');
|
|
35
35
|
const goalCategory = require('../goal-category');
|
|
36
36
|
const autopickup = require('../autopickup');
|
|
37
|
+
const payTermsLib = require('../pay-terms'); // task 1004990
|
|
37
38
|
|
|
38
39
|
const log = api.logger('lifecycle');
|
|
39
40
|
|
|
41
|
+
// --- pay terms: the project's standing pay policy (task 1004990) --------------
|
|
42
|
+
//
|
|
43
|
+
// ADR 0385 decision 3: the project, not Bongos, sets the pay. The owner ruled
|
|
44
|
+
// (2026-10-29) that this policy lives in a database table, changed here, with an
|
|
45
|
+
// audit trail — never in the branding pack. The row carries the current terms,
|
|
46
|
+
// the recent history (who, when, what) and the six types, and suggests no amount.
|
|
47
|
+
function payTermsRow(policy) {
|
|
48
|
+
if (!policy) {
|
|
49
|
+
return {
|
|
50
|
+
key: 'pay_terms',
|
|
51
|
+
title: 'Pay terms',
|
|
52
|
+
editable: false,
|
|
53
|
+
state: 'unavailable',
|
|
54
|
+
sentence: 'The project’s pay terms could not be read just now.',
|
|
55
|
+
source: 'lifecycle_pay_term_sets / lifecycle_pay_terms tables',
|
|
56
|
+
reads: { file: 'lifecycle_pay_terms (migration lifecycle_018)', adr: 'ADR 0385' },
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
const terms = policy.terms || [];
|
|
60
|
+
return {
|
|
61
|
+
key: 'pay_terms',
|
|
62
|
+
title: 'Pay terms',
|
|
63
|
+
editable: true,
|
|
64
|
+
state: 'live',
|
|
65
|
+
value: {
|
|
66
|
+
terms,
|
|
67
|
+
history: (policy && policy.history) || [],
|
|
68
|
+
types: payTermsLib.PAY_TYPES.map((t) => ({ type: t, label: payTermsLib.PAY_TYPE_LABELS[t] })),
|
|
69
|
+
max: payTermsLib.MAX_TERMS,
|
|
70
|
+
self_managed_label: payTermsLib.SELF_MANAGED_LABEL,
|
|
71
|
+
},
|
|
72
|
+
sentence: terms.length
|
|
73
|
+
? 'What this project pays for its work, in its own words. A builder sees it before claiming any task that has no pay terms of its own. Bongos records it and moves no money.'
|
|
74
|
+
: 'This project has not stated standing pay terms. A task can still carry its own. Bongos suggests no amount and moves no money.',
|
|
75
|
+
source: 'lifecycle_pay_term_sets / lifecycle_pay_terms tables',
|
|
76
|
+
reads: { file: 'lifecycle_pay_terms (migration lifecycle_018)', adr: 'ADR 0385' },
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
40
80
|
// --- ship mode: who starts a ship (task 1004628, ADR 0369) -------------------
|
|
41
81
|
//
|
|
42
82
|
// One row shape, two readers: the settings page (inside GET /project-settings) and
|
|
@@ -292,6 +332,12 @@ module.exports = function buildProjectSettingsRouter() {
|
|
|
292
332
|
const categories = await goalCategory.listCategories({ includeInactive: true });
|
|
293
333
|
const shipMode = await api.projectSettings.resolveShipMode();
|
|
294
334
|
const tweak = await api.projectSettings.resolveTweakAutopickup();
|
|
335
|
+
// One unreadable table must not take the whole page down: the row says it
|
|
336
|
+
// could not be read, and the other rows still draw.
|
|
337
|
+
const payPolicy = await payTermsLib.readProjectPayTerms().catch((err) => {
|
|
338
|
+
log.error(`GET /project-settings: pay terms read failed: ${err && err.message}`);
|
|
339
|
+
return null;
|
|
340
|
+
});
|
|
295
341
|
|
|
296
342
|
// A settings read is a configuration comparison — a cached one is wrong the
|
|
297
343
|
// moment somebody turns a knob (the rot.js precedent).
|
|
@@ -339,6 +385,7 @@ module.exports = function buildProjectSettingsRouter() {
|
|
|
339
385
|
source: 'work_categories table',
|
|
340
386
|
reads: { file: 'work_categories (migration core_226)', adr: 'ADR 0192' },
|
|
341
387
|
},
|
|
388
|
+
payTermsRow(payPolicy),
|
|
342
389
|
creditRow(),
|
|
343
390
|
equityRow(),
|
|
344
391
|
upmarketRow(),
|
|
@@ -464,6 +511,53 @@ module.exports = function buildProjectSettingsRouter() {
|
|
|
464
511
|
}
|
|
465
512
|
});
|
|
466
513
|
|
|
514
|
+
// GET /pay-terms?task_ids=1,2,3 — what each task pays, in the project's words
|
|
515
|
+
// (task 1004990, ADR 0385 D3), for a list of tasks in one read: the hall task page
|
|
516
|
+
// and `bongos start` show it so a builder sees the pay BEFORE claiming. Each entry
|
|
517
|
+
// is { source: 'task' | 'project' | 'none', terms, set_by, set_at }; a task with
|
|
518
|
+
// none of its own shows the project's standing terms (set below), and a
|
|
519
|
+
// self-managed term carries its "Bongos does not move or guarantee this money"
|
|
520
|
+
// label. Records only — no amount here is a Bongos figure. It lives beside the
|
|
521
|
+
// standing-terms write because both serve the one pay-terms table pair.
|
|
522
|
+
// rank: any-builder — the same task metadata GET /tasks/:id serves; nothing changed.
|
|
523
|
+
router.get('/pay-terms', api.requireBuilder, async (req, res) => {
|
|
524
|
+
const raw = String((req.query && req.query.task_ids) || '');
|
|
525
|
+
const ids = raw.split(',').map((s) => s.trim()).filter(Boolean);
|
|
526
|
+
if (!ids.length || ids.length > 50 || !ids.every((s) => /^\d{1,18}$/.test(s))) {
|
|
527
|
+
return res.fail('bad_task_ids', { status: 400, message: 'task_ids is a comma list of 1 to 50 task ids.' });
|
|
528
|
+
}
|
|
529
|
+
try {
|
|
530
|
+
const map = await payTermsLib.readPayTerms(ids);
|
|
531
|
+
res.json({ pay_terms: Object.fromEntries(map) });
|
|
532
|
+
} catch (err) {
|
|
533
|
+
log.error(`GET /pay-terms: ${err && err.message}`);
|
|
534
|
+
res.fail('pay_terms_read_failed', { status: 500, message: 'internal error' });
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
// PATCH /project-settings/pay-terms — body { terms: [{ pay_type, amount?, unit?, note? }] }
|
|
539
|
+
// (task 1004990; ADR 0385 decision 3 and the owner's 2026-10-29 ruling). The
|
|
540
|
+
// project's STANDING pay terms: what a builder is shown on any task that carries
|
|
541
|
+
// none of its own. The whole list replaces the current set; [] means none. Every
|
|
542
|
+
// change is a new row naming who set it and when, and the old sets stay, so the
|
|
543
|
+
// table is the audit trail the owner asked for. Bongos fills nothing in.
|
|
544
|
+
//
|
|
545
|
+
// rank: project.settings.manage (metic+, government_010), the same atom as the
|
|
546
|
+
// other writes on this page.
|
|
547
|
+
router.patch('/project-settings/pay-terms', api.requireBuilder, api.requirePermission('project.settings.manage'), api.requireActAlone('rules'), async (req, res) => {
|
|
548
|
+
if (api.validateOrRespond(req, res, { terms: { required: true } })) return; // a list; checked by pay-terms.js
|
|
549
|
+
const parsed = payTermsLib.normalizePayTerms(req.body.terms);
|
|
550
|
+
if (parsed.error) return res.fail('bad_pay_terms', { status: 400, message: parsed.error });
|
|
551
|
+
try {
|
|
552
|
+
await payTermsLib.setPayTerms({ taskId: null, terms: parsed.terms, builderId: req.builder.id });
|
|
553
|
+
const policy = await payTermsLib.readProjectPayTerms();
|
|
554
|
+
res.json({ ok: true, row: payTermsRow(policy) });
|
|
555
|
+
} catch (err) {
|
|
556
|
+
log.error(`PATCH /project-settings/pay-terms: ${err && err.message}`);
|
|
557
|
+
res.fail('project_settings_write_failed', { status: 500, message: 'internal error' });
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
|
|
467
561
|
// --- tweak auto-pickup (task 1004677, ADR 0371 D3/D5) ----------------------
|
|
468
562
|
//
|
|
469
563
|
// GET /project-settings/tweak-autopickup — the switch, the monthly cap, and how
|
|
@@ -28,6 +28,7 @@ const { isModuleKey, moduleKeys } = api.moduleScopeMap;
|
|
|
28
28
|
const { validateOrRespond, LIMITS, parseId } = api;
|
|
29
29
|
const { badKind, badNeeds, detectsMigration } = require('./task-route-helpers.js');
|
|
30
30
|
const { describeCheckViolation } = require('../pg-constraint-fail.js');
|
|
31
|
+
const payTermsLib = require('../pay-terms.js'); // task 1004990
|
|
31
32
|
const { goalNotOpenRefusal } = require('./goal-route-authz.js');
|
|
32
33
|
const { resolveUpkeepHome, isUpkeepFiling, UPKEEP_HINT } = require('../upkeep-home.js'); // task 1004597
|
|
33
34
|
// task 1003208: structured logging (pino via the doorway) — was console.*.
|
|
@@ -39,6 +40,11 @@ module.exports = function registerTaskWriteRoutes(router) {
|
|
|
39
40
|
// shape tasks. Was Archon-only (task #106 / ADR 0016; three-rank model #360 /
|
|
40
41
|
// ADR 0018). A Xenos/Thetes still cannot author — they file ideas. Setting rank
|
|
41
42
|
// and closing versions stay Archon. The every-route audit is task #231 (V3.R06).
|
|
43
|
+
// NO pay_terms HERE, on purpose (task 1004990): the task's rule is that only
|
|
44
|
+
// someone who may EDIT a task sets its pay terms, and this route is gated on
|
|
45
|
+
// task.create, not task.edit — so terms are set with PATCH /tasks/:id right after
|
|
46
|
+
// create, under that route's task.edit gate. The strict validator refuses the
|
|
47
|
+
// field here rather than letting a create quietly widen who may set pay.
|
|
42
48
|
// rank: metic+archon — create work (plan/scope mutation); ADR 0090 (was ADR 0016/0018 archon).
|
|
43
49
|
router.post('/tasks', auth.requireBuilder, auth.requirePermission('task.create'), async (req, res) => {
|
|
44
50
|
if (validateOrRespond(req, res, {
|
|
@@ -387,6 +393,10 @@ module.exports = function registerTaskWriteRoutes(router) {
|
|
|
387
393
|
// the hint had been unreachable dead code behind unknown_field since the
|
|
388
394
|
// R11 strict-validation sweep landed after it.
|
|
389
395
|
status: {},
|
|
396
|
+
// task 1004990 (ADR 0385 D3): the task's pay terms, in the project's own
|
|
397
|
+
// words — a list, [] for none. Checked inline by pay-terms.js; the whole list
|
|
398
|
+
// replaces the current set and the old set stays as history.
|
|
399
|
+
pay_terms: {},
|
|
390
400
|
})) return;
|
|
391
401
|
const id = parseId(req, res, { code: 'bad_task_id', positiveInt: true });
|
|
392
402
|
if (id === null) return;
|
|
@@ -412,6 +422,7 @@ module.exports = function registerTaskWriteRoutes(router) {
|
|
|
412
422
|
const hasTitle = Object.prototype.hasOwnProperty.call(body, 'title');
|
|
413
423
|
const hasPriority = Object.prototype.hasOwnProperty.call(body, 'priority');
|
|
414
424
|
const hasValueSummary = Object.prototype.hasOwnProperty.call(body, 'value_summary');
|
|
425
|
+
const hasPayTerms = Object.prototype.hasOwnProperty.call(body, 'pay_terms');
|
|
415
426
|
// #514: status is a lifecycle field, not a PATCH-able column — it moves only
|
|
416
427
|
// through the dedicated transition routes. The #506 session tried
|
|
417
428
|
// `PATCH status=ready`, got the generic no_supported_fields error, and had
|
|
@@ -423,8 +434,8 @@ module.exports = function registerTaskWriteRoutes(router) {
|
|
|
423
434
|
if (Object.prototype.hasOwnProperty.call(body, 'status')) {
|
|
424
435
|
return res.fail('status_not_patchable', { status: 400, message: STATUS_HINT });
|
|
425
436
|
}
|
|
426
|
-
if (!hasParent && !hasKind && !hasDiscipline && !hasNewcomerOrXenos && !hasParallelSafe && !hasSecuritySensitive && !hasAutomationTag && !hasNeeds && !hasNeedsMigration && !hasRequiresRank && !hasModuleKey && !hasGoalId && !hasDescription && !hasTitle && !hasPriority && !hasValueSummary) {
|
|
427
|
-
return res.fail('no_supported_fields', { status: 400, message: 'PATCH /tasks/:id supports parent_task_id, kind, discipline, title, description, value_summary, priority, parallel_safe, security_sensitive, automation_tag, needs, needs_migration, requires_rank, module_key, goal_id, and newcomer_friendly (alias: xenos_claimable). ' + STATUS_HINT });
|
|
437
|
+
if (!hasParent && !hasKind && !hasDiscipline && !hasNewcomerOrXenos && !hasParallelSafe && !hasSecuritySensitive && !hasAutomationTag && !hasNeeds && !hasNeedsMigration && !hasRequiresRank && !hasModuleKey && !hasGoalId && !hasDescription && !hasTitle && !hasPriority && !hasValueSummary && !hasPayTerms) {
|
|
438
|
+
return res.fail('no_supported_fields', { status: 400, message: 'PATCH /tasks/:id supports parent_task_id, kind, discipline, title, description, value_summary, priority, pay_terms, parallel_safe, security_sensitive, automation_tag, needs, needs_migration, requires_rank, module_key, goal_id, and newcomer_friendly (alias: xenos_claimable). ' + STATUS_HINT });
|
|
428
439
|
}
|
|
429
440
|
// ---- task 1002609 (G-22): settle EVERY gate and validation before the ----
|
|
430
441
|
// ---- FIRST write. The previous shape validated-and-wrote per field in ----
|
|
@@ -555,6 +566,16 @@ module.exports = function registerTaskWriteRoutes(router) {
|
|
|
555
566
|
return res.fail(`bad_${field}`, { status: 400, message: `${field} must be a boolean` });
|
|
556
567
|
}
|
|
557
568
|
}
|
|
569
|
+
// task 1004990: pay terms are checked here with the other gates, so a bad list
|
|
570
|
+
// refuses the whole request before any field is written. Who may set them is
|
|
571
|
+
// this route's own task.edit gate (ADR 0016) — the same person who may edit
|
|
572
|
+
// the task. Nothing is filled in: a term without an amount stays without one.
|
|
573
|
+
let payTerms = null;
|
|
574
|
+
if (hasPayTerms) {
|
|
575
|
+
payTerms = payTermsLib.normalizePayTerms(body.pay_terms);
|
|
576
|
+
if (payTerms.error) return res.fail('bad_pay_terms', { status: 400, message: payTerms.error });
|
|
577
|
+
if (!(await db.getTask(id))) return res.fail('task_not_found', 404);
|
|
578
|
+
}
|
|
558
579
|
// ---- all gates passed: apply the writes. Order preserved from the old ----
|
|
559
580
|
// shape — requires_rank still runs AFTER security_sensitive so a
|
|
560
581
|
// same-request flag flip is already reflected in the derived floor.
|
|
@@ -695,10 +716,20 @@ module.exports = function registerTaskWriteRoutes(router) {
|
|
|
695
716
|
return res.fail('patch_failed', { status: 500, message: 'internal error' });
|
|
696
717
|
}
|
|
697
718
|
}
|
|
698
|
-
|
|
719
|
+
let payTermsSet;
|
|
720
|
+
if (hasPayTerms) {
|
|
721
|
+
try {
|
|
722
|
+
payTermsSet = await payTermsLib.setPayTerms({ taskId: id, terms: payTerms.terms, builderId: req.builder.id });
|
|
723
|
+
if (!updated) updated = await db.getTask(id);
|
|
724
|
+
} catch (err) {
|
|
725
|
+
log.error('[gds] PATCH /tasks/:id pay_terms', err);
|
|
726
|
+
return res.fail('patch_failed', { status: 500, message: 'internal error' });
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
if (!hasParent) return res.json(payTermsSet ? { task: updated, pay_terms: payTermsSet } : { task: updated });
|
|
699
730
|
try {
|
|
700
731
|
const task = await hierarchy.attachParent(id, body.parent_task_id);
|
|
701
|
-
res.json({ task });
|
|
732
|
+
res.json(payTermsSet ? { task, pay_terms: payTermsSet } : { task });
|
|
702
733
|
} catch (err) {
|
|
703
734
|
if (err && err.code) {
|
|
704
735
|
const status =
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"key": "market-watch",
|
|
3
3
|
"title": "Market Watch (a weekly AI watch on a project's market)",
|
|
4
|
-
"description": "A weekly AI watch on a project's competitors, partners and topics, with a sourced log per tracked item and a weekly question to members (goal 1000137). Its data model (tables, read queries and the hub seed of the 2026-10-28 research), its weekly run (an hourly poller that researches each tracked item on the project's own Anthropic key, under the monthly cap), the weekly ask card in the hall's attention inbox (task 1004940)
|
|
4
|
+
"description": "A weekly AI watch on a project's competitors, partners and topics, with a sourced log per tracked item and a weekly question to members (goal 1000137). Its data model (tables, read queries and the hub seed of the 2026-10-28 research), its weekly run (an hourly poller that researches each tracked item on the project's own Anthropic key, under the monthly cap), the weekly ask card in the hall's attention inbox (task 1004940), its rules page (the watch rules in plain words, members' proposed changes, and the owner's or the government's decision on each, task 1004941) and the owner's settings (on/off, monthly cap, cadence, the project key; task 1004938) are in. Off by default, and it cannot be turned on behind an open join door with no payer, because every run will spend the project's AI budget.",
|
|
5
5
|
"version": "0.4.0",
|
|
6
6
|
"coreVersion": "^1.22.0",
|
|
7
7
|
"category": {
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
"migrations": true,
|
|
16
16
|
"routes": [
|
|
17
17
|
"ask",
|
|
18
|
-
"rules"
|
|
18
|
+
"rules",
|
|
19
|
+
"settings"
|
|
19
20
|
],
|
|
20
21
|
"pollers": [
|
|
21
22
|
"hub-seed",
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// modules/market-watch/routes/settings.js — the owner's Market Watch settings (task 1004938,
|
|
2
|
+
// goal 1000137). The page is the hall's /market-watch-settings, built to
|
|
3
|
+
// docs/design/mocks/market-watch/settings.html (option B, the fuel gauge).
|
|
4
|
+
//
|
|
5
|
+
// GET /market-watch/settings the switch, cap, cadence, this month's spend, the next
|
|
6
|
+
// run, recent runs, and whether a key is set
|
|
7
|
+
// PATCH /market-watch/settings change any of { enabled, monthly_cap_usd, cadence }
|
|
8
|
+
// PUT /market-watch/settings/key save or replace the project's Anthropic key { key }
|
|
9
|
+
// DELETE /market-watch/settings/key clear it
|
|
10
|
+
//
|
|
11
|
+
// rank: archon — the owner seat. Two server-side gates on every route, the read included
|
|
12
|
+
// (ADR 0016; the page hiding a control is never the wall):
|
|
13
|
+
// 1. requirePermission('project.settings.manage') — the catalog atom for "the project's
|
|
14
|
+
// own live knobs" (modules/government/catalog.js, floor metic). Module routes gate on
|
|
15
|
+
// atoms, never on a rank gate (tests/government_require_permission.mjs).
|
|
16
|
+
// 2. ownerSeat — the owner seat is every active Archon (government leads.ownerOf), and
|
|
17
|
+
// the task says owner-only, so a Metic who holds the atom is still refused 403
|
|
18
|
+
// rank_forbidden. No archon-floor atom names this authority, and borrowing an
|
|
19
|
+
// unrelated one would name the wrong authority (the scouting holdout's reasoning in
|
|
20
|
+
// that test). The security_sensitive check in lifecycle's task-write-routes is the
|
|
21
|
+
// precedent for an archon check behind a wider atom.
|
|
22
|
+
// The read is owner-only too: it carries the project's AI spend and whether its key is set.
|
|
23
|
+
//
|
|
24
|
+
// The key is write-only. No route returns it or any part of it; the body field is named
|
|
25
|
+
// `key`, which the audit middleware redacts, and no log line here carries the body.
|
|
26
|
+
|
|
27
|
+
'use strict';
|
|
28
|
+
|
|
29
|
+
const express = require('express');
|
|
30
|
+
const api = require('../../../src/module-api');
|
|
31
|
+
const settings = require('../settings');
|
|
32
|
+
const { projectKey } = require('../seed');
|
|
33
|
+
|
|
34
|
+
const log = api.logger('market-watch');
|
|
35
|
+
|
|
36
|
+
const MAX_KEY_BODY = 512;
|
|
37
|
+
const KEY_MESSAGES = {
|
|
38
|
+
key_required: 'Paste the key.',
|
|
39
|
+
key_wrong_length: 'That does not look like an Anthropic key. Paste the whole key.',
|
|
40
|
+
key_has_spaces: 'That does not look like an Anthropic key. Paste it with no spaces.',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// The project is this instance's own public origin, as the run and the seed read it.
|
|
44
|
+
function currentProject() {
|
|
45
|
+
try { return projectKey(api.branding().domains.publicOrigin); } catch (_) { return null; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function ceilingUsd() {
|
|
49
|
+
try { return require('../research').itemCeilingUsd(); } catch (_) { return null; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function storageConfigured() {
|
|
53
|
+
try { return api.secretBox.isConfigured() === true; } catch (_) { return false; }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = function buildMarketWatchSettingsRouter(deps = {}) {
|
|
57
|
+
const router = express.Router();
|
|
58
|
+
// Seams for the tests: the pool, the project and the box. Production passes nothing.
|
|
59
|
+
const pool = () => deps.pool || api.pool;
|
|
60
|
+
const project = () => (deps.project !== undefined ? deps.project : currentProject());
|
|
61
|
+
const box = () => deps.secretBox || api.secretBox;
|
|
62
|
+
const isStorageConfigured = () => (deps.secretBox ? deps.secretBox.isConfigured() === true : storageConfigured());
|
|
63
|
+
|
|
64
|
+
// The owner seat, checked after the atom: holding project.settings.manage is not enough.
|
|
65
|
+
function ownerSeat(req, res, next) {
|
|
66
|
+
const actual = req.builder && req.builder.rank;
|
|
67
|
+
if (!api.rankMeetsThreshold(actual, ['archon'])) {
|
|
68
|
+
return res.fail('rank_forbidden', 403, { required: ['archon'], actual: actual || null, reason: 'Only the project owner can see or change Market Watch settings.' });
|
|
69
|
+
}
|
|
70
|
+
return next();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function withProject(res) {
|
|
74
|
+
const p = project();
|
|
75
|
+
if (!p) res.fail('project_origin_unset', { status: 409, message: 'This project has no public address set, so Market Watch cannot tell which project it is.' });
|
|
76
|
+
return p;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function view(p) {
|
|
80
|
+
return settings.readView(pool(), p, {
|
|
81
|
+
now: deps.now ? new Date(deps.now()) : new Date(),
|
|
82
|
+
ceilingUsd: deps.ceilingUsd !== undefined ? deps.ceilingUsd : ceilingUsd(),
|
|
83
|
+
storageConfigured: isStorageConfigured(),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
router.get('/market-watch/settings', api.requireBuilder, api.requirePermission('project.settings.manage'), ownerSeat, api.asyncHandler('GET /market-watch/settings', async (req, res) => {
|
|
88
|
+
const p = withProject(res);
|
|
89
|
+
if (!p) return;
|
|
90
|
+
res.json(await view(p));
|
|
91
|
+
}));
|
|
92
|
+
|
|
93
|
+
router.patch('/market-watch/settings', api.requireBuilder, api.requirePermission('project.settings.manage'), ownerSeat, api.asyncHandler('PATCH /market-watch/settings', async (req, res) => {
|
|
94
|
+
// Strict field list (unknown fields refused by the envelope); the values are checked by
|
|
95
|
+
// settings.parsePatch, the one validator the tests also run.
|
|
96
|
+
if (api.validateOrRespond(req, res, { enabled: {}, monthly_cap_usd: {}, cadence: {} })) return;
|
|
97
|
+
const parsed = settings.parsePatch(req.body);
|
|
98
|
+
if (parsed.error) return res.fail(parsed.error, { status: 400, message: parsed.message });
|
|
99
|
+
const p = withProject(res);
|
|
100
|
+
if (!p) return;
|
|
101
|
+
const saved = await settings.writeSettings(pool(), p, parsed.patch, req.builder.id);
|
|
102
|
+
log.info({ builder_id: req.builder.id, fields: Object.keys(parsed.patch), enabled: saved.enabled }, 'market watch settings changed');
|
|
103
|
+
res.json(await view(p));
|
|
104
|
+
}));
|
|
105
|
+
|
|
106
|
+
router.put('/market-watch/settings/key', api.requireBuilder, api.requirePermission('project.settings.manage'), ownerSeat, api.asyncHandler('PUT /market-watch/settings/key', async (req, res) => {
|
|
107
|
+
if (api.validateOrRespond(req, res, { key: { required: true, type: 'string', maxLength: MAX_KEY_BODY } })) return;
|
|
108
|
+
const p = withProject(res);
|
|
109
|
+
if (!p) return;
|
|
110
|
+
if (!isStorageConfigured()) {
|
|
111
|
+
return res.fail('storage_not_configured', { status: 409, message: 'Key storage is not set up on this project yet.' });
|
|
112
|
+
}
|
|
113
|
+
const out = await settings.setKey(pool(), p, req.body.key, req.builder.id, { encrypt: (s) => box().encrypt(s) });
|
|
114
|
+
if (out.error) return res.fail(out.error, { status: 400, message: KEY_MESSAGES[out.error] || 'That key could not be saved.' });
|
|
115
|
+
log.info({ builder_id: req.builder.id }, 'market watch key saved');
|
|
116
|
+
res.json(await view(p));
|
|
117
|
+
}));
|
|
118
|
+
|
|
119
|
+
router.delete('/market-watch/settings/key', api.requireBuilder, api.requirePermission('project.settings.manage'), ownerSeat, api.asyncHandler('DELETE /market-watch/settings/key', async (req, res) => {
|
|
120
|
+
const p = withProject(res);
|
|
121
|
+
if (!p) return;
|
|
122
|
+
const out = await settings.clearKey(pool(), p, req.builder.id);
|
|
123
|
+
log.info({ builder_id: req.builder.id, removed: out.removed }, 'market watch key cleared');
|
|
124
|
+
res.json(await view(p));
|
|
125
|
+
}));
|
|
126
|
+
|
|
127
|
+
return router;
|
|
128
|
+
};
|