@bongos/core 1.19.564 → 1.19.565
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 +60 -30
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +4 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0253-the-takedown-lever-has-two-writers-and-provenance-is-a-column.md +144 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +91 -4
- package/docs/api-reference.md +3 -2
- package/docs/module-api-changelog.md +2 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/government/catalog.js +1 -1
- package/modules/platform-identity/migrations/platform_identity_020_visibility_pull.sql +106 -0
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/pollers/visibility-pull.js +99 -0
- package/modules/platform-identity/project-catalog.js +62 -4
- package/modules/platform-identity/project-stats.js +31 -0
- package/modules/platform-identity/projects-feed.js +17 -1
- package/modules/platform-identity/routes/projects.js +48 -1
- package/modules/platform-identity/visibility-pull.js +201 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/middleware/rate-limit.js +44 -2
- package/src/module-api.js +12 -1
- package/tests/module_api.mjs +1 -0
- package/tests/project_takedown_lever.mjs +177 -0
- package/tests/visibility_pull.mjs +213 -0
- package/tests/visible_projects_feed.mjs +12 -1
package/docs/api-reference.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# Bongos API reference
|
|
4
4
|
|
|
5
|
-
> **Generated from the live route files** — the route file is authoritative.
|
|
5
|
+
> **Generated from the live route files** — the route file is authoritative. 358 endpoints across 58 route files.
|
|
6
6
|
> Machine-readable spec: [`docs/api/openapi.json`](api/openapi.json) (OpenAPI 3.1). Rendered docs site: **`/docs`** (e.g. `cloudbongos.com/docs`).
|
|
7
7
|
|
|
8
8
|
Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>.md)): `public` < `any-builder` < `metic+archon` < `archon`.
|
|
@@ -449,13 +449,14 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
|
|
|
449
449
|
| GET | `/api/bongos/project-settings` | `metic+archon` | — | GET /project-settings — all five rows in ONE read. |
|
|
450
450
|
| PATCH | `/api/bongos/project-settings/rot-days` | `metic+archon` | `days` | PATCH /project-settings/rot-days — body { days }. |
|
|
451
451
|
|
|
452
|
-
## `projects` (
|
|
452
|
+
## `projects` (6)
|
|
453
453
|
|
|
454
454
|
| Method | Path | Rank | Body | Description |
|
|
455
455
|
|---|---|---|---|---|
|
|
456
456
|
| GET | `/api/bongos/projects` | `metic+archon` | — | the full curation list (includes unfeatured + owner attribution). |
|
|
457
457
|
| POST | `/api/bongos/projects` | `metic+archon` | `origin`, `name`, `tagline`, `description`, `art_url`, `type`, `template` | create / update a catalog entry (manual seed for hand-wired instances; federated projects auto-catalogue at registration). |
|
|
458
458
|
| PATCH | `/api/bongos/projects/:id/featured` | `metic+archon` | `featured` | the feature / unfeature control (the ADR 0141 §5 curation gate), BOUNDED BY THE PUBLISH GATE (ADR 0182 D3, task 1002335). |
|
|
459
|
+
| PATCH | `/api/bongos/projects/:id/status` | `metic+archon` | `status` | THE TAKEDOWN LEVER (R08, task 1002327; ADR 0252 §5.1). |
|
|
459
460
|
| GET | `/api/bongos/projects/featured` | `public` | — | the public map of projects; no auth by design. |
|
|
460
461
|
| POST | `/api/bongos/projects/invite` | `metic+archon` | `client_id`, `github_login` | own-or-metic in effect: invite a builder into a project (ADR 0141 §5, task #1002115), called by a `project.curate` holder OR the project'… |
|
|
461
462
|
|
|
@@ -1577,5 +1577,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1577
1577
|
landed since 1.19.562 with no explicit bump. run 34049959536. (task 1002620)
|
|
1578
1578
|
1.19.564 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1579
1579
|
landed since 1.19.563 with no explicit bump. run 34052578659. (task 1002620)
|
|
1580
|
+
1.19.565 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1581
|
+
landed since 1.19.564 with no explicit bump. run 34055981223. (task 1002620)
|
|
1580
1582
|
---------------------------------------------------------------------------
|
|
1581
1583
|
```
|
|
@@ -646,6 +646,8 @@ function createClient(opts = {}) {
|
|
|
646
646
|
postProjects: (args) => request("POST", "/projects", { hasBody: true }, args),
|
|
647
647
|
// PATCH /projects/{id}/featured — rank: metic+archon — PATCH /projects/:id/featured
|
|
648
648
|
patchProjectsIdFeatured: (args) => request("PATCH", "/projects/{id}/featured", { hasBody: true }, args),
|
|
649
|
+
// PATCH /projects/{id}/status — rank: metic+archon — PATCH /projects/:id/status
|
|
650
|
+
patchProjectsIdStatus: (args) => request("PATCH", "/projects/{id}/status", { hasBody: true }, args),
|
|
649
651
|
// GET /projects/featured — rank: public — GET /projects/featured
|
|
650
652
|
getProjectsFeatured: (args) => request("GET", "/projects/featured", { hasBody: false }, args),
|
|
651
653
|
// POST /projects/invite — rank: metic+archon — POST /projects/invite
|
|
@@ -202,7 +202,7 @@ const PERMISSIONS = [
|
|
|
202
202
|
// anyone in a room with a stranger on its own.
|
|
203
203
|
{ key: 'mingle.manage', system: false, floor: 'metic', guards: 'PUT /mingle/project (the project half of mingle consent), POST /mingle/round (run a pairing round) — task 1003177, goal 1000075' },
|
|
204
204
|
{ key: 'project.settings.manage', system: false, floor: 'metic', guards: "PATCH /project-settings/* — the project's own live knobs (the rot timer today) and the work-category set's shape (PATCH /work-categories/:id). Metic, matching goal.create's reasoning that extending the project's vocabulary is a planning act rather than an administrative one — task 1003280, goal 1000072" },
|
|
205
|
-
{ key: 'project.curate', system: false, floor: 'metic', guards: 'platform-identity: GET/POST /projects, /projects/invite, PATCH /projects/:id/featured, GET /scouting (ADR 0157, was archon)' },
|
|
205
|
+
{ key: 'project.curate', system: false, floor: 'metic', guards: 'platform-identity: GET/POST /projects, /projects/invite, PATCH /projects/:id/featured, PATCH /projects/:id/status (the takedown lever — ADR 0252 §5.1), GET /scouting (ADR 0157, was archon)' },
|
|
206
206
|
{ key: 'override_request.decide', system: false, floor: 'metic', guards: 'GET /override-requests, POST .../:id/decide (ADR 0157, was archon)' },
|
|
207
207
|
{ key: 'claim.release.any', system: false, floor: 'metic', scope: 'any', resource: 'claim', guards: 'POST /claims/:id/release-on-behalf (ADR 0157, was archon)' },
|
|
208
208
|
{ key: 'idea.ratify', system: false, floor: 'metic', guards: 'POST /inbox/:id/ratify (ADR 0157, was archon)' },
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
-- platform_identity_020_visibility_pull.sql — the three columns the self-hosted
|
|
2
|
+
-- visibility PULL needs (BV1.R08, task 1002327; ADR 0252, corrected by ADR 0253).
|
|
3
|
+
--
|
|
4
|
+
-- ADR 0252 §2 makes the hub READ a self-hosted instance's already-public manifest
|
|
5
|
+
-- rather than accept a pushed report. Two properties that decision needs cannot be
|
|
6
|
+
-- expressed by the columns migration 011 left behind, and this migration adds them.
|
|
7
|
+
--
|
|
8
|
+
-- 1. THE TRI-STATE HAS NOWHERE TO LIVE. `visibility` is `NOT NULL DEFAULT 'public'`,
|
|
9
|
+
-- so it cannot distinguish "reported public" from "never asked". ADR 0252 §4 is
|
|
10
|
+
-- explicit that `null` (never read) ≠ `false` (read, negative) and that a project
|
|
11
|
+
-- read before but unreachable now KEEPS its last state — a distinction the door
|
|
12
|
+
-- column physically cannot carry. `visibility_reported_at` is the null-carrier:
|
|
13
|
+
-- IS NULL means no successful read has ever happened, which is the ONLY condition
|
|
14
|
+
-- under which ADR 0182 D5's fail-closed default applies. A row that answered once
|
|
15
|
+
-- and is down today has a stamp, keeps its value, and stays on the map.
|
|
16
|
+
--
|
|
17
|
+
-- 2. "THE TWO NEVER WRITE THE SAME ROW" WAS A CONVENTION WITH NOTHING ENFORCING IT.
|
|
18
|
+
-- The task body requires the pull to touch self-hosted rows only, leaving
|
|
19
|
+
-- control-plane rows on the bridge's assigned value (ADR 0246). But the catalog
|
|
20
|
+
-- row carries no `hosting_shape` — that column lives on `provisioning_instances`,
|
|
21
|
+
-- in a module platform-identity may not import (ADR 0083's one-way rule), and
|
|
22
|
+
-- opening a seam just to ask would put a cross-module read on every sweep.
|
|
23
|
+
-- `visibility_source` records the provenance ON the row instead, stamped by
|
|
24
|
+
-- whichever writer touched it. The pull's predicate is then a local column read:
|
|
25
|
+
-- it may write a row the control plane has never claimed, and no other.
|
|
26
|
+
--
|
|
27
|
+
-- 3. `hidden` IS STICKY, AND AGAINST TWO WRITERS — not the one ADR 0252 §5.2 names.
|
|
28
|
+
-- The ADR defends a takedown against the pull. The pull is not the writer that
|
|
29
|
+
-- actually undoes takedowns today: `upsertOwnedProject` assigns `status =
|
|
30
|
+
-- EXCLUDED.status` unconditionally, and the ADR 0246 bridge re-takes that
|
|
31
|
+
-- projection on EVERY provisioning write, computing 'active' for any instance
|
|
32
|
+
-- that is not torn down. So an Archon hides a project, its owner saves any
|
|
33
|
+
-- setting, and it is back on the map — the lever is decorative for precisely the
|
|
34
|
+
-- reason ADR 0252 §5.2 gives, one writer over. `status_pinned` is the latch a
|
|
35
|
+
-- curator sets; both writers honour it. ADR 0253 records the correction.
|
|
36
|
+
--
|
|
37
|
+
-- Additive + `platform_identity_`-namespaced (ADR 0083 §5). Forward-safe: ADD COLUMN
|
|
38
|
+
-- only, so the previous code release still runs against this schema — a release that
|
|
39
|
+
-- has never heard of these columns keeps its current behaviour exactly, because an
|
|
40
|
+
-- unpinned row and a NULL source are what every existing row backfills to.
|
|
41
|
+
-- Idempotent (IF NOT EXISTS + the drop-then-add CHECK pattern from migration 006).
|
|
42
|
+
|
|
43
|
+
BEGIN;
|
|
44
|
+
|
|
45
|
+
-- 1. The tri-state's null. NOT backfilled to now(): every existing row genuinely has
|
|
46
|
+
-- never been pulled, and claiming otherwise would assert a reading that never
|
|
47
|
+
-- happened. Control-plane rows never acquire a stamp at all — they are not pulled,
|
|
48
|
+
-- and `visibility_source` is what says so.
|
|
49
|
+
ALTER TABLE platform_identity_projects
|
|
50
|
+
ADD COLUMN IF NOT EXISTS visibility_reported_at timestamptz;
|
|
51
|
+
|
|
52
|
+
-- 2. Which writer owns this row's door. NULL = neither has claimed it yet, which is
|
|
53
|
+
-- the state every pre-existing row starts in and the state the pull may write.
|
|
54
|
+
ALTER TABLE platform_identity_projects
|
|
55
|
+
ADD COLUMN IF NOT EXISTS visibility_source text;
|
|
56
|
+
|
|
57
|
+
ALTER TABLE platform_identity_projects DROP CONSTRAINT IF EXISTS platform_identity_projects_visibility_source_check;
|
|
58
|
+
ALTER TABLE platform_identity_projects ADD CONSTRAINT platform_identity_projects_visibility_source_check
|
|
59
|
+
CHECK (visibility_source IS NULL OR visibility_source IN ('control-plane', 'pull'));
|
|
60
|
+
|
|
61
|
+
-- 3. The takedown latch. DEFAULT false so every existing row is unpinned, i.e. today's
|
|
62
|
+
-- behaviour unchanged: nothing is newly frozen by this migration running.
|
|
63
|
+
ALTER TABLE platform_identity_projects
|
|
64
|
+
ADD COLUMN IF NOT EXISTS status_pinned boolean NOT NULL DEFAULT false;
|
|
65
|
+
|
|
66
|
+
-- THE BACKFILL, AND IT IS THE SAFETY PROPERTY OF THIS MIGRATION.
|
|
67
|
+
--
|
|
68
|
+
-- R08 makes the feed exclude a pullable row that has never answered — ADR 0182 D5's
|
|
69
|
+
-- "unreported => dark matter". Shipped without this UPDATE that predicate would empty
|
|
70
|
+
-- the public map the moment it deployed, because the column it reads is brand new and
|
|
71
|
+
-- therefore NULL on every row. That is not a hypothetical: ADR 0247 D4 found the exact
|
|
72
|
+
-- shape live, on this table, one column over — every catalog row carried
|
|
73
|
+
-- `description: null`, so a fail-closed join gate wired that day would have refused
|
|
74
|
+
-- every join on the platform at once.
|
|
75
|
+
--
|
|
76
|
+
-- The backfill is also simply TRUE, which is why it is safe to make. Every row that
|
|
77
|
+
-- exists at this moment got its door from the hub: either the ADR 0246 bridge assigned
|
|
78
|
+
-- it from `provisioning_instances.settings`, or an Archon typed the row in. Neither is
|
|
79
|
+
-- a pull — no instance has ever been asked, because this migration is what makes asking
|
|
80
|
+
-- possible. Stamping them 'control-plane' records what already happened; it does not
|
|
81
|
+
-- grant anything, and it leaves today's map exactly as it is.
|
|
82
|
+
--
|
|
83
|
+
-- What stays NULL is therefore precisely the new population: rows catalogued AFTER this
|
|
84
|
+
-- lands that the control plane has not claimed. Those are the ones the sweep may read,
|
|
85
|
+
-- and the ones D5's default is scoped to.
|
|
86
|
+
UPDATE platform_identity_projects
|
|
87
|
+
SET visibility_source = 'control-plane'
|
|
88
|
+
WHERE visibility_source IS NULL;
|
|
89
|
+
|
|
90
|
+
-- The pull's working set: rows the control plane has never claimed. Partial, because
|
|
91
|
+
-- the sweep asks this question and no other, and the control-plane rows it excludes
|
|
92
|
+
-- are the majority of the table.
|
|
93
|
+
CREATE INDEX IF NOT EXISTS platform_identity_projects_pullable
|
|
94
|
+
ON platform_identity_projects (origin)
|
|
95
|
+
WHERE visibility_source IS DISTINCT FROM 'control-plane';
|
|
96
|
+
|
|
97
|
+
COMMENT ON COLUMN platform_identity_projects.visibility_reported_at IS
|
|
98
|
+
'When the hub last SUCCESSFULLY read this project''s own manifest (ADR 0252 §2). NULL is load-bearing: it means no read has ever succeeded, and is the ONLY state in which ADR 0182 D5''s fail-closed "unreported => dark matter" applies. A row that answered once and is unreachable today keeps both its `visibility` and this stamp — reporting may degrade where enforcement must refuse (ADR 0226), and liveness is an overlay, never a lifecycle state (ADR 0199). Never set on a control-plane row; those are assigned, not read.';
|
|
99
|
+
|
|
100
|
+
COMMENT ON COLUMN platform_identity_projects.visibility_source IS
|
|
101
|
+
'WHICH writer owns this row''s join door: ''control-plane'' (assigned by the ADR 0246 bridge from provisioning_instances.settings — authoritative) or ''pull'' (read from the instance''s own public manifest — a cached CLAIM, and only as fresh as visibility_reported_at). NULL means neither has claimed the row yet. This column is what makes "the two never write the same row" an invariant the SQL enforces rather than a convention: the pull''s predicate is `visibility_source IS DISTINCT FROM ''control-plane''`. It exists because the catalog row carries no hosting_shape and may not import the module that does (ADR 0083).';
|
|
102
|
+
|
|
103
|
+
COMMENT ON COLUMN platform_identity_projects.status_pinned IS
|
|
104
|
+
'A curator has set `status` by hand (PATCH /projects/:id/status), so no automatic writer may move it. ADR 0252 §5.2 requires a retracted project that keeps reporting `public` to STAY hidden; ADR 0253 found the pull is not the writer that undoes takedowns today — upsertOwnedProject is, assigning status on every re-take of the ADR 0246 projection. Both honour this latch. Cleared only by another curated PATCH, so un-hiding is exactly as deliberate as hiding.';
|
|
105
|
+
|
|
106
|
+
COMMIT;
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"contributes": {
|
|
13
13
|
"routes": ["sso", "projects", "my-projects", "scouting", "profile", "public-profile", "connections"],
|
|
14
14
|
"migrations": true,
|
|
15
|
-
"pollers": ["code-sweep"]
|
|
15
|
+
"pollers": ["code-sweep", "visibility-pull"]
|
|
16
16
|
},
|
|
17
17
|
"provides": ["platform-identity.hub", "project.publishGate", "project.catalog"],
|
|
18
18
|
"consumes": []
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/platform-identity/pollers/visibility-pull.js — the loader poller entry that
|
|
4
|
+
// keeps self-hosted projects' join doors current (R08, task 1002327; ADR 0252 §2).
|
|
5
|
+
// The loader's startModulePollers requires THIS file and calls start(deps) at boot /
|
|
6
|
+
// stop() at shutdown for the ENABLED module only (the hub) — non-blocking and
|
|
7
|
+
// failure-isolated, so a sweep hiccup never touches sign-in. Mirrors code-sweep.js.
|
|
8
|
+
//
|
|
9
|
+
// WHY A POLLER AND NOT THE FEED'S EXISTING FAN-OUT. The obvious cheap move — pull the
|
|
10
|
+
// manifest alongside the four stats fetches the public feed already makes — cannot work,
|
|
11
|
+
// and the reason is worth writing down because it is invisible until you try it:
|
|
12
|
+
//
|
|
13
|
+
// 1. THE FEED EXCLUDES EXACTLY THE ROWS THAT NEED PULLING. A pullable project that has
|
|
14
|
+
// never answered is dark matter, so `listPublicProjects` does not return it, so the
|
|
15
|
+
// enrichment never fetches it, so it never answers, so it stays dark. A read-path
|
|
16
|
+
// pull can only ever refresh projects that are ALREADY visible — which is precisely
|
|
17
|
+
// backwards from what R08 is for. It cannot bootstrap.
|
|
18
|
+
// 2. STEALTH ROWS ARE SKIPPED TOO. `routes/projects.js` filters them out of enrichment
|
|
19
|
+
// on purpose (asking a stealth project's API for its builders would put identity on
|
|
20
|
+
// the wire and tell it it was being enumerated), so a project that went stealth
|
|
21
|
+
// could never be seen coming back.
|
|
22
|
+
// 3. IT WOULD MAKE A DATABASE WRITE A SIDE EFFECT OF AN ANONYMOUS READ, on a route that
|
|
23
|
+
// is unauthenticated and CORS-open — and add a fifth outbound fetch to the platform's
|
|
24
|
+
// most expensive public request while doing it.
|
|
25
|
+
//
|
|
26
|
+
// So the sweep runs on its own clock over its own working set, and the feed keeps reading
|
|
27
|
+
// the cached column exactly as it does today.
|
|
28
|
+
//
|
|
29
|
+
// Proof: ../../../tests/visibility_pull.mjs
|
|
30
|
+
|
|
31
|
+
const api = require('../../../src/module-api');
|
|
32
|
+
const visibilityPull = require('../visibility-pull');
|
|
33
|
+
|
|
34
|
+
const log = api.logger('platform-identity');
|
|
35
|
+
|
|
36
|
+
// Every 15 minutes. A join door is not a liveness signal — it changes when an owner
|
|
37
|
+
// decides something, which is rare — so the cadence is set by how long a stale door may
|
|
38
|
+
// linger, not by how fast anything moves. It is also deliberately slower than the stats
|
|
39
|
+
// TTL (10 min): this sweep reaches out to every catalogued third-party instance whether
|
|
40
|
+
// or not anyone is looking at the map, and being a polite client to other people's
|
|
41
|
+
// servers matters more here than freshness measured in minutes.
|
|
42
|
+
const SWEEP_INTERVAL_MS = 15 * 60 * 1000;
|
|
43
|
+
|
|
44
|
+
// Rows per sweep. A ceiling rather than a page: at 15-minute cadence this is far above
|
|
45
|
+
// any plausible catalog, and `pullableProjects` orders oldest-reading-first, so if the
|
|
46
|
+
// population ever did exceed it the sweep still converges — it just takes more passes,
|
|
47
|
+
// and it spends them on the stalest rows.
|
|
48
|
+
const SWEEP_LIMIT = 200;
|
|
49
|
+
|
|
50
|
+
// Wait before the FIRST sweep so boot is never competing with a fan-out to every
|
|
51
|
+
// catalogued origin. The catalog-backfill poller's precedent.
|
|
52
|
+
const FIRST_SWEEP_DELAY_MS = 60 * 1000;
|
|
53
|
+
|
|
54
|
+
let timer = null;
|
|
55
|
+
let firstTimer = null;
|
|
56
|
+
|
|
57
|
+
async function sweepOnce(deps = {}) {
|
|
58
|
+
try {
|
|
59
|
+
const out = await visibilityPull.sweepVisibilityOnce({
|
|
60
|
+
...(deps.pool ? { pool: deps.pool } : {}),
|
|
61
|
+
limit: SWEEP_LIMIT,
|
|
62
|
+
});
|
|
63
|
+
// One disposition line per sweep, and only when something actually moved — a
|
|
64
|
+
// quiet catalog should not write a log line every quarter hour forever.
|
|
65
|
+
if (out.updated > 0) {
|
|
66
|
+
log.info({ updated: out.updated, scanned: out.scanned }, 'visibility-pull: project door(s) updated');
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
} catch (err) {
|
|
70
|
+
// Best-effort: a sweep failure must never crash the process. Individual projects
|
|
71
|
+
// already fail closed inside pullOne(), so reaching here means the DB read itself
|
|
72
|
+
// failed, not that some instance was down.
|
|
73
|
+
log.error({ err: err && err.message }, 'visibility-pull sweep failed (non-fatal)');
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = {
|
|
79
|
+
start: (deps = {}) => {
|
|
80
|
+
if (timer || firstTimer) return; // idempotent
|
|
81
|
+
firstTimer = setTimeout(() => {
|
|
82
|
+
firstTimer = null;
|
|
83
|
+
sweepOnce(deps);
|
|
84
|
+
timer = setInterval(() => { sweepOnce(deps); }, SWEEP_INTERVAL_MS);
|
|
85
|
+
if (timer.unref) timer.unref();
|
|
86
|
+
}, FIRST_SWEEP_DELAY_MS);
|
|
87
|
+
// unref so neither timer keeps the process alive on its own.
|
|
88
|
+
if (firstTimer.unref) firstTimer.unref();
|
|
89
|
+
},
|
|
90
|
+
stop: () => {
|
|
91
|
+
if (timer) { clearInterval(timer); timer = null; }
|
|
92
|
+
if (firstTimer) { clearTimeout(firstTimer); firstTimer = null; }
|
|
93
|
+
},
|
|
94
|
+
// exported for tests
|
|
95
|
+
sweepOnce,
|
|
96
|
+
SWEEP_INTERVAL_MS,
|
|
97
|
+
SWEEP_LIMIT,
|
|
98
|
+
FIRST_SWEEP_DELAY_MS,
|
|
99
|
+
};
|
|
@@ -127,26 +127,83 @@ async function upsertCatalogOnlyClient(entry, { pool = defaultPool } = {}) {
|
|
|
127
127
|
// WHERE is claim rule 2 (the owner must already match), so an origin someone else's row
|
|
128
128
|
// occupies is refused rather than overwritten. Returns the row, or null when the claim
|
|
129
129
|
// refused. `featured`, `tagline`, `art_url` and `template` are curation and stay untouched.
|
|
130
|
+
//
|
|
131
|
+
// TWO THINGS R08 CHANGED HERE (task 1002327; ADR 0253):
|
|
132
|
+
//
|
|
133
|
+
// 1. `status` NOW HONOURS THE PIN, and this is the takedown lever actually working.
|
|
134
|
+
// ADR 0252 §5.2 requires a retracted project to stay retracted, and defends that
|
|
135
|
+
// against the visibility pull — but the pull is not the writer that undoes takedowns.
|
|
136
|
+
// THIS one is. The ADR 0246 projection is re-taken at every provisioning write, and
|
|
137
|
+
// `catalogStatusForInstance` computes 'active' for any instance that is not torn down,
|
|
138
|
+
// so before this line an Archon could hide a project and the owner's very next
|
|
139
|
+
// settings save would put it back on the public map. The pin is set only by a curated
|
|
140
|
+
// PATCH /projects/:id/status, so what it freezes is a human decision, and only against
|
|
141
|
+
// automatic writers. Un-pinning is another curated PATCH — un-hiding stays exactly as
|
|
142
|
+
// deliberate as hiding.
|
|
143
|
+
//
|
|
144
|
+
// `retireOwnedProject` deliberately does NOT honour the pin: it is a safety retraction
|
|
145
|
+
// for an address the project no longer answers on (the ADR 0181 §2 stale-orb leak), and
|
|
146
|
+
// a curator's 'active' pin must not be able to keep a dead origin drawing an orb.
|
|
147
|
+
// Narrowing is free; widening is what costs.
|
|
148
|
+
//
|
|
149
|
+
// 2. `visibility_source` is stamped 'control-plane'. This row's door is ASSIGNED from
|
|
150
|
+
// `provisioning_instances.settings`, so the pull must never touch it — and the stamp is
|
|
151
|
+
// what makes "the two writers never share a row" an invariant the SQL enforces rather
|
|
152
|
+
// than a convention (the catalog carries no `hosting_shape`, and may not import the
|
|
153
|
+
// module that does — ADR 0083). Written on the UPDATE branch too: a row that predates
|
|
154
|
+
// the bridge, or a hand-seeded one this projection later claims, becomes control-plane
|
|
155
|
+
// the moment the control plane actually writes it.
|
|
130
156
|
async function upsertOwnedProject(entry, { pool = defaultPool } = {}) {
|
|
131
157
|
const e = normalizeEntry(entry);
|
|
132
158
|
const { rows } = await pool.query(
|
|
133
159
|
`INSERT INTO platform_identity_projects
|
|
134
|
-
(origin, name, description, owner_github_id, type, visibility, status)
|
|
135
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
160
|
+
(origin, name, description, owner_github_id, type, visibility, status, visibility_source)
|
|
161
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, 'control-plane')
|
|
136
162
|
ON CONFLICT (origin) DO UPDATE SET
|
|
137
163
|
name = EXCLUDED.name,
|
|
138
164
|
description = EXCLUDED.description,
|
|
139
165
|
visibility = EXCLUDED.visibility,
|
|
140
|
-
status =
|
|
166
|
+
status = CASE WHEN platform_identity_projects.status_pinned
|
|
167
|
+
THEN platform_identity_projects.status
|
|
168
|
+
ELSE EXCLUDED.status END,
|
|
169
|
+
visibility_source = 'control-plane',
|
|
141
170
|
type = COALESCE(EXCLUDED.type, platform_identity_projects.type),
|
|
142
171
|
updated_at = now()
|
|
143
172
|
WHERE platform_identity_projects.owner_github_id = EXCLUDED.owner_github_id
|
|
144
|
-
RETURNING id, origin, name, description, owner_github_id, type, status, featured, visibility`,
|
|
173
|
+
RETURNING id, origin, name, description, owner_github_id, type, status, featured, visibility, status_pinned`,
|
|
145
174
|
[e.origin, e.name, e.description, e.ownerGithubId, e.type, e.visibility, e.status],
|
|
146
175
|
);
|
|
147
176
|
return rows[0] || null;
|
|
148
177
|
}
|
|
149
178
|
|
|
179
|
+
// setProjectStatus — THE TAKEDOWN LEVER (R08, task 1002327; ADR 0252 §5.1).
|
|
180
|
+
//
|
|
181
|
+
// Before this, `platform_identity_projects.status` had NO route that wrote it: the column
|
|
182
|
+
// existed from migration 004, the public feed required 'active', and removing a project
|
|
183
|
+
// from the map meant hand-written SQL on production. Under operator-provisioned curation
|
|
184
|
+
// that was survivable, because the set of rows was bounded by who the operator had
|
|
185
|
+
// provisioned. Auto-appear (ADR 0252 §3) replaces that bound with whoever points a domain
|
|
186
|
+
// at us, and a one-way door onto a public surface is not a door — so the Archon's
|
|
187
|
+
// auto-appear decision was explicitly conditional on this shipping in the SAME task
|
|
188
|
+
// (ADR 0047's rule that a straight-through path and its abuse guard are one piece of work).
|
|
189
|
+
//
|
|
190
|
+
// SETTING THE PIN IS THE POINT, not a side effect: `status_pinned` is what stops the ADR
|
|
191
|
+
// 0246 projection from reverting the decision on the owner's next settings save. A PATCH
|
|
192
|
+
// back to 'active' clears it, returning the row to automatic management.
|
|
193
|
+
async function setProjectStatus(id, status, { pool = defaultPool } = {}) {
|
|
194
|
+
if (!CATALOG_STATUSES.includes(status)) {
|
|
195
|
+
throw new Error(`project-catalog: unknown status ${JSON.stringify(status)}`);
|
|
196
|
+
}
|
|
197
|
+
const { rows } = await pool.query(
|
|
198
|
+
`UPDATE platform_identity_projects
|
|
199
|
+
SET status = $2, status_pinned = ($2 = 'hidden'), updated_at = now()
|
|
200
|
+
WHERE id = $1
|
|
201
|
+
RETURNING id, origin, name, status, status_pinned, featured, visibility`,
|
|
202
|
+
[id, status],
|
|
203
|
+
);
|
|
204
|
+
return rows[0] || null;
|
|
205
|
+
}
|
|
206
|
+
|
|
150
207
|
// Take a catalog row OFF the map when its project moved to a different address (the domain
|
|
151
208
|
// re-point, task 1003237): an abandoned origin left `active` would keep drawing an orb that
|
|
152
209
|
// points at a hostname the project no longer answers on — the ADR 0181 §2 leak, in the
|
|
@@ -222,6 +279,7 @@ module.exports = {
|
|
|
222
279
|
normalizeEntry,
|
|
223
280
|
upsertCatalogOnlyClient,
|
|
224
281
|
upsertOwnedProject,
|
|
282
|
+
setProjectStatus,
|
|
225
283
|
retireOwnedProject,
|
|
226
284
|
catalogedOrigins,
|
|
227
285
|
publishControlPlaneProject,
|
|
@@ -301,10 +301,41 @@ async function getProjectStats(origins = []) {
|
|
|
301
301
|
return out;
|
|
302
302
|
}
|
|
303
303
|
|
|
304
|
+
// fetchInstanceVisibility — ONE origin's public manifest, for the visibility pull (R08,
|
|
305
|
+
// task 1002327; ADR 0252 §2). Lives here, not in visibility-pull.js, for the reason this
|
|
306
|
+
// file's SSRF header gives: this is "the one place the server makes an outbound request
|
|
307
|
+
// to an address that came out of the database", and a pull that opened its own fetch
|
|
308
|
+
// path would be a second, weaker one. It reuses the SAME guard the stats fan-out runs —
|
|
309
|
+
// resolve-all with a public-address assertion, `redirect: 'error'` so the remote never
|
|
310
|
+
// chooses our final address, and the per-request timeout.
|
|
311
|
+
//
|
|
312
|
+
// DELIBERATELY OUTSIDE THE STATS CACHE. The stats cache is warmed by public feed reads
|
|
313
|
+
// and holds only the rows the feed enriches; the pull's working set is the opposite one
|
|
314
|
+
// — rows that are dark, or stealth, and therefore excluded from that enrichment
|
|
315
|
+
// entirely. Sharing the cache would mean the sweep could only ever refresh projects that
|
|
316
|
+
// were already visible, which is exactly backwards. The sweep has its own cadence and
|
|
317
|
+
// carries its own staleness stamp in the database, so a second in-memory TTL would be a
|
|
318
|
+
// third opinion about freshness with nothing to settle it.
|
|
319
|
+
//
|
|
320
|
+
// THE PREFIX IS THE CANONICAL ONE, not the one ADR 0252 §2 wrote. The API router is
|
|
321
|
+
// dual-mounted at `/api/bongos` (canonical) and at a permanent legacy alias — see the
|
|
322
|
+
// header of `src/bongos/routes.js` — so both resolve on every instance and the choice
|
|
323
|
+
// is free.
|
|
324
|
+
// `/api/bongos` matches the discovery fetch four lines up in `fetchStats`, so this file
|
|
325
|
+
// asks a remote instance for everything under ONE prefix.
|
|
326
|
+
//
|
|
327
|
+
// Throws on an unreachable, refused or non-JSON response; the caller reads a throw as
|
|
328
|
+
// "no verdict", never as a value.
|
|
329
|
+
async function fetchInstanceVisibility(origin) {
|
|
330
|
+
await assertPublicHost(siteUrl(origin).hostname);
|
|
331
|
+
return getJson(apiUrl(origin, '/api/bongos/instance'));
|
|
332
|
+
}
|
|
333
|
+
|
|
304
334
|
// tests
|
|
305
335
|
function _reset() { _cache.clear(); _inflight.clear(); _coreModules = null; }
|
|
306
336
|
|
|
307
337
|
module.exports = {
|
|
308
338
|
getProjectStats, isSystemAccount, coreModules, blockedReason, assertPublicHost, httpUrl,
|
|
339
|
+
fetchInstanceVisibility,
|
|
309
340
|
NULL_STATS, SHIPPED_WINDOW_DAYS, FACES_MAX, _reset, TTL_MS,
|
|
310
341
|
};
|
|
@@ -118,11 +118,26 @@ const PUBLIC_FEED_MAX_PROJECTS = 500;
|
|
|
118
118
|
// sort but never projects: feature toggles and client re-registration bump it, so as a
|
|
119
119
|
// public "last updated" it would lie. `description` is the publish BAR, spent in the
|
|
120
120
|
// predicate, never a card field.
|
|
121
|
+
// A PULLABLE row is on the map only once it has actually answered (R08, task 1002327;
|
|
122
|
+
// ADR 0182 D5 via ADR 0252 §4). "Unreported => dark matter" is scoped, deliberately and
|
|
123
|
+
// narrowly, to rows whose door the hub has never successfully read: a control-plane row
|
|
124
|
+
// was ASSIGNED its door by the ADR 0246 bridge and is on the map the moment that lands,
|
|
125
|
+
// and a row that answered once and is unreachable today KEEPS its last state — a project
|
|
126
|
+
// does not leave the map because its server was down for a sweep (ADR 0199 / 0226).
|
|
127
|
+
//
|
|
128
|
+
// THE SCOPING IS THE SAFETY PROPERTY, not a nicety. ADR 0247 D4 found the unscoped
|
|
129
|
+
// version live on this very table: every catalog row carried `description: null`, so a
|
|
130
|
+
// fail-closed gate wired that day would have refused every join on the platform at once.
|
|
131
|
+
// The same predicate written without the `visibility_source` half — or shipped without
|
|
132
|
+
// migration 020's backfill, which stamps every pre-existing row 'control-plane' — would
|
|
133
|
+
// empty the public map on deploy. Both halves are load-bearing.
|
|
134
|
+
const DOOR_KNOWN_SQL = "(visibility_source = 'control-plane' OR visibility_reported_at IS NOT NULL)";
|
|
135
|
+
|
|
121
136
|
async function listPublicProjects({ pool = defaultPool } = {}) {
|
|
122
137
|
const { rows } = await pool.query(
|
|
123
138
|
`SELECT origin, name, tagline, art_url, type, template, created_at, visibility
|
|
124
139
|
FROM platform_identity_projects
|
|
125
|
-
WHERE status = 'active' AND ${publishableSql()}
|
|
140
|
+
WHERE status = 'active' AND ${publishableSql()} AND ${DOOR_KNOWN_SQL}
|
|
126
141
|
ORDER BY featured DESC, updated_at DESC
|
|
127
142
|
LIMIT ${PUBLIC_FEED_MAX_PROJECTS}`,
|
|
128
143
|
);
|
|
@@ -138,5 +153,6 @@ module.exports = {
|
|
|
138
153
|
isStealthProject,
|
|
139
154
|
feedShapeFor,
|
|
140
155
|
PUBLIC_FEED_MAX_PROJECTS,
|
|
156
|
+
DOOR_KNOWN_SQL,
|
|
141
157
|
listPublicProjects,
|
|
142
158
|
};
|
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
// POST /projects/invite — a `project.curate` holder OR the project's OWN owner:
|
|
16
16
|
// pre-seed a 'pending' membership (invite-authz.js)
|
|
17
17
|
// PATCH /projects/:id/featured — Archon: the feature / unfeature control
|
|
18
|
+
// PATCH /projects/:id/status — `project.curate`: the TAKEDOWN lever (R08, ADR 0252
|
|
19
|
+
// §5.1). Setting 'hidden' pins the row so the ADR 0246
|
|
20
|
+
// projection cannot quietly put it back on the map.
|
|
18
21
|
//
|
|
19
22
|
// Reaches core only through the doorway (ADR 0083). Archon routes declared first so
|
|
20
23
|
// route-rank-check's look-ahead never misclassifies the trailing public GET.
|
|
@@ -181,12 +184,56 @@ module.exports = function projectsRoutes() {
|
|
|
181
184
|
}
|
|
182
185
|
});
|
|
183
186
|
|
|
187
|
+
// rank: metic — THE TAKEDOWN LEVER (R08, task 1002327; ADR 0252 §5.1). Until this
|
|
188
|
+
// route, `platform_identity_projects.status` had no writer on the API at all: the
|
|
189
|
+
// column shipped in migration 004, the public feed requires 'active', and removing a
|
|
190
|
+
// project from the map meant hand-written SQL against production.
|
|
191
|
+
//
|
|
192
|
+
// IT SHIPS IN THE SAME TASK AS AUTO-APPEAR, NOT AFTER IT. ADR 0252 §3 lets a
|
|
193
|
+
// self-hosted project put itself on the public map with no human act, and the Archon
|
|
194
|
+
// took that decision explicitly conditional on retraction landing beside it — ADR 0047's
|
|
195
|
+
// rule that a straight-through path and its abuse guard are one piece of work. A
|
|
196
|
+
// one-way door onto a public surface is not a door.
|
|
197
|
+
//
|
|
198
|
+
// Setting 'hidden' PINS the row (project-catalog.js), which is what makes the
|
|
199
|
+
// retraction survive: the ADR 0246 projection re-takes `status` on every provisioning
|
|
200
|
+
// write, so without the pin an owner's next settings save would quietly undo the
|
|
201
|
+
// takedown. PATCHing back to 'active' releases the pin and returns the row to automatic
|
|
202
|
+
// management, so un-hiding is exactly as deliberate an act as hiding.
|
|
203
|
+
router.patch('/projects/:id/status', requireBuilder, requirePermission('project.curate'), async (req, res) => {
|
|
204
|
+
const id = parseId(req, res, { code: 'bad_project_id', positiveInt: true });
|
|
205
|
+
if (id === null) return;
|
|
206
|
+
if (validateOrRespond(req, res, { status: { required: true, type: 'string' } })) return;
|
|
207
|
+
if (!projectCatalog.CATALOG_STATUSES.includes(req.body.status)) {
|
|
208
|
+
return res.fail('bad_project_status', 400, { valid: projectCatalog.CATALOG_STATUSES });
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
const project = await projectCatalog.setProjectStatus(id, req.body.status, { pool });
|
|
212
|
+
if (!project) return res.fail('project_not_found', 404);
|
|
213
|
+
res.json({ project });
|
|
214
|
+
} catch (err) {
|
|
215
|
+
log.error({ err: err.message }, 'PATCH /projects/:id/status');
|
|
216
|
+
res.fail('status_change_failed', { status: 500, message: 'internal error' });
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
184
220
|
// The PUBLIC projects feed — every project ON THE MAP (active + publishable),
|
|
185
221
|
// public projection. CORS-enabled cross-origin read (a status page / the apex /
|
|
186
222
|
// the static orbs site fetches it pre-auth).
|
|
187
223
|
router.use('/projects/featured', corsPublicGet('/projects/featured'));
|
|
188
224
|
// rank: public — the public map of projects; no auth by design.
|
|
189
|
-
|
|
225
|
+
//
|
|
226
|
+
// THE PER-IP CEILING IS PART OF THE ROUTE, not a decoration on it (ADR 0252 §5.3).
|
|
227
|
+
// This GET is unauthenticated and CORS-open, and it was unmetered by construction: the
|
|
228
|
+
// two global limiters return early for reads, and the /public/* read limiter is mounted
|
|
229
|
+
// on a prefix this route does not sit under. It is also the platform's most expensive
|
|
230
|
+
// anonymous read — a cold cache turns one request into as many as 500 rows × 4 outbound
|
|
231
|
+
// fetches at OTHER PEOPLE's servers, so leaving it open aims our fan-out, not just our
|
|
232
|
+
// database. Auto-appear removed the old bound (the set of projects the operator had
|
|
233
|
+
// provisioned) and replaced it with "whoever points a domain at us", which is what makes
|
|
234
|
+
// the ceiling load-bearing rather than prudent. It runs BEFORE the handler, so a refused
|
|
235
|
+
// read costs no query and no fetch.
|
|
236
|
+
router.get('/projects/featured', api.publicProjectFeedRateLimit, async (_req, res) => {
|
|
190
237
|
try {
|
|
191
238
|
const projects = await pi.listPublicProjects({ pool });
|
|
192
239
|
// A stealth project is on the map ANONYMOUSLY (ADR 0182 D2a), so its origin
|