@bongos/core 1.19.162 → 1.19.164
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 +33 -18
- package/clients/bongos-client/index.d.ts +2 -3
- package/docs/api/openapi.json +13 -23
- package/docs/api-reference.md +1 -1
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/ideas/completeness.js +72 -0
- package/modules/ideas/routes/inbox.js +67 -1
- package/modules/ideas/templates.js +15 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +5 -1
- package/tests/idea_completeness.mjs +98 -0
- package/tests/idea_full_capture.mjs +146 -0
- package/tests/idea_quick_guard.mjs +10 -6
- package/tests/idea_templates.mjs +16 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.164",
|
|
6
|
+
"core_contract": "1.19.164",
|
|
7
|
+
"source_commit": "779753af3493b89014e91088a53931afe771ec00",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-
|
|
9
|
+
"built_at": "2026-08-16T17:05:29.044Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 305,
|
|
13
13
|
"agent_docs_stubbed": 21,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1263,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1589,
|
|
20
|
+
"tree_sha256": "ea5a8e73501fb69d4e1d6e808d63a241df8b2ccf76728a49282a328b14b63337",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -367,7 +367,7 @@
|
|
|
367
367
|
{
|
|
368
368
|
"path": "clients/bongos-client/index.d.ts",
|
|
369
369
|
"mode": "0000644",
|
|
370
|
-
"sha256": "
|
|
370
|
+
"sha256": "4245c4fb82c1c4f5c87788030dc4640987fe22e0d588072d4549e9982cea7ecc"
|
|
371
371
|
},
|
|
372
372
|
{
|
|
373
373
|
"path": "clients/bongos-client/index.mjs",
|
|
@@ -1347,12 +1347,12 @@
|
|
|
1347
1347
|
{
|
|
1348
1348
|
"path": "docs/api-reference.md",
|
|
1349
1349
|
"mode": "0000644",
|
|
1350
|
-
"sha256": "
|
|
1350
|
+
"sha256": "879ef6fd06bde11c040c5ef67ccf495f0be6eb95e11be6f8979c9d560a33da64"
|
|
1351
1351
|
},
|
|
1352
1352
|
{
|
|
1353
1353
|
"path": "docs/api/openapi.json",
|
|
1354
1354
|
"mode": "0000644",
|
|
1355
|
-
"sha256": "
|
|
1355
|
+
"sha256": "9382373e7be837c0ce4e26f23c3cd32bf20ab09858650bdee318dd90a06dedbe"
|
|
1356
1356
|
},
|
|
1357
1357
|
{
|
|
1358
1358
|
"path": "docs/architecture.md",
|
|
@@ -2662,7 +2662,7 @@
|
|
|
2662
2662
|
{
|
|
2663
2663
|
"path": "modules/dev-box/app/src/vendor/bongos-client.cjs",
|
|
2664
2664
|
"mode": "0000644",
|
|
2665
|
-
"sha256": "
|
|
2665
|
+
"sha256": "257ac54c66f7969090065a34c29e2874fa6f9d111b6e75f0510ab1a2fcb3ef17"
|
|
2666
2666
|
},
|
|
2667
2667
|
{
|
|
2668
2668
|
"path": "modules/dev-box/app/test/unit.mjs",
|
|
@@ -3454,6 +3454,11 @@
|
|
|
3454
3454
|
"mode": "0000644",
|
|
3455
3455
|
"sha256": "73349f0209190305a61e2f4d771fa466c12be4f9034b4134b60a9e4a560e1c5a"
|
|
3456
3456
|
},
|
|
3457
|
+
{
|
|
3458
|
+
"path": "modules/ideas/completeness.js",
|
|
3459
|
+
"mode": "0000644",
|
|
3460
|
+
"sha256": "84d977372c79a46f8481176b7cdc740cbaca9480af3464b7b66183aad6d6e180"
|
|
3461
|
+
},
|
|
3457
3462
|
{
|
|
3458
3463
|
"path": "modules/ideas/inbox.js",
|
|
3459
3464
|
"mode": "0000644",
|
|
@@ -3477,12 +3482,12 @@
|
|
|
3477
3482
|
{
|
|
3478
3483
|
"path": "modules/ideas/routes/inbox.js",
|
|
3479
3484
|
"mode": "0000644",
|
|
3480
|
-
"sha256": "
|
|
3485
|
+
"sha256": "8604c627617cc90fa3197dcf3776ce708546d754b9acc2d45c16dfea97a478cb"
|
|
3481
3486
|
},
|
|
3482
3487
|
{
|
|
3483
3488
|
"path": "modules/ideas/templates.js",
|
|
3484
3489
|
"mode": "0000644",
|
|
3485
|
-
"sha256": "
|
|
3490
|
+
"sha256": "eade9a2d07da0a39af17c086d188a195be00c544889d61454ed3c6368d39ea04"
|
|
3486
3491
|
},
|
|
3487
3492
|
{
|
|
3488
3493
|
"path": "modules/lifecycle/CLAUDE.md",
|
|
@@ -4197,12 +4202,12 @@
|
|
|
4197
4202
|
{
|
|
4198
4203
|
"path": "package-lock.json",
|
|
4199
4204
|
"mode": "0000644",
|
|
4200
|
-
"sha256": "
|
|
4205
|
+
"sha256": "098af85c69e5a9f9aa4dcfd69292d9bbdecfa6bb4294a344740568344fd36673"
|
|
4201
4206
|
},
|
|
4202
4207
|
{
|
|
4203
4208
|
"path": "package.json",
|
|
4204
4209
|
"mode": "0000644",
|
|
4205
|
-
"sha256": "
|
|
4210
|
+
"sha256": "6efe95b7773147ac8801e5d03fe75b7143f1eda728d123dfa6a19dd41deb5f27"
|
|
4206
4211
|
},
|
|
4207
4212
|
{
|
|
4208
4213
|
"path": "public-docs/index.html",
|
|
@@ -5797,7 +5802,7 @@
|
|
|
5797
5802
|
{
|
|
5798
5803
|
"path": "src/module-api.js",
|
|
5799
5804
|
"mode": "0000644",
|
|
5800
|
-
"sha256": "
|
|
5805
|
+
"sha256": "575799bb117e5a9166aabc3abbba42f9b737fda008bc08e1461147a57831683a"
|
|
5801
5806
|
},
|
|
5802
5807
|
{
|
|
5803
5808
|
"path": "src/module-loader/catalog.js",
|
|
@@ -6904,6 +6909,16 @@
|
|
|
6904
6909
|
"mode": "0000644",
|
|
6905
6910
|
"sha256": "7d384cd84cdc93b8e7b57df291a4600a40f43346fd2a52168324ecde219f1388"
|
|
6906
6911
|
},
|
|
6912
|
+
{
|
|
6913
|
+
"path": "tests/idea_completeness.mjs",
|
|
6914
|
+
"mode": "0000644",
|
|
6915
|
+
"sha256": "e7b6af463185ea77f40d62408026c55c7fb077f18999b972564b3b5bc7656ca5"
|
|
6916
|
+
},
|
|
6917
|
+
{
|
|
6918
|
+
"path": "tests/idea_full_capture.mjs",
|
|
6919
|
+
"mode": "0000644",
|
|
6920
|
+
"sha256": "95b53f1acfa61f723741b4d33b5e882525903ccc549ae01f3094a788251a60ec"
|
|
6921
|
+
},
|
|
6907
6922
|
{
|
|
6908
6923
|
"path": "tests/idea_grades.mjs",
|
|
6909
6924
|
"mode": "0000644",
|
|
@@ -6912,12 +6927,12 @@
|
|
|
6912
6927
|
{
|
|
6913
6928
|
"path": "tests/idea_quick_guard.mjs",
|
|
6914
6929
|
"mode": "0000644",
|
|
6915
|
-
"sha256": "
|
|
6930
|
+
"sha256": "729c511f9bb3d4a9b7fb76c7b3843e1900c8d4c9cfa30fe08e482d1e41113839"
|
|
6916
6931
|
},
|
|
6917
6932
|
{
|
|
6918
6933
|
"path": "tests/idea_templates.mjs",
|
|
6919
6934
|
"mode": "0000644",
|
|
6920
|
-
"sha256": "
|
|
6935
|
+
"sha256": "943afa2366f92c4d6188ce9f8d68f499aac8d67930f91d6860a2cb83020642ab"
|
|
6921
6936
|
},
|
|
6922
6937
|
{
|
|
6923
6938
|
"path": "tests/idea_triage.mjs",
|
|
@@ -222,8 +222,7 @@ export interface PostGovernanceRolesRequest { role_key: string; label: string; d
|
|
|
222
222
|
export interface PostGovernanceRolesResponse { role: unknown }
|
|
223
223
|
export interface PostGovernanceRolesRoleKeyResetResponse { ok: boolean; role_key: unknown; seeded_rank: unknown; permission_count: unknown }
|
|
224
224
|
export interface PostInboxIdRatifyRequest { notes_md?: string }
|
|
225
|
-
export interface PostInboxRequest { title: string; body_md?: string; kind?: string; suggested_version?: string; suggested_priority?: number }
|
|
226
|
-
export interface PostInboxResponse { idea: unknown; open_count: unknown; kind_auto: unknown }
|
|
225
|
+
export interface PostInboxRequest { title: string; body_md?: string; kind?: string; suggested_version?: string; suggested_priority?: number; grade?: string; template_key?: string; field_values?: Record<string, unknown> }
|
|
227
226
|
export interface PostLearningsRequest { title: string; body_md: string; tags?: string[]; related_files?: string[]; source?: string; source_ref?: string; task_id?: number }
|
|
228
227
|
export interface PostLearningsResponse { skipped?: boolean; reason?: unknown; learning?: unknown }
|
|
229
228
|
export interface PostLlmCacheLookupRequest { cache_key?: unknown; tokens_saved_est?: unknown }
|
|
@@ -607,7 +606,7 @@ export interface BongosClient {
|
|
|
607
606
|
/** GET /inbox — rank: any-builder */
|
|
608
607
|
getInbox(args?: RequestArgs): Promise<GetInboxResponse>;
|
|
609
608
|
/** POST /inbox — rank: any-builder */
|
|
610
|
-
postInbox(args: RequestArgs & { body: PostInboxRequest }): Promise<
|
|
609
|
+
postInbox(args: RequestArgs & { body: PostInboxRequest }): Promise<ApiResponse>;
|
|
611
610
|
/** GET /inbox/{id} — rank: any-builder */
|
|
612
611
|
getInboxId(args?: RequestArgs): Promise<GetInboxIdResponse>;
|
|
613
612
|
/** PATCH /inbox/{id} — rank: metic+archon */
|
package/docs/api/openapi.json
CHANGED
|
@@ -5686,14 +5686,7 @@
|
|
|
5686
5686
|
},
|
|
5687
5687
|
"responses": {
|
|
5688
5688
|
"200": {
|
|
5689
|
-
"description": "Success."
|
|
5690
|
-
"content": {
|
|
5691
|
-
"application/json": {
|
|
5692
|
-
"schema": {
|
|
5693
|
-
"$ref": "#/components/schemas/PostInboxResponse"
|
|
5694
|
-
}
|
|
5695
|
-
}
|
|
5696
|
-
}
|
|
5689
|
+
"description": "Success."
|
|
5697
5690
|
},
|
|
5698
5691
|
"400": {
|
|
5699
5692
|
"$ref": "#/components/responses/ValidationFailed"
|
|
@@ -15422,6 +15415,16 @@
|
|
|
15422
15415
|
"type": "integer",
|
|
15423
15416
|
"minimum": 0,
|
|
15424
15417
|
"maximum": 100
|
|
15418
|
+
},
|
|
15419
|
+
"grade": {
|
|
15420
|
+
"type": "string",
|
|
15421
|
+
"maxLength": 16
|
|
15422
|
+
},
|
|
15423
|
+
"template_key": {
|
|
15424
|
+
"type": "string"
|
|
15425
|
+
},
|
|
15426
|
+
"field_values": {
|
|
15427
|
+
"type": "object"
|
|
15425
15428
|
}
|
|
15426
15429
|
},
|
|
15427
15430
|
"required": [
|
|
@@ -15429,19 +15432,6 @@
|
|
|
15429
15432
|
],
|
|
15430
15433
|
"additionalProperties": false
|
|
15431
15434
|
},
|
|
15432
|
-
"PostInboxResponse": {
|
|
15433
|
-
"type": "object",
|
|
15434
|
-
"properties": {
|
|
15435
|
-
"idea": {},
|
|
15436
|
-
"open_count": {},
|
|
15437
|
-
"kind_auto": {}
|
|
15438
|
-
},
|
|
15439
|
-
"required": [
|
|
15440
|
-
"idea",
|
|
15441
|
-
"open_count",
|
|
15442
|
-
"kind_auto"
|
|
15443
|
-
]
|
|
15444
|
-
},
|
|
15445
15435
|
"PostLearningsRequest": {
|
|
15446
15436
|
"type": "object",
|
|
15447
15437
|
"properties": {
|
|
@@ -17109,8 +17099,8 @@
|
|
|
17109
17099
|
}
|
|
17110
17100
|
},
|
|
17111
17101
|
"x-endpoint-count": 271,
|
|
17112
|
-
"x-schema-count":
|
|
17102
|
+
"x-schema-count": 303,
|
|
17113
17103
|
"x-undocumented-bodies": 8,
|
|
17114
|
-
"x-response-schemas":
|
|
17104
|
+
"x-response-schemas": 205,
|
|
17115
17105
|
"x-generated-by": "scripts/gds/gen-api-docs.js"
|
|
17116
17106
|
}
|
package/docs/api-reference.md
CHANGED
|
@@ -245,7 +245,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
|
|
|
245
245
|
| Method | Path | Rank | Body | Description |
|
|
246
246
|
|---|---|---|---|---|
|
|
247
247
|
| GET | `/api/bongos/inbox` | `any-builder` | — | read open ideas; skill-friction rows filtered by viewer rank. |
|
|
248
|
-
| POST | `/api/bongos/inbox` | `any-builder` | `title`, `body_md`, `kind`, `suggested_version`, `suggested_priority` | for ordinary ideas; metic+ for kind='criterion-proposal'. |
|
|
248
|
+
| POST | `/api/bongos/inbox` | `any-builder` | `title`, `body_md`, `kind`, `suggested_version`, `suggested_priority`, `grade`, `template_key`, `field_values` | for ordinary ideas; metic+ for kind='criterion-proposal'. |
|
|
249
249
|
| GET | `/api/bongos/inbox/:id` | `any-builder` | — | single idea read for the "#NNN" deep-link resolver (task 1047). |
|
|
250
250
|
| PATCH | `/api/bongos/inbox/:id` | `metic+archon` | `status`, `kind`, `promoted_to_task_id`, `reviewed_at`, `merged_into_idea_id`, `triage_note` | Metic+ (task 360 / ADR 0018): idea triage (promote/discard/merge) shapes the backlog. |
|
|
251
251
|
| POST | `/api/bongos/inbox/:id/ratify` | `metic+archon` | `notes_md` | V3.R31 (task 252) — Archon ratifies a criterion-proposal idea, atomically: - INSERT done_when_criteria row on the target version - UPDATE… |
|
|
@@ -354,6 +354,8 @@ function createClient(opts = {}) {
|
|
|
354
354
|
deleteGovernanceRolesRoleKeyPermissionsPermissionKey: (args) => request("DELETE", "/governance/roles/{roleKey}/permissions/{permissionKey}", { hasBody: false }, args),
|
|
355
355
|
// PUT /governance/roles/{roleKey}/permissions/{permissionKey} — rank: archon — PUT /governance/roles/:roleKey/permissions/:permissionKey
|
|
356
356
|
putGovernanceRolesRoleKeyPermissionsPermissionKey: (args) => request("PUT", "/governance/roles/{roleKey}/permissions/{permissionKey}", { hasBody: true }, args),
|
|
357
|
+
// POST /governance/roles/{roleKey}/reset — rank: archon — POST /governance/roles/:roleKey/reset
|
|
358
|
+
postGovernanceRolesRoleKeyReset: (args) => request("POST", "/governance/roles/{roleKey}/reset", { hasBody: true }, args),
|
|
357
359
|
},
|
|
358
360
|
"grades": {
|
|
359
361
|
// GET /grades/by-builder — rank: metic+archon — GET /grades/by-builder
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// modules/ideas/completeness.js — the deterministic Full Idea completeness
|
|
2
|
+
// scorer (task 1002918, BV1.R142, goal 1000062).
|
|
3
|
+
//
|
|
4
|
+
// WHY DETERMINISTIC (the owner's call, recorded on the goal): the score feeds
|
|
5
|
+
// karma (R147), so it must be predictable, free and reproducible — the same
|
|
6
|
+
// input scores the same number on every machine, every day, with no model
|
|
7
|
+
// having a good day. An AI depth-judge is a deliberately LATER seam; nothing
|
|
8
|
+
// here calls a network or reads a clock.
|
|
9
|
+
//
|
|
10
|
+
// WHAT IT MEASURES — PRESENCE + SUBSTANCE, NOTHING SMARTER. It walks the R141
|
|
11
|
+
// template declaration (never five hard-coded names): each declared field
|
|
12
|
+
// contributes equally; a field earns its share in proportion to how much
|
|
13
|
+
// actual text it carries against the template's `substanceMin` floor, capped
|
|
14
|
+
// at 1. "Actual text" is the trimmed, whitespace-collapsed length, so a page
|
|
15
|
+
// of blank lines scores as the nothing it is. That is the whole algorithm —
|
|
16
|
+
// honest about its limit: a determined padder can beat a character counter,
|
|
17
|
+
// and catching THAT is the future AI seam (goal notes), not a cleverness this
|
|
18
|
+
// number should pretend to.
|
|
19
|
+
//
|
|
20
|
+
// NEVER THROWS INTO THE CAPTURE PATH (the task's hard requirement): malformed
|
|
21
|
+
// input — unknown template, non-object values, non-string fields — returns a
|
|
22
|
+
// null score, not an exception. A null completeness_score is a legal stored
|
|
23
|
+
// state (ideas_001_grades.sql: scoring never blocks capture), so "could not
|
|
24
|
+
// score" and "not scored yet" deliberately look the same downstream.
|
|
25
|
+
//
|
|
26
|
+
// The write side already exists (inbox.js setIdeaCompleteness, 0–100 int);
|
|
27
|
+
// capture (R143) computes-and-stores via this module, and the triage ordering
|
|
28
|
+
// (R148) reads the stored number — neither re-implements the arithmetic.
|
|
29
|
+
|
|
30
|
+
'use strict';
|
|
31
|
+
|
|
32
|
+
const { templateFor, BUILTIN_TEMPLATES } = require('./templates');
|
|
33
|
+
const { DEFAULT_TEMPLATE_KEY } = require('./inbox');
|
|
34
|
+
|
|
35
|
+
// The substance of a value: the length of its trimmed, whitespace-collapsed
|
|
36
|
+
// text. Non-strings carry no substance (validation refuses them upstream; the
|
|
37
|
+
// scorer just declines to credit them).
|
|
38
|
+
function substanceOf(value) {
|
|
39
|
+
if (typeof value !== 'string') return 0;
|
|
40
|
+
return value.trim().replace(/\s+/g, ' ').length;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Score field_values against a template. Returns
|
|
44
|
+
// { score, fields: [{ key, substance, floor, ratio }] }
|
|
45
|
+
// where score is an integer 0–100 (ready for setIdeaCompleteness) or NULL when
|
|
46
|
+
// the input cannot be scored. `fields` is the per-field breakdown in TEMPLATE
|
|
47
|
+
// ORDER (fieldOrder's contract) — the hall's live meter (R152) and the triage
|
|
48
|
+
// view (R148) render it without redoing the arithmetic.
|
|
49
|
+
function scoreFieldValues(fieldValues, { templateKey = DEFAULT_TEMPLATE_KEY, registry = BUILTIN_TEMPLATES } = {}) {
|
|
50
|
+
const template = templateFor(templateKey, registry);
|
|
51
|
+
if (!template || !template.fields.length) return { score: null, fields: [] };
|
|
52
|
+
if (!fieldValues || typeof fieldValues !== 'object' || Array.isArray(fieldValues)) {
|
|
53
|
+
return { score: null, fields: [] };
|
|
54
|
+
}
|
|
55
|
+
const fields = template.fields.map((f) => {
|
|
56
|
+
const substance = substanceOf(fieldValues[f.key]);
|
|
57
|
+
const floor = f.substanceMin > 0 ? f.substanceMin : 1;
|
|
58
|
+
return { key: f.key, substance, floor, ratio: Math.min(1, substance / floor) };
|
|
59
|
+
});
|
|
60
|
+
const mean = fields.reduce((sum, f) => sum + f.ratio, 0) / fields.length;
|
|
61
|
+
return { score: Math.round(mean * 100), fields };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The one-number convenience for callers that only store: integer 0–100, or
|
|
65
|
+
// null when unscorable. This is what R143's capture writes through
|
|
66
|
+
// setIdeaCompleteness — and because it never throws, the capture path needs no
|
|
67
|
+
// guard around it.
|
|
68
|
+
function completenessScore(fieldValues, opts) {
|
|
69
|
+
return scoreFieldValues(fieldValues, opts).score;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = { completenessScore, scoreFieldValues, substanceOf };
|
|
@@ -4,6 +4,10 @@ const express = require('express');
|
|
|
4
4
|
// sibling — a module never requires a core internal.
|
|
5
5
|
const api = require('../../../src/module-api');
|
|
6
6
|
const inbox = require('../inbox');
|
|
7
|
+
// Full Idea capture (task 1002919 / BV1.R143): the template validator (R141)
|
|
8
|
+
// and the deterministic scorer (R142) — both pure, both walk the declaration.
|
|
9
|
+
const { validateFieldValues } = require('../templates');
|
|
10
|
+
const { completenessScore } = require('../completeness');
|
|
7
11
|
const { validateOrRespond, LIMITS, parseId } = api;
|
|
8
12
|
|
|
9
13
|
const INBOX_STATUS_VALUES = ['open', 'promoted', 'discarded', 'merged'];
|
|
@@ -42,6 +46,12 @@ module.exports = function buildInboxRouter() {
|
|
|
42
46
|
kind: { type: 'string', maxLength: 64 },
|
|
43
47
|
suggested_version: { type: 'string', maxLength: 64 },
|
|
44
48
|
suggested_priority: { type: 'integer', min: 0, max: 100 },
|
|
49
|
+
// Full Idea capture (R143) — ALL OPTIONAL: omitting every one of these is
|
|
50
|
+
// exactly the pre-grade quick capture (tests/idea_quick_guard.mjs pins
|
|
51
|
+
// that title stays the only required field).
|
|
52
|
+
grade: { type: 'string', maxLength: 16 },
|
|
53
|
+
template_key: { type: 'string', maxLength: inbox.TEMPLATE_KEY_MAX },
|
|
54
|
+
field_values: { type: 'object' },
|
|
45
55
|
})) return;
|
|
46
56
|
const body = req.body || {};
|
|
47
57
|
if (body.kind === 'criterion-proposal') {
|
|
@@ -50,6 +60,40 @@ module.exports = function buildInboxRouter() {
|
|
|
50
60
|
return res.fail('rank_forbidden', 403, { required: ['metic', 'archon'], actual: rank, reason: "kind='criterion-proposal' is a planning-session write; Metic+ only." });
|
|
51
61
|
}
|
|
52
62
|
}
|
|
63
|
+
// Full Idea capture (task 1002919 / BV1.R143). Only 'full' means anything
|
|
64
|
+
// at capture: 'quick' is the default (explicit or omitted — no grade row
|
|
65
|
+
// either way, the R140 absence-is-quick design), and 'spark' is REFUSED
|
|
66
|
+
// here because sparking is the author's later, deliberate act on an
|
|
67
|
+
// EXISTING quick idea (R144's surface) — an idea does not arrive pre-marked.
|
|
68
|
+
const grade = body.grade ?? null;
|
|
69
|
+
if (grade !== null && grade !== 'quick' && grade !== 'full') {
|
|
70
|
+
return res.fail('bad_grade_at_capture', 400, {
|
|
71
|
+
grade, allowed: ['quick', 'full'],
|
|
72
|
+
reason: "capture accepts grade 'quick' (the default) or 'full'; a spark is made from an existing idea, not captured as one.",
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
const templateKey = body.template_key ?? undefined;
|
|
76
|
+
if (grade === 'full') {
|
|
77
|
+
// Validate BEFORE any write, so a bad payload is a named 400 and no
|
|
78
|
+
// orphan idea lands. Each error carries { field, reason } — the
|
|
79
|
+
// "clear error naming the missing field" the task's done-when demands.
|
|
80
|
+
//
|
|
81
|
+
// WRAPPED IN try/catch as defence in depth. templateFor's own-property
|
|
82
|
+
// guard already keeps validateFieldValues from throwing on a hostile
|
|
83
|
+
// template_key (the R143 grader's blocker), but this validation runs
|
|
84
|
+
// OUTSIDE the capture try/catch below, so a future consumer that
|
|
85
|
+
// reintroduces a throw would again escape as an unhandled rejection and
|
|
86
|
+
// crash the process. Catching here means the boundary holds regardless:
|
|
87
|
+
// a validator that throws is a 400, never a downed server.
|
|
88
|
+
let v;
|
|
89
|
+
try {
|
|
90
|
+
v = validateFieldValues(body.field_values ?? {}, templateKey ? { templateKey } : undefined);
|
|
91
|
+
} catch (validationErr) {
|
|
92
|
+
console.error('[gds] POST /inbox field-values validation threw:', validationErr);
|
|
93
|
+
return res.fail('invalid_field_values', 400, { errors: [{ field: null, reason: 'unvalidatable' }], template_key: templateKey ?? null });
|
|
94
|
+
}
|
|
95
|
+
if (!v.ok) return res.fail('invalid_field_values', 400, { errors: v.errors, template_key: templateKey ?? null });
|
|
96
|
+
}
|
|
53
97
|
try {
|
|
54
98
|
const idea = await inbox.captureIdea({
|
|
55
99
|
title: body.title,
|
|
@@ -59,8 +103,30 @@ module.exports = function buildInboxRouter() {
|
|
|
59
103
|
suggestedPriority: body.suggested_priority ?? null,
|
|
60
104
|
capturedBy: req.builder.id,
|
|
61
105
|
});
|
|
106
|
+
let grade_warning;
|
|
107
|
+
if (grade === 'full') {
|
|
108
|
+
// The idea is captured; from here every failure is FAIL-OPEN (the
|
|
109
|
+
// task's contract: a scoring/grading hiccup never blocks capture —
|
|
110
|
+
// the idea still lands, as quick, with a named warning).
|
|
111
|
+
try {
|
|
112
|
+
// completenessScore never throws (R142's contract) — null = unscorable.
|
|
113
|
+
const row = await inbox.setIdeaGrade(idea.id, {
|
|
114
|
+
grade: 'full',
|
|
115
|
+
...(templateKey ? { templateKey } : {}),
|
|
116
|
+
fieldValues: body.field_values,
|
|
117
|
+
completenessScore: completenessScore(body.field_values, templateKey ? { templateKey } : undefined),
|
|
118
|
+
});
|
|
119
|
+
idea.grade = row.grade;
|
|
120
|
+
idea.template_key = row.template_key;
|
|
121
|
+
idea.field_values = row.field_values;
|
|
122
|
+
idea.completeness_score = row.completeness_score;
|
|
123
|
+
} catch (gradeErr) {
|
|
124
|
+
console.error('[gds] POST /inbox full-grade write failed (idea captured, landing as quick):', gradeErr);
|
|
125
|
+
grade_warning = 'captured_but_ungraded';
|
|
126
|
+
}
|
|
127
|
+
}
|
|
62
128
|
const open_count = await inbox.countOpenIdeas({ viewerRank: req.builder?.rank ?? null });
|
|
63
|
-
res.status(201).json({ idea, open_count, kind_auto: !body.kind });
|
|
129
|
+
res.status(201).json({ idea, open_count, kind_auto: !body.kind, ...(grade_warning ? { grade_warning } : {}) });
|
|
64
130
|
} catch (err) {
|
|
65
131
|
if (err && err.code) {
|
|
66
132
|
const status =
|
|
@@ -74,7 +74,21 @@ if (BASE_TEMPLATE.key !== DEFAULT_TEMPLATE_KEY) {
|
|
|
74
74
|
// whether that is a 400 (capture) or a render-as-raw fallback (display).
|
|
75
75
|
function templateFor(key, registry = BUILTIN_TEMPLATES) {
|
|
76
76
|
if (typeof key !== 'string' || !key) return null;
|
|
77
|
-
|
|
77
|
+
// OWN-PROPERTY LOOKUP, never a bare `registry[key]`. `template_key` is
|
|
78
|
+
// attacker-controlled at capture (POST /inbox, R143) and only length-checked,
|
|
79
|
+
// so a key like 'constructor' / '__proto__' / 'toString' would otherwise
|
|
80
|
+
// resolve up the prototype chain to an Object.prototype member — a truthy
|
|
81
|
+
// non-template whose `.fields` is undefined, and fieldOrder(template) then
|
|
82
|
+
// throws a synchronous TypeError from inside the pure validator, OUTSIDE the
|
|
83
|
+
// route's try/catch → an unhandled rejection that crashes the process. One
|
|
84
|
+
// authenticated request, any rank. Object.hasOwn confines resolution to keys
|
|
85
|
+
// the registry actually declares. (Found by the R143 grader.)
|
|
86
|
+
if (!Object.hasOwn(registry, key)) return null;
|
|
87
|
+
const t = registry[key];
|
|
88
|
+
// Defence in depth: even an own key must resolve to a real template shape
|
|
89
|
+
// before any consumer reads `.fields`. A malformed entry is a null, never a
|
|
90
|
+
// throw.
|
|
91
|
+
return t && Array.isArray(t.fields) ? t : null;
|
|
78
92
|
}
|
|
79
93
|
|
|
80
94
|
// The one legal source of field display order (see header). Returns key order
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.164",
|
|
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.164",
|
|
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.164",
|
|
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
|
@@ -751,8 +751,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
751
751
|
// landed since 1.19.160 with no explicit bump. run 31959299652. (task 1002620)
|
|
752
752
|
// 1.19.162 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
753
753
|
// landed since 1.19.161 with no explicit bump. run 31959496815. (task 1002620)
|
|
754
|
+
// 1.19.163 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
755
|
+
// landed since 1.19.162 with no explicit bump. run 31959864781. (task 1002620)
|
|
756
|
+
// 1.19.164 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
757
|
+
// landed since 1.19.163 with no explicit bump. run 31960518012. (task 1002620)
|
|
754
758
|
// ---------------------------------------------------------------------------
|
|
755
|
-
const CORE_VERSION = '1.19.
|
|
759
|
+
const CORE_VERSION = '1.19.164'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
756
760
|
|
|
757
761
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
758
762
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// tests/idea_completeness.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1002918 (BV1.R142, goal 1000062) — the deterministic completeness scorer.
|
|
4
|
+
//
|
|
5
|
+
// The three claims under guard, straight from the task's done-when:
|
|
6
|
+
// 1. SAME INPUT, SAME SCORE — determinism is what lets the number feed karma.
|
|
7
|
+
// 2. The empty / thin / padded / complete shapes score sensibly, with padding
|
|
8
|
+
// (whitespace, blank lines) earning exactly nothing.
|
|
9
|
+
// 3. IT NEVER THROWS — malformed input is a null score, because scoring must
|
|
10
|
+
// never block capture (the R140 schema's contract).
|
|
11
|
+
//
|
|
12
|
+
// Pure module, pure tests — no DB, no harness.
|
|
13
|
+
//
|
|
14
|
+
// Run: node tests/idea_completeness.mjs
|
|
15
|
+
|
|
16
|
+
import { strict as assert } from 'node:assert';
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
const C = require('../modules/ideas/completeness.js');
|
|
21
|
+
const { BASE_TEMPLATE } = require('../modules/ideas/templates.js');
|
|
22
|
+
|
|
23
|
+
let passed = 0;
|
|
24
|
+
let failed = 0;
|
|
25
|
+
function test(name, fn) {
|
|
26
|
+
try { fn(); passed++; console.log(` ok ${name}`); }
|
|
27
|
+
catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Five answers comfortably past every substanceMin (40 chars).
|
|
31
|
+
const COMPLETE = Object.fromEntries(
|
|
32
|
+
BASE_TEMPLATE.fields.map((f) => [f.key, `A real, substantive answer for ${f.key} that clears the declared floor easily.`]),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
test('a complete idea scores 100, an empty one 0', () => {
|
|
36
|
+
assert.equal(C.completenessScore(COMPLETE), 100);
|
|
37
|
+
assert.equal(C.completenessScore({}), 0);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('same input, same score — byte-for-byte determinism', () => {
|
|
41
|
+
const a = C.completenessScore(COMPLETE);
|
|
42
|
+
for (let i = 0; i < 5; i++) assert.equal(C.completenessScore({ ...COMPLETE }), a);
|
|
43
|
+
assert.deepEqual(C.scoreFieldValues(COMPLETE), C.scoreFieldValues(COMPLETE));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('a thin answer earns its proportion, not the full share', () => {
|
|
47
|
+
const oneThin = { ...COMPLETE, purpose: 'ten chars.' }; // 10 of the 40-char floor
|
|
48
|
+
const s = C.completenessScore(oneThin);
|
|
49
|
+
assert.ok(s > 80 && s < 100, `four full + one quarter-credit field ≈ 85, got ${s}`);
|
|
50
|
+
const breakdown = C.scoreFieldValues(oneThin).fields.find((f) => f.key === 'purpose');
|
|
51
|
+
assert.equal(breakdown.substance, 10);
|
|
52
|
+
assert.equal(breakdown.ratio, 0.25);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('padding earns exactly nothing: whitespace and blank lines are not substance', () => {
|
|
56
|
+
const padded = { ...COMPLETE, purpose: ' \n\n\t \n \n\n ' };
|
|
57
|
+
const b = C.scoreFieldValues(padded).fields.find((f) => f.key === 'purpose');
|
|
58
|
+
assert.equal(b.substance, 0, 'a page of blank lines is the nothing it is');
|
|
59
|
+
const spaced = { ...COMPLETE, purpose: 'a b\n\n\nc d e' }; // collapses to 9
|
|
60
|
+
const b2 = C.scoreFieldValues(spaced).fields.find((f) => f.key === 'purpose');
|
|
61
|
+
assert.equal(b2.substance, 9, 'runs of whitespace collapse to one');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('one missing field costs exactly its equal share', () => {
|
|
65
|
+
const { fit, ...four } = COMPLETE;
|
|
66
|
+
assert.equal(C.completenessScore(four), 80, 'five equal fields: one absent = -20');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('the breakdown comes back in TEMPLATE order, ready to render', () => {
|
|
70
|
+
const keys = C.scoreFieldValues(COMPLETE).fields.map((f) => f.key);
|
|
71
|
+
assert.deepEqual(keys, ['purpose', 'big_picture', 'why_had', 'why_matters', 'fit']);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('it never throws: malformed input is a null score, not an exception', () => {
|
|
75
|
+
for (const bad of [null, undefined, [], 'a string', 42]) {
|
|
76
|
+
assert.equal(C.completenessScore(bad), null, `completenessScore(${JSON.stringify(bad)})`);
|
|
77
|
+
}
|
|
78
|
+
assert.equal(C.completenessScore(COMPLETE, { templateKey: 'no-such-template' }), null);
|
|
79
|
+
// a non-string FIELD does not throw either — it just earns nothing
|
|
80
|
+
assert.equal(typeof C.completenessScore({ ...COMPLETE, purpose: 42 }), 'number');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('the scorer walks the declaration — a different template scores by ITS fields', () => {
|
|
84
|
+
const registry = {
|
|
85
|
+
'two-v1': { key: 'two-v1', label: 'Two', fields: [
|
|
86
|
+
{ key: 'a', label: 'A', required: true, substanceMin: 10 },
|
|
87
|
+
{ key: 'b', label: 'B', required: true, substanceMin: 10 },
|
|
88
|
+
] },
|
|
89
|
+
};
|
|
90
|
+
const opts = { templateKey: 'two-v1', registry };
|
|
91
|
+
assert.equal(C.completenessScore({ a: 'ten chars!', b: 'ten chars!' }, opts), 100);
|
|
92
|
+
assert.equal(C.completenessScore({ a: 'ten chars!' }, opts), 50);
|
|
93
|
+
// base fields mean nothing to it
|
|
94
|
+
assert.equal(C.completenessScore(COMPLETE, opts), 0);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
console.log(`\nidea_completeness: ${passed} passed, ${failed} failed`);
|
|
98
|
+
if (failed > 0) process.exit(1);
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// tests/idea_full_capture.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1002919 (BV1.R143, goal 1000062) — POST /inbox accepts a Full Idea.
|
|
4
|
+
//
|
|
5
|
+
// The done-when, as assertions: a Full Idea round-trips with its fields and
|
|
6
|
+
// score intact; an invalid field set is a 400 NAMING the field, refused BEFORE
|
|
7
|
+
// any write; a grade-write failure never blocks capture (fail-open: the idea
|
|
8
|
+
// lands as quick with a named warning); 'spark' is refused at capture (sparking
|
|
9
|
+
// is R144's act on an EXISTING idea); and the quick path stays byte-identical
|
|
10
|
+
// (the deep version of that lives in tests/idea_quick_guard.mjs).
|
|
11
|
+
//
|
|
12
|
+
// DB-free the idea_grades.mjs way (pool.query patched on the doorway before the
|
|
13
|
+
// modules load), driving the REAL route handler through a stub req/res — the
|
|
14
|
+
// same validator, the same error map a client sees.
|
|
15
|
+
//
|
|
16
|
+
// Run: node tests/idea_full_capture.mjs
|
|
17
|
+
|
|
18
|
+
import { strict as assert } from 'node:assert';
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
const api = require('../src/module-api.js');
|
|
23
|
+
|
|
24
|
+
let queries = [];
|
|
25
|
+
let failNextGradeWrite = false;
|
|
26
|
+
api.pool.query = async (sql, params = []) => {
|
|
27
|
+
queries.push({ sql, params });
|
|
28
|
+
if (/INSERT INTO ideas_grades/i.test(sql)) {
|
|
29
|
+
if (failNextGradeWrite) { failNextGradeWrite = false; throw new Error('synthetic grade-write outage'); }
|
|
30
|
+
return { rows: [{ idea_id: params[0], grade: params[1], template_key: params[2], field_values: JSON.parse(params[3]), completeness_score: params[4] }] };
|
|
31
|
+
}
|
|
32
|
+
if (/INSERT INTO idea_inbox/i.test(sql)) {
|
|
33
|
+
return { rows: [{ id: '77', title: params[0], status: 'open' }] };
|
|
34
|
+
}
|
|
35
|
+
if (/SELECT COUNT/i.test(sql) || /count/i.test(sql)) return { rows: [{ n: '3', count: '3' }] };
|
|
36
|
+
return { rows: [] };
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const { BASE_TEMPLATE } = require('../modules/ideas/templates.js');
|
|
40
|
+
const routesFactory = require('../modules/ideas/routes/inbox.js');
|
|
41
|
+
|
|
42
|
+
// Pull the real POST /inbox handler off the mounted router.
|
|
43
|
+
const router = routesFactory({});
|
|
44
|
+
const layer = router.stack.find((l) => l.route && l.route.path === '/inbox' && l.route.methods.post);
|
|
45
|
+
const handlers = layer.route.stack.map((s) => s.handle);
|
|
46
|
+
const postInbox = handlers[handlers.length - 1]; // after requireBuilder
|
|
47
|
+
|
|
48
|
+
function call(bodyIn) {
|
|
49
|
+
return new Promise((resolve) => {
|
|
50
|
+
const req = { body: bodyIn, builder: { id: '9', rank: 'metic' }, query: {} };
|
|
51
|
+
const res = {
|
|
52
|
+
statusCode: 200,
|
|
53
|
+
status(c) { this.statusCode = c; return this; },
|
|
54
|
+
json(payload) { resolve({ status: this.statusCode, payload }); },
|
|
55
|
+
fail(code, status, extra) {
|
|
56
|
+
const s = typeof status === 'number' ? status : (status && status.status) || 500;
|
|
57
|
+
resolve({ status: s, payload: { error: code, ...(extra || {}) } });
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
postInbox(req, res);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const FULL = Object.fromEntries(
|
|
65
|
+
BASE_TEMPLATE.fields.map((f) => [f.key, `A substantive answer for ${f.key}, long enough to clear the floor.`]),
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
let passed = 0;
|
|
69
|
+
let failed = 0;
|
|
70
|
+
async function test(name, fn) {
|
|
71
|
+
queries = [];
|
|
72
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
73
|
+
catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
await test('a Full Idea round-trips: 201 with grade, fields and score intact', async () => {
|
|
77
|
+
const r = await call({ title: 'a full idea', grade: 'full', field_values: FULL });
|
|
78
|
+
assert.equal(r.status, 201);
|
|
79
|
+
assert.equal(r.payload.idea.grade, 'full');
|
|
80
|
+
assert.deepEqual(r.payload.idea.field_values, FULL);
|
|
81
|
+
assert.equal(r.payload.idea.completeness_score, 100, 'scored via R142 at capture');
|
|
82
|
+
assert.ok(queries.some((q) => /INSERT INTO idea_inbox/i.test(q.sql)));
|
|
83
|
+
assert.ok(queries.some((q) => /INSERT INTO ideas_grades/i.test(q.sql)));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
await test('an invalid field set is a 400 naming the field — and NOTHING is written', async () => {
|
|
87
|
+
const r = await call({ title: 'bad full', grade: 'full', field_values: { ...FULL, rogue: 'x' } });
|
|
88
|
+
assert.equal(r.status, 400);
|
|
89
|
+
assert.equal(r.payload.error, 'invalid_field_values');
|
|
90
|
+
assert.deepEqual(r.payload.errors, [{ field: 'rogue', reason: 'unknown_field' }]);
|
|
91
|
+
assert.equal(queries.length, 0, 'refused before any write — no orphan idea');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
await test('a missing required field is named the same way', async () => {
|
|
95
|
+
const { fit, ...four } = FULL;
|
|
96
|
+
const r = await call({ title: 'incomplete full', grade: 'full', field_values: four });
|
|
97
|
+
assert.equal(r.status, 400);
|
|
98
|
+
assert.ok(r.payload.errors.some((e) => e.field === 'fit' && e.reason === 'missing_required'));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await test("'spark' is refused at capture — sparking is an act on an existing idea (R144)", async () => {
|
|
102
|
+
const r = await call({ title: 'pre-marked?', grade: 'spark' });
|
|
103
|
+
assert.equal(r.status, 400);
|
|
104
|
+
assert.equal(r.payload.error, 'bad_grade_at_capture');
|
|
105
|
+
assert.deepEqual(r.payload.allowed, ['quick', 'full']);
|
|
106
|
+
assert.equal(queries.length, 0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await test('a grade-write failure never blocks capture: idea lands, named warning returned', async () => {
|
|
110
|
+
failNextGradeWrite = true;
|
|
111
|
+
const r = await call({ title: 'resilient full', grade: 'full', field_values: FULL });
|
|
112
|
+
assert.equal(r.status, 201, 'the capture still succeeds');
|
|
113
|
+
assert.equal(r.payload.grade_warning, 'captured_but_ungraded');
|
|
114
|
+
assert.ok(queries.some((q) => /INSERT INTO idea_inbox/i.test(q.sql)), 'the idea landed');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
await test('the quick path is untouched: a bare capture writes idea_inbox only', async () => {
|
|
118
|
+
const r = await call({ title: 'just a thought' });
|
|
119
|
+
assert.equal(r.status, 201);
|
|
120
|
+
// since R140 every new idea reads grade='quick' BY DEFINITION (the absence
|
|
121
|
+
// design) — what the guard forbids is a grade-table statement, not the label
|
|
122
|
+
assert.equal(r.payload.idea.grade, 'quick');
|
|
123
|
+
assert.ok(!queries.some((q) => /ideas_grades/i.test(q.sql)), 'zero grade statements');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
await test("an explicit grade:'quick' is the same no-op quick capture", async () => {
|
|
127
|
+
const r = await call({ title: 'explicitly quick', grade: 'quick' });
|
|
128
|
+
assert.equal(r.status, 201);
|
|
129
|
+
assert.ok(!queries.some((q) => /ideas_grades/i.test(q.sql)));
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
await test('SECURITY (R143 grader blocker): a hostile template_key is a 400, the process survives', async () => {
|
|
133
|
+
// The exact crash the grader found: template_key:'constructor' with grade:full.
|
|
134
|
+
// Pre-fix this threw a synchronous TypeError out of the async handler (an
|
|
135
|
+
// unhandled rejection → process down). Now it is a clean 400 and NOTHING is
|
|
136
|
+
// written. Proven at the route layer, over the real handler.
|
|
137
|
+
for (const hostile of ['constructor', '__proto__', 'toString']) {
|
|
138
|
+
const r = await call({ title: 'attack', grade: 'full', field_values: FULL, template_key: hostile });
|
|
139
|
+
assert.equal(r.status, 400, `template_key='${hostile}' must be a 400, not a crash`);
|
|
140
|
+
assert.equal(r.payload.error, 'invalid_field_values');
|
|
141
|
+
assert.equal(queries.length, 0, 'refused before any write');
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
console.log(`\nidea_full_capture: ${passed} passed, ${failed} failed`);
|
|
146
|
+
if (failed > 0) process.exit(1);
|
|
@@ -13,9 +13,10 @@
|
|
|
13
13
|
// title is the only requirement, and its INSERT touches idea_inbox alone:
|
|
14
14
|
// ZERO statements against ideas_grades. A quick idea is quick because no
|
|
15
15
|
// grade row exists (the R140 design), not because one was written.
|
|
16
|
-
// 2. THE ROUTE SCHEMA —
|
|
17
|
-
// field
|
|
18
|
-
//
|
|
16
|
+
// 2. THE ROUTE SCHEMA — the five pre-grade keys survive and title stays the
|
|
17
|
+
// ONLY required field. (R143 later added grade-era keys as OPTIONALS —
|
|
18
|
+
// the promise was never "the schema can't grow", it is "no new REQUIRED
|
|
19
|
+
// field appears on the quick path".)
|
|
19
20
|
// 3. THE READ — an idea with no grade row reads back grade='quick' with null
|
|
20
21
|
// template/score, via the LEFT JOIN + COALESCE (idea_grades.mjs owns the
|
|
21
22
|
// deep version of this; here it is asserted once as part of the contract).
|
|
@@ -97,15 +98,18 @@ await test('capture: the title requirement is the SAME refusal it always was', a
|
|
|
97
98
|
|
|
98
99
|
// ── 2. the route schema ─────────────────────────────────────────────────────
|
|
99
100
|
|
|
100
|
-
await test('POST /inbox
|
|
101
|
+
await test('POST /inbox: the pre-grade fields survive and title stays the ONLY requirement', () => {
|
|
102
|
+
// R143 legitimately added grade / template_key / field_values as OPTIONAL
|
|
103
|
+
// keys — the promise this guard protects (R150's done-when) was never "the
|
|
104
|
+
// schema can't grow", it is "no new REQUIRED field appears on the quick
|
|
105
|
+
// path". So the pin is: the five original keys remain, and required is
|
|
106
|
+
// exactly ['title'] — a grade-era key turning required fails here by name.
|
|
101
107
|
const src = fs.readFileSync(path.join(__dirname, '..', 'modules', 'ideas', 'routes', 'inbox.js'), 'utf8');
|
|
102
108
|
const post = src.slice(src.indexOf("router.post('/inbox'"), src.indexOf('captureIdea('));
|
|
103
109
|
const schema = post.slice(post.indexOf('{', post.indexOf('validateOrRespond')), post.indexOf('})') + 1);
|
|
104
110
|
for (const key of ['title', 'body_md', 'kind', 'suggested_version', 'suggested_priority']) {
|
|
105
111
|
assert.ok(schema.includes(key), `the quick path still accepts ${key}`);
|
|
106
112
|
}
|
|
107
|
-
assert.doesNotMatch(schema, /grade|template|field_values|completeness/,
|
|
108
|
-
'no grade-era key in the quick capture schema');
|
|
109
113
|
const required = [...schema.matchAll(/(\w+):\s*\{[^}]*required:\s*true/g)].map((m) => m[1]);
|
|
110
114
|
assert.deepEqual(required, ['title'], 'title is the ONLY required field, as it always was');
|
|
111
115
|
});
|
package/tests/idea_templates.mjs
CHANGED
|
@@ -62,6 +62,22 @@ test('templateFor: base key resolves, unknown/blank/non-string do not', () => {
|
|
|
62
62
|
assert.equal(T.templateFor(null), null);
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
+
test('SECURITY (R143 grader blocker): a prototype-chain key resolves to null, never a crash', () => {
|
|
66
|
+
// template_key is attacker-controlled at capture and only length-checked, so
|
|
67
|
+
// a bare registry[key] would resolve 'constructor' / '__proto__' / 'toString'
|
|
68
|
+
// up the prototype chain to a truthy Object.prototype member — a non-template
|
|
69
|
+
// whose .fields is undefined — and validateFieldValues would then throw a
|
|
70
|
+
// synchronous TypeError, crashing the process. Own-property lookup returns
|
|
71
|
+
// null for every one of these, and the validator path stays a clean refusal.
|
|
72
|
+
for (const hostile of ['constructor', '__proto__', 'toString', 'valueOf', 'hasOwnProperty', 'isPrototypeOf']) {
|
|
73
|
+
assert.equal(T.templateFor(hostile), null, `templateFor('${hostile}') must be null`);
|
|
74
|
+
assert.doesNotThrow(() => T.validateFieldValues({}, { templateKey: hostile }),
|
|
75
|
+
`validateFieldValues must not throw on template_key='${hostile}'`);
|
|
76
|
+
assert.deepEqual(T.validateFieldValues({}, { templateKey: hostile }).errors,
|
|
77
|
+
[{ field: null, reason: 'unknown_template' }]);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
65
81
|
// ── validation follows the declaration ──────────────────────────────────────
|
|
66
82
|
|
|
67
83
|
test('a complete five-field payload validates', () => {
|