@bongos/core 1.19.811 → 1.19.813
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 +108 -43
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +24 -0
- package/clients/bongos-client/index.cjs +24 -0
- package/clients/bongos-client/index.d.ts +39 -0
- package/clients/bongos-client/index.mjs +24 -0
- package/docs/adr/0296-a-role-speciality-is-data-over-three-existing-surfaces.md +71 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +867 -73
- package/docs/api-reference.md +19 -3
- package/docs/copy-inventory.md +48 -20
- package/docs/copy-registry.json +275 -23
- package/docs/module-api-changelog.md +4 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +24 -0
- package/modules/hall-ui/public/settings-specialities.js +241 -0
- package/modules/hall-ui/public/settings.css +56 -0
- package/modules/hall-ui/public/settings.html +65 -0
- package/modules/lifecycle/db-claim-reads.js +5 -1
- package/modules/lifecycle/db-executor.js +45 -0
- package/modules/lifecycle/db-goals.js +29 -7
- package/modules/lifecycle/db-tasks.js +11 -5
- package/modules/lifecycle/db-versions.js +17 -3
- package/modules/lifecycle/done-when.js +5 -2
- package/modules/lifecycle/goal-edits.js +5 -1
- package/modules/lifecycle/routes/done-when.js +150 -86
- package/modules/memory/learnings.js +21 -2
- package/modules/provisioning/starter-bundles.js +1 -0
- package/modules/specialities/CLAUDE.md +15 -0
- package/modules/specialities/db.js +234 -0
- package/modules/specialities/migrations/specialities_001_specialities.sql +143 -0
- package/modules/specialities/module.json +12 -0
- package/modules/specialities/routes/specialities.js +379 -0
- package/modules/specialities/specialities.js +198 -0
- package/modules/ui-design/kit/serve.js +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/publish-manifest.js +1 -0
- package/scripts/gds/search-ingest-db.js +37 -0
- package/src/bongos/routes/me.js +17 -0
- package/src/module-api.js +1 -1
- package/tests/criterion_authoring_gate.mjs +22 -1
- package/tests/criterion_goal_attach.mjs +233 -8
- package/tests/module_loader.mjs +1 -1
- package/tests/own_scope_guard.mjs +10 -0
- package/tests/specialities.mjs +214 -0
- package/tests/speciality_corpus_scope.mjs +97 -0
- package/tests/speciality_db_shape.mjs +124 -0
- package/tests/speciality_injection.mjs +107 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
-- specialities_001_specialities.sql — role sub-specialities: a named bundle of
|
|
2
|
+
-- context, skills and an interaction contract that sits UNDER a craft
|
|
3
|
+
-- (task 1003855, goal 1000095; ADR 0296).
|
|
4
|
+
--
|
|
5
|
+
-- WHAT A SPECIALITY IS, AND WHY IT IS DATA RATHER THAN CODE. The owner's framing
|
|
6
|
+
-- is that specialities "are more or less MODULES" — but a builder authors one,
|
|
7
|
+
-- so it cannot be an ADR 0083 code module without making every subject-matter
|
|
8
|
+
-- expert ship a package. A speciality is therefore a ROW that layers over the
|
|
9
|
+
-- three surfaces the platform already has: discipline-modes.json (craft ->
|
|
10
|
+
-- playbook), behavior_prefs.interaction (the per-session interaction contract),
|
|
11
|
+
-- and the ADR 0083 manifest (declared skills). The only genuinely new part is
|
|
12
|
+
-- the knowledge bundle, which is why it gets its own table below.
|
|
13
|
+
--
|
|
14
|
+
-- OWNER RULING, 2026-09-17, recorded on the task. Training is TWO-FOLD: the
|
|
15
|
+
-- builder supplies knowledge or documentation they created (specialities_documents)
|
|
16
|
+
-- AND the speciality sharpens from session learning (specialities_learnings).
|
|
17
|
+
-- Specialities are per-builder AND per-project. Each carries a sharing decision
|
|
18
|
+
-- made by its owner, and an explicit "may be offered for sale" flag.
|
|
19
|
+
--
|
|
20
|
+
-- WHY `sellable` EXISTS WITH NO MARKETPLACE BEHIND IT. ADR 0107 records that
|
|
21
|
+
-- there is no catalog or registry and explicitly rejected building one as
|
|
22
|
+
-- premature; a marketplace is a deferred fork. The flag captures the owner's
|
|
23
|
+
-- decision AT THE MOMENT THEY MAKE IT so the deferred marketplace reads a field
|
|
24
|
+
-- that already has honest values in it, instead of asking every existing
|
|
25
|
+
-- speciality owner to answer retroactively. It grants nothing and lists nowhere.
|
|
26
|
+
|
|
27
|
+
-- ── the speciality itself ───────────────────────────────────────────────────
|
|
28
|
+
CREATE TABLE IF NOT EXISTS specialities_catalog (
|
|
29
|
+
id BIGSERIAL PRIMARY KEY,
|
|
30
|
+
slug TEXT NOT NULL,
|
|
31
|
+
name TEXT NOT NULL,
|
|
32
|
+
-- The craft this sits under. NOT a foreign key: disciplines come from
|
|
33
|
+
-- discipline-modes.json plus module contributions (enabledDisciplines()), so
|
|
34
|
+
-- the legal set is computed at runtime and a DB constraint would freeze it.
|
|
35
|
+
discipline TEXT NOT NULL,
|
|
36
|
+
summary TEXT NOT NULL DEFAULT '',
|
|
37
|
+
-- The fragment injected into a session, exactly like behavior_prefs.interaction.
|
|
38
|
+
-- Kept as prose, not structured knobs: a speciality is an expert describing how
|
|
39
|
+
-- they want to be worked with, which is not enumerable up front.
|
|
40
|
+
contract_md TEXT NOT NULL DEFAULT '',
|
|
41
|
+
|
|
42
|
+
-- OWNERSHIP. Exactly one of builder/project owns a speciality (ruling 2).
|
|
43
|
+
owner_kind TEXT NOT NULL CHECK (owner_kind IN ('builder', 'project')),
|
|
44
|
+
owner_builder_id BIGINT REFERENCES builders(id) ON DELETE CASCADE,
|
|
45
|
+
owner_project_id BIGINT,
|
|
46
|
+
|
|
47
|
+
-- SHARING (ruling 3): the owner's decision, never a platform default.
|
|
48
|
+
-- private → only the owner sees it
|
|
49
|
+
-- project → any member of the owning project may adopt it
|
|
50
|
+
-- public → any builder on this instance may adopt it
|
|
51
|
+
visibility TEXT NOT NULL DEFAULT 'private'
|
|
52
|
+
CHECK (visibility IN ('private', 'project', 'public')),
|
|
53
|
+
-- SALE (ruling 4): records the owner's intent. See the header — no storefront.
|
|
54
|
+
sellable BOOLEAN NOT NULL DEFAULT FALSE,
|
|
55
|
+
|
|
56
|
+
created_by BIGINT REFERENCES builders(id) ON DELETE SET NULL,
|
|
57
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
58
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
59
|
+
|
|
60
|
+
-- The owner columns must agree with owner_kind, or a speciality could be owned
|
|
61
|
+
-- by nobody (invisible, unadoptable) or by both (whose sharing rule wins?).
|
|
62
|
+
CONSTRAINT specialities_catalog_owner_exactly_one CHECK (
|
|
63
|
+
(owner_kind = 'builder' AND owner_builder_id IS NOT NULL AND owner_project_id IS NULL) OR
|
|
64
|
+
(owner_kind = 'project' AND owner_project_id IS NOT NULL AND owner_builder_id IS NULL)
|
|
65
|
+
),
|
|
66
|
+
-- A speciality may only be offered for sale if someone other than its owner
|
|
67
|
+
-- could ever see it. Enforced here rather than in the route because it is a
|
|
68
|
+
-- property of the row, and a private-but-sellable row is simply incoherent.
|
|
69
|
+
CONSTRAINT specialities_catalog_sellable_needs_visibility CHECK (
|
|
70
|
+
sellable = FALSE OR visibility <> 'private'
|
|
71
|
+
)
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
-- Slug is unique PER OWNER, not globally: two experts may both have an
|
|
75
|
+
-- "economics" speciality, and a global unique would make the second one's
|
|
76
|
+
-- authoring fail with a collision against a row they cannot even see.
|
|
77
|
+
CREATE UNIQUE INDEX IF NOT EXISTS specialities_catalog_builder_slug_key
|
|
78
|
+
ON specialities_catalog (owner_builder_id, slug) WHERE owner_builder_id IS NOT NULL;
|
|
79
|
+
CREATE UNIQUE INDEX IF NOT EXISTS specialities_catalog_project_slug_key
|
|
80
|
+
ON specialities_catalog (owner_project_id, slug) WHERE owner_project_id IS NOT NULL;
|
|
81
|
+
CREATE INDEX IF NOT EXISTS specialities_catalog_discipline_idx ON specialities_catalog (discipline);
|
|
82
|
+
-- The adoptable-catalogue read: "what may I adopt", which filters on visibility.
|
|
83
|
+
CREATE INDEX IF NOT EXISTS specialities_catalog_visibility_idx ON specialities_catalog (visibility)
|
|
84
|
+
WHERE visibility <> 'private';
|
|
85
|
+
|
|
86
|
+
-- ── training half (a): knowledge the builder supplied ───────────────────────
|
|
87
|
+
-- Ruling 1a, and the part the original task did not consider at all. A document
|
|
88
|
+
-- is the expert's own material; the speciality learns the subject from it.
|
|
89
|
+
CREATE TABLE IF NOT EXISTS specialities_documents (
|
|
90
|
+
id BIGSERIAL PRIMARY KEY,
|
|
91
|
+
speciality_id BIGINT NOT NULL REFERENCES specialities_catalog(id) ON DELETE CASCADE,
|
|
92
|
+
title TEXT NOT NULL,
|
|
93
|
+
-- Content is stored inline. These are documents a person wrote or pasted, not
|
|
94
|
+
-- uploads of arbitrary size; keeping them in-row means the search chunker and
|
|
95
|
+
-- every read go through one place with no blob store to provision.
|
|
96
|
+
body_md TEXT NOT NULL,
|
|
97
|
+
source TEXT NOT NULL DEFAULT 'paste',
|
|
98
|
+
content_sha TEXT NOT NULL,
|
|
99
|
+
added_by BIGINT REFERENCES builders(id) ON DELETE SET NULL,
|
|
100
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
101
|
+
);
|
|
102
|
+
-- Re-adding the same document is idempotent rather than a silent duplicate that
|
|
103
|
+
-- would double its weight in the corpus.
|
|
104
|
+
CREATE UNIQUE INDEX IF NOT EXISTS specialities_documents_sha_key
|
|
105
|
+
ON specialities_documents (speciality_id, content_sha);
|
|
106
|
+
|
|
107
|
+
-- ── training half (b): what the speciality learned from sessions ────────────
|
|
108
|
+
-- Ruling 1b. This does NOT grow a parallel memory: a row points at an existing
|
|
109
|
+
-- `learnings` row and says "this one belongs to this speciality", so capture,
|
|
110
|
+
-- search and retention keep working exactly as they do today.
|
|
111
|
+
CREATE TABLE IF NOT EXISTS specialities_learnings (
|
|
112
|
+
speciality_id BIGINT NOT NULL REFERENCES specialities_catalog(id) ON DELETE CASCADE,
|
|
113
|
+
learning_id BIGINT NOT NULL,
|
|
114
|
+
captured_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
115
|
+
PRIMARY KEY (speciality_id, learning_id)
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
-- ── adoption: which speciality a builder is actually working in ─────────────
|
|
119
|
+
CREATE TABLE IF NOT EXISTS specialities_adoptions (
|
|
120
|
+
builder_id BIGINT NOT NULL REFERENCES builders(id) ON DELETE CASCADE,
|
|
121
|
+
speciality_id BIGINT NOT NULL REFERENCES specialities_catalog(id) ON DELETE CASCADE,
|
|
122
|
+
-- THE CRAFT IS DENORMALISED ONTO THIS ROW ON PURPOSE. The rule is "at most one
|
|
123
|
+
-- ACTIVE speciality per builder per craft" — two contracts under one craft
|
|
124
|
+
-- would both be injected and could contradict each other, with nothing in the
|
|
125
|
+
-- session able to arbitrate. A partial unique index is the only way to enforce
|
|
126
|
+
-- that at the point of insert, and an index cannot reach through the FK to
|
|
127
|
+
-- specialities.discipline. So the craft is copied here and the unique index
|
|
128
|
+
-- below makes the rule real rather than merely documented.
|
|
129
|
+
--
|
|
130
|
+
-- Safe to copy because a speciality's discipline is IMMUTABLE: the update path
|
|
131
|
+
-- rejects a discipline change (moving a speciality between crafts would
|
|
132
|
+
-- silently re-scope everyone who adopted it). Pinned by a test.
|
|
133
|
+
discipline TEXT NOT NULL,
|
|
134
|
+
active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
135
|
+
adopted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
136
|
+
PRIMARY KEY (builder_id, speciality_id)
|
|
137
|
+
);
|
|
138
|
+
-- The rule the comment above claims. Deactivating is how you switch, so an
|
|
139
|
+
-- inactive row never blocks adopting a different speciality in the same craft.
|
|
140
|
+
CREATE UNIQUE INDEX IF NOT EXISTS specialities_adoptions_one_active_per_craft
|
|
141
|
+
ON specialities_adoptions (builder_id, discipline) WHERE active;
|
|
142
|
+
CREATE INDEX IF NOT EXISTS specialities_adoptions_builder_idx
|
|
143
|
+
ON specialities_adoptions (builder_id) WHERE active;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"key": "specialities",
|
|
3
|
+
"title": "Role sub-specialities (harnesses)",
|
|
4
|
+
"description": "Role SUB-SPECIALITIES — a named bundle of knowledge, skills and an interaction contract that sits UNDER a craft (task 1003855; ADR 0296). A craft says how you work (engineer / ideator / artist / ui); a speciality says what you work ON and how a subject-matter expert wants to be engaged inside it. Deliberately a thin layer over three surfaces that already exist — discipline-modes.json for craft->playbook, behavior_prefs.interaction for the per-session contract (a speciality contract rides that SAME GET /me -> Conductor path rather than adding a second one), and the ADR 0083 manifest for declared skills; the only new machinery is the knowledge bundle. Training is two-fold per the owner's 2026-09-17 ruling: documents the builder supplied, plus learnings captured from sessions worked in the speciality (pointing at existing `learnings` rows, never a second corpus). Owned per-builder OR per-project, each carrying its owner's sharing decision (private / project / public) and an explicit sellable flag — the flag records intent only, because ADR 0107 defers the marketplace and there is nothing to list on. default-on: a speciality is how an instance's expertise enters the platform, so it ships like the hall and the status page.",
|
|
5
|
+
"coreVersion": "^1.19.0",
|
|
6
|
+
"default": true,
|
|
7
|
+
"maintenance": { "status": "core-maintained" },
|
|
8
|
+
"migrations": true,
|
|
9
|
+
"contributes": { "routes": ["specialities"] },
|
|
10
|
+
"provides": ["specialities"],
|
|
11
|
+
"consumes": []
|
|
12
|
+
}
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
// modules/specialities/routes/specialities.js — the HTTP surface for role
|
|
2
|
+
// sub-specialities (task 1003855; ADR 0296).
|
|
3
|
+
//
|
|
4
|
+
// SHAPE. Authoring and adoption are open to any builder: a speciality is how a
|
|
5
|
+
// subject-matter expert's knowledge enters the platform, and gating that behind a
|
|
6
|
+
// rank would mean only trusted builders could be experts. What IS gated is
|
|
7
|
+
// touching someone else's — every write below re-reads the row and checks
|
|
8
|
+
// ownership server-side rather than trusting an id from the client.
|
|
9
|
+
//
|
|
10
|
+
// THE RULES ARE NOT IN THIS FILE. Visibility, validation and the injected
|
|
11
|
+
// contract live in ../specialities.js as pure functions, so they are unit-tested
|
|
12
|
+
// without a database and cannot drift between the route and the session path.
|
|
13
|
+
// A widening here that does not go through canAdopt is a bug.
|
|
14
|
+
//
|
|
15
|
+
// MODULE BOUNDARY: everything core arrives through the published doorway
|
|
16
|
+
// (src/module-api.js); `../db` and `../specialities` are intra-module siblings.
|
|
17
|
+
// Project membership belongs to platform-identity, which exposes no port for it,
|
|
18
|
+
// so it is resolved through an OPTIONAL seam — present on a hub, absent
|
|
19
|
+
// elsewhere, and absent means "no projects", never "all projects".
|
|
20
|
+
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
const express = require('express');
|
|
24
|
+
const crypto = require('node:crypto');
|
|
25
|
+
const api = require('../../../src/module-api');
|
|
26
|
+
const S = require('../specialities');
|
|
27
|
+
const db = require('../db');
|
|
28
|
+
|
|
29
|
+
const { validateOrRespond, parseId, asyncHandler } = api;
|
|
30
|
+
|
|
31
|
+
// Resolve the viewer's projects WITHOUT reaching into another module's tables.
|
|
32
|
+
// resolveOptional returns undefined when nothing provides the port, which is the
|
|
33
|
+
// common case (a non-hub instance), and the safe answer there is "no projects".
|
|
34
|
+
async function viewerProjectIds(builder) {
|
|
35
|
+
try {
|
|
36
|
+
const port = api.resolveOptional && api.resolveOptional('project.membership');
|
|
37
|
+
if (!port || typeof port.projectIdsForBuilder !== 'function') return [];
|
|
38
|
+
const ids = await port.projectIdsForBuilder(builder.id);
|
|
39
|
+
return Array.isArray(ids) ? ids.map(String) : [];
|
|
40
|
+
} catch {
|
|
41
|
+
// A membership lookup that throws must not take the catalogue down with it —
|
|
42
|
+
// it degrades to "your own + public", which is a smaller answer, never a wider one.
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const sha256 = (s) => crypto.createHash('sha256').update(String(s), 'utf8').digest('hex');
|
|
48
|
+
|
|
49
|
+
// "Can this builder see this row at all?" — the same rule the catalogue uses,
|
|
50
|
+
// reused so a 403 never leaks the existence of something a 404 would have hidden.
|
|
51
|
+
async function visibleTo(speciality, builder) {
|
|
52
|
+
if (owns(speciality, builder)) return true;
|
|
53
|
+
const projectIds = await viewerProjectIds(builder);
|
|
54
|
+
return S.canAdopt(speciality, { builderId: builder.id, projectIds });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function owns(speciality, builder) {
|
|
58
|
+
return speciality.owner_kind === 'builder'
|
|
59
|
+
&& String(speciality.owner_builder_id) === String(builder.id);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
// THE KERNEL PORT. GET /me resolves 'specialities' and calls describeActiveFor;
|
|
64
|
+
// that is the ENTIRE core-facing surface, and it is deliberately one read. The
|
|
65
|
+
// core must keep working with this module off, so the caller treats an absent
|
|
66
|
+
// port as "no specialities" rather than an error (src/bongos/routes/me.js).
|
|
67
|
+
//
|
|
68
|
+
// Registered here rather than in a module index because the router factory is the
|
|
69
|
+
// one thing the loader is guaranteed to call for an enabled module
|
|
70
|
+
// (mountModuleRoutes), which is the convention builder-settings and economy use.
|
|
71
|
+
const port = {
|
|
72
|
+
// Every ACTIVE speciality this builder has, one per craft at most, each with
|
|
73
|
+
// its own injectable contract. Craft-scoped selection happens in the Conductor,
|
|
74
|
+
// which knows the craft of the claim in hand; this just reports them all.
|
|
75
|
+
async describeActiveFor(builderId) {
|
|
76
|
+
const rows = await db.listAdoptions(builderId);
|
|
77
|
+
const out = [];
|
|
78
|
+
for (const row of rows) {
|
|
79
|
+
if (!row.active) continue;
|
|
80
|
+
const training = await db.countTraining(row.id);
|
|
81
|
+
// Self-authored is the common case and stays unfenced; anything adopted from
|
|
82
|
+
// another builder or from a project carries its provenance into the session.
|
|
83
|
+
const full = await db.getSpeciality(row.id);
|
|
84
|
+
const selfAuthored = !!full && full.owner_kind === 'builder'
|
|
85
|
+
&& String(full.owner_builder_id) === String(builderId);
|
|
86
|
+
const described = S.describeForApi(row, { ...training, selfAuthored });
|
|
87
|
+
if (described) out.push({ ...described, discipline: row.discipline, name: row.name });
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
// Training half (b): attach a learning captured in a session to the speciality
|
|
93
|
+
// it was learned in. Idempotent — learning-capture may retry.
|
|
94
|
+
async recordLearning(specialityId, learningId) {
|
|
95
|
+
return db.linkLearning(specialityId, learningId);
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
// The automatic half of (b), called by the memory module after a capture.
|
|
99
|
+
// Resolves the craft from the task, then the builder's ACTIVE speciality in
|
|
100
|
+
// that craft — so a learning lands in the speciality it was actually learned
|
|
101
|
+
// in, and in nothing when the builder was not working in one.
|
|
102
|
+
//
|
|
103
|
+
// Returns false rather than throwing on every miss: a capture must never fail
|
|
104
|
+
// because the speciality bookkeeping could not be done.
|
|
105
|
+
async recordLearningForTask({ learningId, builderId, taskId }) {
|
|
106
|
+
try {
|
|
107
|
+
if (!learningId || !builderId || !taskId) return false;
|
|
108
|
+
const discipline = await db.taskDiscipline(taskId);
|
|
109
|
+
if (!discipline) return false;
|
|
110
|
+
const active = await db.activeForCraft(builderId, discipline);
|
|
111
|
+
if (!active) return false;
|
|
112
|
+
return await db.linkLearning(active.id, learningId);
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
// Used by the settings surface and by anything that wants the counts without
|
|
119
|
+
// pulling the corpus itself.
|
|
120
|
+
async trainingCounts(specialityId) {
|
|
121
|
+
return db.countTraining(specialityId);
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
module.exports = function buildSpecialitiesRouter() {
|
|
126
|
+
const router = express.Router();
|
|
127
|
+
|
|
128
|
+
// hasProvider-GUARDED, matching builder-settings/routes/settings.js: the factory
|
|
129
|
+
// runs both live AND every time a test rebuilds the router, and a port is
|
|
130
|
+
// single-provider — an unguarded register throws on the second build.
|
|
131
|
+
if (!api.hasProvider('specialities')) api.registerProvider('specialities', port);
|
|
132
|
+
|
|
133
|
+
// GET /specialities — the catalogue this builder may see. `?discipline=` narrows
|
|
134
|
+
// it to one craft, which is what the settings surface asks for.
|
|
135
|
+
router.get('/specialities', api.requireBuilder, asyncHandler('GET /specialities', async (req, res) => {
|
|
136
|
+
const builder = req.builder;
|
|
137
|
+
const projectIds = await viewerProjectIds(builder);
|
|
138
|
+
const rows = await db.listVisibleTo({
|
|
139
|
+
builderId: builder.id,
|
|
140
|
+
projectIds,
|
|
141
|
+
discipline: req.query.discipline || null,
|
|
142
|
+
});
|
|
143
|
+
// The SQL is the cheap filter; canAdopt is the ACTUAL rule. Running both means
|
|
144
|
+
// a loosened query cannot silently widen who may adopt what.
|
|
145
|
+
const viewer = { builderId: builder.id, projectIds };
|
|
146
|
+
res.json({
|
|
147
|
+
specialities: rows.map((s) => ({ ...s, adoptable: S.canAdopt(s, viewer), mine: owns(s, builder) })),
|
|
148
|
+
});
|
|
149
|
+
}));
|
|
150
|
+
|
|
151
|
+
// POST /specialities — author one for YOURSELF. The owner is req.builder and is
|
|
152
|
+
// never read from the body: a route whose path names no subject must act on the
|
|
153
|
+
// caller (tests/own_scope_guard.mjs). A PROJECT speciality names its project in
|
|
154
|
+
// the path instead — see POST /projects/:projectId/specialities below.
|
|
155
|
+
router.post('/specialities', api.requireBuilder, asyncHandler('POST /specialities', async (req, res) => {
|
|
156
|
+
if (validateOrRespond(req, res, {
|
|
157
|
+
name: { required: true, type: 'string', maxLength: S.LIMITS.NAME, minLength: 1 },
|
|
158
|
+
discipline: { required: true, type: 'string', maxLength: 64 },
|
|
159
|
+
summary: { type: 'string', maxLength: S.LIMITS.SUMMARY },
|
|
160
|
+
contract_md: { type: 'string', maxLength: S.LIMITS.CONTRACT },
|
|
161
|
+
visibility: { type: 'string', maxLength: 16 },
|
|
162
|
+
sellable: { type: 'boolean' },
|
|
163
|
+
})) return;
|
|
164
|
+
|
|
165
|
+
const b = req.body || {};
|
|
166
|
+
const builder = req.builder;
|
|
167
|
+
const ownerKind = 'builder';
|
|
168
|
+
|
|
169
|
+
const draft = {
|
|
170
|
+
name: String(b.name).trim(),
|
|
171
|
+
discipline: b.discipline,
|
|
172
|
+
summary: b.summary || '',
|
|
173
|
+
contract_md: b.contract_md || '',
|
|
174
|
+
visibility: b.visibility || 'private',
|
|
175
|
+
sellable: !!b.sellable,
|
|
176
|
+
owner_kind: ownerKind,
|
|
177
|
+
owner_builder_id: builder.id,
|
|
178
|
+
owner_project_id: null,
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const check = S.validateDraft(draft, { disciplines: api.enabledDisciplines() });
|
|
182
|
+
if (!check.ok) return res.fail('validation_failed', { status: 400, details: { errors: check.errors } });
|
|
183
|
+
|
|
184
|
+
draft.slug = S.slugify(draft.name);
|
|
185
|
+
draft.created_by = builder.id;
|
|
186
|
+
try {
|
|
187
|
+
const created = await db.createSpeciality(draft);
|
|
188
|
+
res.status(201).json({ speciality: created });
|
|
189
|
+
} catch (err) {
|
|
190
|
+
if (err && err.code === '23505') {
|
|
191
|
+
return res.fail('duplicate_slug', { status: 409, message: `You already have a speciality called "${draft.name}".` });
|
|
192
|
+
}
|
|
193
|
+
throw err;
|
|
194
|
+
}
|
|
195
|
+
}));
|
|
196
|
+
|
|
197
|
+
// POST /projects/:projectId/specialities — author one OWNED BY A PROJECT.
|
|
198
|
+
// The target is in the PATH, not the body, which is what makes it auditable:
|
|
199
|
+
// the gate below is membership of that same project, checked server-side.
|
|
200
|
+
router.post('/projects/:projectId/specialities', api.requireBuilder, asyncHandler('POST /projects/:projectId/specialities', async (req, res) => {
|
|
201
|
+
if (validateOrRespond(req, res, {
|
|
202
|
+
name: { required: true, type: 'string', maxLength: S.LIMITS.NAME, minLength: 1 },
|
|
203
|
+
discipline: { required: true, type: 'string', maxLength: 64 },
|
|
204
|
+
summary: { type: 'string', maxLength: S.LIMITS.SUMMARY },
|
|
205
|
+
contract_md: { type: 'string', maxLength: S.LIMITS.CONTRACT },
|
|
206
|
+
visibility: { type: 'string', maxLength: 16 },
|
|
207
|
+
sellable: { type: 'boolean' },
|
|
208
|
+
})) return;
|
|
209
|
+
|
|
210
|
+
const projectId = String(req.params.projectId);
|
|
211
|
+
const projectIds = await viewerProjectIds(req.builder);
|
|
212
|
+
if (!projectIds.includes(projectId)) {
|
|
213
|
+
// Also the answer when no membership port is registered at all, which is
|
|
214
|
+
// the non-hub case: absent means NO projects, never all of them.
|
|
215
|
+
return res.fail('not_a_project_member', {
|
|
216
|
+
status: 403,
|
|
217
|
+
message: 'A project speciality can only be authored by a member of that project.',
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const b = req.body || {};
|
|
222
|
+
const draft = {
|
|
223
|
+
name: String(b.name).trim(),
|
|
224
|
+
discipline: b.discipline,
|
|
225
|
+
summary: b.summary || '',
|
|
226
|
+
contract_md: b.contract_md || '',
|
|
227
|
+
visibility: b.visibility || 'private',
|
|
228
|
+
sellable: !!b.sellable,
|
|
229
|
+
owner_kind: 'project',
|
|
230
|
+
owner_builder_id: null,
|
|
231
|
+
owner_project_id: projectId,
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const check = S.validateDraft(draft, { disciplines: api.enabledDisciplines() });
|
|
235
|
+
if (!check.ok) return res.fail('validation_failed', { status: 400, details: { errors: check.errors } });
|
|
236
|
+
|
|
237
|
+
draft.slug = S.slugify(draft.name);
|
|
238
|
+
draft.created_by = req.builder.id;
|
|
239
|
+
try {
|
|
240
|
+
res.status(201).json({ speciality: await db.createSpeciality(draft) });
|
|
241
|
+
} catch (err) {
|
|
242
|
+
if (err && err.code === '23505') {
|
|
243
|
+
return res.fail('duplicate_slug', { status: 409, message: `That project already has a speciality called "${draft.name}".` });
|
|
244
|
+
}
|
|
245
|
+
throw err;
|
|
246
|
+
}
|
|
247
|
+
}));
|
|
248
|
+
|
|
249
|
+
router.get('/specialities/:id', api.requireBuilder, asyncHandler('GET /specialities/:id', async (req, res) => {
|
|
250
|
+
const id = parseId(req, res, { code: 'bad_id' });
|
|
251
|
+
if (id === null) return;
|
|
252
|
+
const s = await db.getSpeciality(id);
|
|
253
|
+
if (!s) return res.fail('not_found', 404);
|
|
254
|
+
const projectIds = await viewerProjectIds(req.builder);
|
|
255
|
+
const viewer = { builderId: req.builder.id, projectIds };
|
|
256
|
+
// A row you may neither adopt nor own is not yours to read: answering 404
|
|
257
|
+
// rather than 403 avoids confirming that a private speciality exists.
|
|
258
|
+
if (!(await visibleTo(s, req.builder))) return res.fail('not_found', 404);
|
|
259
|
+
const training = await db.countTraining(id);
|
|
260
|
+
res.json({ speciality: { ...s, ...training, adoptable: S.canAdopt(s, viewer), mine: owns(s, req.builder) } });
|
|
261
|
+
}));
|
|
262
|
+
|
|
263
|
+
// PATCH — owner only. `discipline` is absent from db.UPDATABLE on purpose: see
|
|
264
|
+
// the note there, and the adoption rows that carry a denormalised copy.
|
|
265
|
+
router.patch('/specialities/:id', api.requireBuilder, asyncHandler('PATCH /specialities/:id', async (req, res) => {
|
|
266
|
+
const id = parseId(req, res, { code: 'bad_id' });
|
|
267
|
+
if (id === null) return;
|
|
268
|
+
if (validateOrRespond(req, res, {
|
|
269
|
+
name: { type: 'string', maxLength: S.LIMITS.NAME, minLength: 1 },
|
|
270
|
+
summary: { type: 'string', maxLength: S.LIMITS.SUMMARY },
|
|
271
|
+
contract_md: { type: 'string', maxLength: S.LIMITS.CONTRACT },
|
|
272
|
+
visibility: { type: 'string', maxLength: 16 },
|
|
273
|
+
sellable: { type: 'boolean' },
|
|
274
|
+
})) return;
|
|
275
|
+
|
|
276
|
+
const s = await db.getSpeciality(id);
|
|
277
|
+
// 404, not 403, for a speciality this viewer cannot even see — a 403 here
|
|
278
|
+
// would confirm that a private speciality with this id exists, which is the
|
|
279
|
+
// disclosure GET /specialities/:id already refuses to make. A viewer who CAN
|
|
280
|
+
// see it but does not own it still gets a truthful 403.
|
|
281
|
+
if (!s || !(await visibleTo(s, req.builder))) return res.fail('not_found', 404);
|
|
282
|
+
if (!owns(s, req.builder)) return res.fail('not_yours', { status: 403, message: 'Only the owner may edit a speciality.' });
|
|
283
|
+
if (req.body && req.body.discipline !== undefined) {
|
|
284
|
+
return res.fail('discipline_immutable', {
|
|
285
|
+
status: 400,
|
|
286
|
+
message: 'A speciality cannot change craft — everyone who adopted it would be silently re-scoped. Author a new one.',
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const merged = { ...s, ...req.body };
|
|
291
|
+
const check = S.validateDraft(merged, { disciplines: api.enabledDisciplines() });
|
|
292
|
+
if (!check.ok) return res.fail('validation_failed', { status: 400, details: { errors: check.errors } });
|
|
293
|
+
|
|
294
|
+
res.json({ speciality: await db.updateSpeciality(id, req.body || {}) });
|
|
295
|
+
}));
|
|
296
|
+
|
|
297
|
+
// ── training (a): the owner's own documents ───────────────────────────────
|
|
298
|
+
router.post('/specialities/:id/documents', api.requireBuilder, asyncHandler('POST /specialities/:id/documents', async (req, res) => {
|
|
299
|
+
const id = parseId(req, res, { code: 'bad_id' });
|
|
300
|
+
if (id === null) return;
|
|
301
|
+
if (validateOrRespond(req, res, {
|
|
302
|
+
title: { required: true, type: 'string', maxLength: S.LIMITS.DOC_TITLE, minLength: 1 },
|
|
303
|
+
body_md: { required: true, type: 'string', maxLength: S.LIMITS.DOC_BODY, minLength: 1 },
|
|
304
|
+
source: { type: 'string', maxLength: 64 },
|
|
305
|
+
})) return;
|
|
306
|
+
|
|
307
|
+
const s = await db.getSpeciality(id);
|
|
308
|
+
if (!s || !(await visibleTo(s, req.builder))) return res.fail('not_found', 404);
|
|
309
|
+
if (!owns(s, req.builder)) return res.fail('not_yours', { status: 403, message: 'Only the owner may train a speciality.' });
|
|
310
|
+
|
|
311
|
+
// COUNT(*), not a fetch: reading the cap by pulling up to 200 metadata rows
|
|
312
|
+
// and taking .length is work the database can do in one number.
|
|
313
|
+
const { documentCount } = await db.countTraining(id);
|
|
314
|
+
if (documentCount >= S.LIMITS.DOCS_PER_SPECIALITY) {
|
|
315
|
+
return res.fail('too_many_documents', { status: 409, details: { max: S.LIMITS.DOCS_PER_SPECIALITY } });
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const doc = await db.addDocument({
|
|
319
|
+
speciality_id: id,
|
|
320
|
+
title: String(req.body.title).trim(),
|
|
321
|
+
body_md: req.body.body_md,
|
|
322
|
+
source: req.body.source || 'paste',
|
|
323
|
+
content_sha: sha256(req.body.body_md),
|
|
324
|
+
added_by: req.builder.id,
|
|
325
|
+
});
|
|
326
|
+
// addDocument returns null on ON CONFLICT DO NOTHING — the same document is
|
|
327
|
+
// already trained in, so say so rather than claiming a create.
|
|
328
|
+
if (!doc) return res.status(200).json({ skipped: true, reason: 'already_trained' });
|
|
329
|
+
res.status(201).json({ document: doc });
|
|
330
|
+
}));
|
|
331
|
+
|
|
332
|
+
router.get('/specialities/:id/documents', api.requireBuilder, asyncHandler('GET /specialities/:id/documents', async (req, res) => {
|
|
333
|
+
const id = parseId(req, res, { code: 'bad_id' });
|
|
334
|
+
if (id === null) return;
|
|
335
|
+
const s = await db.getSpeciality(id);
|
|
336
|
+
if (!s) return res.fail('not_found', 404);
|
|
337
|
+
if (!(await visibleTo(s, req.builder))) return res.fail('not_found', 404);
|
|
338
|
+
res.json({ documents: await db.listDocuments(id) });
|
|
339
|
+
}));
|
|
340
|
+
|
|
341
|
+
router.delete('/specialities/:id/documents/:docId', api.requireBuilder, asyncHandler('DELETE /specialities/:id/documents/:docId', async (req, res) => {
|
|
342
|
+
const id = parseId(req, res, { code: 'bad_id' });
|
|
343
|
+
if (id === null) return;
|
|
344
|
+
const s = await db.getSpeciality(id);
|
|
345
|
+
if (!s || !(await visibleTo(s, req.builder))) return res.fail('not_found', 404);
|
|
346
|
+
if (!owns(s, req.builder)) return res.fail('not_yours', 403);
|
|
347
|
+
const gone = await db.deleteDocument(id, req.params.docId);
|
|
348
|
+
if (!gone) return res.fail('not_found', 404);
|
|
349
|
+
res.json({ ok: true });
|
|
350
|
+
}));
|
|
351
|
+
|
|
352
|
+
// ── adoption ──────────────────────────────────────────────────────────────
|
|
353
|
+
router.post('/specialities/:id/adopt', api.requireBuilder, asyncHandler('POST /specialities/:id/adopt', async (req, res) => {
|
|
354
|
+
const id = parseId(req, res, { code: 'bad_id' });
|
|
355
|
+
if (id === null) return;
|
|
356
|
+
const s = await db.getSpeciality(id);
|
|
357
|
+
// 404, not 403: a 403 here confirms that a speciality with this id exists,
|
|
358
|
+
// which is exactly the disclosure GET /specialities/:id refuses to make. The
|
|
359
|
+
// previous round moved PATCH and the document routes and missed this one.
|
|
360
|
+
if (!s || !(await visibleTo(s, req.builder))) return res.fail('not_found', 404);
|
|
361
|
+
// The craft is passed from the SPECIALITY, never from the client: it is what
|
|
362
|
+
// the one-active-per-craft unique index keys on.
|
|
363
|
+
const adoption = await db.adopt({ builderId: req.builder.id, specialityId: id, discipline: s.discipline });
|
|
364
|
+
res.json({ adoption });
|
|
365
|
+
}));
|
|
366
|
+
|
|
367
|
+
router.post('/specialities/:id/abandon', api.requireBuilder, asyncHandler('POST /specialities/:id/abandon', async (req, res) => {
|
|
368
|
+
const id = parseId(req, res, { code: 'bad_id' });
|
|
369
|
+
if (id === null) return;
|
|
370
|
+
const dropped = await db.abandon(req.builder.id, id);
|
|
371
|
+
res.json({ ok: true, was_active: dropped });
|
|
372
|
+
}));
|
|
373
|
+
|
|
374
|
+
router.get('/me/specialities', api.requireBuilder, asyncHandler('GET /me/specialities', async (req, res) => {
|
|
375
|
+
res.json({ adoptions: await db.listAdoptions(req.builder.id) });
|
|
376
|
+
}));
|
|
377
|
+
|
|
378
|
+
return router;
|
|
379
|
+
};
|