@bongos/core 1.19.478 → 1.19.480
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 +100 -35
- package/.claude/skills/design/SKILL.md +9 -3
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +4 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0230-a-look-skill-is-a-composition-grammar-the-palette-is-the-pack.md +36 -0
- package/docs/adr/0231-the-style-session-an-owner-authors-a-look-inside-the-contract.md +32 -0
- package/docs/adr/README.md +2 -0
- package/docs/api/openapi.json +80 -1
- package/docs/api-reference.md +5 -3
- package/docs/copy-inventory.md +6 -6
- package/docs/copy-registry.json +6 -6
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
- package/modules/economy/credits.js +78 -0
- package/modules/economy/module.json +1 -1
- package/modules/economy/reward.js +4 -0
- package/modules/economy/routes/credits.js +151 -0
- package/modules/hall-ui/public/md-reader.js +24 -1
- package/modules/ui-design/module.json +1 -1
- package/modules/ui-design/skills/README.md +14 -2
- package/modules/ui-design/skills/gpt-taste/SKILL.md +110 -0
- package/modules/ui-design/skills/industrial-brutalist-ui/SKILL.md +110 -0
- package/modules/ui-design/skills/minimalist-ui/SKILL.md +103 -0
- package/modules/ui-design/skills/stitch-design-taste/SKILL.md +91 -0
- package/modules/ui-design/skills/style/SKILL.md +112 -0
- package/modules/ui-design/styles/README.md +3 -0
- package/modules/ui-design/styles/blueprint/DESIGN.md +152 -0
- package/modules/ui-design/styles/blueprint/mock.html +308 -0
- package/modules/ui-design/styles/blueprint/mock.states.json +11 -0
- package/modules/ui-design/styles/blueprint/pack.json +33 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/credit_ledger_read.mjs +347 -0
- package/tests/hall_reading_world.mjs +80 -0
- package/tests/own_scope_guard.mjs +24 -2
- package/tests/ui_design_skills.mjs +102 -11
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_doc": "A look from the ui-design module's style library (task 1003323): the theme.ui overlay a branding pack carries, and nothing else. The stub lays it over the instance's pack (STYLE=blueprint); an instance adopts it by copying `theme` into config/branding.json. Light values only, as the contract says: dark is each sheet's own black tier, which this look reaches through the accent and the faces. Authored through the /style session of task 1003475.",
|
|
3
|
+
"name": "blueprint",
|
|
4
|
+
"title": "The Blueprint",
|
|
5
|
+
"summary": "A drafting table under north light: cool grey-blue paper, graphite rules, one cyanotype blue on everything pressable or live, and a rust annotation held for what went wrong.",
|
|
6
|
+
"origin": "authored through the /style session (task 1003475, ADR 0231) from a two-line brief: \"a drafting table under north light, cool paper and graphite; one cyanotype blue on what is pressable, a rust pencil for what went wrong\". The library had no cool look and no cool accent; the retune against the contract is recorded in DESIGN.md.",
|
|
7
|
+
"theme": {
|
|
8
|
+
"ui": {
|
|
9
|
+
"colors": {
|
|
10
|
+
"bg": "#e6ebf0",
|
|
11
|
+
"bgCard": "#f7fafc",
|
|
12
|
+
"bgDeep": "#d3dae2",
|
|
13
|
+
"bgFrame": "#edf1f5",
|
|
14
|
+
"ink": "#10161d",
|
|
15
|
+
"inkSoft": "#464f5a",
|
|
16
|
+
"inkFaint": "#566069",
|
|
17
|
+
"rule": "#aeb9c4",
|
|
18
|
+
"ruleSoft": "#cfd7de",
|
|
19
|
+
"accent": "#4f9fd6",
|
|
20
|
+
"accentDeep": "#17527e",
|
|
21
|
+
"sea": "#3f7f6a",
|
|
22
|
+
"terracotta": "#b04a2c"
|
|
23
|
+
},
|
|
24
|
+
"fonts": {
|
|
25
|
+
"display": "\"IBM Plex Sans\", \"Segoe UI\", system-ui, -apple-system, sans-serif",
|
|
26
|
+
"body": "\"IBM Plex Sans\", \"Segoe UI\", system-ui, -apple-system, sans-serif",
|
|
27
|
+
"links": [
|
|
28
|
+
"https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600&family=JetBrains+Mono:wght@400&display=swap"
|
|
29
|
+
]
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.480",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.480",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.480",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/src/module-api.js
CHANGED
|
@@ -34,7 +34,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
34
34
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
35
35
|
// the entry to that file. Look for a version's history there, not here.
|
|
36
36
|
// ---------------------------------------------------------------------------
|
|
37
|
-
const CORE_VERSION = '1.19.
|
|
37
|
+
const CORE_VERSION = '1.19.480'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
38
38
|
|
|
39
39
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
40
40
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
// tests/credit_ledger_read.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1003484 (goal 1000067) — the credit ledger gets a read surface, resolving
|
|
4
|
+
// blocker 1000127. Until this, `credit_log` had NO reader anywhere in the API:
|
|
5
|
+
// every in-tree query was an internal aggregate or an existence probe, so the
|
|
6
|
+
// three ideator streams (ADR 0172) could not be audited from outside at all.
|
|
7
|
+
//
|
|
8
|
+
// What this pins, in the order it matters:
|
|
9
|
+
//
|
|
10
|
+
// 1. THE ANTI-DRIFT GUARD. The stream reason keys EMBED the idea id
|
|
11
|
+
// (`idea.credit.full_pass:<ideaId>`), because that string is the
|
|
12
|
+
// idempotency predicate. So per-stream audit is a PREFIX question, and the
|
|
13
|
+
// prefixes this route publishes are asserted against the actual writers in
|
|
14
|
+
// credits.js — not against a transcription of them. If someone renames a
|
|
15
|
+
// reason key, this test fails rather than the audit filter silently
|
|
16
|
+
// returning nothing forever.
|
|
17
|
+
// 2. The filter SQL: builder scoping, exact vs prefix reason, LIKE-wildcard
|
|
18
|
+
// escaping (a `%` in a query string must not widen the scan), and the
|
|
19
|
+
// unpaginated total.
|
|
20
|
+
// 3. The leak rule: reward_basis is session top-up bookkeeping, not money a
|
|
21
|
+
// reader should see.
|
|
22
|
+
// 4. The two gates: /me/credits takes its builder from the SESSION (there is
|
|
23
|
+
// no parameter to tamper with), and /builders/:id/credits refuses a caller
|
|
24
|
+
// without the roster-read atom.
|
|
25
|
+
//
|
|
26
|
+
// Pure — a recording fake pool over the real router, no Postgres, no network
|
|
27
|
+
// beyond a loopback listener. Runs in the DB-free unit gate.
|
|
28
|
+
//
|
|
29
|
+
// Run: node tests/credit_ledger_read.mjs
|
|
30
|
+
|
|
31
|
+
import { strict as assert } from 'node:assert';
|
|
32
|
+
import { test, after } from 'node:test';
|
|
33
|
+
import { createRequire } from 'node:module';
|
|
34
|
+
import http from 'node:http';
|
|
35
|
+
import { readFileSync } from 'node:fs';
|
|
36
|
+
import { fileURLToPath } from 'node:url';
|
|
37
|
+
import { dirname, join } from 'node:path';
|
|
38
|
+
|
|
39
|
+
const require = createRequire(import.meta.url);
|
|
40
|
+
const express = require('express');
|
|
41
|
+
|
|
42
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
43
|
+
const CREDITS_SRC = readFileSync(join(ROOT, 'modules/economy/credits.js'), 'utf8');
|
|
44
|
+
|
|
45
|
+
// ---- the recording fake pool ----------------------------------------------
|
|
46
|
+
//
|
|
47
|
+
// One shared pool object, so the domain's `pool` (destructured once at require
|
|
48
|
+
// time, through the pinned module-api below) and each test's queue are the same
|
|
49
|
+
// thing.
|
|
50
|
+
let queued = [];
|
|
51
|
+
let calls = [];
|
|
52
|
+
const pool = {
|
|
53
|
+
query: async (text, params) => {
|
|
54
|
+
calls.push({ text: String(text), params: params || [] });
|
|
55
|
+
return queued.shift() || { rows: [], rowCount: 0 };
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
function expect(...responses) { queued = responses; calls = []; }
|
|
59
|
+
const lastCall = () => calls[calls.length - 1];
|
|
60
|
+
// Collapse whitespace so an assertion reads like the SQL, not like its indentation.
|
|
61
|
+
const sqlOf = (c) => c.text.replace(/\s+/g, ' ').trim();
|
|
62
|
+
|
|
63
|
+
// ---- the route over the real domain, with core pinned ----------------------
|
|
64
|
+
|
|
65
|
+
const SESSION = { id: 77, github_login: 'octo', rank: 'xenos' };
|
|
66
|
+
let permissionGranted = true; // flipped by the refusal test
|
|
67
|
+
let knownBuilder = true; // flipped by the 404 test
|
|
68
|
+
|
|
69
|
+
const helpers = require('../src/bongos/routes/_helpers.js');
|
|
70
|
+
const moduleApiPath = require.resolve('../src/module-api.js');
|
|
71
|
+
require.cache[moduleApiPath] = {
|
|
72
|
+
id: moduleApiPath, filename: moduleApiPath, loaded: true,
|
|
73
|
+
exports: {
|
|
74
|
+
pool,
|
|
75
|
+
parsePagination: helpers.parsePagination,
|
|
76
|
+
pageMeta: helpers.pageMeta,
|
|
77
|
+
parseId: helpers.parseId,
|
|
78
|
+
validateOrRespond: helpers.validateOrRespond,
|
|
79
|
+
LIMITS: helpers.LIMITS,
|
|
80
|
+
// The own-scope stub: a live session, exactly as requireBuilder leaves it.
|
|
81
|
+
requireBuilder: (req, _res, next) => { req.builder = { ...SESSION }; next(); },
|
|
82
|
+
// The atom gate, controllable — so the refusal is proven, not assumed.
|
|
83
|
+
requirePermission: () => (req, res, next) => (
|
|
84
|
+
permissionGranted ? next() : res.fail('permission_forbidden', 403)
|
|
85
|
+
),
|
|
86
|
+
getBuilderById: async (id) => (knownBuilder ? { id, github_login: 'target' } : null),
|
|
87
|
+
// Loaded-but-unused by the credit domain at require time.
|
|
88
|
+
branding: () => ({}),
|
|
89
|
+
llmPricing: { priceModelUsage: () => ({ totalUsd: 0 }), PRICES: {} },
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const credits = require('../modules/economy/credits.js');
|
|
94
|
+
const buildCreditsRouter = require('../modules/economy/routes/credits.js');
|
|
95
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
96
|
+
|
|
97
|
+
const app = express();
|
|
98
|
+
app.use(express.json());
|
|
99
|
+
app.use(attachFail);
|
|
100
|
+
app.use('/api/gds', buildCreditsRouter());
|
|
101
|
+
const server = app.listen(0, '127.0.0.1');
|
|
102
|
+
await new Promise((r) => server.once('listening', r));
|
|
103
|
+
const port = server.address().port;
|
|
104
|
+
after(() => server.close());
|
|
105
|
+
|
|
106
|
+
function call(method, path) {
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
const req = http.request({ host: '127.0.0.1', port, method, path }, (res) => {
|
|
109
|
+
const chunks = [];
|
|
110
|
+
res.on('data', (c) => chunks.push(c));
|
|
111
|
+
res.on('end', () => {
|
|
112
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
113
|
+
let json = null; try { json = JSON.parse(text); } catch { /* non-JSON body */ }
|
|
114
|
+
resolve({ status: res.statusCode, json, text });
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
req.on('error', reject);
|
|
118
|
+
req.end();
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// A ledger row as the SELECT hands it back (window count rides along).
|
|
123
|
+
const row = (over = {}) => ({
|
|
124
|
+
id: '5', delta: 5, reason: 'idea.credit.full_pass:20', description: null,
|
|
125
|
+
task_id: null, session_id: null, recorded_at: '2026-09-01T00:00:00.000Z',
|
|
126
|
+
total_count: 1, ...over,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ---- 1. the anti-drift guard ----------------------------------------------
|
|
130
|
+
|
|
131
|
+
test('the published stream prefixes are the REAL writers in credits.js, not a transcription', () => {
|
|
132
|
+
const p = credits.IDEA_STREAM_REASON_PREFIXES;
|
|
133
|
+
// Each writer builds its reason as `<prefix>${...}` — assert that exact
|
|
134
|
+
// template literal is present in the source. A renamed key breaks this test
|
|
135
|
+
// instead of silently making the audit filter match nothing, forever.
|
|
136
|
+
for (const [stream, prefix] of Object.entries(p)) {
|
|
137
|
+
assert.ok(
|
|
138
|
+
CREDITS_SRC.includes('`' + prefix + '${'),
|
|
139
|
+
`stream '${stream}' publishes prefix '${prefix}', but no writer in credits.js builds a reason with it`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('all three ideator streams are covered — one missing prefix is one unauditable lane', () => {
|
|
145
|
+
assert.deepEqual(
|
|
146
|
+
Object.keys(credits.IDEA_STREAM_REASON_PREFIXES).sort(),
|
|
147
|
+
['full_pass', 'governance_pass', 'task_ship'],
|
|
148
|
+
'ADR 0172 defines exactly three streams; each needs a prefix or it cannot be filtered for'
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('the prefixes are prefixes, not whole keys — the reason embeds the idea id', () => {
|
|
153
|
+
for (const prefix of Object.values(credits.IDEA_STREAM_REASON_PREFIXES)) {
|
|
154
|
+
assert.ok(prefix.endsWith(':'), `'${prefix}' must end at the separator, or it matches nothing`);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// ---- 2. the filter SQL -----------------------------------------------------
|
|
159
|
+
|
|
160
|
+
test('an unfiltered read is scoped to the builder, newest-first, and paged', async () => {
|
|
161
|
+
expect({ rows: [row()] });
|
|
162
|
+
const out = await credits.creditLedger(77, { limit: 10, offset: 0 });
|
|
163
|
+
const sql = sqlOf(lastCall());
|
|
164
|
+
assert.match(sql, /WHERE builder_id = \$1/, 'the ledger is never read unscoped');
|
|
165
|
+
assert.match(sql, /ORDER BY recorded_at DESC, id DESC/, 'newest first, with id as the stable tiebreak');
|
|
166
|
+
assert.match(sql, /LIMIT \$2 OFFSET \$3/);
|
|
167
|
+
assert.deepEqual(lastCall().params, [77, 10, 0]);
|
|
168
|
+
assert.equal(out.total, 1);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('total is the UNPAGINATED count, so a caller can tell more rows exist', async () => {
|
|
172
|
+
expect({ rows: [row({ total_count: 412 }), row({ id: '6', total_count: 412 })] });
|
|
173
|
+
const out = await credits.creditLedger(77, { limit: 2, offset: 0 });
|
|
174
|
+
assert.match(sqlOf(lastCall()), /COUNT\(\*\) OVER \(\)::int AS total_count/);
|
|
175
|
+
assert.equal(out.total, 412, 'the window count, not the page length');
|
|
176
|
+
assert.equal(out.rows.length, 2);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('an empty page reports total 0 rather than throwing on rows[0]', async () => {
|
|
180
|
+
expect({ rows: [] });
|
|
181
|
+
const out = await credits.creditLedger(77, { limit: 10, offset: 900 });
|
|
182
|
+
assert.deepEqual(out, { rows: [], total: 0 });
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('reason is an EXACT match — right for the unparameterized keys', async () => {
|
|
186
|
+
expect({ rows: [] });
|
|
187
|
+
await credits.creditLedger(77, { reason: 'task.shipped' });
|
|
188
|
+
assert.match(sqlOf(lastCall()), /reason = \$2/);
|
|
189
|
+
assert.equal(lastCall().params[1], 'task.shipped');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('reasonPrefix is a LIKE with an explicit ESCAPE — the per-stream audit filter', async () => {
|
|
193
|
+
expect({ rows: [] });
|
|
194
|
+
await credits.creditLedger(77, { reasonPrefix: credits.IDEA_STREAM_REASON_PREFIXES.full_pass });
|
|
195
|
+
// ESCAPE '\' — one literal backslash, which is what the SQL carries.
|
|
196
|
+
assert.match(sqlOf(lastCall()), /reason LIKE \$2 ESCAPE '\\'/);
|
|
197
|
+
// The stream keys contain '_', which is itself a LIKE wildcard — so our OWN
|
|
198
|
+
// prefix has to be escaped too, or 'full_pass' would also match 'fullXpass'.
|
|
199
|
+
assert.equal(lastCall().params[1], 'idea.credit.full\\_pass:%', 'anchored at the start, open at the end');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("a caller's LIKE wildcards are escaped — a '%' cannot widen the scan", async () => {
|
|
203
|
+
expect({ rows: [] });
|
|
204
|
+
await credits.creditLedger(77, { reasonPrefix: '100%_off\\x' });
|
|
205
|
+
assert.equal(lastCall().params[1], '100\\%\\_off\\\\x%',
|
|
206
|
+
'every %, _ and backslash is escaped; only the trailing % is ours');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('filters AND together and number their placeholders in order', async () => {
|
|
210
|
+
expect({ rows: [] });
|
|
211
|
+
await credits.creditLedger(77, {
|
|
212
|
+
reason: 'task.shipped', taskId: 1234, since: '2026-01-01T00:00:00Z', limit: 5, offset: 10,
|
|
213
|
+
});
|
|
214
|
+
const sql = sqlOf(lastCall());
|
|
215
|
+
assert.match(sql, /WHERE builder_id = \$1 AND reason = \$2 AND task_id = \$3 AND recorded_at >= \$4::timestamptz/);
|
|
216
|
+
assert.match(sql, /LIMIT \$5 OFFSET \$6/);
|
|
217
|
+
assert.deepEqual(lastCall().params, [77, 'task.shipped', 1234, '2026-01-01T00:00:00Z', 5, 10]);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('a non-positive builder id reads nothing at all — it never reaches SQL', async () => {
|
|
221
|
+
expect({ rows: [row()] });
|
|
222
|
+
const out = await credits.creditLedger(0);
|
|
223
|
+
assert.deepEqual(out, { rows: [], total: 0 });
|
|
224
|
+
assert.equal(calls.length, 0, 'a bad id must not turn into an unscoped query');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// ---- 3. the leak rule ------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
test('reward_basis is never selected — it is bookkeeping, not money a reader should see', async () => {
|
|
230
|
+
expect({ rows: [] });
|
|
231
|
+
await credits.creditLedger(77, {});
|
|
232
|
+
assert.doesNotMatch(sqlOf(lastCall()), /reward_basis/);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('total_count is stripped from the rows it rode in on', async () => {
|
|
236
|
+
expect({ rows: [row({ total_count: 9 })] });
|
|
237
|
+
const out = await credits.creditLedger(77, {});
|
|
238
|
+
assert.equal(out.total, 9);
|
|
239
|
+
assert.ok(!('total_count' in out.rows[0]), 'an internal window count is not a ledger field');
|
|
240
|
+
assert.deepEqual(Object.keys(out.rows[0]).sort(),
|
|
241
|
+
['delta', 'description', 'id', 'reason', 'recorded_at', 'session_id', 'task_id']);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// ---- 4. the two gates ------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
test('GET /me/credits takes its builder from the SESSION — there is no parameter to tamper with', async () => {
|
|
247
|
+
expect({ rows: [row()] });
|
|
248
|
+
const res = await call('GET', '/api/gds/me/credits?builder_id=999');
|
|
249
|
+
assert.equal(res.status, 200);
|
|
250
|
+
assert.equal(res.json.builder_id, 77, 'the session id, never the query string');
|
|
251
|
+
assert.equal(lastCall().params[0], 77);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('GET /me/credits serves the stream prefixes beside the rows, so the filter is discoverable', async () => {
|
|
255
|
+
expect({ rows: [row()] });
|
|
256
|
+
const res = await call('GET', '/api/gds/me/credits');
|
|
257
|
+
assert.deepEqual(res.json.streams, credits.IDEA_STREAM_REASON_PREFIXES);
|
|
258
|
+
assert.deepEqual(res.json.page, { limit: 50, offset: 0, total: 1 });
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test('the page limit is clamped — the ledger grows forever, so an uncapped read cannot be asked for', async () => {
|
|
262
|
+
expect({ rows: [] });
|
|
263
|
+
const res = await call('GET', '/api/gds/me/credits?limit=100000');
|
|
264
|
+
assert.equal(res.json.page.limit, 200);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test('a ledger read is never cached — a stale balance is a wrong one', async () => {
|
|
268
|
+
expect({ rows: [] });
|
|
269
|
+
const res = await new Promise((resolve, reject) => {
|
|
270
|
+
http.request({ host: '127.0.0.1', port, method: 'GET', path: '/api/gds/me/credits' }, (r) => {
|
|
271
|
+
r.resume(); r.on('end', () => resolve(r));
|
|
272
|
+
}).on('error', reject).end();
|
|
273
|
+
});
|
|
274
|
+
assert.equal(res.headers['cache-control'], 'no-store');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('a caller WITHOUT the roster-read atom cannot read another builder ledger', async () => {
|
|
278
|
+
permissionGranted = false;
|
|
279
|
+
expect({ rows: [row()] });
|
|
280
|
+
const res = await call('GET', '/api/gds/builders/999/credits');
|
|
281
|
+
permissionGranted = true;
|
|
282
|
+
assert.equal(res.status, 403);
|
|
283
|
+
assert.equal(calls.length, 0, 'refused before any query — the gate is not decorative');
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test('a permitted caller reads the TARGET builder ledger, not their own', async () => {
|
|
287
|
+
expect({ rows: [row()] });
|
|
288
|
+
const res = await call('GET', '/api/gds/builders/999/credits');
|
|
289
|
+
assert.equal(res.status, 200);
|
|
290
|
+
assert.equal(res.json.builder_id, 999);
|
|
291
|
+
assert.equal(lastCall().params[0], 999);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('an unknown builder is a 404, not an empty ledger that reads like "paid nothing"', async () => {
|
|
295
|
+
knownBuilder = false;
|
|
296
|
+
expect({ rows: [] });
|
|
297
|
+
const res = await call('GET', '/api/gds/builders/999/credits');
|
|
298
|
+
knownBuilder = true;
|
|
299
|
+
assert.equal(res.status, 404);
|
|
300
|
+
assert.equal(res.json.error.code, 'builder_not_found');
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('a non-numeric builder id is refused — /builders/me/credits is not a route', async () => {
|
|
304
|
+
const res = await call('GET', '/api/gds/builders/me/credits');
|
|
305
|
+
assert.equal(res.status, 400);
|
|
306
|
+
assert.equal(res.json.error.code, 'bad_builder_id');
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// ---- 5. bad filters are 400s, not 500s -------------------------------------
|
|
310
|
+
|
|
311
|
+
test('an unparseable since is a bad request, not a Postgres error surfacing as a 500', async () => {
|
|
312
|
+
expect({ rows: [] });
|
|
313
|
+
const res = await call('GET', '/api/gds/me/credits?since=not-a-date');
|
|
314
|
+
assert.equal(res.status, 400);
|
|
315
|
+
assert.equal(res.json.error.code, 'bad_since');
|
|
316
|
+
assert.equal(calls.length, 0);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('a non-positive task_id filter is refused', async () => {
|
|
320
|
+
const res = await call('GET', '/api/gds/me/credits?task_id=0');
|
|
321
|
+
assert.equal(res.status, 400);
|
|
322
|
+
assert.equal(res.json.error.code, 'bad_task_id');
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test('an over-long reason filter is refused rather than passed to the scan', async () => {
|
|
326
|
+
const res = await call('GET', `/api/gds/me/credits?reason=${'x'.repeat(201)}`);
|
|
327
|
+
assert.equal(res.status, 400);
|
|
328
|
+
assert.equal(res.json.error.code, 'bad_reason_filter');
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test('the reason_prefix query param reaches the domain as a prefix filter', async () => {
|
|
332
|
+
expect({ rows: [] });
|
|
333
|
+
const res = await call('GET', '/api/gds/me/credits?reason_prefix=idea.credit.governance_pass%3A');
|
|
334
|
+
assert.equal(res.status, 200);
|
|
335
|
+
assert.equal(lastCall().params[1], 'idea.credit.governance\\_pass:%',
|
|
336
|
+
"the writer's own '_' is escaped, so it matches literally");
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// ---- 6. the port ------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
test('the read rides the reward PORT, so a core hall view can resolve it without importing economy', async () => {
|
|
342
|
+
const reward = require('../modules/economy/reward.js');
|
|
343
|
+
assert.equal(typeof reward.creditLedger, 'function');
|
|
344
|
+
expect({ rows: [row()] });
|
|
345
|
+
const out = await reward.creditLedger(77, { limit: 1 });
|
|
346
|
+
assert.equal(out.total, 1, 'delegated, not spread — the port reaches the live function');
|
|
347
|
+
});
|
|
@@ -428,3 +428,83 @@ test('with no repo bases the resolver degrades instead of crashing the page', ()
|
|
|
428
428
|
assert.equal(resolve('../../CLAUDE.md'), '../../CLAUDE.md',
|
|
429
429
|
'the bases are fetched at boot; before they land a relative href passes through and 404s harmlessly');
|
|
430
430
|
});
|
|
431
|
+
|
|
432
|
+
// A link target reaches an href only if the reader vouches for its scheme
|
|
433
|
+
// (task 1003482). escapeHtml stops markup, not URI schemes: a `javascript:`
|
|
434
|
+
// target would run on click. Every document the hall renders today is
|
|
435
|
+
// repo-controlled, but the reference room widened what flows through the
|
|
436
|
+
// shared reader, so the door closes before it meets a document someone else
|
|
437
|
+
// wrote. The hostile list carries the shapes a browser's URL parser would
|
|
438
|
+
// still read as a scheme: tab/newline inside, whitespace before, backslashes
|
|
439
|
+
// for slashes.
|
|
440
|
+
const HOSTILE_TARGETS = [
|
|
441
|
+
'javascript:alert(1)',
|
|
442
|
+
'JavaScript:alert(1)',
|
|
443
|
+
'java\tscript:alert(1)',
|
|
444
|
+
' javascript:alert(1)',
|
|
445
|
+
'data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==',
|
|
446
|
+
'vbscript:MsgBox(1)',
|
|
447
|
+
'file:///etc/passwd',
|
|
448
|
+
'ftp://example.org/x',
|
|
449
|
+
'//evil.example/x',
|
|
450
|
+
'\\\\evil.example/x',
|
|
451
|
+
'/\\evil.example/x',
|
|
452
|
+
];
|
|
453
|
+
const REPO_BASES = {
|
|
454
|
+
baseDir: 'docs/onboarding/',
|
|
455
|
+
getBlobBase: () => 'https://github.com/o/r/blob/main',
|
|
456
|
+
getTreeBase: () => 'https://github.com/o/r/tree/main',
|
|
457
|
+
};
|
|
458
|
+
const VOUCHED_TARGETS = [
|
|
459
|
+
['https://example.org/x', 'https://example.org/x'],
|
|
460
|
+
['http://example.org/x', 'http://example.org/x'],
|
|
461
|
+
['mailto:hello@example.org', 'mailto:hello@example.org'],
|
|
462
|
+
['tel:+15555550100', 'tel:+15555550100'],
|
|
463
|
+
['#a-section', '#a-section'],
|
|
464
|
+
['../../CLAUDE.md', 'https://github.com/o/r/blob/main/CLAUDE.md'],
|
|
465
|
+
];
|
|
466
|
+
|
|
467
|
+
test('the allowlist is the reader\'s own, and it is short', () => {
|
|
468
|
+
const MD = loadReader();
|
|
469
|
+
for (const [target] of VOUCHED_TARGETS) assert.ok(MD.isAllowedLinkHref(target), `${target} is vouched for`);
|
|
470
|
+
assert.ok(MD.isAllowedLinkHref('docs/recipes/'), 'a bare relative path is a repo path');
|
|
471
|
+
assert.ok(MD.isAllowedLinkHref('/docs/recipes/'), 'one leading slash is a repo path, not a host');
|
|
472
|
+
for (const target of HOSTILE_TARGETS) assert.ok(!MD.isAllowedLinkHref(target), `${JSON.stringify(target)} is refused`);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
test('a hostile link target renders as its text, with no <a> at all', () => {
|
|
476
|
+
const MD = loadReader();
|
|
477
|
+
const resolvers = {
|
|
478
|
+
'no bases': MD.makeResolveLinkHref({ baseDir: 'docs/onboarding/' }),
|
|
479
|
+
'bases up': MD.makeResolveLinkHref(REPO_BASES),
|
|
480
|
+
identity: (h) => h,
|
|
481
|
+
};
|
|
482
|
+
for (const target of HOSTILE_TARGETS) {
|
|
483
|
+
for (const [name, resolve] of Object.entries(resolvers)) {
|
|
484
|
+
const { html } = MD.parseMarkdown(`see [the door](${target}) here\n`, resolve);
|
|
485
|
+
assert.doesNotMatch(html, /<a[\s>]|href=/, `${JSON.stringify(target)} under the ${name} resolver writes no anchor`);
|
|
486
|
+
// (a `)` inside a target ends the link — the parser's own limit, so
|
|
487
|
+
// `alert(1)` leaves one behind as text; what matters is no anchor)
|
|
488
|
+
assert.match(html, /<p>see the door\)? here<\/p>/, `… and the label survives as text`);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
test('the resolver refuses a hostile target before the repo bases can dress it as a GitHub path', () => {
|
|
494
|
+
const MD = loadReader();
|
|
495
|
+
const resolve = MD.makeResolveLinkHref(REPO_BASES);
|
|
496
|
+
for (const target of HOSTILE_TARGETS) {
|
|
497
|
+
assert.equal(resolve(target), null, `${JSON.stringify(target)} is refused, not rewritten to ${resolve(target)}`);
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test('the vouched shapes still become links, exactly as before', () => {
|
|
502
|
+
const MD = loadReader();
|
|
503
|
+
const resolve = MD.makeResolveLinkHref(REPO_BASES);
|
|
504
|
+
for (const [target, href] of VOUCHED_TARGETS) {
|
|
505
|
+
const { html } = MD.parseMarkdown(`[t](${target})\n`, resolve);
|
|
506
|
+
assert.ok(html.includes(`<a href="${href}"`), `${target} → ${href}, got ${html}`);
|
|
507
|
+
}
|
|
508
|
+
const degraded = MD.parseMarkdown('[t](../../CLAUDE.md)\n', MD.makeResolveLinkHref({ baseDir: 'docs/onboarding/' })).html;
|
|
509
|
+
assert.ok(degraded.includes('<a href="../../CLAUDE.md"'), 'and with no bases a repo path still degrades to a link, as §10 pins above');
|
|
510
|
+
});
|
|
@@ -89,6 +89,20 @@ const OWN_SCOPED_SURFACES = [
|
|
|
89
89
|
why: 'the per-builder preference domain (wandering, skill, sound, render, model-allocation, display name) carved out of /me — read and write, all keyed on req.builder.id.',
|
|
90
90
|
crossOwner: [],
|
|
91
91
|
},
|
|
92
|
+
{
|
|
93
|
+
file: 'modules/economy/routes/credits.js',
|
|
94
|
+
why: 'the credit-ledger read (task 1003484, blocker 1000127). GET /me/credits takes its builder from req.builder.id and never reads an id off the request, so there is no parameter to tamper with. The one cross-owner route names its target in the path and is gate-verified below.',
|
|
95
|
+
// Two routes, one of them cross-owner — so one own-scoped route is the whole
|
|
96
|
+
// truth here, not a parser that stopped matching.
|
|
97
|
+
minOwnScoped: 1,
|
|
98
|
+
crossOwner: [
|
|
99
|
+
{
|
|
100
|
+
path: '/builders/:builderId/credits',
|
|
101
|
+
gate: /requirePermission\(\s*['"]builder\.roster\.read['"]\s*\)/,
|
|
102
|
+
why: "itemised read of ANOTHER builder's credit ledger — permission-gated on the builder.roster.read atom, the same gate /builders/:id/active-claims carries for the same act (reading another builder's record off the roster).",
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
},
|
|
92
106
|
{
|
|
93
107
|
file: 'modules/platform-identity/routes/profile.js',
|
|
94
108
|
why: "the builder's OWN cross-project profile controls (hide flag, handle, bio/links) — all keyed on req.builder.github_id.",
|
|
@@ -139,6 +153,12 @@ const OWN_SCOPED_SURFACES = [
|
|
|
139
153
|
|
|
140
154
|
// Floors that keep this guard from going quietly vacuous (a regex that stops
|
|
141
155
|
// matching would otherwise "pass" by checking nothing).
|
|
156
|
+
//
|
|
157
|
+
// The per-file floor is a DEFAULT, not a law: a surface may declare its own
|
|
158
|
+
// `minOwnScoped` when it genuinely mounts fewer (modules/economy/routes/credits.js
|
|
159
|
+
// is one own-scoped read plus one gated cross-owner read). The floor's job is to
|
|
160
|
+
// notice a file whose routes STOPPED parsing — so what matters is that every
|
|
161
|
+
// surface has a non-zero floor it must clear, not that they all share one number.
|
|
142
162
|
const MIN_OWN_SCOPED_ROUTES_TOTAL = 28;
|
|
143
163
|
const MIN_OWN_SCOPED_ROUTES_PER_FILE = 3;
|
|
144
164
|
|
|
@@ -416,9 +436,11 @@ for (const surface of OWN_SCOPED_SURFACES) {
|
|
|
416
436
|
}
|
|
417
437
|
|
|
418
438
|
t(`${surface.file} — contributes a real number of own-scoped routes (not vacuous)`, () => {
|
|
439
|
+
const floor = surface.minOwnScoped ?? MIN_OWN_SCOPED_ROUTES_PER_FILE;
|
|
440
|
+
assert.ok(floor > 0, 'a declared floor of 0 would exempt the file from this check entirely');
|
|
419
441
|
assert.ok(
|
|
420
|
-
ownScopedHere >=
|
|
421
|
-
`only ${ownScopedHere} own-scoped routes found (floor ${
|
|
442
|
+
ownScopedHere >= floor,
|
|
443
|
+
`only ${ownScopedHere} own-scoped routes found (floor ${floor}) — either routes moved out or the parser stopped seeing them`
|
|
422
444
|
);
|
|
423
445
|
});
|
|
424
446
|
|