@bongos/core 1.19.419 → 1.19.421

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.
Files changed (61) hide show
  1. package/.bongos-core.json +108 -58
  2. package/.claude/skills/new-project/SKILL.md +22 -11
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +2 -0
  5. package/clients/bongos-client/index.cjs +2 -0
  6. package/clients/bongos-client/index.d.ts +5 -1
  7. package/clients/bongos-client/index.mjs +2 -0
  8. package/docs/adr/0209-one-budget-for-the-account-existence-oracle.md +79 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +103 -5
  11. package/docs/api-reference.md +4 -3
  12. package/docs/copy-inventory.md +282 -269
  13. package/docs/copy-registry.json +487 -368
  14. package/docs/design/mocks/hub/mine.html +377 -0
  15. package/docs/design/projects-hub-direction-v2.md +25 -1
  16. package/docs/file-map.md +2 -0
  17. package/docs/module-api-changelog.md +4 -0
  18. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  19. package/modules/hall-ui/public/settings.html +3 -2
  20. package/modules/hall-ui/public/settings.js +10 -6
  21. package/modules/onboarding/routes/access-requests.js +16 -3
  22. package/modules/platform-identity/account-visibility.js +94 -3
  23. package/modules/platform-identity/platform-identity.js +50 -55
  24. package/modules/platform-identity/routes/connections.js +3 -3
  25. package/modules/platform-identity/tests/platform-identity.mjs +20 -0
  26. package/modules/provisioning/migrations/provisioning_013_project_detail.sql +54 -0
  27. package/modules/provisioning/onboard-plan.js +13 -0
  28. package/modules/provisioning/provisioning.js +113 -5
  29. package/modules/provisioning/routes/provisioning.js +105 -0
  30. package/modules/provisioning/tests/provisioning.mjs +84 -0
  31. package/modules/ui-design/kit/fixtures/featured-hub.json +1 -0
  32. package/modules/ui-design/kit/fixtures/provisioning-instances-empty.json +4 -0
  33. package/modules/ui-design/kit/fixtures/provisioning-instances.json +99 -0
  34. package/modules/ui-design/kit/lib.js +6 -2
  35. package/modules/ui-design/kit/probe.js +1 -1
  36. package/modules/ui-design/kit/render.js +1 -1
  37. package/modules/ui-design/kit/serve.js +63 -2
  38. package/package-lock.json +2 -2
  39. package/package.json +1 -1
  40. package/public-landing/assets/cosmos.css +224 -18
  41. package/public-landing/projects-empty.states.json +49 -0
  42. package/public-landing/projects-floor.states.json +63 -0
  43. package/public-landing/projects.html +273 -93
  44. package/public-landing/projects.probes.json +32 -7
  45. package/public-landing/projects.states.json +10 -6
  46. package/scripts/gds/fitness.js +9 -0
  47. package/scripts/gds/onboard.js +41 -1
  48. package/scripts/gds/provision-net.js +7 -3
  49. package/src/bongos/middleware/rate-limit.js +68 -2
  50. package/src/bongos/routes/auth.js +19 -12
  51. package/src/module-api.js +13 -1
  52. package/tests/account_existence_rate_limit.mjs +375 -0
  53. package/tests/account_privacy_flags.mjs +17 -10
  54. package/tests/effective_visibility_predicate.mjs +891 -0
  55. package/tests/module_api.mjs +1 -0
  56. package/tests/onboard.mjs +56 -0
  57. package/tests/profile_route.mjs +22 -11
  58. package/tests/projects_hub.mjs +102 -5
  59. package/tests/projects_hub_pre_uat.mjs +45 -11
  60. package/tests/provision.mjs +20 -0
  61. package/tests/ui_design_kit.mjs +32 -2
@@ -1,7 +1,8 @@
1
1
  {
2
- "_": "The hub's interaction contract for modules/ui-design/kit/probe.js (task 1003320): the disclosure bands, the views, the card, the fold under 480, signed out. A probe with `continue` keeps the previous page — the contract is a sequence.",
2
+ "_": "The hub's interaction contract for modules/ui-design/kit/probe.js (task 1003320): the disclosure bands, the views, the card, the fold under 480, signed out. A probe with `continue` keeps the previous page — the contract is a sequence. The manage flows (task 1003345): attach, restart, teardown (the row and the ender), revive, retry and join drive the stub's receipts end to end, so the makeover cannot silently break a door. The catalog is featured-hub.json (stub.fixtures): the signed-in stub owns Demo, and an owned orb's plain click opens its manage view, never its card — the card probes open the landing's other rows and Tidewater, the un-owned floor row.",
3
3
  "page": "/projects",
4
4
  "surface": "public-landing",
5
+ "stub": { "fixtures": { "featured": "featured-hub" } },
5
6
  "probes": [
6
7
  { "name": "closed: the main content starts under the bar", "auth": true, "width": 1440,
7
8
  "steps": [],
@@ -46,7 +47,7 @@
46
47
  "steps": [["click", "#menuProfile a[data-nav-to=\"mine\"]"], ["wait", 300]],
47
48
  "expect": { "param": { "view": "mine" }, "closed": "#menuProfile", "text": [["[aria-current=\"page\"]", "Your projects"]] } },
48
49
  { "name": "an orb opens the card", "continue": true,
49
- "steps": [["back"], ["wait", 400], ["dispatchClick", "#orbField a.worldOrb:not(#selfOrb)"], ["wait", 400]],
50
+ "steps": [["back"], ["wait", 400], ["dispatchClick", "#orbField a.worldOrb:not(#selfOrb):not([data-manage])"], ["wait", 400]],
50
51
  "expect": { "dialog": true } },
51
52
  { "name": "Escape closes the card when no band is open", "continue": true,
52
53
  "steps": [["press", "Escape"], ["wait", 150]],
@@ -59,11 +60,11 @@
59
60
  "text": [["#cardBuilders", "11"], ["#cardShipped", "0"], ["#cardRel", "you build here"], ["#cardRuns", "amazonprimea.com"], ["#cardFaces .facesMore", "+6"], ["#cardHall", "Builders' hall"]],
60
61
  "href": [["#cardHall", "https://builders.amazonprimea.com/"], ["#cardOpen", "https://amazonprimea.com/"]] } },
61
62
  { "name": "the ladder: a project that answered nothing is the card that shipped before (disc, name, tagline, address, listed, one door)", "continue": true,
62
- "steps": [["press", "Escape"], ["wait", 150], ["dispatchClick", "#orbField a.worldOrb[data-origin=\"https://demo.cloudbongos.com\"]"], ["wait", 500]],
63
+ "steps": [["press", "Escape"], ["wait", 150], ["dispatchClick", "#orbField a.worldOrb[data-origin=\"https://tidewater.cloudbongos.com\"]"], ["wait", 500]],
63
64
  "expect": { "dialog": true, "hasClass": [["#projCard", "has-art", false]],
64
65
  "hidden": ["#cardArt", "#cardFaces", "#cardFigs", "#cardBuildersFig", "#cardShippedFig", "#cardHall", "#cardRel", "#cardTypeL", "#cardOwnerL"],
65
66
  "visible": ["#cardDot", "#cardName", "#cardTag", "#cardRuns", "#cardListed", "#cardOpen"],
66
- "text": [["#cardRuns", "demo.cloudbongos.com"]] } },
67
+ "text": [["#cardRuns", "tidewater.cloudbongos.com"]] } },
67
68
  { "name": "the hall door is address-gated: a member of a project whose hall is its own site gets no door; the capped figure reads N+", "continue": true,
68
69
  "steps": [["press", "Escape"], ["wait", 150], ["dispatchClick", "#orbField a.worldOrb[data-origin=\"https://emersonian-circles.cloudbongos.com\"]"], ["wait", 500]],
69
70
  "expect": { "dialog": true, "hidden": ["#cardArt", "#cardFaces", "#cardHall"], "visible": ["#cardFigs", "#cardBuildersFig", "#cardShippedFig", "#cardOpen"],
@@ -74,7 +75,7 @@
74
75
  "text": [["#cardRel", "yours"]], "href": [["#cardOpen", "/"]] } },
75
76
  { "name": "the table: the hall glyph rides the door cell on the one member row whose hall is elsewhere, and on the platform's row", "continue": true,
76
77
  "steps": [["press", "Escape"], ["wait", 150], ["click", "#projTrig"], ["wait", 120], ["click", "a[data-sub=\"table\"]"], ["wait", 300]],
77
- "expect": { "dialog": false, "visible": ["#selfHall"], "count": [["#mapRows tr[data-generated] .rowHall", 1], ["#mapRows tr[data-generated] td", 18]],
78
+ "expect": { "dialog": false, "visible": ["#selfHall"], "count": [["#mapRows tr[data-generated] .rowHall", 1], ["#mapRows tr[data-generated] td", 42]],
78
79
  "href": [["#mapRows tr[data-generated] .rowHall", "https://builders.amazonprimea.com/"]] } },
79
80
  { "name": "back to the sky for the rest of the sequence", "continue": true,
80
81
  "steps": [["click", "#projTrig"], ["wait", 120], ["click", "a[data-sub=\"sky\"]"], ["wait", 300]],
@@ -93,7 +94,7 @@
93
94
  "expect": { "count": [["#menuProjects .bandCell:not([hidden])", 3], ["#menuProjects a[href=\"/docs\"]", 0]], "hidden": ["#menuProjects [data-nav=\"mine\"]"],
94
95
  "href": [[".footNav a[href=\"/#about\"]", "/#about"], [".footNav a[href=\"/docs\"]", "/docs"]] } },
95
96
  { "name": "signed out: the card still closes on Escape (nothing throws with no session)", "continue": true,
96
- "steps": [["press", "Escape"], ["wait", 100], ["dispatchClick", "#orbField a.worldOrb:not(#selfOrb)"], ["wait", 400], ["press", "Escape"], ["wait", 150]],
97
+ "steps": [["press", "Escape"], ["wait", 100], ["dispatchClick", "#orbField a.worldOrb:not(#selfOrb):not([data-manage])"], ["wait", 400], ["press", "Escape"], ["wait", 150]],
97
98
  "expect": { "dialog": false, "closed": "#menuProjects" } },
98
99
  { "name": "390: the magnifier opens the row and focuses the field", "auth": true, "width": 390,
99
100
  "steps": [["click", "#qToggle"], ["wait", 150]],
@@ -106,6 +107,30 @@
106
107
  "expect": { "hasClass": [["#qWrap", "is-open", false]], "open": "#menuProjects", "top": [["#hubMain", 60]] } },
107
108
  { "name": "390: a solo Shift tap opens the fold and focuses the field, without overflow", "continue": true,
108
109
  "steps": [["press", "Escape"], ["wait", 100], ["press", "Shift"], ["wait", 150]],
109
- "expect": { "hasClass": [["#qWrap", "is-open", true]], "activeId": "mapQ", "noOverflow": true } }
110
+ "expect": { "hasClass": [["#qWrap", "is-open", true]], "activeId": "mapQ", "noOverflow": true } },
111
+ { "name": "manage flows (task 1003345): the address fold on a live row records a domain", "auth": true, "width": 1440, "url": "/projects?view=mine",
112
+ "steps": [["wait",600],["click","li[data-inst=\"101\"] details.attachFold summary"],["fill","#dom-101","demo.example.com"],["click","[data-attach=\"101\"]"],["wait",600]],
113
+ "expect": {"text":[["#msg-101","Address recorded."]],"hasClass":[["#msg-101","ok",true]]} },
114
+ { "name": "manage flows: restart on an unreachable row asks; the row re-reads the project and shows the queued rail, the door leaves while the intent is open", "auth": true, "width": 1440, "url": "/projects?view=mine",
115
+ "steps": [["wait",600],["click","[data-restart=\"105\"]"],["wait",900]],
116
+ "expect": {"text":[["li[data-inst=\"105\"] .strip .pnote","A restart is queued — this card updates when it starts."],["li[data-inst=\"105\"] .strip .stages li.now .slbl","Restart queued"]],"visible":["li[data-inst=\"105\"] .strip","li[data-inst=\"105\"] .worldOrb.ashen"],"hidden":["[data-restart=\"105\"]"]} },
117
+ { "name": "manage flows: tear it down asks twice on a row — the truth first, then the door", "auth": true, "width": 1440, "url": "/projects?view=mine",
118
+ "steps": [["wait",600],["click","[data-teardown=\"101\"]"],["wait",200]],
119
+ "expect": {"visible":["li[data-inst=\"101\"] .confirmRow","[data-teardown-yes=\"101\"]","[data-teardown-no=\"101\"]"],"hidden":["[data-teardown=\"101\"]"]} },
120
+ { "name": "manage flows: the second press queues the teardown; the row re-reads the project and shows the wind-down rail, the door leaves", "continue": true,
121
+ "steps": [["click","[data-teardown-yes=\"101\"]"],["wait",900]],
122
+ "expect": {"text":[["li[data-inst=\"101\"] .strip .pnote","A teardown is queued — this card updates when it starts."]],"visible":["li[data-inst=\"101\"] .strip.flowDown"],"hidden":["[data-teardown=\"101\"]","[data-teardown-yes=\"101\"]"]} },
123
+ { "name": "manage flows: bring it back on a torn-down row re-files the project", "auth": true, "width": 1440, "url": "/projects?view=mine",
124
+ "steps": [["wait",600],["click","[data-revive=\"104\"]"],["wait",600]],
125
+ "expect": {"text":[["#msg-104","Queued. The platform picks it up from here."]],"hasClass":[["#msg-104","ok",true]],"visible":["li[data-inst=\"104\"] .worldOrb.ghost"]} },
126
+ { "name": "manage flows: try again on a snagged row re-files it too", "auth": true, "width": 1440, "url": "/projects?view=mine",
127
+ "steps": [["wait",600],["click","[data-retry=\"103\"]"],["wait",600]],
128
+ "expect": {"text":[["#msg-103","Queued. The platform picks it up from here."]],"hasClass":[["#msg-103","ok",true]]} },
129
+ { "name": "manage flows: how people join — pick a cell, save, the receipt lands under the choice", "auth": true, "width": 1440, "url": "/projects?view=manage&id=101",
130
+ "steps": [["wait",900],["check","input[name=\"mJoinPick\"][value=\"apply\"]"],["click","[data-join-save]"],["wait",600]],
131
+ "expect": {"text":[["#mJoinMsg","Saved. The project restarts to apply it."],["#mName","demo"]],"checked":"input[name=\"mJoinPick\"][value=\"apply\"]","visible":["#mOrb .worldOrb","#mFacts .ledger .k"]} },
132
+ { "name": "manage flows: the ender asks for the name typed back, queues the teardown, and the page re-reads the project as queued", "auth": true, "width": 1440, "url": "/projects?view=manage&id=105",
133
+ "steps": [["wait",900],["click","[data-del-ask]"],["wait",200],["fill","#mDelName","mooring"],["wait",100],["click","[data-del-yes]"],["wait",900]],
134
+ "expect": {"text":[["#mDel .addr","A teardown is queued for this project."],["#mName","mooring"]],"hidden":["#mDelName","[data-del-ask]"],"visible":["#mStrip .strip.flowDown"]} }
110
135
  ]
111
136
  }
@@ -1,28 +1,32 @@
1
1
  {
2
- "_": "The hub's states for modules/ui-design/kit/render.js (task 1003320): name → url + actions + expectations. `auth` picks the signed-in stub. Rendered dark only at 1440/390/320 (the apex pins data-mode=\"dark\", task 1003336) and audited against the platform floors. The card states (task 1003301) walk the poster's ladder: the full poster, the artless two-up band, the all-null floor, the platform's own card.",
2
+ "_": "The hub's states for modules/ui-design/kit/render.js (task 1003320): name → url + actions + expectations. `auth` picks the signed-in stub. Rendered dark only at 1440/390/320 (the apex pins data-mode=\"dark\", task 1003336) and audited against the platform floors. The card states (task 1003301) walk the poster's ladder: the full poster, the artless two-up band, the all-null floor, the platform's own card. The signed-in stub OWNS Demo (provisioning-instances.json, task 1003345), and an owned orb's plain click opens its manage view, never its card — so the all-null floor card renders from projects-floor.states.json on the hub's own catalog (featured-hub.json, one un-owned null row more).",
3
3
  "page": "/projects",
4
4
  "surface": "public-landing",
5
5
  "modes": ["dark"],
6
6
  "modeQuery": false,
7
- "ignoreRequests": ["/api/(gds|bongos)/me$", "/my-projects", "/provisioning/"],
8
- "ignoreConsole": ["/api/(gds|bongos)/me", "\\b401\\b", "Unauthorized"],
7
+ "ignoreRequests": ["/api/(gds|bongos)/me$", "/my-projects", "/provisioning/instances/999", "/provisioning/instances/101$", "/api/gds/instance$"],
8
+ "ignoreConsole": ["/api/(gds|bongos)/me", "\\b401\\b", "\\b404\\b", "Unauthorized"],
9
9
  "ignoreControls": [".skip", ".vh"],
10
10
  "states": {
11
11
  "out-map": { "auth": false, "expect": { "expanded": false } },
12
- "in-map": { "auth": true, "expect": { "expanded": false, "count": [["#orbField .worldOrb[data-shape][data-style]", 4]], "attr": [["#selfOrb", "data-template", "pair-chrome"], ["#orbField a.worldOrb[data-origin=\"https://amazonprimea.com\"]", "data-template", "bead-wash"], ["#orbField a.worldOrb[data-origin=\"https://demo.cloudbongos.com\"]", "data-shape", "pair"], ["#orbField a.worldOrb[data-origin=\"https://demo.cloudbongos.com\"]", "data-material", "gold"], ["#orbField a.worldOrb[data-origin=\"https://demo.cloudbongos.com\"]", "data-template", "pair-gold-wash"], ["#orbField a.worldOrb[data-origin=\"https://emersonian-circles.cloudbongos.com\"]", "data-template", "bead-obsidian-plain"]] } },
12
+ "in-map": { "auth": true, "expect": { "expanded": false, "count": [["#orbField .worldOrb[data-shape][data-style]", 7]], "attr": [["#selfOrb", "data-template", "pair-chrome"], ["#orbField a.worldOrb[data-origin=\"https://amazonprimea.com\"]", "data-template", "bead-wash"], ["#orbField a.worldOrb[data-origin=\"https://demo.cloudbongos.com\"]", "data-shape", "pair"], ["#orbField a.worldOrb[data-origin=\"https://demo.cloudbongos.com\"]", "data-material", "gold"], ["#orbField a.worldOrb[data-origin=\"https://demo.cloudbongos.com\"]", "data-template", "pair-gold-wash"], ["#orbField a.worldOrb[data-origin=\"https://emersonian-circles.cloudbongos.com\"]", "data-template", "bead-obsidian-plain"]] } },
13
13
  "in-map-projects": { "auth": true, "actions": [["click", "#projTrig"], ["wait", 350]], "expect": { "expanded": true, "open": "#menuProjects" } },
14
14
  "in-map-profile": { "auth": true, "actions": [["click", "#who"], ["wait", 350]], "expect": { "expanded": true, "open": "#menuProfile" } },
15
15
  "in-map-kbd": { "auth": true, "actions": [["focus", "#projTrig"], ["press", "Enter"], ["wait", 350], ["press", "ArrowDown"], ["wait", 120]], "expect": { "expanded": true, "activeText": "Table" } },
16
16
  "in-map-search": { "auth": true, "actions": [["clickIfVisible", "#qToggle"], ["wait", 200], ["fill", "#mapQ", "emer"], ["wait", 300]], "expect": { "value": [["#mapQ", "emer"]] } },
17
17
  "in-map-mine-on": { "auth": true, "actions": [["check", "#onlyMine"], ["wait", 300]], "expect": { "checked": "#onlyMine" } },
18
18
  "in-table": { "auth": true, "actions": [["click", "#projTrig"], ["wait", 250], ["click", "a[data-sub=\"table\"]"], ["wait", 400]], "expect": { "visible": ["#sub-table-body", ".tblMast", ".mastObj", "#selfHall"], "closed": "#menuProjects", "count": [["#mapRows tr[data-generated] .rowHall", 1]] } },
19
- "in-mine": { "auth": true, "url": "/projects?view=mine", "expect": { "hidden": ["#qWrap"] } },
19
+ "in-mine": { "auth": true, "url": "/projects?view=mine", "expect": { "hidden": ["#qWrap", "#empty"], "visible": ["#mineBar", "#projectsList", "#projectsList .proj .worldOrb .disc"], "count": [["#projectsList .proj", 5], ["#projectsList .proj.gone", 1], ["#projectsList .proj .worldOrb[data-shape][data-material][data-style]", 5], ["#projectsList .proj .strip", 1], ["#projectsList .proj .worldOrb.ashen", 2], ["#projectsList [data-restart]", 1]] } },
20
+ "in-manage": { "auth": true, "url": "/projects?view=manage&id=101", "expect": { "visible": ["#mMain", "#mChip .chip", "#mOrb .worldOrb", "#mFacts .ledger .k", "#mActs"], "hidden": ["#qWrap", "#mGate", "#mMissing", "#mErr"], "text": [["#mName", "demo"], ["#mTitle", "demo"]] } },
21
+ "in-manage-snag": { "auth": true, "url": "/projects?view=manage&id=103", "expect": { "visible": ["#mMain", "#mErr", "#mActs"], "text": [["#mName", "tidepool"]] } },
22
+ "in-manage-gone": { "auth": true, "url": "/projects?view=manage&id=104", "expect": { "visible": ["#mMain", "#mActs"], "hidden": ["#mErr"], "text": [["#mName", "old-lantern"]] } },
23
+ "in-manage-missing": { "auth": true, "url": "/projects?view=manage&id=999", "expect": { "visible": ["#mMissing"], "hidden": ["#mMain", "#mGate"] } },
24
+ "out-manage": { "auth": false, "url": "/projects?view=manage&id=101", "expect": { "visible": ["#mGate"], "hidden": ["#mMain", "#mMissing"] } },
20
25
  "in-new": { "auth": true, "url": "/projects?view=new", "expect": { "hidden": ["#qWrap"] } },
21
26
  "out-new": { "auth": false, "url": "/projects?view=new", "expect": { "hidden": ["#qWrap"] } },
22
27
  "out-map-projects": { "auth": false, "actions": [["click", "#projTrig"], ["wait", 350]], "expect": { "expanded": true, "hidden": ["#menuProjects [data-nav=\"mine\"]"] } },
23
28
  "card": { "auth": true, "actions": [["dispatchClick", "#orbField a.worldOrb[data-origin=\"https://amazonprimea.com\"]"], ["wait", 900]], "expect": { "dialog": true, "hasClass": [["#projCard", "has-art"]], "visible": ["#cardArt", "#cardFaces", "#cardBuildersFig", "#cardShippedFig", "#cardOpen", "#cardHall"], "text": [["#cardBuilders", "11"], ["#cardShipped", "0"], ["#cardRel", "you build here"], ["#cardRuns", "amazonprimea.com"], ["#cardFaces .facesMore", "+6"]], "count": [["#cardFaces img", 5]], "href": [["#cardHall", "https://builders.amazonprimea.com/"]] } },
24
29
  "card-noart": { "auth": true, "actions": [["dispatchClick", "#orbField a.worldOrb[data-origin=\"https://emersonian-circles.cloudbongos.com\"]"], ["wait", 600]], "expect": { "dialog": true, "hasClass": [["#projCard", "has-art", false]], "hidden": ["#cardArt", "#cardFaces", "#cardHall"], "visible": ["#cardBuildersFig", "#cardShippedFig", "#cardOpen"], "text": [["#cardShipped", "30+"], ["#cardRel", "you build here"]] } },
25
- "card-floor": { "auth": true, "actions": [["dispatchClick", "#orbField a.worldOrb[data-origin=\"https://demo.cloudbongos.com\"]"], ["wait", 600]], "expect": { "dialog": true, "hidden": ["#cardArt", "#cardFaces", "#cardFigs", "#cardHall", "#cardRel", "#cardTypeL", "#cardOwnerL"], "visible": ["#cardOpen", "#cardRuns", "#cardListed"] } },
26
30
  "card-self": { "auth": true, "actions": [["dispatchClick", "#selfOrb"], ["wait", 600]], "expect": { "dialog": true, "hidden": ["#cardArt"], "visible": ["#cardBuildersFig", "#cardShippedFig", "#cardFaces", "#cardOpen", "#cardHall"], "text": [["#cardRel", "yours"]] } }
27
31
  },
28
32
  "reducedMotion": { "auth": true, "actions": [["click", "#projTrig"], ["wait", 100]] }
@@ -494,6 +494,15 @@ const KERNEL_FILES = [
494
494
  // and pino, so joining the roster costs nothing and buys the import-direction
495
495
  // guarantee on the file every module now logs through.
496
496
  'src/bongos/logger.js', // pino + request correlation + ADR 0022 redaction
497
+ // task 1003339 (ADR 0209): the abuse ceiling is a trust primitive in the same
498
+ // sense the authz gates are — authz says WHO may ask, this says HOW OFTEN, and
499
+ // an unauthenticated read route has nothing else holding it. src/module-api.js
500
+ // now hands modules the shared account-existence limiter (the onboarding
501
+ // module's account-existence probe spends the same budget as core's), so it
502
+ // sits BELOW the module boundary exactly as logger.js does. It requires NOTHING
503
+ // — no relative imports at all — so joining the roster costs nothing and buys
504
+ // the import-direction guarantee on a file two surfaces now trust.
505
+ 'src/bongos/middleware/rate-limit.js', // the sliding-window abuse ceilings (write + per-surface read)
497
506
  'src/bongos/db-kernel.js', // the kernel db slice — identity/audit/rank/withTx (BV1.R69)
498
507
  'src/bongos/routes/_helpers.js', // shared request validators the doorway re-exports
499
508
  // config / identity (host context a module reads, never hardcodes)
@@ -60,6 +60,23 @@ function buildInitSpec(a = {}) {
60
60
 
61
61
  function healthzUrl(domain) { return `https://${domain}/healthz`; }
62
62
 
63
+ // The optional project-detail step's answers, as the CLI collects them (task
64
+ // 1002334) — the same shape POST /provisioning/instances takes, so the terminal
65
+ // form and the web wizard file identical requests. A blank answer is a SKIP, and a
66
+ // skip is a supported default: the project stands up, it just stays off the map
67
+ // until a description exists (ADR 0182 D4). Off-vocabulary team answers are dropped
68
+ // rather than guessed — the server's normalizeProjectDetail applies the same rule.
69
+ // PURE.
70
+ const TEAM_SHAPES = ['solo', 'small-team', 'community'];
71
+ function buildProjectDetail(a = {}) {
72
+ const out = {};
73
+ const told = typeof a.description === 'string' ? a.description.replace(/\s+/g, ' ').trim() : '';
74
+ if (told) out.description = told;
75
+ const team = String(a.teamShape || '').trim().toLowerCase();
76
+ if (TEAM_SHAPES.includes(team)) out.detail = { team_shape: team };
77
+ return out;
78
+ }
79
+
63
80
  // ── Interactive shell ────────────────────────────────────────────────────────
64
81
 
65
82
  function ask(rl, q, dflt) {
@@ -90,6 +107,29 @@ async function run() {
90
107
  a.slug = await ask(rl, 'Instance slug? (DNS/db-safe, e.g. mercury)');
91
108
  a.domain = await ask(rl, 'Domain you own (bring-your-own; blank = the platform assigns <slug>.<its apex> when configured)?');
92
109
 
110
+ // Step 1b — the OPTIONAL project detail (task 1002334). Every form in the
111
+ // sequence offers it and every owner may skip it: Enter past both questions and
112
+ // the standup is unaffected. Say what skipping costs BEFORE asking, so the skip
113
+ // is an informed choice rather than a shrug — a project with no description
114
+ // stays off the map (ADR 0182 D4) and `bongos` can recommend nothing.
115
+ log('\nTell us about your project — optional. Press Enter to skip either question.');
116
+ log(' A description is what puts your project on the map (you can add it later);');
117
+ log(` who's building it is what the platform's setup recommendations read.`);
118
+ a.description = await ask(rl, 'What are you building, in a sentence or two?');
119
+ a.teamShape = await ask(rl, `Who's building it — ${TEAM_SHAPES.join(' / ')}?`);
120
+ const detail = buildProjectDetail(a);
121
+ if (Object.keys(detail).length) {
122
+ // This CLI prints commands rather than calling the API (the trust boundary),
123
+ // so it shows the answers as the request fields they are — the SAME body the
124
+ // web wizard files, which is what keeps the two forms in lockstep.
125
+ log(' These ride the project\'s provisioning request (POST /provisioning/instances):');
126
+ log(' ' + JSON.stringify(detail));
127
+ }
128
+ if (!detail.description) {
129
+ log(' → no description. Your project stands up either way; it stays off the public map until it');
130
+ log(' has one, and you can add it any time (PATCH /provisioning/instances/:id/detail).');
131
+ }
132
+
93
133
  // Step 2 — human: the GitHub repo (create empty for greenfield; use existing for adopt)
94
134
  if (adopt) {
95
135
  log('\n① Use your EXISTING GitHub repository:');
@@ -224,4 +264,4 @@ async function run() {
224
264
 
225
265
  if (require.main === module) run().catch((e) => { console.error('onboard failed:', e && e.message ? e.message : e); process.exit(1); });
226
266
 
227
- module.exports = { onboardPlan, oauthAppInstructions, buildInitSpec, healthzUrl };
267
+ module.exports = { onboardPlan, oauthAppInstructions, buildInitSpec, healthzUrl, buildProjectDetail, TEAM_SHAPES };
@@ -75,9 +75,13 @@ async function federateInstance(inst, deps) {
75
75
  ownerGithubId = rows[0] ? rows[0].github_id : null;
76
76
  } catch { /* non-fatal */ }
77
77
  await platformIdentity.registerClient(
78
- // type: the owner's declaration rides the instance row from the create
79
- // wizard (task 1002772) and lands on the catalog row here — never guessed.
80
- { clientId, secret: clientSecret, name: inst.slug, redirectUris, origin, ownerGithubId, type: inst.type },
78
+ // type + description: the owner's answers ride the instance row from the
79
+ // create form (tasks 1002772 / 1002334) and land on the catalog row here —
80
+ // never guessed. description is half the publish gate (ADR 0182 D4), so a
81
+ // project that described itself at creation is publishable the moment it
82
+ // federates; one that skipped the optional step passes null and stays dark
83
+ // matter until it fills the answer in.
84
+ { clientId, secret: clientSecret, name: inst.slug, redirectUris, origin, ownerGithubId, type: inst.type, description: inst.description },
81
85
  { pool: db },
82
86
  );
83
87
  log(` [federate] registered client '${clientId}' on the hub (origin ${origin})`);
@@ -1,7 +1,9 @@
1
1
  // src/bongos/middleware/rate-limit.js — V3.R34 (#255).
2
2
  //
3
- // Two sliding-window in-memory limiters that protect write methods on
4
- // /api/gds/*. Read methods (GET, HEAD, OPTIONS) bypass entirely.
3
+ // Four sliding-window in-memory limiters. The first two are the GLOBAL pair and
4
+ // protect write methods on /api/gds/* only — read methods (GET, HEAD, OPTIONS)
5
+ // bypass them entirely; #3 and #4 are mounted per-surface to close exactly that
6
+ // gap where an unauthenticated READ is itself the abuse.
5
7
  //
6
8
  // 1. Per-IP limiter — 30 writes / 60s. Anchors on req.ip (which honors
7
9
  // 'trust proxy' in server.js so Cloudflare's
@@ -32,6 +34,22 @@
32
34
  // server.js sets 'trust proxy', so req.ip is the end
33
35
  // user, not Cloudflare's single origin IP.
34
36
  //
37
+ // 4. Account-existence limiter — 120 reads / 60s per IP, spent from ONE bucket
38
+ // by BOTH routes that answer "does this GitHub login
39
+ // have an account here?": the onboarding module's
40
+ // GET /access-requests/status (the richer
41
+ // member|invited|pending|dismissed|none) and core's
42
+ // GET /auth/web/admission-status (its boolean twin).
43
+ // Task 1003339 / ADR 0209. They are ONE oracle read
44
+ // two ways, so they share one budget — give each its
45
+ // own and an enumerator just alternates between them
46
+ // for double the rate. Both are unauthenticated by
47
+ // design (a would-be builder is outside the system),
48
+ // so the per-IP budget is the only ceiling there is.
49
+ // 120/min clears the honest pollers with wide margin —
50
+ // the access-pending page polls every 3s (20/min) —
51
+ // while capping a scraper at ~2 logins/s.
52
+ //
35
53
  // Any limiter tripping → 429 with Retry-After (seconds rounded up to the
36
54
  // nearest whole second). Audit middleware still fires on a write 429 — the row
37
55
  // in audit_log is forensic value for "who tried too hard." (Reads are never
@@ -54,11 +72,16 @@ const PER_SESSION_WINDOW_MS = 60 * 1000;
54
72
  const PER_SESSION_LIMIT = 60;
55
73
  const PUBLIC_READ_WINDOW_MS = 60 * 1000;
56
74
  const PUBLIC_READ_LIMIT = 120;
75
+ const ACCOUNT_EXISTENCE_WINDOW_MS = 60 * 1000;
76
+ const ACCOUNT_EXISTENCE_LIMIT = 120;
57
77
  const SWEEP_MS = 5 * 60 * 1000;
58
78
 
59
79
  const ipBuckets = new Map();
60
80
  const sessionBuckets = new Map();
61
81
  const publicReadBuckets = new Map();
82
+ // ONE bucket map for limiter #4 — both account-existence probes key into it, so
83
+ // the budget is genuinely shared rather than merely identically configured.
84
+ const accountExistenceBuckets = new Map();
62
85
 
63
86
  const WRITE_METHODS = new Set(['POST', 'PATCH', 'PUT', 'DELETE']);
64
87
  // HEAD is metered too: Express runs the GET handler (incl. the DB query) for a
@@ -105,6 +128,10 @@ function sweep() {
105
128
  pruneAndCount(arr, now, PUBLIC_READ_WINDOW_MS);
106
129
  if (arr.length === 0) publicReadBuckets.delete(k);
107
130
  }
131
+ for (const [k, arr] of accountExistenceBuckets) {
132
+ pruneAndCount(arr, now, ACCOUNT_EXISTENCE_WINDOW_MS);
133
+ if (arr.length === 0) accountExistenceBuckets.delete(k);
134
+ }
108
135
  }
109
136
 
110
137
  // Schedule the sweep. unref() so it doesn't keep the process alive during
@@ -182,9 +209,46 @@ function publicReadRateLimitMiddleware(req, res, next) {
182
209
  next();
183
210
  }
184
211
 
212
+ // perIpSlidingWindow(buckets, opts) — build a per-IP sliding-window middleware
213
+ // over the given bucket map. Exists so a second route can spend from the SAME
214
+ // budget as the first simply by mounting the same returned function; `scope`
215
+ // names that budget in the 429 body + headers so a caller can tell which ceiling
216
+ // it hit. Keyed on req.ip — the same real-client-IP resolution the write limiter
217
+ // uses (Caddy forwards the client IP, server sets 'trust proxy').
218
+ function perIpSlidingWindow(buckets, { limit, windowMs, scope }) {
219
+ return function slidingWindowRateLimit(req, res, next) {
220
+ const now = Date.now();
221
+ const ip = req.ip || req.socket?.remoteAddress || 'unknown';
222
+ let bucket = buckets.get(ip);
223
+ if (!bucket) { bucket = []; buckets.set(ip, bucket); }
224
+ if (pruneAndCount(bucket, now, windowMs) >= limit) {
225
+ const retryAfter = Math.max(1, Math.ceil((bucket[0] + windowMs - now) / 1000));
226
+ res.set('Retry-After', String(retryAfter));
227
+ res.set('X-RateLimit-Scope', scope);
228
+ res.set('X-RateLimit-Limit', String(limit));
229
+ res.set('X-RateLimit-Window-Seconds', String(windowMs / 1000));
230
+ return res.fail('rate_limited', 429, { scope, retry_after_seconds: retryAfter });
231
+ }
232
+ bucket.push(now);
233
+ return next();
234
+ };
235
+ }
236
+
237
+ // accountExistenceReadRateLimit — limiter #4 in the header. ONE instance, mounted
238
+ // on both account-existence probes (core /auth/web/admission-status and the
239
+ // onboarding module's /access-requests/status, which reaches it through the
240
+ // doorway). Mounting the same function on both is what makes the budget shared:
241
+ // exhausting it on either route closes the other for the rest of the window.
242
+ const accountExistenceReadRateLimit = perIpSlidingWindow(accountExistenceBuckets, {
243
+ limit: ACCOUNT_EXISTENCE_LIMIT,
244
+ windowMs: ACCOUNT_EXISTENCE_WINDOW_MS,
245
+ scope: 'account-existence',
246
+ });
247
+
185
248
  module.exports = {
186
249
  rateLimitMiddleware,
187
250
  publicReadRateLimitMiddleware,
251
+ accountExistenceReadRateLimit,
188
252
  // exported for tests + the security-baseline-check script
189
253
  PER_IP_WINDOW_MS,
190
254
  PER_IP_LIMIT,
@@ -192,4 +256,6 @@ module.exports = {
192
256
  PER_SESSION_LIMIT,
193
257
  PUBLIC_READ_WINDOW_MS,
194
258
  PUBLIC_READ_LIMIT,
259
+ ACCOUNT_EXISTENCE_WINDOW_MS,
260
+ ACCOUNT_EXISTENCE_LIMIT,
195
261
  };
@@ -34,6 +34,7 @@ const { reportActivityRollupForShippedTask } = require('../auth-admission.js');
34
34
  const db = require('../db');
35
35
  const { pairStore } = require('../app-pair');
36
36
  const { publicOrigin, parseCookie, validateOrRespond } = require('./_helpers');
37
+ const { accountExistenceReadRateLimit } = require('../middleware/rate-limit');
37
38
  const { branding } = require('../../branding');
38
39
  const seams = require('../../module-seams');
39
40
  // task 1003208: structured logging — was console.*. A core file logs through
@@ -564,23 +565,29 @@ module.exports = function buildAuthRouter() {
564
565
  // NOT make it non-enumerable: with open enrollment off, and for any login
565
566
  // that is not the configured firstAdmin, `admitted:true` means that login is
566
567
  // a member or has been invited. That reveals account existence to an
567
- // unauthenticated caller. It is also NOT throttled — rateLimitMiddleware
568
- // returns early for non-write methods, so GETs bypass it entirely; the page's
569
- // 3s poll bounds the honest client, not an attacker.
568
+ // unauthenticated caller.
570
569
  //
571
- // Why it is nonetheless not a new exposure: the onboarding module's
572
- // GET /access-requests/status?github_login=X is ALREADY public and returns
573
- // the strictly richer member|invited|pending|dismissed|none plus its own
574
- // `admitted`. Anything derivable here is already derivable there, so this
575
- // adds no net-new surface — and hardening only this route would accomplish
576
- // nothing while that one stays open. Whether unauthenticated login-existence
577
- // probing is acceptable at all is a real question, but it belongs to both
578
- // routes together, not to a comment here.
570
+ // IT IS NOW THROTTLED (task 1003339 / ADR 0209). It used to say "NOT
571
+ // throttled" here, and that was true: rateLimitMiddleware returns early for
572
+ // non-write methods, so GETs bypassed it entirely and the page's 3s poll
573
+ // bounded the honest client, not an attacker. accountExistenceReadRateLimit
574
+ // below is the ceiling — 120 reads/60s per IP, and the SAME instance is
575
+ // mounted on the onboarding module's GET /access-requests/status, which is
576
+ // this route's strictly richer twin (member|invited|pending|dismissed|none
577
+ // plus its own `admitted`). One budget across both, because they are one
578
+ // oracle: hardening either alone would accomplish nothing, and giving each its
579
+ // own budget would just let an enumerator alternate for double the rate.
580
+ //
581
+ // WHAT IS STILL OPEN and is deliberately NOT decided here: whether the answer
582
+ // itself should collapse, so a caller who is not the subject cannot tell
583
+ // pending/dismissed/none apart from a bare "not admitted". That changes what a
584
+ // real invited user sees during onboarding, so it is the owner's call — filed
585
+ // as a blocker against task 1003339, not settled by a comment in this file.
579
586
  //
580
587
  // That endpoint is also why this one exists: it models neither the firstAdmin
581
588
  // bypass nor open enrollment, so it reports the OWNER as 'none' forever —
582
589
  // the exact case this page has to resolve.
583
- router.get('/auth/web/admission-status', async (req, res) => {
590
+ router.get('/auth/web/admission-status', accountExistenceReadRateLimit, async (req, res) => {
584
591
  const login = typeof req.query.login === 'string' ? req.query.login.slice(0, 100) : '';
585
592
  if (!login) return res.json({ admitted: false });
586
593
  try {
package/src/module-api.js CHANGED
@@ -33,7 +33,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
33
33
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
34
34
  // the entry to that file. Look for a version's history there, not here.
35
35
  // ---------------------------------------------------------------------------
36
- const CORE_VERSION = '1.19.419'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
36
+ const CORE_VERSION = '1.19.421'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
37
37
 
38
38
  // A namespaced logger so a module's log lines are attributable + consistent.
39
39
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -131,6 +131,18 @@ module.exports = {
131
131
  // The ladder as the canonical 1-based rank → tier map (BV1.R108) — modules
132
132
  // derive tier comparisons from this instead of hand-copying the map.
133
133
  get RANK_TIER() { return require('./bongos/db-kernel').RANK_TIER; },
134
+ // accountExistenceReadRateLimit — the SHARED per-IP budget (120 reads/60s)
135
+ // spent by every route that answers "does this login have an account
136
+ // here?". A kernel trust primitive in the same sense the authz gates are:
137
+ // the abuse ceiling on an unauthenticated read. It is one MIDDLEWARE
138
+ // INSTANCE, not a factory, on purpose — core's /auth/web/admission-status
139
+ // and the onboarding module's richer /access-requests/status are one
140
+ // oracle read two ways, so they must spend from one bucket or an
141
+ // enumerator alternates between them for double the rate (task 1003339,
142
+ // ADR 0209). Vendoring a second copy in a module is the failure this
143
+ // export exists to prevent.
144
+ accountExistenceReadRateLimit: require('./bongos/middleware/rate-limit').accountExistenceReadRateLimit,
145
+
134
146
  // --- per-builder secrets at rest (AES-256-GCM box) — a kernel trust
135
147
  // primitive; the full secretBox object is exposed so a module can call
136
148
  // isConfigured/encrypt/decrypt/provisionMasterKey without reaching core.