@bongos/core 1.19.358 → 1.19.360
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 +64 -24
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +20 -0
- package/clients/bongos-client/index.cjs +20 -0
- package/clients/bongos-client/index.d.ts +30 -0
- package/clients/bongos-client/index.mjs +20 -0
- package/docs/api/openapi.json +512 -3
- package/docs/api-reference.md +20 -2
- package/modules/builder-settings/builder-needs.js +29 -2
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +20 -0
- package/modules/lifecycle/closeness-weights.json +64 -0
- package/modules/lifecycle/closeness.js +363 -0
- package/modules/lifecycle/lifecycle.js +5 -0
- package/modules/lifecycle/migrations/lifecycle_005_task_recommendations.sql +90 -0
- package/modules/lifecycle/module.json +2 -0
- package/modules/lifecycle/routes/closeness.js +118 -0
- package/modules/lifecycle/routes/recommendations.js +148 -0
- package/modules/lifecycle/task-recommendations.js +247 -0
- package/modules/platform-identity/platform-identity.js +28 -0
- package/modules/platform-identity/routes/sso.js +4 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/routes/me.js +15 -0
- package/src/module-api.js +5 -1
- package/tests/closeness.mjs +324 -0
- package/tests/task_recommendations.mjs +297 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// modules/lifecycle/routes/closeness.js — the closeness graph, and the surface
|
|
2
|
+
// that can explain it (task 1003174 / C04, goal 1000075; criterion
|
|
3
|
+
// collab-closeness-consented, ADR 0187 §2).
|
|
4
|
+
//
|
|
5
|
+
// THREE ROUTES, AND ONE OF THEM IS THE POINT:
|
|
6
|
+
// GET /closeness/weights — the scoring model, served VERBATIM from the
|
|
7
|
+
// data file. The owner's condition was that
|
|
8
|
+
// the weights be reviewable without a code
|
|
9
|
+
// dig; this is where they are reviewed.
|
|
10
|
+
// GET /closeness/for-me — my graph. Every entry carries its own `why`.
|
|
11
|
+
// GET /closeness/with/:builderId — one pair, same breakdown, asked directly.
|
|
12
|
+
//
|
|
13
|
+
// THE EXPLANATION IS NOT A SEPARATE ENDPOINT, deliberately. "Why am I close to
|
|
14
|
+
// them" must be answerable on ANY surface that shows closeness, and a second call
|
|
15
|
+
// a caller may simply not make is how a score ends up on a page with nothing to
|
|
16
|
+
// point at. So `why` rides in the same payload as the number, always.
|
|
17
|
+
//
|
|
18
|
+
// CONSENT is resolved here and passed INTO the read as a gate (see
|
|
19
|
+
// closeness.js applyConsent), never applied to the output afterwards. The hub owns
|
|
20
|
+
// the hide flag (ADR 0171 D2/D4) and lifecycle must not read another module's
|
|
21
|
+
// table (ADR 0083), so the port is resolved optionally and a reader is handed down.
|
|
22
|
+
//
|
|
23
|
+
// Reaches core only through the doorway (ADR 0083).
|
|
24
|
+
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
const express = require('express');
|
|
28
|
+
const api = require('../../../src/module-api');
|
|
29
|
+
const closeness = require('../closeness');
|
|
30
|
+
|
|
31
|
+
const { asyncHandler, parseId } = api;
|
|
32
|
+
|
|
33
|
+
// Build the consent reader the graph gates on, or null where no hub is enabled.
|
|
34
|
+
//
|
|
35
|
+
// NULL IS NOT A HOLE. Where the platform-identity module is off there is no
|
|
36
|
+
// hub-level hide flag to honour, because nobody on that instance has been offered
|
|
37
|
+
// the switch — suppressing everyone would not be consent, it would be an outage.
|
|
38
|
+
// What must never happen is an ERROR being read as "nobody is hidden", and that is
|
|
39
|
+
// why the reader THROWS rather than swallowing: applyConsent catches and empties
|
|
40
|
+
// the graph, which is the fail-closed direction ADR 0171 asks for.
|
|
41
|
+
function consentReader(pool) {
|
|
42
|
+
const hub = api.resolveOptional('platform-identity.hub');
|
|
43
|
+
if (!hub || typeof hub.hiddenGithubIds !== 'function') return null;
|
|
44
|
+
|
|
45
|
+
return async (builderIds) => {
|
|
46
|
+
if (!builderIds || !builderIds.length) return new Set();
|
|
47
|
+
// builder_id -> github_id, from the CORE builders table (a read-only core
|
|
48
|
+
// read, which the module contract permits; the hub keys consent by github_id
|
|
49
|
+
// and cannot see our builder ids).
|
|
50
|
+
const { rows } = await pool.query(
|
|
51
|
+
'SELECT id, github_id FROM builders WHERE id = ANY($1::bigint[])',
|
|
52
|
+
[builderIds]
|
|
53
|
+
);
|
|
54
|
+
const githubIdByBuilder = new Map(rows.map((r) => [String(r.id), r.github_id]));
|
|
55
|
+
const hiddenGithub = await hub.hiddenGithubIds([...githubIdByBuilder.values()].filter((g) => g != null));
|
|
56
|
+
const hiddenSet = hiddenGithub instanceof Set ? hiddenGithub : new Set((hiddenGithub || []).map(String));
|
|
57
|
+
const hidden = new Set();
|
|
58
|
+
for (const [builderId, githubId] of githubIdByBuilder) {
|
|
59
|
+
if (githubId != null && hiddenSet.has(String(githubId))) hidden.add(builderId);
|
|
60
|
+
}
|
|
61
|
+
return hidden;
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = function buildClosenessRouter() {
|
|
66
|
+
const router = express.Router();
|
|
67
|
+
|
|
68
|
+
// rank: any-builder — the MODEL is not private, and publishing it is the whole
|
|
69
|
+
// point of the weights being data. It describes how the system scores people;
|
|
70
|
+
// a builder ranked by it is entitled to read it.
|
|
71
|
+
router.get('/closeness/weights', api.requireBuilder, asyncHandler('GET /closeness/weights', async (req, res) => {
|
|
72
|
+
// Served verbatim from the file the scorer loads, NOT restated here — an
|
|
73
|
+
// audit surface that paraphrases the model can drift from it, which would
|
|
74
|
+
// make it worse than nothing.
|
|
75
|
+
return res.json({
|
|
76
|
+
weights: closeness.weightsModel(),
|
|
77
|
+
window_days: closeness.WINDOW_DAYS,
|
|
78
|
+
// Named explicitly so a reader does not have to infer the renormalisation
|
|
79
|
+
// rule from the numbers.
|
|
80
|
+
note: 'Signals marked available:false are EXCLUDED and their weight is redistributed across the rest — they are never counted as zero. Each score carries the same breakdown this model produces.',
|
|
81
|
+
});
|
|
82
|
+
}, { errorCode: 'closeness_weights_failed' }));
|
|
83
|
+
|
|
84
|
+
// rank: any-builder, and OWN-SCOPED — your own graph, never someone else's.
|
|
85
|
+
// Reading another builder's graph would expose who THEY are close to, which is
|
|
86
|
+
// a fact about third parties they did not consent to share.
|
|
87
|
+
router.get('/closeness/for-me', api.requireBuilder, asyncHandler('GET /closeness/for-me', async (req, res) => {
|
|
88
|
+
const rows = await closeness.closenessFor(req.builder.id, {
|
|
89
|
+
hiddenIdsFor: consentReader(api.pool),
|
|
90
|
+
});
|
|
91
|
+
return res.json({ closeness: rows, count: rows.length });
|
|
92
|
+
}, { errorCode: 'closeness_failed' }));
|
|
93
|
+
|
|
94
|
+
// One pair — "why am I close to THEM", asked directly rather than by paging the
|
|
95
|
+
// graph. Always relative to the CALLER: the pair is (you, them).
|
|
96
|
+
//
|
|
97
|
+
// rank: any-builder.
|
|
98
|
+
router.get('/closeness/with/:builderId', api.requireBuilder, asyncHandler('GET /closeness/with/:builderId', async (req, res) => {
|
|
99
|
+
const otherId = parseId(req, res, { param: 'builderId', code: 'bad_builder_id', positiveInt: true });
|
|
100
|
+
if (otherId === null) return;
|
|
101
|
+
|
|
102
|
+
const pair = await closeness.closenessBetween(req.builder.id, otherId, {
|
|
103
|
+
hiddenIdsFor: consentReader(api.pool),
|
|
104
|
+
});
|
|
105
|
+
if (!pair) {
|
|
106
|
+
// ONE answer for "nothing measurable happened" AND "consent says no".
|
|
107
|
+
// Distinguishing them would let a caller detect that a specific builder has
|
|
108
|
+
// hidden themselves — the exact thing the hide flag exists to prevent.
|
|
109
|
+
return res.json({
|
|
110
|
+
closeness: null,
|
|
111
|
+
why: { in_words: 'Nothing measurable connects you two.', breakdown: [], excluded: [] },
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return res.json({ closeness: pair, why: pair.why });
|
|
115
|
+
}, { errorCode: 'closeness_failed' }));
|
|
116
|
+
|
|
117
|
+
return router;
|
|
118
|
+
};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// modules/lifecycle/routes/recommendations.js — the write path and the reads for
|
|
2
|
+
// recommending a task to someone (task 1003173 / C03, goal 1000075; criterion
|
|
3
|
+
// collab-pass-a-task, ADR 0187 §3).
|
|
4
|
+
//
|
|
5
|
+
// WHY NOT `task-recommendations.js`, WHICH IS THE OBVIOUS NAME. `task-*.js` in
|
|
6
|
+
// this directory is a RESERVED PREFIX: tests/helpers.mjs taskRoutesSource()
|
|
7
|
+
// concatenates tasks.js with every sibling matching `task-*.js` and reads the
|
|
8
|
+
// result as ONE source, because the tasks router was carved into pieces that must
|
|
9
|
+
// still be asserted together. A separate surface named task-recommendations.js is
|
|
10
|
+
// swept into that family and silently breaks assertions about the tasks router —
|
|
11
|
+
// est_advisory and goal_advisory both matched THIS file's 201 body instead of the
|
|
12
|
+
// task-create one. This surface is a sibling of help-requests.js, not a piece of
|
|
13
|
+
// the tasks router, so it is named like help-requests.js. Do not rename it back.
|
|
14
|
+
//
|
|
15
|
+
// FIVE ROUTES, AND NOTHING THAT CLAIMS. Send one, see what is waiting on me, see
|
|
16
|
+
// what I sent, decline, withdraw. There is deliberately no "accept" route: accepting
|
|
17
|
+
// a recommendation IS claiming the task, which POST /claims already owns and which
|
|
18
|
+
// is the recipient's own action. A route here that claimed on the recipient's
|
|
19
|
+
// behalf would be the transfer this criterion exists to refuse, and even an
|
|
20
|
+
// "accept" that only flipped a status would make this table look like the place
|
|
21
|
+
// claims come from.
|
|
22
|
+
//
|
|
23
|
+
// Reaches core only through the doorway (ADR 0083).
|
|
24
|
+
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
const express = require('express');
|
|
28
|
+
const api = require('../../../src/module-api');
|
|
29
|
+
const recs = require('../task-recommendations');
|
|
30
|
+
|
|
31
|
+
const { asyncHandler, validateOrRespond, parseId } = api;
|
|
32
|
+
|
|
33
|
+
module.exports = function buildTaskRecommendationsRouter() {
|
|
34
|
+
const router = express.Router();
|
|
35
|
+
|
|
36
|
+
// rank: any-builder — recommending is not a privileged act. It grants the
|
|
37
|
+
// recipient nothing they did not already have (the task was claimable by them
|
|
38
|
+
// or it was not), so gating it by rank would only stop newcomers from pointing
|
|
39
|
+
// at something useful.
|
|
40
|
+
router.post('/task-recommendations', api.requireBuilder, asyncHandler('POST /task-recommendations', async (req, res) => {
|
|
41
|
+
if (validateOrRespond(req, res, {
|
|
42
|
+
task_id: { required: true, type: 'integer', min: 1 },
|
|
43
|
+
recommended_to: { required: true, type: 'integer', min: 1 },
|
|
44
|
+
reason: { required: true, type: 'string', minLength: 1, maxLength: recs.MAX_REASON },
|
|
45
|
+
})) return;
|
|
46
|
+
|
|
47
|
+
// The CHECK constraints are the authority; this restates them so the refusal
|
|
48
|
+
// names the field rather than surfacing a Postgres string as a 500. The
|
|
49
|
+
// sender comes from the SESSION, never the body — self-recommendation is
|
|
50
|
+
// checked against the authenticated builder.
|
|
51
|
+
const v = recs.validateRecommendation(req.body, req.builder.id);
|
|
52
|
+
if (!v.ok) return res.fail('invalid_recommendation', 400, { errors: v.errors });
|
|
53
|
+
|
|
54
|
+
// Referenced rows that do not exist would otherwise hit the FKs and surface
|
|
55
|
+
// as a generic 500, telling the sender nothing. Checked here so the refusal
|
|
56
|
+
// names the field; the FKs remain the authority.
|
|
57
|
+
if (!(await recs.taskExists(req.body.task_id))) {
|
|
58
|
+
return res.fail('invalid_recommendation', 400, {
|
|
59
|
+
errors: [{ field: 'task_id', reason: 'no_such_task' }],
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
if (!(await recs.builderExists(req.body.recommended_to))) {
|
|
63
|
+
return res.fail('invalid_recommendation', 400, {
|
|
64
|
+
errors: [{ field: 'recommended_to', reason: 'no_such_builder' }],
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const out = await recs.createRecommendation({
|
|
69
|
+
taskId: req.body.task_id,
|
|
70
|
+
recommendedBy: req.builder.id,
|
|
71
|
+
recommendedTo: req.body.recommended_to,
|
|
72
|
+
reason: req.body.reason,
|
|
73
|
+
});
|
|
74
|
+
if (!out.ok) {
|
|
75
|
+
// Not a body the sender can fix — the same recommendation is already open.
|
|
76
|
+
return res.fail('recommendation_already_open', 409, {
|
|
77
|
+
task_id: req.body.task_id,
|
|
78
|
+
recommended_to: req.body.recommended_to,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return res.status(201).json({ ok: true, recommendation: out.recommendation });
|
|
82
|
+
}, { errorCode: 'recommendation_failed' }));
|
|
83
|
+
|
|
84
|
+
// rank: any-builder — you only ever see what is addressed to you.
|
|
85
|
+
//
|
|
86
|
+
// NOTE WHAT THIS RESPONSE DOES NOT CONTAIN: any signal that the recipient now
|
|
87
|
+
// holds the task. It carries the task's own status so the recipient can judge,
|
|
88
|
+
// and that is all. Claiming is a separate, deliberate act on their part.
|
|
89
|
+
router.get('/task-recommendations/for-me', api.requireBuilder, asyncHandler('GET /task-recommendations/for-me', async (req, res) => {
|
|
90
|
+
const rows = await recs.listForRecipient({ builderId: req.builder.id });
|
|
91
|
+
return res.json({ recommendations: rows, count: rows.length });
|
|
92
|
+
}, { errorCode: 'recommendation_list_failed' }));
|
|
93
|
+
|
|
94
|
+
// rank: any-builder — what I recommended, and what became of it. Unfiltered by
|
|
95
|
+
// default: a declined recommendation stays readable, which is the criterion's
|
|
96
|
+
// "the recommendation and its author are readable afterwards".
|
|
97
|
+
router.get('/task-recommendations/mine', api.requireBuilder, asyncHandler('GET /task-recommendations/mine', async (req, res) => {
|
|
98
|
+
const status = typeof req.query.status === 'string' ? req.query.status : null;
|
|
99
|
+
const rows = await recs.listFromSender({ builderId: req.builder.id, status });
|
|
100
|
+
return res.json({ recommendations: rows, count: rows.length });
|
|
101
|
+
}, { errorCode: 'recommendation_list_failed' }));
|
|
102
|
+
|
|
103
|
+
// The per-task audit read: who recommended this task, to whom, and why.
|
|
104
|
+
//
|
|
105
|
+
// rank: any-builder, and deliberately NOT scoped to the two parties. The
|
|
106
|
+
// criterion's argument for a ledger is that an untraced recommendation is
|
|
107
|
+
// indistinguishable from a task quietly changing hands — a trace only the two
|
|
108
|
+
// people involved can read does not answer that. Tasks are already visible to
|
|
109
|
+
// every builder, and a recommendation says nothing more private than "this
|
|
110
|
+
// person thought that person should look at this".
|
|
111
|
+
router.get('/tasks/:id/recommendations', api.requireBuilder, asyncHandler('GET /tasks/:id/recommendations', async (req, res) => {
|
|
112
|
+
const id = parseId(req, res, { code: 'bad_task_id', positiveInt: true });
|
|
113
|
+
if (id === null) return;
|
|
114
|
+
const rows = await recs.listForTask({ taskId: id });
|
|
115
|
+
return res.json({ recommendations: rows, count: rows.length });
|
|
116
|
+
}, { errorCode: 'recommendation_list_failed' }));
|
|
117
|
+
|
|
118
|
+
// Decline. Costs nothing, and this route is where that is visible: it flips a
|
|
119
|
+
// status and returns the row. No karma call, no counter, no notification to the
|
|
120
|
+
// sender beyond the row itself turning up in their own list.
|
|
121
|
+
//
|
|
122
|
+
// rank: any-builder — ownership is enforced in the UPDATE's WHERE, not by rank.
|
|
123
|
+
router.patch('/task-recommendations/:id', api.requireBuilder, asyncHandler('PATCH /task-recommendations/:id', async (req, res) => {
|
|
124
|
+
const id = parseId(req, res, { code: 'bad_recommendation_id', positiveInt: true });
|
|
125
|
+
if (id === null) return;
|
|
126
|
+
if (validateOrRespond(req, res, {
|
|
127
|
+
status: { required: true, type: 'string', enum: ['declined', 'withdrawn'] },
|
|
128
|
+
})) return;
|
|
129
|
+
|
|
130
|
+
// Who may set which: the RECIPIENT declines, the SENDER withdraws. Each
|
|
131
|
+
// update scopes itself to the right party in its own WHERE.
|
|
132
|
+
const row = req.body.status === 'declined'
|
|
133
|
+
? await recs.declineRecommendation({ id, builderId: req.builder.id })
|
|
134
|
+
: await recs.withdrawRecommendation({ id, builderId: req.builder.id });
|
|
135
|
+
|
|
136
|
+
if (!row) {
|
|
137
|
+
// ONE refusal for every miss: absent, not yours to settle, or already
|
|
138
|
+
// settled. Looking the row up to say which would let a caller enumerate
|
|
139
|
+
// existing ids — the lesson help-requests learned from its grader round.
|
|
140
|
+
return res.fail('recommendation_not_settleable', 404, {
|
|
141
|
+
reason: 'no open recommendation you can settle with that id',
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return res.json({ ok: true, recommendation: row });
|
|
145
|
+
}, { errorCode: 'recommendation_update_failed' }));
|
|
146
|
+
|
|
147
|
+
return router;
|
|
148
|
+
};
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// modules/lifecycle/task-recommendations.js — "you should look at this": the data
|
|
2
|
+
// layer for recommending a task to someone (task 1003173 / C03, goal 1000075;
|
|
3
|
+
// criterion collab-pass-a-task, ADR 0187 §3).
|
|
4
|
+
//
|
|
5
|
+
// THE ONE RULE THIS FILE EXISTS TO HOLD. A pass RECOMMENDS; it never transfers.
|
|
6
|
+
// Nothing here writes to `claims`, and nothing here reads a claim in order to move
|
|
7
|
+
// one. The recipient still claims the task themselves through the ordinary claim
|
|
8
|
+
// path, so claim/release stays the single way to hold work and nobody acquires a
|
|
9
|
+
// task without opting in. There is deliberately no transfer function and no seam
|
|
10
|
+
// for one: the decision was recommend-only, and an unused seam is a cost that
|
|
11
|
+
// later reads as an intention.
|
|
12
|
+
//
|
|
13
|
+
// THE REASON IS REQUIRED, NOT OPTIONAL. The criterion's own argument is that an
|
|
14
|
+
// untraced recommendation is indistinguishable from a task quietly changing hands.
|
|
15
|
+
// So a reason the sender must write is what makes this a ledger rather than a
|
|
16
|
+
// nudge, and it is enforced in three places that agree: the CHECK constraint (the
|
|
17
|
+
// authority), validateRecommendation() (so the refusal names the field instead of
|
|
18
|
+
// surfacing a Postgres string), and the route's body schema.
|
|
19
|
+
//
|
|
20
|
+
// DECLINING COSTS NOTHING. That is a claim about what this module does NOT do:
|
|
21
|
+
// no counter, no cooldown, no karma or credit call, nothing written to either
|
|
22
|
+
// builder. `decline` sets a status and returns the row. If a penalty ever appears
|
|
23
|
+
// anywhere for declining, it did not come from here.
|
|
24
|
+
//
|
|
25
|
+
// Sibling of ./help-requests.js under the same goal, and deliberately shaped like
|
|
26
|
+
// it — same deps.pool injection, same capRows bound, same pure-validator split —
|
|
27
|
+
// so the two collab surfaces read as one thing.
|
|
28
|
+
//
|
|
29
|
+
// Reaches core only through the doorway (ADR 0083).
|
|
30
|
+
|
|
31
|
+
'use strict';
|
|
32
|
+
|
|
33
|
+
const { pool } = require('../../src/module-api');
|
|
34
|
+
|
|
35
|
+
const COLS = 'id, task_id, recommended_by, recommended_to, reason, status, created_at, updated_at';
|
|
36
|
+
|
|
37
|
+
const MAX_REASON = 2000;
|
|
38
|
+
// Defence in depth, matching help-requests: both reads are bounded today (open
|
|
39
|
+
// status, per-builder scope), but an unsettled pile should degrade the page rather
|
|
40
|
+
// than the server.
|
|
41
|
+
const MAX_ROWS = 200;
|
|
42
|
+
|
|
43
|
+
function capRows(n) {
|
|
44
|
+
const v = Number(n);
|
|
45
|
+
return Number.isInteger(v) && v > 0 && v <= MAX_ROWS ? v : MAX_ROWS;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// PURE. Returns { ok, errors } so the route can 400 with named fields before any
|
|
49
|
+
// query runs, and every branch is testable without a database. The table's CHECK
|
|
50
|
+
// constraints remain the authority; these exist so a refusal is a named 400
|
|
51
|
+
// instead of a 500 carrying a Postgres string.
|
|
52
|
+
//
|
|
53
|
+
// `senderId` is passed rather than read from the body on purpose: self-recommendation
|
|
54
|
+
// is checked against the AUTHENTICATED builder, never against a client-supplied id.
|
|
55
|
+
function validateRecommendation(input, senderId) {
|
|
56
|
+
const errors = [];
|
|
57
|
+
const b = input || {};
|
|
58
|
+
|
|
59
|
+
const taskId = b.task_id == null ? null : Number(b.task_id);
|
|
60
|
+
if (taskId === null) errors.push({ field: 'task_id', reason: 'required' });
|
|
61
|
+
else if (!Number.isInteger(taskId) || taskId <= 0) errors.push({ field: 'task_id', reason: 'bad_task_id' });
|
|
62
|
+
|
|
63
|
+
const to = b.recommended_to == null ? null : Number(b.recommended_to);
|
|
64
|
+
if (to === null) errors.push({ field: 'recommended_to', reason: 'required' });
|
|
65
|
+
else if (!Number.isInteger(to) || to <= 0) errors.push({ field: 'recommended_to', reason: 'bad_builder_id' });
|
|
66
|
+
else if (senderId != null && String(to) === String(senderId)) {
|
|
67
|
+
// Recommending a task to yourself is not a recommendation. Caught here so the
|
|
68
|
+
// refusal names the field; the not-self CHECK stays the authority.
|
|
69
|
+
errors.push({ field: 'recommended_to', reason: 'cannot_recommend_to_self' });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The trace. See the header for why this is not nullable.
|
|
73
|
+
const reason = typeof b.reason === 'string' ? b.reason.trim() : '';
|
|
74
|
+
if (!reason) errors.push({ field: 'reason', reason: 'required' });
|
|
75
|
+
else if (reason.length > MAX_REASON) errors.push({ field: 'reason', reason: 'too_long' });
|
|
76
|
+
|
|
77
|
+
return { ok: errors.length === 0, errors };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Existence checks so a bad reference is a named 400 rather than an FK violation
|
|
81
|
+
// rendered as a 500. The FKs remain the authority — a row deleted between the
|
|
82
|
+
// check and the insert still fails safely there.
|
|
83
|
+
async function builderExists(id, deps = {}) {
|
|
84
|
+
const activePool = deps.pool || pool;
|
|
85
|
+
const { rows } = await activePool.query('SELECT 1 FROM builders WHERE id = $1', [id]);
|
|
86
|
+
return rows.length > 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function taskExists(id, deps = {}) {
|
|
90
|
+
const activePool = deps.pool || pool;
|
|
91
|
+
const { rows } = await activePool.query('SELECT 1 FROM tasks WHERE id = $1', [id]);
|
|
92
|
+
return rows.length > 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Send one. Returns { ok: true, recommendation } or { ok: false, reason }.
|
|
96
|
+
//
|
|
97
|
+
// ON CONFLICT DO NOTHING against the partial unique index, rather than letting the
|
|
98
|
+
// 23505 escape as a 500: a second send of the same task to the same person while
|
|
99
|
+
// one is still open is the same judgement repeated, and the caller should be told
|
|
100
|
+
// that plainly. It is NOT an error the sender can fix by changing the body, so it
|
|
101
|
+
// reads as `already_open`, not as a validation failure.
|
|
102
|
+
async function createRecommendation(input, deps = {}) {
|
|
103
|
+
const activePool = deps.pool || pool;
|
|
104
|
+
const { rows } = await activePool.query(
|
|
105
|
+
`INSERT INTO lifecycle_task_recommendations
|
|
106
|
+
(task_id, recommended_by, recommended_to, reason)
|
|
107
|
+
VALUES ($1, $2, $3, $4)
|
|
108
|
+
ON CONFLICT DO NOTHING
|
|
109
|
+
RETURNING ${COLS}`,
|
|
110
|
+
[input.taskId, input.recommendedBy, input.recommendedTo, String(input.reason).trim()]
|
|
111
|
+
);
|
|
112
|
+
if (!rows[0]) return { ok: false, reason: 'already_open' };
|
|
113
|
+
return { ok: true, recommendation: rows[0] };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// The recipient's read: what has been recommended to me, and by whom. Joined to
|
|
117
|
+
// the task and the sender because a bare id list cannot be acted on — the whole
|
|
118
|
+
// point is that the recipient can judge whether to go and claim it.
|
|
119
|
+
//
|
|
120
|
+
// It reports `recommended_task_status` rather than a "can I claim this" boolean.
|
|
121
|
+
// Claimability is the claim path's answer (deps, touches, rank, an existing
|
|
122
|
+
// claim), and duplicating that judgement here would be a second, staler copy of a
|
|
123
|
+
// rule that already has an owner.
|
|
124
|
+
async function listForRecipient({ builderId, limit = MAX_ROWS }, deps = {}) {
|
|
125
|
+
const activePool = deps.pool || pool;
|
|
126
|
+
const { rows } = await activePool.query(
|
|
127
|
+
`SELECT r.id, r.task_id, r.recommended_by, r.recommended_to, r.reason,
|
|
128
|
+
r.status, r.created_at, r.updated_at,
|
|
129
|
+
t.title AS task_title,
|
|
130
|
+
t.status AS task_status,
|
|
131
|
+
b.github_login AS recommended_by_login,
|
|
132
|
+
b.display_name AS recommended_by_name
|
|
133
|
+
FROM lifecycle_task_recommendations r
|
|
134
|
+
JOIN tasks t ON t.id = r.task_id
|
|
135
|
+
JOIN builders b ON b.id = r.recommended_by
|
|
136
|
+
WHERE r.recommended_to = $1 AND r.status = 'open'
|
|
137
|
+
ORDER BY r.created_at DESC, r.id DESC
|
|
138
|
+
LIMIT $2`,
|
|
139
|
+
[builderId, capRows(limit)]
|
|
140
|
+
);
|
|
141
|
+
return rows;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// The sender's read: what I have recommended, and what became of it. NOT filtered
|
|
145
|
+
// to open — the criterion asks that a recommendation and its author stay readable
|
|
146
|
+
// afterwards, so a declined row is still here, still naming who declined it.
|
|
147
|
+
async function listFromSender({ builderId, status = null, limit = MAX_ROWS }, deps = {}) {
|
|
148
|
+
const activePool = deps.pool || pool;
|
|
149
|
+
const { rows } = await activePool.query(
|
|
150
|
+
`SELECT r.id, r.task_id, r.recommended_by, r.recommended_to, r.reason,
|
|
151
|
+
r.status, r.created_at, r.updated_at,
|
|
152
|
+
t.title AS task_title,
|
|
153
|
+
t.status AS task_status,
|
|
154
|
+
b.github_login AS recommended_to_login,
|
|
155
|
+
b.display_name AS recommended_to_name
|
|
156
|
+
FROM lifecycle_task_recommendations r
|
|
157
|
+
JOIN tasks t ON t.id = r.task_id
|
|
158
|
+
JOIN builders b ON b.id = r.recommended_to
|
|
159
|
+
WHERE r.recommended_by = $1 AND ($2::text IS NULL OR r.status = $2)
|
|
160
|
+
ORDER BY r.created_at DESC, r.id DESC
|
|
161
|
+
LIMIT $3`,
|
|
162
|
+
[builderId, status, capRows(limit)]
|
|
163
|
+
);
|
|
164
|
+
return rows;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// "Who recommended this task, and why" — the per-task audit read, and the direct
|
|
168
|
+
// answer to the criterion's "the recommendation and its author are readable
|
|
169
|
+
// afterwards". Every status, oldest-relevant-first ordering aside, because the
|
|
170
|
+
// history is the point: a declined recommendation still records that someone
|
|
171
|
+
// thought this person should see this task.
|
|
172
|
+
async function listForTask({ taskId, limit = MAX_ROWS }, deps = {}) {
|
|
173
|
+
const activePool = deps.pool || pool;
|
|
174
|
+
const { rows } = await activePool.query(
|
|
175
|
+
`SELECT r.id, r.task_id, r.recommended_by, r.recommended_to, r.reason,
|
|
176
|
+
r.status, r.created_at, r.updated_at,
|
|
177
|
+
sender.github_login AS recommended_by_login,
|
|
178
|
+
sender.display_name AS recommended_by_name,
|
|
179
|
+
recipient.github_login AS recommended_to_login,
|
|
180
|
+
recipient.display_name AS recommended_to_name
|
|
181
|
+
FROM lifecycle_task_recommendations r
|
|
182
|
+
JOIN builders sender ON sender.id = r.recommended_by
|
|
183
|
+
JOIN builders recipient ON recipient.id = r.recommended_to
|
|
184
|
+
WHERE r.task_id = $1
|
|
185
|
+
ORDER BY r.created_at DESC, r.id DESC
|
|
186
|
+
LIMIT $2`,
|
|
187
|
+
[taskId, capRows(limit)]
|
|
188
|
+
);
|
|
189
|
+
return rows;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// The recipient declines. Costs nothing — see the header: this sets a status and
|
|
193
|
+
// returns the row, and writes nothing anywhere else about either party.
|
|
194
|
+
//
|
|
195
|
+
// Ownership is enforced in the WHERE, not by rank, and ONE refusal covers all
|
|
196
|
+
// three miss cases (absent, not addressed to you, already settled) so a caller
|
|
197
|
+
// cannot enumerate existing ids by reading which one it was — the same lesson
|
|
198
|
+
// help-requests learned from its own grader round.
|
|
199
|
+
async function declineRecommendation({ id, builderId }, deps = {}) {
|
|
200
|
+
const activePool = deps.pool || pool;
|
|
201
|
+
const { rows } = await activePool.query(
|
|
202
|
+
`UPDATE lifecycle_task_recommendations
|
|
203
|
+
SET status = 'declined', updated_at = now()
|
|
204
|
+
WHERE id = $1 AND recommended_to = $2 AND status = 'open'
|
|
205
|
+
RETURNING ${COLS}`,
|
|
206
|
+
[id, builderId]
|
|
207
|
+
);
|
|
208
|
+
return rows[0] || null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// The sender takes it back. Same shape, other party, same silence about it.
|
|
212
|
+
async function withdrawRecommendation({ id, builderId }, deps = {}) {
|
|
213
|
+
const activePool = deps.pool || pool;
|
|
214
|
+
const { rows } = await activePool.query(
|
|
215
|
+
`UPDATE lifecycle_task_recommendations
|
|
216
|
+
SET status = 'withdrawn', updated_at = now()
|
|
217
|
+
WHERE id = $1 AND recommended_by = $2 AND status = 'open'
|
|
218
|
+
RETURNING ${COLS}`,
|
|
219
|
+
[id, builderId]
|
|
220
|
+
);
|
|
221
|
+
return rows[0] || null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// The needs-feed read: how many open recommendations are waiting on this builder.
|
|
225
|
+
// A COUNT rather than the rows, for the same reason countOpenForBuilder is a count
|
|
226
|
+
// in help-requests — /me runs on every session open and should not carry bodies it
|
|
227
|
+
// will not render.
|
|
228
|
+
async function countOpenForRecipient(builderId, deps = {}) {
|
|
229
|
+
const activePool = deps.pool || pool;
|
|
230
|
+
const { rows } = await activePool.query(
|
|
231
|
+
`SELECT count(*)::int AS n
|
|
232
|
+
FROM lifecycle_task_recommendations
|
|
233
|
+
WHERE recommended_to = $1 AND status = 'open'`,
|
|
234
|
+
[builderId]
|
|
235
|
+
);
|
|
236
|
+
return rows[0] ? rows[0].n : 0;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
module.exports = {
|
|
240
|
+
MAX_REASON, MAX_ROWS, capRows,
|
|
241
|
+
validateRecommendation,
|
|
242
|
+
builderExists, taskExists,
|
|
243
|
+
createRecommendation,
|
|
244
|
+
listForRecipient, listFromSender, listForTask,
|
|
245
|
+
declineRecommendation, withdrawRecommendation,
|
|
246
|
+
countOpenForRecipient,
|
|
247
|
+
};
|
|
@@ -629,6 +629,33 @@ async function getPublicProfileExtras(githubId, { pool = defaultPool } = {}) {
|
|
|
629
629
|
return getRollupForBuilder(githubId, { pool });
|
|
630
630
|
}
|
|
631
631
|
|
|
632
|
+
// The CONSENT read, in bulk (task 1003174 / C04, goal 1000075). Answers exactly
|
|
633
|
+
// one question — "which of these accounts have HIDDEN themselves?" — and returns
|
|
634
|
+
// a Set of the github_ids that have.
|
|
635
|
+
//
|
|
636
|
+
// WHY NOT REUSE getPublicProfileExtras. It returns null for a hidden account AND
|
|
637
|
+
// for one that is merely absent or still provisional, because for a profile
|
|
638
|
+
// rollup those cases are identical: there is nothing to show. For a consent gate
|
|
639
|
+
// they are opposites. A builder who has never federated at the hub has not opted
|
|
640
|
+
// OUT of anything — nobody offered them the switch — so treating their missing
|
|
641
|
+
// row as "hidden" would empty the closeness graph of every builder on a fresh
|
|
642
|
+
// instance and call it consent. HIDDEN means a row exists and hide_stats is true.
|
|
643
|
+
//
|
|
644
|
+
// FAIL-CLOSED is the CALLER's job here, and deliberately so: this function
|
|
645
|
+
// THROWS on a bad read rather than returning an empty set, because an empty set
|
|
646
|
+
// means "nobody is hidden" and a database error must never be able to say that.
|
|
647
|
+
// modules/lifecycle/closeness.js applyConsent catches and empties the graph.
|
|
648
|
+
async function hiddenGithubIds(githubIds, { pool = defaultPool } = {}) {
|
|
649
|
+
const ids = (Array.isArray(githubIds) ? githubIds : []).filter(isNumericGithubId).map(String);
|
|
650
|
+
if (!ids.length) return new Set();
|
|
651
|
+
const { rows } = await pool.query(
|
|
652
|
+
`SELECT github_id FROM platform_identity_accounts
|
|
653
|
+
WHERE github_id = ANY($1::bigint[]) AND hide_stats = true`,
|
|
654
|
+
[ids],
|
|
655
|
+
);
|
|
656
|
+
return new Set(rows.map((r) => String(r.github_id)));
|
|
657
|
+
}
|
|
658
|
+
|
|
632
659
|
// The SELF read (ADR 0171 D2's first narrow side door): a builder inspects their
|
|
633
660
|
// own numbers in Settings regardless of profile_state/hide_stats. Callers MUST
|
|
634
661
|
// key this by the authenticated session's github_id, never a caller-named one.
|
|
@@ -1069,6 +1096,7 @@ async function sweepExpiredCodes({ pool = defaultPool } = {}) {
|
|
|
1069
1096
|
}
|
|
1070
1097
|
|
|
1071
1098
|
module.exports = {
|
|
1099
|
+
hiddenGithubIds,
|
|
1072
1100
|
DEFAULT_CODE_TTL_MS,
|
|
1073
1101
|
// pure helpers
|
|
1074
1102
|
sha256hex,
|
|
@@ -110,6 +110,10 @@ function registerSeams() {
|
|
|
110
110
|
api.registerProvider('platform-identity.hub', {
|
|
111
111
|
getPublicProfileExtras: (githubId) => pi.getPublicProfileExtras(githubId, { pool }),
|
|
112
112
|
getOwnProfileExtras: (githubId) => pi.getOwnProfileExtras(githubId, { pool }),
|
|
113
|
+
// task 1003174 (C04): the bulk consent read the closeness graph gates on.
|
|
114
|
+
// THROWS on a bad read by design — an empty set means "nobody is hidden",
|
|
115
|
+
// which a database error must never be allowed to say.
|
|
116
|
+
hiddenGithubIds: (githubIds) => pi.hiddenGithubIds(githubIds, { pool }),
|
|
113
117
|
});
|
|
114
118
|
}
|
|
115
119
|
}
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.360",
|
|
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.360",
|
|
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.360",
|
|
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/bongos/routes/me.js
CHANGED
|
@@ -84,6 +84,7 @@ module.exports = function buildMeRouter() {
|
|
|
84
84
|
builder, activeClaims, estVsActual, unlocked, lockedWithProgress, streakInfo,
|
|
85
85
|
recentGrades, builderAvg, onboardingRow, shipCount, recentKarma, karma30d, discordLink, boxConnected, cliAuthed,
|
|
86
86
|
behaviorPrefs, geminiKeyRow, needsRebaseTasks, foundingBuilder, helpRequestCount,
|
|
87
|
+
taskRecommendationCount,
|
|
87
88
|
] = await Promise.all([
|
|
88
89
|
db.getBuilderById(req.builder.id),
|
|
89
90
|
lc().getActiveClaimsForBuilder(req.builder.id),
|
|
@@ -160,6 +161,16 @@ module.exports = function buildMeRouter() {
|
|
|
160
161
|
return await lifecycle.openHelpRequestsFor(req.builder.id);
|
|
161
162
|
} catch (_) { return 0; }
|
|
162
163
|
})(),
|
|
164
|
+
// task 1003173 (C03): open task recommendations waiting on this builder.
|
|
165
|
+
// Same port, same fan-out slot, same fail-open posture as the help-request
|
|
166
|
+
// count above — a collab read must never be able to break a profile.
|
|
167
|
+
(async () => {
|
|
168
|
+
try {
|
|
169
|
+
const lifecycle = lc();
|
|
170
|
+
if (!lifecycle || !lifecycle.openTaskRecommendationsFor) return 0;
|
|
171
|
+
return await lifecycle.openTaskRecommendationsFor(req.builder.id);
|
|
172
|
+
} catch (_) { return 0; }
|
|
173
|
+
})(),
|
|
163
174
|
]);
|
|
164
175
|
// V3.R84 (#302), criterion C3: the builder's month-to-date spend vs their
|
|
165
176
|
// monthly budget. Surfaced here so the CLI (session-start ping) and the hall
|
|
@@ -301,6 +312,10 @@ module.exports = function buildMeRouter() {
|
|
|
301
312
|
// module (ADR 0093); undefined when lifecycle is off, which the need reads
|
|
302
313
|
// as "nothing asked".
|
|
303
314
|
openHelpRequests: helpRequestCount,
|
|
315
|
+
// task 1003173 (C03, goal 1000075): open task recommendations waiting on
|
|
316
|
+
// this builder, read through the same `lifecycle` port so core never
|
|
317
|
+
// imports the module (ADR 0093).
|
|
318
|
+
openTaskRecommendations: taskRecommendationCount,
|
|
304
319
|
}) : [],
|
|
305
320
|
};
|
|
306
321
|
// Module→core contribution seam (BV1.R42, ADR 0083). Let each enabled module
|
package/src/module-api.js
CHANGED
|
@@ -1146,8 +1146,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1146
1146
|
// landed since 1.19.356 with no explicit bump. run 32871335939. (task 1002620)
|
|
1147
1147
|
// 1.19.358 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1148
1148
|
// landed since 1.19.357 with no explicit bump. run 32872551850. (task 1002620)
|
|
1149
|
+
// 1.19.359 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1150
|
+
// landed since 1.19.358 with no explicit bump. run 32877754251. (task 1002620)
|
|
1151
|
+
// 1.19.360 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1152
|
+
// landed since 1.19.359 with no explicit bump. run 32881040669. (task 1002620)
|
|
1149
1153
|
// ---------------------------------------------------------------------------
|
|
1150
|
-
const CORE_VERSION = '1.19.
|
|
1154
|
+
const CORE_VERSION = '1.19.360'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1151
1155
|
|
|
1152
1156
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1153
1157
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|